From 41cfdc6b0085a3aa7808777183d4bb7527b35860 Mon Sep 17 00:00:00 2001 From: Koen van Greevenbroek Date: Thu, 25 Jun 2026 14:01:22 -0700 Subject: [PATCH] feat: add per-region hover breakdown to carbon-dial maps Hovering a region on the cropland/pasture maps now shows a tooltip with the region's land use: cropland split by crop group (Mha and share) and grazing area, with the hovered region outlined. Headers use full country names (via pycountry, shipped in surrogate.json meta) instead of ISO3 codes. PCA-reconstructed area fields are clipped to >= 0 since the linear decoder can produce small negative values. --- docs/_static/carbon-dial/app.js | 78 +++++++++++++++++++- docs/_static/carbon-dial/export_surrogate.py | 21 ++++++ docs/_static/carbon-dial/index.html | 4 +- docs/_static/carbon-dial/style.css | 29 ++++++++ 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/docs/_static/carbon-dial/app.js b/docs/_static/carbon-dial/app.js index ad5a4a4c..d49d7487 100644 --- a/docs/_static/carbon-dial/app.js +++ b/docs/_static/carbon-dial/app.js @@ -191,7 +191,7 @@ function init(data, geo) { if (col < 0) { vals[fi] = 0; continue; } let s = f.mean[col]; for (let c = 0; c < f.nComp; c++) s += scores[c] * f.comp[c * f.nKeys + col]; - vals[fi] = s; + vals[fi] = Math.max(0, s); // PCA reconstruction can dip below 0; area >= 0 } return vals; } @@ -210,7 +210,9 @@ function init(data, geo) { regionIntensity[i] = Math.max(0, Math.min(1, crop[i] / (meta.cropMaxFrac * landByFeat[i]))); regionPasture[i] = Math.max(0, Math.min(1, past[i] / (meta.pastureMaxFrac * landByFeat[i]))); } - return { regionGroup, regionIntensity, regionPasture }; + // crop/past/groupAreas (raw Mha, aligned to geo features) feed the hover + // tooltip's per-region land-use breakdown; the region* arrays drive fills. + return { regionGroup, regionIntensity, regionPasture, cropMha: crop, pastMha: past, groupAreas }; } // Full scenario object consumed by the panels. @@ -245,6 +247,77 @@ function init(data, geo) { const cropPaths = buildMap("#map"); const pasturePaths = buildMap("#mapPasture"); + // ---- per-region hover tooltip (land-use breakdown) ---- + // The maps are land-use only (emissions/diet/feed/cost are global scalars), + // so the tooltip reports cropland (split by crop group) and grazing area for + // the hovered region, read live from the last rendered scenario. + const tip = d3.select("body").append("div").attr("class", "map-tip").style("display", "none"); + let lastScenario = null; + const featIndex = new Map(geo.features.map((f, i) => [f, i])); + const fmtMha = (v) => (v >= 10 ? v.toFixed(0) : v >= 1 ? v.toFixed(1) : v.toFixed(2)); + const pctOf = (v, tot) => (tot > 0 ? Math.round(100 * v / tot) : 0); + + const countryNames = meta.countryNames || {}; + function tipHead(d) { + const name = countryNames[d.properties.country] || d.properties.country; + return `
${name}` + + `${d.properties.region}
`; + } + function tipMetric(label, v, area) { + const share = isFinite(area) ? ` ${pctOf(v, area)}% of land` : ""; + return `
${label} ${fmtMha(v)} Mha${share}
`; + } + function tipCropland(i, d) { + if (!lastScenario) return ""; + const total = lastScenario.cropMha[i]; + const rows = meta.mapGroups + .map((g, gi) => ({ name: g.name, color: g.color, v: lastScenario.groupAreas[gi][i] })) + .filter((r) => r.v > 1e-4).sort((a, b) => b.v - a.v); + let html = tipHead(d) + tipMetric("Cropland", total, landByFeat[i]); + if (rows.length) { + html += `
` + rows.map((r) => + `
` + + `${r.name}` + + `${fmtMha(r.v)}` + + `
`).join("") + `
`; + } else { + html += `
No cropland
`; + } + return html; + } + function tipPasture(i, d) { + if (!lastScenario) return ""; + return tipHead(d) + tipMetric("Pasture & grazing", lastScenario.pastMha[i], landByFeat[i]); + } + function attachTip(paths, builder) { + // Outline the hovered region: dark stroke + raise so it sits above + // neighbours; restore the thin white border on leave. + let hovered = null; + const clearHover = () => { + if (hovered) d3.select(hovered).attr("stroke", "#fff").attr("stroke-width", 0.2); + hovered = null; + }; + paths + .style("cursor", "default") + .on("mousemove", (event, d) => { + const node = event.currentTarget; + if (node !== hovered) { + clearHover(); + hovered = node; + d3.select(node).attr("stroke", "#1f2a26").attr("stroke-width", 1.2).raise(); + } + tip.html(builder(featIndex.get(d), d)).style("display", "block"); + const pad = 14, w = tip.node().offsetWidth, h = tip.node().offsetHeight; + let x = event.clientX + pad, y = event.clientY + pad; + if (x + w > window.innerWidth) x = event.clientX - pad - w; + if (y + h > window.innerHeight) y = event.clientY - pad - h; + tip.style("left", `${x}px`).style("top", `${y}px`); + }) + .on("mouseleave", () => { clearHover(); tip.style("display", "none"); }); + } + attachTip(cropPaths, tipCropland); + attachTip(pasturePaths, tipPasture); + const legend = d3.select("#legend"); meta.mapGroups.forEach((g) => { const it = legend.append("div").attr("class", "legend__item"); @@ -418,6 +491,7 @@ function init(data, geo) { function render() { const s = scenario(mode, price, yll); + lastScenario = s; // hover tooltip reads per-region land use from this priceValue.textContent = Math.round(price); if (yllValue) yllValue.textContent = yll ? Math.round(yll).toLocaleString() : "–"; dietHint.textContent = (dietUnit === "kcal" ? "kcal / person / day" : "g / person / day") diff --git a/docs/_static/carbon-dial/export_surrogate.py b/docs/_static/carbon-dial/export_surrogate.py index 4ac393ad..f3fbf1f8 100644 --- a/docs/_static/carbon-dial/export_surrogate.py +++ b/docs/_static/carbon-dial/export_surrogate.py @@ -493,6 +493,26 @@ def group_total(prefix, factor): } +def country_names(): + """ISO3 -> human-readable country name for every code in regions.geojson. + + The dial's region tooltip shows full names instead of bare alpha-3 codes; + the short ``common_name`` is preferred over the formal ``name`` when present + (e.g. "Bolivia" over "Bolivia, Plurinational State of"). + """ + import pycountry + + geo = json.loads((OUT_DIR / "regions.geojson").read_text()) + codes = {f["properties"]["country"] for f in geo["features"]} + names = {} + for c in codes: + rec = pycountry.countries.get(alpha_3=c) + if rec is None: + raise ValueError(f"no country name for ISO3 code {c!r}") + names[c] = getattr(rec, "common_name", None) or rec.name + return names + + def region_land_areas(): """Per-region land area (Mha) from the widget's regions.geojson. @@ -608,6 +628,7 @@ def main(): meta = {k: v for k, v in common.items() if k not in ("pop", "food2group")} meta["modes"] = list(modes.keys()) meta["regionArea"] = {k: float(v) for k, v in region_area.items()} + meta["countryNames"] = country_names() meta["cropMaxFrac"] = crop_max_frac meta["pastureMaxFrac"] = pasture_max_frac out = {"meta": meta, "modes": modes} diff --git a/docs/_static/carbon-dial/index.html b/docs/_static/carbon-dial/index.html index 65e92c51..0abedb46 100644 --- a/docs/_static/carbon-dial/index.html +++ b/docs/_static/carbon-dial/index.html @@ -78,7 +78,7 @@

What if we put a price on food's carbon?

Cropland use - colour = main crop group · fade = cropland per land area + colour = main crop group · fade = cropland per land area · hover a region for the breakdown
@@ -86,7 +86,7 @@

What if we put a price on food's carbon?

Pasture & grazing land - shade = pasture per land area + shade = pasture per land area · hover a region for details
diff --git a/docs/_static/carbon-dial/style.css b/docs/_static/carbon-dial/style.css index 9f3e66a8..0691b67f 100644 --- a/docs/_static/carbon-dial/style.css +++ b/docs/_static/carbon-dial/style.css @@ -193,6 +193,35 @@ input[type="range"]::-moz-range-thumb { .map-wrap { width: 100%; } #map { width: 100%; height: auto; display: block; } +#mapPasture { width: 100%; height: auto; display: block; } + +/* per-region hover tooltip: land-use breakdown */ +.map-tip { + position: fixed; z-index: 1000; pointer-events: none; + min-width: 150px; max-width: 240px; + background: var(--card); color: var(--ink); + border: 1px solid var(--line); border-radius: 10px; + box-shadow: var(--shadow); padding: 9px 11px; + font-family: var(--sans); font-size: 0.74rem; line-height: 1.35; +} +.map-tip__head { + display: flex; align-items: baseline; justify-content: space-between; + gap: 8px; font-weight: 700; margin-bottom: 5px; +} +.map-tip__head span { font-weight: 500; font-size: 0.66rem; color: #9aa8a2; } +.map-tip__metric { color: var(--muted); margin-bottom: 6px; } +.map-tip__metric b { color: var(--ink); font-variant-numeric: tabular-nums; } +.map-tip__metric span { color: #9aa8a2; } +.map-tip__rows { display: grid; gap: 3px; } +.map-tip__row { + display: grid; grid-template-columns: 11px 1fr auto auto; gap: 6px; + align-items: center; font-variant-numeric: tabular-nums; +} +.map-tip__sw { width: 11px; height: 11px; border-radius: 3px; } +.map-tip__name { color: var(--ink); white-space: nowrap; } +.map-tip__val { color: var(--ink); font-weight: 600; } +.map-tip__share { color: #9aa8a2; min-width: 30px; text-align: right; } +.map-tip__empty { color: #9aa8a2; font-style: italic; } .legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 8px; } .legend__item { display: flex; align-items: center; gap: 5px; font-size: 0.74rem; color: var(--muted); }