@@ -267,6 +267,8 @@ <h2>Forecast horizon vs performance</h2>
267267 < p class ="muted " id ="horizon-curves-meta "> </ p >
268268 < div class ="curve-chart-wrap " id ="horizon-curves-chart "> </ div >
269269 < div class ="curve-legend " id ="horizon-curves-legend "> </ div >
270+ < h3 style ="font-size:1rem;margin:1rem 0 0.5rem; "> By family (median across countries)</ h3 >
271+ < div class ="table-scroll " id ="horizon-curves-table-wrap "> </ div >
270272 < p class ="muted note " id ="horizon-curves-metric-note "> </ p >
271273 </ div >
272274
@@ -791,11 +793,14 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
791793 const hscMeta = document . getElementById ( "horizon-curves-meta" ) ;
792794 const hscChart = document . getElementById ( "horizon-curves-chart" ) ;
793795 const hscLegend = document . getElementById ( "horizon-curves-legend" ) ;
796+ const hscTableWrap = document . getElementById ( "horizon-curves-table-wrap" ) ;
794797 const hscMetricNote = document . getElementById ( "horizon-curves-metric-note" ) ;
795798 const hscCropSelect = document . getElementById ( "horizon-curves-crop" ) ;
796799 const hscMetricSelect = document . getElementById ( "horizon-curves-metric" ) ;
797800 let hscCrop = "all" ;
798801 let hscMetric = "nrmse" ;
802+ let hscTableSort = "family" ;
803+ let hscTableDir = "asc" ;
799804
800805 hscNote . textContent = HSC . note || "Horizon skill curves require at least two collected horizons." ;
801806
@@ -825,7 +830,13 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
825830 const slice = ( HSC . by_crop || { } ) [ hscCrop ] || { families : [ ] , n_countries : 0 , countries : [ ] } ;
826831 const horizons = HSC . horizons || [ ] ;
827832 const metricNotes = HSC . metric_notes || { } ;
828- hscMetricNote . textContent = metricNotes [ hscMetric ] || "" ;
833+ const plotFamilies = ( slice . families || [ ] ) . filter ( fam => fam . plot !== false ) ;
834+ const tableFamilies = slice . families || [ ] ;
835+ let metricNote = metricNotes [ hscMetric ] || "" ;
836+ if ( HSC . plot_excluded_note && ( slice . families || [ ] ) . some ( fam => fam . eos_only ) ) {
837+ metricNote = metricNote ? `${ metricNote } ${ HSC . plot_excluded_note } ` : HSC . plot_excluded_note ;
838+ }
839+ hscMetricNote . textContent = metricNote ;
829840
830841 const reps = HSC . representatives_summary || "" ;
831842 const excluded = slice . excluded_countries || [ ] ;
@@ -837,9 +848,10 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
837848 }
838849 hscMeta . textContent = meta ;
839850
840- if ( ! slice . families || ! slice . families . length || horizons . length < 2 ) {
851+ if ( ! tableFamilies . length || horizons . length < 2 ) {
841852 hscChart . innerHTML = `<p class="muted">Not enough paired horizon data for this crop.</p>` ;
842853 hscLegend . innerHTML = "" ;
854+ hscTableWrap . innerHTML = `<p class="muted">No horizon table for this crop.</p>` ;
843855 return ;
844856 }
845857
@@ -851,8 +863,12 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
851863 const nX = horizons . length ;
852864 const xAt = ( i ) => pad . l + ( nX <= 1 ? innerW / 2 : ( i / ( nX - 1 ) ) * innerW ) ;
853865
866+ if ( ! plotFamilies . length ) {
867+ hscChart . innerHTML = `<p class="muted">No multi-horizon families to plot for this crop.</p>` ;
868+ hscLegend . innerHTML = "" ;
869+ } else {
854870 const allVals = [ ] ;
855- for ( const fam of slice . families ) {
871+ for ( const fam of plotFamilies ) {
856872 for ( const pt of fam . points || [ ] ) {
857873 const v = hscMetricValue ( pt , hscMetric ) ;
858874 if ( v !== null && v !== undefined && ! Number . isNaN ( v ) ) allVals . push ( v ) ;
@@ -864,8 +880,7 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
864880 if ( ! allVals . length ) {
865881 hscChart . innerHTML = `<p class="muted">No metric values for this selection.</p>` ;
866882 hscLegend . innerHTML = "" ;
867- return ;
868- }
883+ } else {
869884
870885 const yMinRaw = Math . min ( ...allVals ) ;
871886 const yMaxRaw = Math . max ( ...allVals ) ;
@@ -892,7 +907,7 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
892907 svg += `<text x="${ pad . l + innerW / 2 } " y="${ height - 6 } " text-anchor="middle" font-size="11" fill="#1f2328">Season progress (more of season observed →)</text>` ;
893908 svg += `<text x="14" y="${ pad . t + innerH / 2 } " text-anchor="middle" font-size="11" fill="#1f2328" transform="rotate(-90 14 ${ pad . t + innerH / 2 } )">${ yLabel } </text>` ;
894909
895- for ( const fam of slice . families ) {
910+ for ( const fam of plotFamilies ) {
896911 const color = fam . color || "#457b9d" ;
897912 const pts = ( fam . points || [ ] ) . map ( ( pt , i ) => {
898913 const hzIdx = horizons . findIndex ( h => h . id === pt . horizon ) ;
@@ -922,9 +937,61 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per country × model</h3
922937 svg += "</svg>" ;
923938 hscChart . innerHTML = svg ;
924939
925- hscLegend . innerHTML = slice . families . map ( fam =>
940+ hscLegend . innerHTML = plotFamilies . map ( fam =>
926941 `<span><span class="swatch" style="background:${ fam . color } "></span>${ fam . family } (${ fam . display } )</span>`
927942 ) . join ( "" ) ;
943+ }
944+ }
945+
946+ const tableRows = tableFamilies . map ( fam => {
947+ const byHz = Object . fromEntries ( ( fam . points || [ ] ) . map ( pt => [ pt . horizon , pt ] ) ) ;
948+ const row = {
949+ family : fam . family ,
950+ model : fam . display || fam . model ,
951+ eos_only : ! ! fam . eos_only ,
952+ } ;
953+ for ( const hz of horizons ) {
954+ const pt = byHz [ hz . id ] ;
955+ row [ `hz_${ hz . id } ` ] = pt ? hscMetricValue ( pt , hscMetric ) : null ;
956+ }
957+ return row ;
958+ } ) ;
959+ const sortedRows = [ ...tableRows ] . sort ( ( a , b ) => {
960+ const cmp = compareSortValues ( a , b , hscTableSort ) ;
961+ return hscTableDir === "asc" ? cmp : - cmp ;
962+ } ) ;
963+ const tableCols = [
964+ { key : "family" , label : "Family" , sortable : true } ,
965+ { key : "model" , label : "Model" , sortable : true } ,
966+ ...horizons . map ( hz => ( {
967+ key : `hz_${ hz . id } ` ,
968+ label : hz . label || hz . id ,
969+ sortable : true ,
970+ format : ( v , row ) => {
971+ if ( v === null || v === undefined ) {
972+ return row . eos_only ? '<span class="muted">EOS only</span>' : "—" ;
973+ }
974+ return fmt ( v ) ;
975+ } ,
976+ bg : ( v ) => ( hscMetric === "nrmse" ? fixedNrmseBg ( v ) : null ) ,
977+ } ) ) ,
978+ ] ;
979+ renderSortableTable (
980+ hscTableWrap ,
981+ tableCols ,
982+ sortedRows ,
983+ hscTableSort ,
984+ hscTableDir ,
985+ ( key ) => {
986+ if ( hscTableSort === key ) {
987+ hscTableDir = hscTableDir === "asc" ? "desc" : "asc" ;
988+ } else {
989+ hscTableSort = key ;
990+ hscTableDir = key === "family" || key === "model" ? "asc" : "desc" ;
991+ }
992+ renderHorizonSkillCurves ( ) ;
993+ } ,
994+ ) ;
928995 }
929996
930997 hscCropSelect . addEventListener ( "change" , ( ) => {
0 commit comments