summaryrefslogtreecommitdiff
path: root/assets/js/ui
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/ui
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/ui')
-rw-r--r--assets/js/ui/compare.js72
-rw-r--r--assets/js/ui/controls.js59
-rw-r--r--assets/js/ui/modal.js31
3 files changed, 155 insertions, 7 deletions
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]) => `<option value="${key}">${val.country}</option>`)
+ .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 =
+ "<div class='alert alert-danger'>Please select two different countries.</div>";
+ 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]) =>
+ `<tr><td><b>${label}</b></td><td>${v1}</td><td>${v2}</td></tr>`
+ )
+ .join("");
+}
+
+function _fmt(val, type) {
+ if (val === "--" || val === null || val === undefined || (typeof val === "number" && isNaN(val))) {
+ return "<span class='text-danger'>N/A</span>";
+ }
+ 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 === "--") ? "<span class='text-danger'>N/A</span>" : 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 = `
+ <p><b>Prison Population:</b> ${_fmt(countryData.pop, "pop")}</p>
+ <p><b>Rate per 100,000 Citizens:</b> ${_fmt(countryData.rate, "rate")}</p>
+ <p><b>Females:</b> ${_fmt(countryData.females, "pct")}</p>
+ <p><b>Juveniles:</b> ${_fmt(countryData.juveniles, "pct")}</p>
+ <p><b>Occupancy:</b> ${_fmt(countryData.occupancy, "pct")}</p>
+ <p><b>Government:</b> ${_fmtGov(countryData.government)}</p>
+ `;
+ 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 "<span class='text-danger'>Information not available.</span>";
+ }
+ 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 === "--")
+ ? "<span class='text-danger'>Information not available.</span>"
+ : val;
+}