@@ -6,12 +6,14 @@ const { data } = Astro.props;
66const history = data .history ?? [];
77const forecast = data .forecast ?? [];
88const backtest = data .backtest ?? [];
9+ const backtestPoints = data .backtest_points ?? [];
910
1011const runTitle = data ?.meta ?.slug || data .request ?.run_name_root || (data .request ?.series_names || []).join (' , ' ) || ' series' ;
1112
1213const seriesIds = [... new Set ([
1314 ... history .map ((r ) => r .unique_id ),
1415 ... forecast .map ((r ) => r .unique_id ),
16+ ... backtestPoints .map ((r ) => r .unique_id ),
1517])];
1618
1719const parseDs = (v ) => {
@@ -21,13 +23,18 @@ const parseDs = (v) => {
2123 return Number .isNaN (d .getTime ()) ? null : d ;
2224};
2325
24- const svgChartForSeries = (seriesId , valuesType = ' forecast' ) => {
25- const hRows = history .filter ((r ) => r .unique_id === seriesId ).map ((r ) => ({ ... r , iso: parseDs (r .ds )?.toISOString () })).filter ((r ) => r .iso );
26- const fRows = forecast .filter ((r ) => r .unique_id === seriesId ).map ((r ) => ({ ... r , iso: parseDs (r .ds )?.toISOString () })).filter ((r ) => r .iso );
27- const labels = [... new Set ([... hRows .map ((r ) => r .iso ), ... fRows .map ((r ) => r .iso )])].sort ();
26+ const buildChartState = (actualRows , forecastRows , h = 320 ) => {
27+ const aRows = actualRows
28+ .map ((r ) => ({ ... r , iso: parseDs (r .ds )?.toISOString () }))
29+ .filter ((r ) => r .iso );
30+ const fRows = forecastRows
31+ .map ((r ) => ({ ... r , iso: parseDs (r .ds )?.toISOString () }))
32+ .filter ((r ) => r .iso );
33+
34+ const labels = [... new Set ([... aRows .map ((r ) => r .iso ), ... fRows .map ((r ) => r .iso )])].sort ();
2835
2936 const actualValues = labels .map ((l ) => {
30- const row = hRows .find ((r ) => r .iso === l );
37+ const row = aRows .find ((r ) => r .iso === l );
3138 return row ? Number (row .y ) : null ;
3239 });
3340
@@ -44,11 +51,12 @@ const svgChartForSeries = (seriesId, valuesType = 'forecast') => {
4451 ... actualValues .filter ((v ) => Number .isFinite (v )),
4552 ... modelSeries .flatMap ((s ) => s .values .filter ((v ) => Number .isFinite (v ))),
4653 ];
54+
4755 const yMin = allY .length ? Math .min (... allY ) : 0 ;
4856 const yMax = allY .length ? Math .max (... allY ) : 1 ;
4957
5058 const W = 920 ;
51- const H = valuesType === ' backtest ' ? 240 : 320 ;
59+ const H = h ;
5260 const PAD = { t: 18 , r: 18 , b: 40 , l: 44 };
5361 const innerW = W - PAD .l - PAD .r ;
5462 const innerH = H - PAD .t - PAD .b ;
@@ -77,31 +85,32 @@ const svgChartForSeries = (seriesId, valuesType = 'forecast') => {
7785 return { W , H , PAD , innerW , innerH , labels , actualValues , actualPath , modelSeries , colors , xFor , yFor , yMin , yMax };
7886};
7987
80-
81- const buildSmapeSpark = (rows ) => {
82- const W = 920 , H = 200 , PAD = { t: 16 , r: 16 , b: 32 , l: 38 };
83- const vals = rows .map ((r ) => Number (r .smape || 0 ));
84- const min = vals .length ? Math .min (... vals ) : 0 ;
85- const max = vals .length ? Math .max (... vals ) : 1 ;
86- const range = max - min || 1 ;
87- const x = (i ) => PAD .l + (rows .length <= 1 ? 0 : (i / (rows .length - 1 )) * (W - PAD .l - PAD .r ));
88- const y = (v ) => PAD .t + (1 - ((v - min ) / range )) * (H - PAD .t - PAD .b );
89- let path = ' ' ;
90- rows .forEach ((r , i ) => {
91- const xv = x (i ), yv = y (Number (r .smape || 0 ));
92- path += path ? ` L ${xv } ${yv } ` : ` M ${xv } ${yv } ` ;
93- });
94- const points = rows .map ((r , i ) => ({ x: x (i ), y: y (Number (r .smape || 0 )) }));
95- return { W , H , PAD , path , points };
88+ const getMainChart = (sid ) => {
89+ const hRows = history .filter ((r ) => r .unique_id === sid );
90+ const fRows = forecast .filter ((r ) => r .unique_id === sid );
91+ return buildChartState (hRows , fRows , 320 );
9692};
9793
9894const backtestBySeries = seriesIds .map ((sid ) => {
99- const rows = backtest .filter ((r ) => r .unique_id === sid );
100- const byModel = [... new Set (rows .map ((r ) => r .model ))].map ((m ) => ({
101- model: m ,
102- rows: rows .filter ((r ) => r .model === m ).sort ((a , b ) => Number (a .window ) - Number (b .window )),
103- }));
104- return { sid , rows , byModel };
95+ const scoreRows = backtest .filter ((r ) => r .unique_id === sid );
96+ const windowIds = [... new Set (scoreRows .map ((r ) => Number (r .window )).filter (Number .isFinite ))].sort ((a , b ) => a - b );
97+
98+ const windows = windowIds .map ((w ) => {
99+ const pts = backtestPoints .filter ((p ) => p .unique_id === sid && Number (p .window ) === w );
100+ const actualMap = new Map ();
101+ pts .forEach ((p ) => {
102+ const key = String (p .ds );
103+ if (! actualMap .has (key )) actualMap .set (key , { ds: p .ds , y: p .y });
104+ });
105+ const actualRows = [... actualMap .values ()];
106+ const forecastRows = pts .map ((p ) => ({ ds: p .ds , model: p .model , yhat: p .yhat }));
107+ const chart = buildChartState (actualRows , forecastRows , 260 );
108+
109+ const scores = scoreRows .filter ((r ) => Number (r .window ) === w ).sort ((a , b ) => String (a .model ).localeCompare (String (b .model )));
110+ return { window: w , chart , scores };
111+ });
112+
113+ return { sid , windows , scoreRows };
105114});
106115---
107116<MainLayout title ={ ` Forecast ${runTitle } ` } >
@@ -112,7 +121,7 @@ const backtestBySeries = seriesIds.map((sid) => {
112121
113122 <section class =" mt-4 space-y-4" >
114123 { seriesIds .map ((sid ) => {
115- const chart = svgChartForSeries (sid );
124+ const chart = getMainChart (sid );
116125 const back = backtestBySeries .find ((x ) => x .sid === sid );
117126 return (
118127 <article class = " rounded-xl border border-slate-700 bg-slate-900/70 p-4" >
@@ -175,35 +184,82 @@ const backtestBySeries = seriesIds.map((sid) => {
175184
176185 <details class = " mt-4 rounded-lg border border-slate-700 bg-slate-950/40 p-3" >
177186 <summary class = " cursor-pointer font-semibold text-sm" >Backfill testing + accuracies</summary >
178- { back && back .rows .length > 0 ? (
179- <div class = " mt-3 space-y-3" >
180- { back .byModel .map ((m , mi ) => {
181- const spark = buildSmapeSpark (m .rows );
182- const color = [' #ef4444' ,' #22c55e' ,' #a855f7' ,' #f97316' ][mi % 4 ];
183- return (
184- <div class = " rounded border border-slate-700 p-2" >
185- <div class = " text-xs text-slate-300 mb-1" >{ m .model } SMAPE by backtest window</div >
186- <div class = " overflow-x-auto" >
187- <svg viewBox = { ` 0 0 ${spark .W } ${spark .H } ` } class = " min-w-[680px] w-full rounded bg-slate-950" >
188- <rect x = { spark .PAD .l } y = { spark .PAD .t } width = { spark .W - spark .PAD .l - spark .PAD .r } height = { spark .H - spark .PAD .t - spark .PAD .b } fill = " none" stroke = " #1e293b" />
189- <path d = { spark .path } fill = " none" stroke = { color } stroke-width = " 2" />
190- { spark .points .map ((pt ) => <circle cx = { pt .x } cy = { pt .y } r = " 3" fill = { color } />)}
191- </svg >
192- </div >
187+ { back && back .windows .length > 0 ? (
188+ <div class = " mt-3 space-y-4" >
189+ { back .windows .map ((wObj ) => (
190+ <div class = " rounded border border-slate-700 p-3" >
191+ <div class = " text-xs text-slate-300 mb-2" >Backtest window { wObj .window } · actual vs predicted</div >
192+ <div class = " overflow-x-auto" >
193+ <svg viewBox = { ` 0 0 ${wObj .chart .W } ${wObj .chart .H } ` } class = " min-w-[720px] w-full rounded bg-slate-950" >
194+ <rect x = { wObj .chart .PAD .l } y = { wObj .chart .PAD .t } width = { wObj .chart .innerW } height = { wObj .chart .innerH } fill = " none" stroke = " #1e293b" />
195+
196+ { Array .from ({ length: 5 }).map ((_ , i ) => {
197+ const y = wObj .chart .PAD .t + (i / 4 ) * wObj .chart .innerH ;
198+ const val = (wObj .chart .yMax - ((i / 4 ) * (wObj .chart .yMax - wObj .chart .yMin ))).toFixed (1 );
199+ return (
200+ <g >
201+ <line x1 = { wObj .chart .PAD .l } y1 = { y } x2 = { wObj .chart .W - wObj .chart .PAD .r } y2 = { y } stroke = " #0f172a" />
202+ <text x = " 6" y = { y + 4 } font-size = " 11" fill = " #94a3b8" >{ val } </text >
203+ </g >
204+ );
205+ })}
206+
207+ { wObj .chart .actualPath && <path d = { wObj .chart .actualPath } fill = " none" stroke = " #38bdf8" stroke-width = " 2" />}
208+ { wObj .chart .actualValues .map ((v , i ) => {
209+ const y = wObj .chart .yFor (v );
210+ if (y === null ) return null ;
211+ return <circle cx = { wObj .chart .xFor (i )} cy = { y } r = " 2.7" fill = " #38bdf8" />;
212+ })}
213+
214+ { wObj .chart .modelSeries .map ((s , idx ) => {
215+ const d = (() => {
216+ let p = ' ' ;
217+ s .values .forEach ((v , i ) => {
218+ const y = wObj .chart .yFor (v );
219+ if (y === null ) return ;
220+ const x = wObj .chart .xFor (i );
221+ p += p ? ` L ${x } ${y } ` : ` M ${x } ${y } ` ;
222+ });
223+ return p ;
224+ })();
225+
226+ return (
227+ <>
228+ { d && <path d = { d } fill = " none" stroke = { wObj .chart .colors [idx % wObj .chart .colors .length ]} stroke-width = " 2" stroke-dasharray = " 6 4" />}
229+ { s .values .map ((v , i ) => {
230+ const y = wObj .chart .yFor (v );
231+ if (y === null ) return null ;
232+ return <circle cx = { wObj .chart .xFor (i )} cy = { y } r = " 2.4" fill = { wObj .chart .colors [idx % wObj .chart .colors .length ]} />;
233+ })}
234+ </>
235+ );
236+ })}
237+ </svg >
193238 </div >
194- );
195- })}
196-
197- <div class = " overflow-x-auto" >
198- <table class = " min-w-[760px] text-sm w-full" >
199- <thead ><tr class = " text-slate-300" ><th class = " p-2 text-left" >Window</th ><th class = " p-2 text-left" >Model</th ><th class = " p-2 text-left" >SMAPE</th ><th class = " p-2 text-left" >Holdout</th ></tr ></thead >
200- <tbody >
201- { back .rows .sort ((a ,b )=> Number (a .window )- Number (b .window )).map ((row ) => (
202- <tr ><td class = " p-2" >{ row .window } </td ><td class = " p-2" >{ row .model } </td ><td class = " p-2" >{ row .smape } </td ><td class = " p-2" >{ String (row .holdout_start )} → { String (row .holdout_end )} </td ></tr >
239+
240+ <div class = " mt-2 flex flex-wrap gap-3 text-xs text-slate-300" >
241+ <span class = " inline-flex items-center gap-1" ><span class = " inline-block w-4 h-[2px] bg-sky-400" ></span >Actual</span >
242+ { wObj .chart .modelSeries .map ((s , idx ) => (
243+ <span class = " inline-flex items-center gap-1" ><span class = " inline-block w-4 h-[2px]" style = { ` background:${wObj .chart .colors [idx % wObj .chart .colors .length ]} ` } ></span >Predicted ({ s .model } )</span >
203244 ))}
204- </tbody >
205- </table >
206- </div >
245+ </div >
246+
247+ <div class = " mt-2 overflow-x-auto" >
248+ <table class = " min-w-[460px] text-xs w-full" >
249+ <thead ><tr class = " text-slate-300" ><th class = " p-2 text-left" >Model</th ><th class = " p-2 text-left" >SMAPE</th ><th class = " p-2 text-left" >Holdout Range</th ></tr ></thead >
250+ <tbody >
251+ { wObj .scores .map ((row ) => (
252+ <tr >
253+ <td class = " p-2" >{ row .model } </td >
254+ <td class = " p-2" >{ row .smape } </td >
255+ <td class = " p-2" >{ String (row .holdout_start )} → { String (row .holdout_end )} </td >
256+ </tr >
257+ ))}
258+ </tbody >
259+ </table >
260+ </div >
261+ </div >
262+ ))}
207263 </div >
208264 ) : (
209265 <p class = " mt-2 text-sm text-slate-400" >No backtest rows for this series yet (need more history or windows).</p >
0 commit comments