From 623ae07ebacb9d596b225179727dc87641ad1cca Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Apr 2026 19:41:31 -0500 Subject: phase 2-6: complete D3/TopoJSON migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/ui/compare.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++-- assets/js/ui/controls.js | 59 +++++++++++++++++++++++++++++++++++++-- assets/js/ui/modal.js | 31 +++++++++++++++++++-- 3 files changed, 155 insertions(+), 7 deletions(-) (limited to 'assets/js/ui') diff --git a/assets/js/ui/compare.js b/assets/js/ui/compare.js index d933609..3b0a8bb 100644 --- a/assets/js/ui/compare.js +++ b/assets/js/ui/compare.js @@ -1,2 +1,70 @@ -// Phase 5: side-by-side country comparison panel -export function initCompare(countries) {} +let _pendingResult = false; + +export function initCompare(countries) { + const opts = Object.entries(countries) + .sort((a, b) => a[1].country.localeCompare(b[1].country)) + .map(([key, val]) => ``) + .join(""); + + document.getElementById("compare-select-1").innerHTML = opts; + document.getElementById("compare-select-2").innerHTML = opts; + + document.getElementById("compare-go-btn").addEventListener("click", () => { + const k1 = document.getElementById("compare-select-1").value; + const k2 = document.getElementById("compare-select-2").value; + + if (k1 === k2) { + document.getElementById("compare-alert").innerHTML = + "
Please select two different countries.
"; + return; + } + document.getElementById("compare-alert").innerHTML = ""; + _prepareResult(countries[k1], countries[k2]); + _pendingResult = true; + const cm = document.getElementById("compareModal"); + (bootstrap.Modal.getInstance(cm) || new bootstrap.Modal(cm)).hide(); + }); + + // Show result modal only after compareModal finishes hiding + document.getElementById("compareModal").addEventListener("hidden.bs.modal", () => { + if (_pendingResult) { + _pendingResult = false; + const rm = document.getElementById("compareResultModal"); + (bootstrap.Modal.getInstance(rm) || new bootstrap.Modal(rm)).show(); + } + }); +} + +function _prepareResult(c1, c2) { + document.getElementById("compare-result-c1").textContent = c1.country; + document.getElementById("compare-result-c2").textContent = c2.country; + + const rows = [ + ["Prison Population", _fmt(c1.pop, "pop"), _fmt(c2.pop, "pop")], + ["Rate per 100,000", _fmt(c1.rate, "rate"), _fmt(c2.rate, "rate")], + ["Females", _fmt(c1.females, "pct"), _fmt(c2.females, "pct")], + ["Juveniles", _fmt(c1.juveniles, "pct"), _fmt(c2.juveniles, "pct")], + ["Occupancy", _fmt(c1.occupancy, "pct"), _fmt(c2.occupancy, "pct")], + ["Government", _fmtGov(c1.government), _fmtGov(c2.government)], + ]; + + document.getElementById("compare-result-body").innerHTML = rows + .map(([label, v1, v2]) => + `${label}${v1}${v2}` + ) + .join(""); +} + +function _fmt(val, type) { + if (val === "--" || val === null || val === undefined || (typeof val === "number" && isNaN(val))) { + return "N/A"; + } + if (type === "pop") return Number(val).toLocaleString("en"); + if (type === "rate") return String(val); + if (type === "pct") return (val * 100).toFixed(2) + " %"; + return String(val); +} + +function _fmtGov(val) { + return (!val || val === "--") ? "N/A" : val; +} diff --git a/assets/js/ui/controls.js b/assets/js/ui/controls.js index 15c1a3b..4c229d4 100644 --- a/assets/js/ui/controls.js +++ b/assets/js/ui/controls.js @@ -1,3 +1,56 @@ -// Phase 5: metric selector, label toggle, reset button bindings -export function initControls(onMetricChange, onLabelToggle, onReset) {} -export function getTop5(countries, metricKey) {} +const BTN_IDS = ["totPop", "perCapita", "women", "juveniles", "occupancy"]; +const METRIC_KEYS = ["pop", "rate", "females", "juveniles", "occupancy"]; + +export function initControls(onMetricChange, onLabelToggle, onReset) { + BTN_IDS.forEach((id, i) => { + const btn = document.getElementById(id); + if (!btn) return; + btn.addEventListener("click", () => { + BTN_IDS.forEach(b => document.getElementById(b)?.classList.remove("active-metric")); + btn.classList.add("active-metric"); + onMetricChange(METRIC_KEYS[i]); + }); + }); + + document.querySelector("[data-action='labels']")?.addEventListener("click", e => { + e.preventDefault(); + onLabelToggle(); + }); + + document.querySelector("[data-action='reset']")?.addEventListener("click", e => { + e.preventDefault(); + onReset(); + }); +} + +// Returns [{label, value, alpha3}] top-5 plus a rest-of-world entry. +export function getTop5(countries, metricKey) { + const valid = Object.entries(countries) + .filter(([, c]) => typeof c[metricKey] === "number" && !isNaN(c[metricKey])) + .sort((a, b) => b[1][metricKey] - a[1][metricKey]); + + const top5 = valid.slice(0, 5).map(([alpha3, c]) => ({ + label: c.country, + value: c[metricKey], + alpha3, + })); + + const restValue = valid.slice(5).reduce((sum, [, c]) => sum + c[metricKey], 0); + if (restValue > 0) { + top5.push({ label: "Rest of World", value: restValue, alpha3: null }); + } + + return top5; +} + +export function formatMetricValue(val, key) { + if (val === "--" || val === null || val === undefined || (typeof val === "number" && isNaN(val))) { + return "N/A"; + } + if (key === "pop") return Number(val).toLocaleString("en"); + if (key === "rate") return String(val); + if (key === "females" || key === "juveniles" || key === "occupancy") { + return (val * 100).toFixed(1) + "%"; + } + return String(val); +} diff --git a/assets/js/ui/modal.js b/assets/js/ui/modal.js index e110764..2fd8491 100644 --- a/assets/js/ui/modal.js +++ b/assets/js/ui/modal.js @@ -1,2 +1,29 @@ -// Phase 5: country detail modal -export function showCountryModal(countryData) {} +export function showCountryModal(countryData) { + document.getElementById("country-modal-title").textContent = countryData.country; + document.getElementById("country-modal-body").innerHTML = ` +

Prison Population: ${_fmt(countryData.pop, "pop")}

+

Rate per 100,000 Citizens: ${_fmt(countryData.rate, "rate")}

+

Females: ${_fmt(countryData.females, "pct")}

+

Juveniles: ${_fmt(countryData.juveniles, "pct")}

+

Occupancy: ${_fmt(countryData.occupancy, "pct")}

+

Government: ${_fmtGov(countryData.government)}

+ `; + const el = document.getElementById("countryModal"); + (bootstrap.Modal.getInstance(el) || new bootstrap.Modal(el)).show(); +} + +function _fmt(val, type) { + if (val === "--" || val === null || val === undefined || (typeof val === "number" && isNaN(val))) { + return "Information not available."; + } + if (type === "pop") return Number(val).toLocaleString("en"); + if (type === "rate") return String(val); + if (type === "pct") return (val * 100).toFixed(2) + " %"; + return String(val); +} + +function _fmtGov(val) { + return (!val || val === "--") + ? "Information not available." + : val; +} -- cgit v1.2.3