// Use related colors to distinguish input variants of the same model. const methodColors = { seasonal_naive: "#7f7f7f", chronos_2: "#17becf", toto_2_0_313m: "#8c564b", moirai_2: "#d62728", timesfm_3: "#393b79", timebraid_6p7b_demo_rewrite_v1: "#3b82f6", claude_opus_4_8_hist_only: "#e377c2", claude_opus_4_8_hist_macro_micro: "#e377c2", gpt_5_6_sol_hist_only: "#2ca02c", gpt_5_6_sol_hist_macro: "#008c95", gpt_5_6_sol_hist_macro_micro: "#2ca02c", moirai_agent_hist_macro: "#bcbd22", moirai_agent_hist_macro_micro: "#7c9636", }; const eventColors = { impulse: "#c78463", sustained: "#8d76b5", recurrent: "#5a9d89", mean_shift: "#ba9953", amplitude: "#6f9cbd", onset: "#649b80", disappearance: "#ae8092", phase: "#ab9e58", period: "#858bc1" }; export function eventName(event) { let name = [event.family, event.subtype, event.direction].filter((value) => value != null && value !== "").join(" / ").replaceAll("_", " "); if (event.amplitude_trend) name += ` (inner ${event.amplitude_trend})`; else if (event.envelope_change) name += ` (envelope ${event.envelope_change})`; return name; } export function historySeries(history, context = 300) { const count = context === "all" ? history.length : Math.min(history.length, Number(context)); return { x: Array.from({ length: count }, (_, index) => index - count), y: history.slice(-count) }; } export function intervalGeometry(event) { return { center: (event.start + event.end) / 2, width: event.end - event.start + 1 }; } export function trendSegments(trend, horizon) { let start = 0; return (trend || []).map((segment, index) => { const end = index === trend.length - 1 ? horizon : Math.max(start + 1, Math.min(horizon, start + Math.round(segment.duration * horizon))); const result = { ...segment, start, end }; start = end; return result; }); } export function rankedMethods(forecasts) { return [...forecasts].sort((a, b) => a.mase - b.mase || a.id.localeCompare(b.id)); } export function makePlot(sample, selected, context = 300) { const history = historySeries(sample.history, context); const futureX = Array.from({ length: sample.n_horizon }, (_, i) => i); const predictions = rankedMethods(sample.forecasts).filter((method) => selected.has(method.id)); const nameCounts = new Map(); for (const method of sample.forecasts) nameCounts.set(method.name, (nameCounts.get(method.name) || 0) + 1); const extent = [...history.y, ...sample.future, ...predictions.flatMap((method) => method.forecast), ...sample.tsfm_average_forecast].filter((value) => value != null && Number.isFinite(value)); const low = Math.min(...extent), high = Math.max(...extent); const padding = (high - low || Math.abs(high) || 1) * .08; const bottom = low - padding, top = high + padding; const events = new Map(); for (const event of sample.micro) { const name = eventName(event); if (!events.has(name)) events.set(name, []); events.get(name).push(event); } const traces = [...events].map(([name, group], index) => ({ type: "bar", name, legendgroup: name, x: group.map((event) => intervalGeometry(event).center), width: group.map((event) => intervalGeometry(event).width), y: group.map(() => top - bottom), base: bottom, marker: { color: eventColors[group[0].subtype || group[0].family] || "#8298b1", opacity: .15, line: { width: 0 } }, customdata: group.map((event) => [event.start, event.end, event.intensity == null ? "—" : Number(event.intensity).toFixed(3)]), hovertemplate: `${name}
Future indices %{customdata[0]}–%{customdata[1]}
Intensity %{customdata[2]}`, legendrank: 3 + index, })); const line = (name, x, y, color, dash, legendrank, width = 1.7) => ({ type: "scatter", mode: "lines", name, x, y, legendrank, connectgaps: false, line: { color, width, dash }, hovertemplate: `${name}
Step %{x}
Value %{y:.4g}` }); traces.push(line("History", history.x, history.y, "#1f77b4", "solid", 0, 1.6)); traces.push(line("Future", futureX, sample.future, "#ff7f0e", "dash", 1, 2.4)); traces.push(line("TSFM average forecast", futureX, sample.tsfm_average_forecast, "#9467bd", "dot", 2)); for (const [index, method] of predictions.entries()) { const dash = method.id.endsWith("_hist_only") ? "dash" : "solid"; const name = nameCounts.get(method.name) > 1 ? `${method.name} (${method.input})` : method.name; traces.push(line(name, futureX, method.forecast, methodColors[method.id] || "#8c564b", dash, 3 + events.size + index)); } const shapes = [{ type: "line", x0: -.5, x1: -.5, y0: 0, y1: 1, yref: "paper", line: { color: "#a8b1bc", width: 1, dash: "dot" } }]; const annotations = []; const trendBand = { y0: 1.005, y1: 1.08, yref: "paper" }; const segments = trendSegments(sample.macro.trend, sample.n_horizon); if (segments.length) { annotations.push({ x: -.5, xref: "x", y: (trendBand.y0 + trendBand.y1) / 2, yref: "paper", xanchor: "right", yanchor: "middle", xshift: -8, text: "Macro trend:", showarrow: false, font: { size: 11, color: "#5b6877" } }); } for (const segment of segments) { shapes.push({ ...trendBand, type: "rect", x0: segment.start - .5, x1: segment.end - .5, fillcolor: { up: "#e5f1ec", down: "#f7e9df", flat: "#eef0f4" }[segment.direction] || "#eef0f4", line: { color: "#fff", width: 1 } }); annotations.push({ x: (segment.start + segment.end - 1) / 2, y: (trendBand.y0 + trendBand.y1) / 2, yref: "paper", xanchor: "center", yanchor: "middle", text: segment.direction, hovertext: `Macro trend: ${segment.direction}`, showarrow: false, font: { size: 11, color: "#5b6877" } }); } return { data: traces, layout: { height: 545, margin: { l: 65, r: 22, t: 55, b: 90 }, paper_bgcolor: "#fff", plot_bgcolor: "#fff", font: { family: "system-ui, sans-serif", color: "#687383", size: 11 }, xaxis: { title: { text: "Time Step" }, range: [-history.x.length - .5, sample.n_horizon - .5], gridcolor: "#eef1f4", zeroline: false }, yaxis: { title: { text: "Value" }, range: [bottom, top], gridcolor: "#eef1f4", zeroline: false }, legend: { orientation: "h", x: 0, y: -.17, yanchor: "top", font: { size: 10 }, traceorder: "normal", maxheight: .25 }, barmode: "overlay", bargap: 0, hovermode: "closest", shapes, annotations, uirevision: `${sample.id}/${context}`, dragmode: "zoom", }, config: { responsive: true, displaylogo: false, scrollZoom: false, modeBarButtonsToRemove: ["select2d", "lasso2d"] }, }; }