33< head >
44 < meta charset ="utf-8 " />
55 < title > CY-Bench Multi-Model Dashboard</ title >
6+ < script src ="https://cdn.jsdelivr.net/npm/d3@7 "> </ script >
67 < style >
78 : root {
89 --bg : # f6f8fa ;
186187 min-height : 120px ;
187188 background : # fff ;
188189 }
190+ .region-map-wrap { width : 100% ; overflow : hidden; min-height : 280px ; }
191+ .region-map-wrap svg { display : block; width : 100% ; height : auto; }
192+ .region-map-wrap .region {
193+ stroke : # fff ;
194+ stroke-width : 0.6px ;
195+ transition : fill 0.12s ;
196+ }
197+ .region-map-legend {
198+ display : flex;
199+ flex-wrap : wrap;
200+ align-items : center;
201+ gap : 0.5rem 0.75rem ;
202+ margin-top : 0.35rem ;
203+ font-size : 0.78rem ;
204+ color : var (--muted );
205+ justify-content : center;
206+ }
207+ .region-map-gradient {
208+ display : inline-block;
209+ width : 140px ;
210+ height : 10px ;
211+ border-radius : 3px ;
212+ border : 1px solid var (--border );
213+ background : linear-gradient (to right, # ffffcc, # fd8d3c, # 800026 );
214+ }
189215 </ style >
190216</ head >
191217< body >
@@ -230,6 +256,18 @@ <h3>Drill-down plots</h3>
230256 < button data-panel ="temporal "> Temporal</ button >
231257 </ div >
232258 < div id ="missing-note " class ="muted "> </ div >
259+ < div id ="map-grid-dynamic " class ="img-grid two " style ="display:none; ">
260+ < div >
261+ < div id ="map-caption-left-dyn " class ="map-caption muted "> Ground truth (mean yield)</ div >
262+ < div id ="region-map-actual " class ="region-map-wrap "> </ div >
263+ < div class ="region-map-legend "> < span class ="region-map-gradient " aria-hidden ="true "> </ span > < span id ="region-map-legend-actual "> —</ span > </ div >
264+ </ div >
265+ < div >
266+ < div id ="map-caption-right-dyn " class ="map-caption muted "> Model prediction</ div >
267+ < div id ="region-map-pred " class ="region-map-wrap "> </ div >
268+ < div class ="region-map-legend "> < span class ="region-map-gradient " aria-hidden ="true "> </ span > < span id ="region-map-legend-pred "> —</ span > </ div >
269+ </ div >
270+ </ div >
233271 < div id ="img-grid " class ="img-grid two ">
234272 < div >
235273 < div id ="map-caption-left " class ="map-caption muted "> Ground truth (mean yield)</ div >
@@ -245,6 +283,7 @@ <h3>Drill-down plots</h3>
245283
246284 < script >
247285 const DATA = __DATA_JSON__ ;
286+ const MAP_DATA = __MAP_DATA_JSON__ ;
248287 const coverageInfo = document . getElementById ( "coverage-info" ) ;
249288 const heatmapWrap = document . getElementById ( "heatmap-wrap" ) ;
250289 const datasetFilter = document . getElementById ( "dataset-filter" ) ;
@@ -259,8 +298,134 @@ <h3>Drill-down plots</h3>
259298 const mapCaptionRight = document . getElementById ( "map-caption-right" ) ;
260299 const drillImageA = document . getElementById ( "drill-image-a" ) ;
261300 const drillImageB = document . getElementById ( "drill-image-b" ) ;
301+ const mapGridDynamic = document . getElementById ( "map-grid-dynamic" ) ;
302+ const regionMapActual = document . getElementById ( "region-map-actual" ) ;
303+ const regionMapPred = document . getElementById ( "region-map-pred" ) ;
304+ const regionMapLegendActual = document . getElementById ( "region-map-legend-actual" ) ;
305+ const regionMapLegendPred = document . getElementById ( "region-map-legend-pred" ) ;
262306 const panelButtons = Array . from ( document . querySelectorAll ( ".toolbar button" ) ) ;
263307
308+ const HAS_DYNAMIC_MAPS = Boolean (
309+ MAP_DATA && MAP_DATA . datasets && Object . keys ( MAP_DATA . datasets ) . length
310+ && MAP_DATA . geojson_by_country && Object . keys ( MAP_DATA . geojson_by_country ) . length
311+ ) ;
312+ const geoCache = new Map ( ) ;
313+ const REGION_MAP_WIDTH = 520 ;
314+ const REGION_MAP_HEIGHT = 420 ;
315+ const REGION_MAP_MARGIN = 10 ;
316+ const REGION_FILL_MISSING = "#f0f0f0" ;
317+
318+ function fmtMap ( v , digits = 2 ) {
319+ if ( v === null || v === undefined || Number . isNaN ( v ) ) return "—" ;
320+ return Number ( v ) . toFixed ( digits ) ;
321+ }
322+
323+ async function loadCountryGeojson ( country ) {
324+ if ( geoCache . has ( country ) ) return geoCache . get ( country ) ;
325+ const href = ( MAP_DATA . geojson_by_country || { } ) [ country ] ;
326+ if ( ! href ) return null ;
327+ const geo = await d3 . json ( href ) ;
328+ const features = ( geo . features || [ ] ) . map ( f => ( {
329+ ...f ,
330+ properties : { ...f . properties , loc : String ( f . properties . loc ?? "" ) } ,
331+ } ) ) ;
332+ geoCache . set ( country , features ) ;
333+ return features ;
334+ }
335+
336+ function yieldColor ( value , extent ) {
337+ if ( value == null || ! extent ) return REGION_FILL_MISSING ;
338+ const [ lo , hi ] = extent ;
339+ if ( hi <= lo ) return d3 . interpolateOrRd ( 0.5 ) ;
340+ const t = Math . max ( 0 , Math . min ( 1 , ( value - lo ) / ( hi - lo ) ) ) ;
341+ return d3 . interpolateOrRd ( t ) ;
342+ }
343+
344+ function sharedYieldExtent ( actualMap , predMap ) {
345+ const vals = [
346+ ...Object . values ( actualMap || { } ) ,
347+ ...Object . values ( predMap || { } ) ,
348+ ] . filter ( v => v != null && ! Number . isNaN ( v ) ) ;
349+ if ( ! vals . length ) return null ;
350+ const lo = Math . min ( ...vals ) ;
351+ const hi = Math . max ( ...vals ) ;
352+ const pad = ( hi - lo ) * 0.05 || 0.5 ;
353+ return [ lo - pad , hi + pad ] ;
354+ }
355+
356+ function renderRegionChoropleth ( container , features , valueByLoc , extent , title ) {
357+ container . innerHTML = "" ;
358+ if ( ! features || ! features . length ) {
359+ container . innerHTML = `<p class="muted">${ title } : no geometry.</p>` ;
360+ return ;
361+ }
362+ const svg = d3 . select ( container ) . append ( "svg" )
363+ . attr ( "viewBox" , `0 0 ${ REGION_MAP_WIDTH } ${ REGION_MAP_HEIGHT } ` )
364+ . attr ( "role" , "img" )
365+ . attr ( "aria-label" , title ) ;
366+ const projection = d3 . geoMercator ( ) . fitExtent (
367+ [ [ REGION_MAP_MARGIN , REGION_MAP_MARGIN ] ,
368+ [ REGION_MAP_WIDTH - REGION_MAP_MARGIN , REGION_MAP_HEIGHT - REGION_MAP_MARGIN ] ] ,
369+ { type : "FeatureCollection" , features } ,
370+ ) ;
371+ const path = d3 . geoPath ( projection ) ;
372+ svg . selectAll ( "path.region" )
373+ . data ( features )
374+ . join ( "path" )
375+ . attr ( "class" , "region" )
376+ . attr ( "d" , path )
377+ . attr ( "fill" , d => {
378+ const loc = d . properties . loc ;
379+ const v = valueByLoc ? valueByLoc [ loc ] : null ;
380+ return yieldColor ( v , extent ) ;
381+ } )
382+ . attr ( "stroke" , "#fff" )
383+ . selectAll ( "title" )
384+ . data ( d => [ d ] )
385+ . join ( "title" )
386+ . text ( d => {
387+ const loc = d . properties . loc ;
388+ const v = valueByLoc ? valueByLoc [ loc ] : null ;
389+ return v == null ? `${ loc } : no data` : `${ loc } : ${ fmtMap ( v ) } ` ;
390+ } ) ;
391+ }
392+
393+ async function renderDynamicMaps ( dataset , model ) {
394+ const slice = ( MAP_DATA . datasets || { } ) [ dataset ] ;
395+ if ( ! slice ) return false ;
396+ const country = slice . country ;
397+ const features = await loadCountryGeojson ( country ) ;
398+ if ( ! features ) return false ;
399+ const actual = slice . actual || { } ;
400+ const pred = ( slice . models || { } ) [ model ] || { } ;
401+ const extent = sharedYieldExtent ( actual , pred ) ;
402+ renderRegionChoropleth (
403+ regionMapActual ,
404+ features ,
405+ actual ,
406+ extent ,
407+ "Ground truth (mean yield)" ,
408+ ) ;
409+ renderRegionChoropleth (
410+ regionMapPred ,
411+ features ,
412+ pred ,
413+ extent ,
414+ `Model prediction (${ model } )` ,
415+ ) ;
416+ if ( extent ) {
417+ const label = `${ fmtMap ( extent [ 0 ] ) } → ${ fmtMap ( extent [ 1 ] ) } ` ;
418+ regionMapLegendActual . textContent = label ;
419+ regionMapLegendPred . textContent = label ;
420+ }
421+ return true ;
422+ }
423+
424+ function showMapMode ( useDynamic ) {
425+ mapGridDynamic . style . display = useDynamic ? "" : "none" ;
426+ imgGrid . style . display = useDynamic ? "none" : "" ;
427+ }
428+
264429 const METRIC_COLUMNS = [
265430 { key : "region_year_r2" , label : "R²" , group : "Region-Year" , higherBetter : true , title : "R² on all region-year rows (absolute prediction quality)" } ,
266431 { key : "region_year_nrmse" , label : "NRMSE" , group : "Region-Year" , higherBetter : false , title : "Normalized RMSE on absolute yields" } ,
@@ -652,6 +817,23 @@ <h3>Drill-down plots</h3>
652817 missingNote . textContent = "" ;
653818
654819 if ( activePanel === "maps" ) {
820+ if ( HAS_DYNAMIC_MAPS && ( MAP_DATA . datasets || { } ) [ selectedCell . dataset ] ) {
821+ showMapMode ( true ) ;
822+ renderDynamicMaps ( selectedCell . dataset , selectedCell . model ) . then ( ok => {
823+ if ( ! ok ) {
824+ showMapMode ( false ) ;
825+ missingNote . textContent =
826+ "Could not render dynamic maps (missing geometry or preds). "
827+ + "Re-run collect to export preds/ and ensure polygons exist." ;
828+ }
829+ } ) . catch ( err => {
830+ showMapMode ( false ) ;
831+ missingNote . textContent = `Map error: ${ err } ` ;
832+ } ) ;
833+ return ;
834+ }
835+
836+ showMapMode ( false ) ;
655837 imgGrid . classList . add ( "two" ) ;
656838 drillColB . style . display = "" ;
657839 mapCaptionLeft . style . display = "" ;
@@ -665,14 +847,15 @@ <h3>Drill-down plots</h3>
665847 drillImageA . removeAttribute ( "src" ) ;
666848 drillImageB . removeAttribute ( "src" ) ;
667849 missingNote . textContent =
668- "No map images found. Re-run collect with plot enabled ." ;
850+ "No map images found. Re-run collect with -- plot, or ensure preds/ exists for dynamic maps ." ;
669851 return ;
670852 }
671853 if ( actual ) drillImageA . src = actual ; else drillImageA . removeAttribute ( "src" ) ;
672854 if ( pred ) drillImageB . src = pred ; else drillImageB . removeAttribute ( "src" ) ;
673855 return ;
674856 }
675857
858+ showMapMode ( false ) ;
676859 imgGrid . classList . remove ( "two" ) ;
677860 drillColB . style . display = "none" ;
678861 mapCaptionLeft . style . display = "none" ;
0 commit comments