File size: 6,544 Bytes
d65a1ea
b5f29f5
 
 
 
 
 
8479181
b5f29f5
d65a1ea
b5f29f5
d65a1ea
 
 
 
b5f29f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597db8a
ba21178
 
 
 
 
b5f29f5
 
 
 
 
 
597db8a
b5f29f5
 
 
 
597db8a
b5f29f5
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// 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}<br>Future indices %{customdata[0]}–%{customdata[1]}<br>Intensity %{customdata[2]}<extra></extra>`,
    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}<br>Step %{x}<br>Value %{y:.4g}<extra></extra>` });
  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"] },
  };
}