188188 border : 1px solid var (--border );
189189 background : linear-gradient (to right, # d73027, # fee08b, # 1a9850 );
190190 }
191- .map- export-toolbar {
191+ .export-toolbar {
192192 display : flex;
193193 flex-wrap : wrap;
194194 gap : 0.45rem ;
195195 margin-top : 0.35rem ;
196196 }
197- .map- export-toolbar button {
197+ .export-toolbar button {
198198 font : inherit;
199199 font-size : 0.85rem ;
200200 padding : 0.35rem 0.65rem ;
204204 color : var (--text );
205205 cursor : pointer;
206206 }
207- .map- export-toolbar button : hover : not (: disabled ) {
207+ .export-toolbar button : hover : not (: disabled ) {
208208 border-color : var (--accent );
209209 color : var (--accent );
210210 }
211- .map- export-toolbar button : disabled {
211+ .export-toolbar button : disabled {
212212 opacity : 0.45 ;
213213 cursor : not-allowed;
214214 }
@@ -261,6 +261,10 @@ <h2>Median across countries (family representatives)</h2>
261261 < tbody > </ tbody >
262262 </ table >
263263 </ div >
264+ < div class ="export-toolbar ">
265+ < button type ="button " id ="table-export-latex "> Download table (LaTeX)</ button >
266+ < button type ="button " id ="table-export-latex-copy "> Copy LaTeX</ button >
267+ </ div >
264268 </ div >
265269
266270 < div class ="card ">
@@ -283,10 +287,11 @@ <h2>World map</h2>
283287 < p class ="muted " id ="map-mode-note "> Colors show which model family wins in each country for the selected evaluation aspect.</ p >
284288 < p class ="muted " id ="map-coverage-note "> </ p >
285289 < div class ="world-map-wrap " id ="winner-map-wrap "> < p class ="muted "> Loading map…</ p > </ div >
286- < div class ="map- export-toolbar ">
290+ < div class ="export-toolbar ">
287291 < button type ="button " id ="map-export-svg " disabled > Download map (SVG)</ button >
288292 < button type ="button " id ="map-export-png " disabled > Download map (PNG)</ button >
289293 </ div >
294+ < p class ="muted "> For LaTeX figures: use PNG with < code > \includegraphics</ code > , or convert SVG to PDF (Inkscape) for vector output — pdfLaTeX does not embed SVG directly.</ p >
290295 < div class ="winner-legend " id ="winner-legend "> </ div >
291296 < div class ="benefit-legend " id ="benefit-legend " style ="display:none; ">
292297 < span class ="benefit-gradient " aria-hidden ="true "> </ span >
@@ -322,6 +327,10 @@ <h2>World map</h2>
322327 const mapCoverageNote = document . getElementById ( "map-coverage-note" ) ;
323328 const mapExportSvgBtn = document . getElementById ( "map-export-svg" ) ;
324329 const mapExportPngBtn = document . getElementById ( "map-export-png" ) ;
330+ const tableExportLatexBtn = document . getElementById ( "table-export-latex" ) ;
331+ const tableExportLatexCopyBtn = document . getElementById ( "table-export-latex-copy" ) ;
332+
333+ let currentTableFamilies = [ ] ;
325334
326335 const MAP_FILL_OUTSIDE = "#e8eaed" ;
327336 const MAP_FILL_NO_DATA = "#f5f5f5" ;
@@ -725,7 +734,100 @@ <h2>World map</h2>
725734 return `${ Number ( raw ) . toFixed ( 3 ) } [${ Number ( band . q25 ) . toFixed ( 3 ) } , ${ Number ( band . q75 ) . toFixed ( 3 ) } ]` ;
726735 }
727736
737+ function escapeLatex ( text ) {
738+ return String ( text )
739+ . replace ( / \\ / g, "\\textbackslash{}" )
740+ . replace ( / [ & % $ # _ { } ] / g, ch => `\\${ ch } ` )
741+ . replace ( / ~ / g, "\\textasciitilde{}" )
742+ . replace ( / \^ / g, "\\textasciicaret{}" ) ;
743+ }
744+
745+ function latexViewHeader ( view ) {
746+ const metric = view . display || view . metric ;
747+ if ( metric === "r" ) return `${ view . label } ($r$)` ;
748+ if ( metric === "NRMSE" ) return `${ view . label } (NRMSE)` ;
749+ return `${ view . label } (${ metric } )` ;
750+ }
751+
752+ function formatMetricLatex ( fam , metric ) {
753+ const raw = fam . raw [ metric ] ;
754+ if ( raw == null ) return "---" ;
755+ const band = ( fam . iqr || { } ) [ metric ] ;
756+ const med = Number ( raw ) . toFixed ( 3 ) ;
757+ if ( ! band || band . q25 == null || band . q75 == null ) return med ;
758+ return `${ med } [${ Number ( band . q25 ) . toFixed ( 3 ) } , ${ Number ( band . q75 ) . toFixed ( 3 ) } ]` ;
759+ }
760+
761+ function tableExportMeta ( ) {
762+ const hz = horizonSelect . value ;
763+ const crop = cropSelect . value ;
764+ const hzLabel = hz === "eos" ? "end-of-season" : hz === "mid" ? "mid-season" : hz ;
765+ const cropLabel = crop === "all" ? "all crops" : crop ;
766+ return { hz, crop, hzLabel, cropLabel } ;
767+ }
768+
769+ function tableExportFilename ( ) {
770+ const { hz, crop } = tableExportMeta ( ) ;
771+ return `model_families_table_${ hz } _${ crop } .tex` ;
772+ }
773+
774+ function buildMetricsTableLatex ( families ) {
775+ if ( ! families . length ) return "" ;
776+ const { hzLabel, cropLabel } = tableExportMeta ( ) ;
777+ const headers = [ "Family" , "Representative" , ...DATA . views . map ( latexViewHeader ) ] ;
778+ const colSpec = `ll${ "r" . repeat ( DATA . views . length ) } ` ;
779+ const headerRow = headers . map ( escapeLatex ) . join ( " & " ) ;
780+ const bodyRows = families . map ( fam => {
781+ const cells = [
782+ escapeLatex ( fam . family ) ,
783+ escapeLatex ( fam . display_name ) ,
784+ ...DATA . views . map ( v => formatMetricLatex ( fam , v . metric ) ) ,
785+ ] ;
786+ return ` ${ cells . join ( " & " ) } \\\\` ;
787+ } ) . join ( "\n" ) ;
788+ return `% CY-Bench model families table (generated from dashboard)
789+ % Requires: \\usepackage{booktabs}
790+ \\begin{table}[ht]
791+ \\centering
792+ \\caption{Model family performance: median [IQR] across countries. Horizon: ${ escapeLatex ( hzLabel ) } ; crops: ${ escapeLatex ( cropLabel ) } .}
793+ \\label{tab:cybench-model-families}
794+ \\begin{tabular}{${ colSpec } }
795+ \\toprule
796+ ${ headerRow } \\\\
797+ \\midrule
798+ ${ bodyRows }
799+ \\bottomrule
800+ \\end{tabular}
801+ \\end{table}
802+ ` ;
803+ }
804+
805+ function downloadMetricsTableLatex ( ) {
806+ const latex = buildMetricsTableLatex ( currentTableFamilies ) ;
807+ if ( ! latex ) return ;
808+ triggerBlobDownload (
809+ new Blob ( [ latex ] , { type : "text/plain;charset=utf-8" } ) ,
810+ tableExportFilename ( ) ,
811+ ) ;
812+ }
813+
814+ async function copyMetricsTableLatex ( ) {
815+ const latex = buildMetricsTableLatex ( currentTableFamilies ) ;
816+ if ( ! latex || ! tableExportLatexCopyBtn ) return ;
817+ const original = tableExportLatexCopyBtn . textContent ;
818+ try {
819+ await navigator . clipboard . writeText ( latex ) ;
820+ tableExportLatexCopyBtn . textContent = "Copied!" ;
821+ setTimeout ( ( ) => { tableExportLatexCopyBtn . textContent = original ; } , 1600 ) ;
822+ } catch ( _err ) {
823+ downloadMetricsTableLatex ( ) ;
824+ tableExportLatexCopyBtn . textContent = "Downloaded instead" ;
825+ setTimeout ( ( ) => { tableExportLatexCopyBtn . textContent = original ; } , 1600 ) ;
826+ }
827+ }
828+
728829 function renderTable ( families ) {
830+ currentTableFamilies = families ;
729831 const viewHeaders = DATA . views . map ( v => `${ v . label } (${ v . display || v . metric } )` ) ;
730832 const headers = [ "Family" , "Representative" , ...viewHeaders ] ;
731833 metricsTable . querySelector ( "thead" ) . innerHTML =
@@ -1049,6 +1151,8 @@ <h2>World map</h2>
10491151
10501152 if ( mapExportSvgBtn ) mapExportSvgBtn . addEventListener ( "click" , downloadMapSvg ) ;
10511153 if ( mapExportPngBtn ) mapExportPngBtn . addEventListener ( "click" , downloadMapPng ) ;
1154+ if ( tableExportLatexBtn ) tableExportLatexBtn . addEventListener ( "click" , downloadMetricsTableLatex ) ;
1155+ if ( tableExportLatexCopyBtn ) tableExportLatexCopyBtn . addEventListener ( "click" , copyMetricsTableLatex ) ;
10521156
10531157 function loadWinnerMapGeometry ( ) {
10541158 if ( ! DATA . geojson_href ) {
0 commit comments