diff options
Diffstat (limited to 'assets/js/charts')
| -rw-r--r-- | assets/js/charts/bar.js | 99 | ||||
| -rw-r--r-- | assets/js/charts/pie.js | 73 |
2 files changed, 168 insertions, 4 deletions
diff --git a/assets/js/charts/bar.js b/assets/js/charts/bar.js index 4aa61ef..572cb50 100644 --- a/assets/js/charts/bar.js +++ b/assets/js/charts/bar.js @@ -1,2 +1,97 @@ -// Phase 4: horizontal bar chart for top-N countries -export function renderBarChart(container, data) {} +import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm"; + +const MARGIN = { top: 44, right: 90, bottom: 30, left: 150 }; + +// data: [{label, value (0–100 normalized), displayValue}] +// opts: { title?, onBack? } +export function renderBarChart(container, data, opts = {}) { + const { title, onBack } = opts; + const el = typeof container === "string" + ? document.querySelector(container) + : container; + + el.innerHTML = ""; + + const totalWidth = Math.max(el.clientWidth || 500, 400); + const totalHeight = data.length * 52 + MARGIN.top + MARGIN.bottom; + const width = totalWidth - MARGIN.left - MARGIN.right; + const height = totalHeight - MARGIN.top - MARGIN.bottom; + + const svg = d3.select(el).append("svg") + .attr("width", "100%") + .attr("viewBox", `0 0 ${totalWidth} ${totalHeight}`); + + if (title) { + svg.append("text") + .attr("x", totalWidth / 2) + .attr("y", 22) + .attr("text-anchor", "middle") + .style("fill", "#fff") + .style("font-size", "14px") + .style("font-weight", "bold") + .text(title); + } + + if (onBack) { + svg.append("text") + .attr("x", 8) + .attr("y", 22) + .style("fill", "#24ab48") + .style("font-size", "13px") + .style("cursor", "pointer") + .text("← Back") + .on("click", onBack); + } + + const g = svg.append("g") + .attr("transform", `translate(${MARGIN.left},${MARGIN.top})`); + + const yScale = d3.scaleBand() + .domain(data.map(d => d.label)) + .range([0, height]) + .padding(0.3); + + const xScale = d3.scaleLinear() + .domain([0, 100]) + .range([0, width]); + + // Y axis (country/metric labels) + g.append("g") + .call(d3.axisLeft(yScale).tickSize(0)) + .call(ax => ax.select(".domain").remove()) + .selectAll("text") + .style("fill", "#ccc") + .style("font-size", "12px"); + + // X axis (percentage) + g.append("g") + .attr("transform", `translate(0,${height})`) + .call(d3.axisBottom(xScale).ticks(5).tickFormat(d => d + "%")) + .call(ax => ax.select(".domain").remove()) + .selectAll("text") + .style("fill", "#666") + .style("font-size", "10px"); + + // Bars + g.selectAll(".bar") + .data(data) + .join("rect") + .attr("class", "bar") + .attr("y", d => yScale(d.label)) + .attr("height", yScale.bandwidth()) + .attr("x", 0) + .attr("width", d => xScale(Math.max(0, d.value))) + .attr("fill", "rgba(36, 171, 72, 0.8)"); + + // Value labels at end of bar + g.selectAll(".bar-label") + .data(data) + .join("text") + .attr("class", "bar-label") + .attr("x", d => xScale(Math.max(0, d.value)) + 4) + .attr("y", d => yScale(d.label) + yScale.bandwidth() / 2) + .attr("dy", "0.35em") + .style("fill", "#fff") + .style("font-size", "11px") + .text(d => d.displayValue); +} diff --git a/assets/js/charts/pie.js b/assets/js/charts/pie.js index ff5a2e5..d59f31a 100644 --- a/assets/js/charts/pie.js +++ b/assets/js/charts/pie.js @@ -1,2 +1,71 @@ -// Phase 4: donut pie chart for top-N countries -export function renderPieChart(container, data) {} +import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm"; + +const COLORS = ["#0062ff", "#da1e28", "#fdd13a", "#24ab48", "#9966ff", "#606e85"]; +const SIZE = 420; +const RADIUS = SIZE / 2 - 30; +const INNER_RADIUS = RADIUS * 0.45; + +// data: [{label, value, alpha3}] (alpha3 is null for "Rest of World") +// onSliceClick: (alpha3) => void — called only for named-country slices +export function renderPieChart(container, data, onSliceClick) { + const el = typeof container === "string" + ? document.querySelector(container) + : container; + + el.innerHTML = ""; + + const svg = d3.select(el).append("svg") + .attr("viewBox", `0 0 ${SIZE} ${SIZE}`) + .attr("width", "100%"); + + const g = svg.append("g") + .attr("transform", `translate(${SIZE / 2},${SIZE / 2 - 10})`); + + const pie = d3.pie().value(d => d.value).sort(null); + const arc = d3.arc().outerRadius(RADIUS).innerRadius(INNER_RADIUS); + const labelArc = d3.arc() + .outerRadius(RADIUS * 0.78) + .innerRadius(RADIUS * 0.78); + + const arcs = g.selectAll(".arc") + .data(pie(data)) + .join("g") + .attr("class", "arc"); + + arcs.append("path") + .attr("d", arc) + .attr("fill", (d, i) => COLORS[i % COLORS.length]) + .attr("stroke", "#161616") + .attr("stroke-width", 2) + .style("cursor", d => d.data.alpha3 ? "pointer" : "default") + .on("click", (event, d) => { + if (d.data.alpha3 && onSliceClick) onSliceClick(d.data.alpha3); + }); + + arcs.append("text") + .attr("transform", d => `translate(${labelArc.centroid(d)})`) + .attr("text-anchor", "middle") + .attr("dy", "0.35em") + .style("fill", "#fff") + .style("font-size", "11px") + .style("pointer-events", "none") + .text(d => { + const angle = d.endAngle - d.startAngle; + return angle > 0.3 ? d.data.label : ""; + }); + + // Legend + const legend = svg.append("g") + .attr("transform", `translate(10, ${SIZE - 20 - data.length * 18})`); + + data.forEach((d, i) => { + const row = legend.append("g").attr("transform", `translate(0, ${i * 18})`); + row.append("rect").attr("width", 12).attr("height", 12).attr("fill", COLORS[i % COLORS.length]); + row.append("text") + .attr("x", 16) + .attr("y", 10) + .style("fill", "#ccc") + .style("font-size", "11px") + .text(d.label); + }); +} |
