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); }