Skip to content

Commit a381619

Browse files
dashboard
1 parent 65f5a14 commit a381619

3 files changed

Lines changed: 173 additions & 54 deletions

File tree

cybench/runs/viz/dashboard_template.html

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,15 @@
190190
.region-map-wrap { width: 100%; overflow: hidden; min-height: 280px; }
191191
.region-map-wrap svg { display: block; width: 100%; height: auto; }
192192
.region-map-wrap .region {
193-
stroke: #fff;
194-
stroke-width: 0.6px;
193+
stroke: #000;
194+
stroke-width: 0.2px;
195195
transition: fill 0.12s;
196196
}
197+
.region-map-wrap .world-country {
198+
fill: #d3d3d3;
199+
stroke: #000;
200+
stroke-width: 0.15px;
201+
}
197202
.region-map-legend {
198203
display: flex;
199204
flex-wrap: wrap;
@@ -210,7 +215,7 @@
210215
height: 10px;
211216
border-radius: 3px;
212217
border: 1px solid var(--border);
213-
background: linear-gradient(to right, #440154, #31688e, #35b779, #fde725);
218+
background: linear-gradient(to right, #f7fbff, #c6dbef, #6baed6, #2171b5, #08306b);
214219
}
215220
.map-year-controls {
216221
display: flex;
@@ -345,22 +350,43 @@ <h3>Drill-down plots</h3>
345350
let mapYearSelection = MAP_YEAR_MEAN;
346351
let mapFeaturesCache = null;
347352
let mapFeaturesCacheCountry = null;
353+
let mapWorldFeaturesCache = null;
348354

349355
const HAS_DYNAMIC_MAPS = Boolean(
350356
MAP_DATA && MAP_DATA.datasets && Object.keys(MAP_DATA.datasets).length
351357
&& MAP_DATA.geojson_by_country && Object.keys(MAP_DATA.geojson_by_country).length
352358
);
353359
const geoCache = new Map();
360+
const worldGeoCache = { href: null, features: null };
354361
const REGION_MAP_WIDTH = 520;
355362
const REGION_MAP_HEIGHT = 420;
356363
const REGION_MAP_MARGIN = 10;
357-
const REGION_FILL_MISSING = "#f0f0f0";
364+
const REGION_FILL_MISSING = "#d3d3d3";
358365

359366
function fmtMap(v, digits = 2) {
360367
if (v === null || v === undefined || Number.isNaN(v)) return "—";
361368
return Number(v).toFixed(digits);
362369
}
363370

371+
async function loadWorldGeojson() {
372+
const href = MAP_DATA.world_geojson_href;
373+
if (!href) return null;
374+
if (worldGeoCache.href === href && worldGeoCache.features) {
375+
return worldGeoCache.features;
376+
}
377+
try {
378+
const geo = await d3.json(href);
379+
if (!geo || !geo.features) return null;
380+
const features = geo.features.map(f => rewindFeatureForSvg({ ...f }));
381+
worldGeoCache.href = href;
382+
worldGeoCache.features = features;
383+
return features;
384+
} catch (err) {
385+
console.warn("World GeoJSON load failed:", href, err);
386+
return null;
387+
}
388+
}
389+
364390
async function loadCountryGeojson(country) {
365391
if (geoCache.has(country)) return geoCache.get(country);
366392
const href = (MAP_DATA.geojson_by_country || {})[country];
@@ -422,22 +448,17 @@ <h3>Drill-down plots</h3>
422448
};
423449
}
424450

425-
function featuresForDataset(features, ...valueMaps) {
426-
const locs = new Set();
427-
for (const m of valueMaps) {
428-
if (!m) continue;
429-
Object.keys(m).forEach(k => locs.add(k));
430-
}
431-
if (!locs.size) return features || [];
432-
return (features || []).filter(f => locs.has(f.properties.loc));
451+
function regionsWithData(features, valueByLoc) {
452+
if (!features || !valueByLoc) return [];
453+
return features.filter(f => valueByLoc[f.properties.loc] != null);
433454
}
434455

435456
function yieldColor(value, extent) {
436457
if (value == null || !extent) return REGION_FILL_MISSING;
437458
const [lo, hi] = extent;
438-
if (hi <= lo) return d3.interpolateViridis(0.5);
459+
if (hi <= lo) return d3.interpolateBlues(0.55);
439460
const t = Math.max(0, Math.min(1, (value - lo) / (hi - lo)));
440-
return d3.interpolateViridis(t);
461+
return d3.interpolateBlues(0.15 + t * 0.85);
441462
}
442463

443464
function sharedYieldExtent(actualMap, predMap) {
@@ -464,18 +485,22 @@ <h3>Drill-down plots</h3>
464485

465486
function renderRegionChoropleth(
466487
container,
467-
features,
488+
regionFeatures,
468489
valueByLoc,
469490
extent,
470491
title,
492+
worldFeatures,
471493
) {
472494
container.innerHTML = "";
473-
if (!features || !features.length) {
495+
if (!regionFeatures || !regionFeatures.length) {
474496
container.innerHTML = `<p class="muted">${title}: no map geometry.</p>`;
475497
return;
476498
}
477-
let fitFeatures = featuresForProjectionFit(features);
478-
if (!fitFeatures.length) fitFeatures = features;
499+
const dataFeatures = regionsWithData(regionFeatures, valueByLoc);
500+
let fitFeatures = featuresForProjectionFit(
501+
dataFeatures.length ? dataFeatures : regionFeatures,
502+
);
503+
if (!fitFeatures.length) fitFeatures = regionFeatures;
479504
const svg = d3.select(container).append("svg")
480505
.attr("viewBox", `0 0 ${REGION_MAP_WIDTH} ${REGION_MAP_HEIGHT}`)
481506
.attr("role", "img")
@@ -492,8 +517,15 @@ <h3>Drill-down plots</h3>
492517
.attr("width", REGION_MAP_WIDTH)
493518
.attr("height", REGION_MAP_HEIGHT)
494519
.attr("fill", "#f8f9fa");
520+
if (worldFeatures && worldFeatures.length) {
521+
svg.selectAll("path.world-country")
522+
.data(worldFeatures)
523+
.join("path")
524+
.attr("class", "world-country")
525+
.attr("d", path);
526+
}
495527
svg.selectAll("path.region")
496-
.data(features)
528+
.data(regionFeatures)
497529
.join("path")
498530
.attr("class", "region")
499531
.attr("d", path)
@@ -502,15 +534,15 @@ <h3>Drill-down plots</h3>
502534
const v = valueByLoc ? valueByLoc[loc] : null;
503535
return yieldColor(v, extent);
504536
})
505-
.attr("stroke", "#fff")
506-
.attr("stroke-width", 0.5)
507537
.selectAll("title")
508538
.data(d => [d])
509539
.join("title")
510540
.text(d => {
511541
const loc = d.properties.loc;
512542
const v = valueByLoc ? valueByLoc[loc] : null;
513-
return v == null ? loc : `${loc}: ${fmtMap(v)}`;
543+
return v == null
544+
? `${loc}: outside benchmark`
545+
: `${loc}: ${fmtMap(v)}`;
514546
});
515547
}
516548

@@ -571,8 +603,10 @@ <h3>Drill-down plots</h3>
571603
const slice = (MAP_DATA.datasets || {})[dataset];
572604
if (!slice || !mapFeaturesCache) return false;
573605
const { actual, pred } = mapValuesForSelection(slice, model, yearKey);
574-
const features = featuresForDataset(mapFeaturesCache, actual, pred);
575-
if (!features.length) return false;
606+
if (!regionsWithData(mapFeaturesCache, actual).length
607+
&& !regionsWithData(mapFeaturesCache, pred).length) {
608+
return false;
609+
}
576610
const extent = pooledMapExtent(slice, model);
577611
const yearSuffix = yearKey === MAP_YEAR_MEAN ? "mean yield" : yearKey;
578612
if (mapCaptionLeftDyn) {
@@ -583,17 +617,19 @@ <h3>Drill-down plots</h3>
583617
}
584618
renderRegionChoropleth(
585619
regionMapActual,
586-
features,
620+
mapFeaturesCache,
587621
actual,
588622
extent,
589623
`Ground truth (${yearSuffix})`,
624+
mapWorldFeaturesCache,
590625
);
591626
renderRegionChoropleth(
592627
regionMapPred,
593-
features,
628+
mapFeaturesCache,
594629
pred,
595630
extent,
596631
`Model prediction (${model}, ${yearSuffix})`,
632+
mapWorldFeaturesCache,
597633
);
598634
if (extent) {
599635
const label = `${fmtMap(extent[0])}${fmtMap(extent[1])}`;
@@ -610,7 +646,10 @@ <h3>Drill-down plots</h3>
610646
setupMapYearSlider(slice);
611647
if (mapFeaturesCacheCountry !== country) {
612648
mapFeaturesCache = await loadCountryGeojson(country);
649+
mapWorldFeaturesCache = await loadWorldGeojson();
613650
mapFeaturesCacheCountry = country;
651+
} else if (!mapWorldFeaturesCache) {
652+
mapWorldFeaturesCache = await loadWorldGeojson();
614653
}
615654
if (!mapFeaturesCache) return false;
616655
return renderDynamicMapLayers(dataset, model, mapYearSelection);

cybench/runs/viz/region_map_lib.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,70 @@ def prepare_geometry_for_geojson(geometry: BaseGeometry | None) -> BaseGeometry
4646
return geometry
4747

4848

49+
def export_country_border_geojson(
50+
country_code: str,
51+
dest: Path,
52+
*,
53+
scale: str = "50",
54+
simplify: float = 0.02,
55+
) -> Path | None:
56+
"""Write simplified admin-0 country outline (Natural Earth, default 50m like map PNGs)."""
57+
try:
58+
from cybench.util.geo import get_country_border_gdf
59+
except ImportError:
60+
return None
61+
62+
country = country_code.upper()
63+
try:
64+
gdf = get_country_border_gdf(country, scale=scale)
65+
except (FileNotFoundError, OSError, ValueError):
66+
return None
67+
68+
slim = gdf[["geometry"]].copy()
69+
slim["geometry"] = slim.geometry.simplify(simplify, preserve_topology=True)
70+
if slim.empty:
71+
return None
72+
dest.parent.mkdir(parents=True, exist_ok=True)
73+
slim.to_file(dest, driver="GeoJSON")
74+
return dest
75+
76+
77+
def export_world_context_geojson(
78+
dest: Path,
79+
*,
80+
simplify: float = 0.04,
81+
) -> Path | None:
82+
"""Simplified global admin-0 backdrop (50m Natural Earth, same source as map PNGs)."""
83+
try:
84+
import geopandas as gpd
85+
86+
from cybench.util.geo import world_shape_path, _explode_metropolitan_map_units, _normalize_world_iso
87+
except ImportError:
88+
return None
89+
90+
try:
91+
world = gpd.read_file(world_shape_path("50")).to_crs(4326)
92+
except (FileNotFoundError, OSError, ValueError):
93+
return None
94+
95+
keep = _explode_metropolitan_map_units(_normalize_world_iso(world))
96+
keep = keep[["ISO_A2", "geometry"]].copy()
97+
keep["geometry"] = keep.geometry.simplify(simplify, preserve_topology=True)
98+
if keep.empty:
99+
return None
100+
dest.parent.mkdir(parents=True, exist_ok=True)
101+
keep.to_file(dest, driver="GeoJSON")
102+
return dest
103+
104+
49105
def export_region_geojson(
50106
country_code: str,
51107
dest: Path,
52108
*,
53-
simplify: float = 0.01,
109+
simplify: float = 0.005,
54110
locations: set[str] | frozenset[str] | None = None,
55111
) -> Path | None:
56-
"""Write simplified admin-region GeoJSON for benchmark locations in one country."""
112+
"""Write simplified admin-region GeoJSON (all units unless ``locations`` is set)."""
57113
try:
58114
from cybench.util.geo import get_shapes_from_polygons
59115
except ImportError:
@@ -243,13 +299,13 @@ def build_region_map_payload(
243299

244300
return {
245301
"geojson_by_country": {cc: "" for cc in sorted(countries_needed)},
302+
"world_geojson_href": "",
246303
"yield_ranges": CROP_YIELD_RANGES,
247304
"datasets": datasets,
248305
"note": (
249-
"Maps default to multi-year regional means; use the year slider for single years. "
250-
"Map extent covers benchmark regions only. "
251-
"Colors autoscale to pooled actual+pred across all years. "
252-
"Geometry is simplified admin boundaries from cybench/data/polygons."
306+
"Maps mimic matplotlib PNGs: 50m Natural Earth backdrop, all admin units, "
307+
"Blues choropleth on benchmark regions. Use the year slider for single years. "
308+
"Colors autoscale to pooled actual+pred across all years."
253309
),
254310
}
255311

@@ -267,21 +323,21 @@ def bundle_region_map_assets(
267323
assets_dir = Path(output_dir) / assets_dirname
268324
assets_dir.mkdir(parents=True, exist_ok=True)
269325
geojson_by_country: dict[str, str] = {}
270-
locs_by_country = benchmark_locs_by_country(map_payload)
326+
327+
world_dest = assets_dir / "world_countries.geojson"
328+
world_exported = export_world_context_geojson(world_dest)
329+
world_href = f"{assets_dirname}/{world_dest.name}" if world_exported else ""
271330

272331
for country in map_payload.get("geojson_by_country", {}):
273332
dest = assets_dir / f"regions_{country}.geojson"
274-
exported = export_region_geojson(
275-
country,
276-
dest,
277-
locations=locs_by_country.get(country),
278-
)
333+
exported = export_region_geojson(country, dest)
279334
if exported is not None:
280335
geojson_by_country[country] = f"{assets_dirname}/{dest.name}"
281336

282337
return {
283338
**map_payload,
284339
"geojson_by_country": geojson_by_country,
340+
"world_geojson_href": world_href,
285341
}
286342

287343
def write_region_map_sidecar(

0 commit comments

Comments
 (0)