summaryrefslogtreecommitdiff
path: root/assets/js/charts
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-17 19:41:31 -0500
committerChristian Cleberg <[email protected]>2026-04-17 19:41:31 -0500
commit623ae07ebacb9d596b225179727dc87641ad1cca (patch)
treef1045282b3369bf3f4b0249cd649b47a5d010dad /assets/js/charts
parentfd412c5197286518ff9491a1b51e6319c9a22904 (diff)
downloadworld-incarceration-623ae07ebacb9d596b225179727dc87641ad1cca.tar.gz
world-incarceration-623ae07ebacb9d596b225179727dc87641ad1cca.tar.bz2
world-incarceration-623ae07ebacb9d596b225179727dc87641ad1cca.zip
phase 2-6: complete D3/TopoJSON migration
Replace ZingChart + jQuery with D3 v7 + TopoJSON. Implements all planned phases: choropleth map (NaturalEarth1 projection, YlOrRd gradient), hover tooltip, zoom/pan/reset, country detail modal, side-by-side compare, metric selector, label toggle, donut pie chart with per-country drilldown bar chart. Upgrades Bootstrap JS bundle from v4.5.3 (jQuery-dependent) to v5.0.0, fixes modal API calls to use getInstance/constructor pattern compatible with BS 5.0.0+. Removes jquery, zingchart ×3 library files.
Diffstat (limited to 'assets/js/charts')
-rw-r--r--assets/js/charts/bar.js99
-rw-r--r--assets/js/charts/pie.js73
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);
+ });
+}