@jorgerdz/timeview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +263 -0
  4. package/dist/cli/timeview.js +6710 -0
  5. package/dist/timeview.cjs +1 -0
  6. package/dist/timeview.js +5667 -0
  7. package/dist/tokens.css +67 -0
  8. package/dist/types/timeview/BandedTimeline.d.ts +11 -0
  9. package/dist/types/timeview/BandedTimeline.d.ts.map +1 -0
  10. package/dist/types/timeview/DensityHeatmap.d.ts +11 -0
  11. package/dist/types/timeview/DensityHeatmap.d.ts.map +1 -0
  12. package/dist/types/timeview/LaneCalendar.d.ts +11 -0
  13. package/dist/types/timeview/LaneCalendar.d.ts.map +1 -0
  14. package/dist/types/timeview/MetricTimeline.d.ts +8 -0
  15. package/dist/types/timeview/MetricTimeline.d.ts.map +1 -0
  16. package/dist/types/timeview/SpanMatrix.d.ts +8 -0
  17. package/dist/types/timeview/SpanMatrix.d.ts.map +1 -0
  18. package/dist/types/timeview/config.d.ts +22 -0
  19. package/dist/types/timeview/config.d.ts.map +1 -0
  20. package/dist/types/timeview/core/aggregate.d.ts +113 -0
  21. package/dist/types/timeview/core/aggregate.d.ts.map +1 -0
  22. package/dist/types/timeview/core/calendar.d.ts +27 -0
  23. package/dist/types/timeview/core/calendar.d.ts.map +1 -0
  24. package/dist/types/timeview/core/intervals.d.ts +8 -0
  25. package/dist/types/timeview/core/intervals.d.ts.map +1 -0
  26. package/dist/types/timeview/core/labels.d.ts +5 -0
  27. package/dist/types/timeview/core/labels.d.ts.map +1 -0
  28. package/dist/types/timeview/core/metric.d.ts +58 -0
  29. package/dist/types/timeview/core/metric.d.ts.map +1 -0
  30. package/dist/types/timeview/core/time.d.ts +22 -0
  31. package/dist/types/timeview/core/time.d.ts.map +1 -0
  32. package/dist/types/timeview/dashboard.d.ts +17 -0
  33. package/dist/types/timeview/dashboard.d.ts.map +1 -0
  34. package/dist/types/timeview/data.d.ts +21 -0
  35. package/dist/types/timeview/data.d.ts.map +1 -0
  36. package/dist/types/timeview/export.d.ts +14 -0
  37. package/dist/types/timeview/export.d.ts.map +1 -0
  38. package/dist/types/timeview/index.d.ts +28 -0
  39. package/dist/types/timeview/index.d.ts.map +1 -0
  40. package/dist/types/timeview/registry.d.ts +285 -0
  41. package/dist/types/timeview/registry.d.ts.map +1 -0
  42. package/dist/types/timeview/shared/Caption.d.ts +9 -0
  43. package/dist/types/timeview/shared/Caption.d.ts.map +1 -0
  44. package/dist/types/timeview/shared/EmptyState.d.ts +16 -0
  45. package/dist/types/timeview/shared/EmptyState.d.ts.map +1 -0
  46. package/dist/types/timeview/shared/Legend.d.ts +10 -0
  47. package/dist/types/timeview/shared/Legend.d.ts.map +1 -0
  48. package/dist/types/timeview/shared/Tooltip.d.ts +15 -0
  49. package/dist/types/timeview/shared/Tooltip.d.ts.map +1 -0
  50. package/dist/types/timeview/shared/useMeasuredWidth.d.ts +2 -0
  51. package/dist/types/timeview/shared/useMeasuredWidth.d.ts.map +1 -0
  52. package/dist/types/timeview/types.d.ts +158 -0
  53. package/dist/types/timeview/types.d.ts.map +1 -0
  54. package/docs/AGENT-USAGE.md +93 -0
  55. package/docs/COMPATIBILITY.md +134 -0
  56. package/docs/STUDIO.md +41 -0
  57. package/examples/README.md +21 -0
  58. package/examples/configs/bandedTimeline.json +31 -0
  59. package/examples/configs/densityHeatmap.json +33 -0
  60. package/examples/configs/laneCalendar.json +31 -0
  61. package/examples/configs/metricTimeline.json +51 -0
  62. package/examples/configs/spanMatrix.json +31 -0
  63. package/package.json +94 -0
  64. package/render.html +12 -0
  65. package/src/render.tsx +67 -0
  66. package/src/styles/tokens.css +67 -0
  67. package/src/timeview/BandedTimeline.tsx +620 -0
  68. package/src/timeview/DensityHeatmap.tsx +513 -0
  69. package/src/timeview/LaneCalendar.tsx +496 -0
  70. package/src/timeview/MetricTimeline.tsx +993 -0
  71. package/src/timeview/SpanMatrix.tsx +721 -0
  72. package/src/timeview/config.ts +399 -0
  73. package/src/timeview/core/aggregate.ts +317 -0
  74. package/src/timeview/core/calendar.ts +81 -0
  75. package/src/timeview/core/intervals.ts +52 -0
  76. package/src/timeview/core/labels.ts +19 -0
  77. package/src/timeview/core/metric.ts +263 -0
  78. package/src/timeview/core/time.ts +103 -0
  79. package/src/timeview/dashboard.ts +80 -0
  80. package/src/timeview/data.ts +242 -0
  81. package/src/timeview/export.ts +48 -0
  82. package/src/timeview/index.ts +106 -0
  83. package/src/timeview/registry.ts +207 -0
  84. package/src/timeview/shared/Caption.tsx +40 -0
  85. package/src/timeview/shared/EmptyState.tsx +90 -0
  86. package/src/timeview/shared/Legend.tsx +67 -0
  87. package/src/timeview/shared/Tooltip.tsx +59 -0
  88. package/src/timeview/shared/useMeasuredWidth.ts +21 -0
  89. package/src/timeview/types.ts +159 -0
  90. package/vite.config.ts +11 -0
@@ -0,0 +1,993 @@
1
+ // MetricTimeline.tsx - Timeview numeric metric visualizer.
2
+ //
3
+ // A primary metric line over time plus contextual state intervals, shown as a
4
+ // separate state band or by tinting the line itself. The viewport is navigable
5
+ // and represented in a minimap so static exports preserve the active window.
6
+
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import type { KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, WheelEvent } from "react";
9
+ import { DAY_MS, fmt, tvScale } from "./core/time";
10
+ import {
11
+ METRIC_MIN_SPAN,
12
+ tvClampViewport,
13
+ tvDefaultViewport,
14
+ tvLineSegments,
15
+ tvMetricModel,
16
+ tvMetricTimeTicks,
17
+ tvNearest,
18
+ tvNiceTicks,
19
+ tvStateAt,
20
+ } from "./core/metric";
21
+ import type { MetricModel, MetricPoint, MetricState } from "./core/metric";
22
+ import { colorMap, labelName, mix } from "./core/labels";
23
+ import { Caption } from "./shared/Caption";
24
+ import { EmptyState } from "./shared/EmptyState";
25
+ import { useMeasuredWidth } from "./shared/useMeasuredWidth";
26
+ import type { Density, MetricTimelineSpec, TimeDataset } from "./types";
27
+
28
+ export interface MetricTimelineProps {
29
+ dataset: TimeDataset;
30
+ spec: MetricTimelineSpec;
31
+ palette: string[];
32
+ }
33
+
34
+ const METRIC_INK = "#15181e";
35
+
36
+ interface DensitySpec {
37
+ pad: number;
38
+ plotH: number;
39
+ bandH: number;
40
+ miniH: number;
41
+ axisW: number;
42
+ xAxisH: number;
43
+ dot: number;
44
+ line: number;
45
+ valueLabel: number;
46
+ }
47
+
48
+ const DENS: Record<Density, DensitySpec> = {
49
+ comfortable: { pad: 22, plotH: 300, bandH: 30, miniH: 56, axisW: 50, xAxisH: 30, dot: 3.4, line: 2.2, valueLabel: 11 },
50
+ compact: { pad: 16, plotH: 226, bandH: 24, miniH: 46, axisW: 44, xAxisH: 26, dot: 2.8, line: 2, valueLabel: 10 },
51
+ };
52
+
53
+ type Hover =
54
+ | { kind: "sample"; point: MetricPoint; mx: number; my: number }
55
+ | { kind: "state"; state: MetricState; mx: number; my: number }
56
+ | { kind: "event"; event: MetricModel["events"][number]; mx: number; my: number };
57
+
58
+ function spanLabel(ms: number) {
59
+ const days = Math.round(ms / DAY_MS);
60
+ return days <= 90 ? `${days} days` : `${Math.round(days / 30)} months`;
61
+ }
62
+
63
+ function nearlySameViewport(a: [number, number], b: [number, number]) {
64
+ return Math.abs(a[0] - b[0]) < DAY_MS && Math.abs(a[1] - b[1]) < DAY_MS;
65
+ }
66
+
67
+ function valueText(value: number) {
68
+ return Number.isInteger(value) ? String(value) : value.toFixed(1);
69
+ }
70
+
71
+ export function MetricTimeline({ dataset, spec, palette }: MetricTimelineProps) {
72
+ const [wrapRef, width] = useMeasuredWidth<HTMLDivElement>(1100);
73
+ const [hover, setHover] = useState<Hover | null>(null);
74
+ const [selected, setSelected] = useState<{ kind: "sample" | "state"; id: string | number } | null>(null);
75
+ const [focusIdx, setFocusIdx] = useState<number | null>(null);
76
+ const dragRef = useRef(false);
77
+
78
+ const density: Density = spec.density || "comfortable";
79
+ const D = DENS[density] || DENS.comfortable;
80
+ const mode = spec.stateMode || "band";
81
+ const showStates = mode !== "off";
82
+ const showPointsPref = spec.showPoints !== false;
83
+ const showValues = !!spec.showValues;
84
+ const showTarget = spec.showTarget !== false;
85
+ const showEvents = spec.events?.showMarkers !== false;
86
+ const showMinimap = spec.minimap !== false;
87
+ const yFull = spec.yAxis === "full";
88
+ const legendPos = spec.legend?.position || "bottom";
89
+ const captionPos = spec.caption?.position || "bottom";
90
+ const captionText = spec.caption?.text || "";
91
+ const captionOn = captionPos !== "off" && !!captionText;
92
+
93
+ const model = useMemo(() => tvMetricModel(dataset), [dataset]);
94
+ const cmap = useMemo(() => colorMap(dataset, palette), [dataset, palette]);
95
+ const defaultVp = useMemo(() => tvDefaultViewport(model, spec.defaultDays || 90, spec.today), [model, spec.defaultDays, spec.today]);
96
+ const [viewport, setViewport] = useState<[number, number]>(defaultVp);
97
+
98
+ useEffect(() => {
99
+ setViewport(defaultVp);
100
+ setHover(null);
101
+ setSelected(null);
102
+ setFocusIdx(null);
103
+ }, [defaultVp]);
104
+
105
+ const [v0, v1] = viewport;
106
+ const narrow = width < 560;
107
+ const isEmpty = model.samples.length === 0;
108
+ const legendOn = legendPos !== "off" && showStates && (dataset.labels || []).length > 0;
109
+ const axisW = narrow ? Math.max(36, D.axisW - 12) : D.axisW;
110
+ const legendW = legendPos === "right" && !narrow ? Math.min(210, Math.max(160, width * 0.2)) : 0;
111
+ const plotW = Math.max(140, width - D.pad * 2 - axisW - (legendW ? legendW + D.pad : 0));
112
+ const plotH = narrow ? Math.max(180, D.plotH - 70) : D.plotH;
113
+
114
+ const x = useMemo(() => tvScale(v0, v1, plotW), [v0, v1, plotW]);
115
+ const samplesInView = useMemo(() => model.samples.filter((point) => point.t >= v0 && point.t <= v1), [model.samples, v0, v1]);
116
+ const ySamples = yFull ? model.samples : samplesInView.length ? samplesInView : model.samples;
117
+
118
+ let vMin = ySamples.length ? Math.min(...ySamples.map((point) => point.value)) : 0;
119
+ let vMax = ySamples.length ? Math.max(...ySamples.map((point) => point.value)) : 1;
120
+ if (showTarget && model.target) {
121
+ vMin = Math.min(vMin, model.target.value);
122
+ vMax = Math.max(vMax, model.target.value);
123
+ }
124
+ const yPad = (vMax - vMin) * 0.12 || 1;
125
+ const yTicks = tvNiceTicks(vMin - yPad, vMax + yPad, narrow ? 4 : 5);
126
+ const yOf = (value: number) => (1 - (value - yTicks.lo) / Math.max(1e-9, yTicks.hi - yTicks.lo)) * plotH;
127
+ const xTicks = useMemo(() => tvMetricTimeTicks(v0, v1), [v0, v1]);
128
+ const segments = useMemo(() => tvLineSegments(model.samples, model.states), [model.samples, model.states]);
129
+
130
+ const full = model.T1 - model.T0;
131
+ const atFull = v1 - v0 >= full - 1;
132
+ const atMin = v1 - v0 <= METRIC_MIN_SPAN + 1;
133
+ const isDefault = nearlySameViewport(viewport, defaultVp);
134
+ const visiblePoints = showPointsPref && samplesInView.length <= 130;
135
+
136
+ const linePath = (points: MetricPoint[]) =>
137
+ points.map((point, index) => `${index ? "L" : "M"}${x(point.t).toFixed(1)} ${yOf(point.value).toFixed(1)}`).join(" ");
138
+
139
+ const areaPath = (points: MetricPoint[]) => {
140
+ if (points.length < 2) return "";
141
+ return `M${x(points[0].t).toFixed(1)} ${plotH} L${points
142
+ .map((point) => `${x(point.t).toFixed(1)} ${yOf(point.value).toFixed(1)}`)
143
+ .join(" L")} L${x(points[points.length - 1].t).toFixed(1)} ${plotH} Z`;
144
+ };
145
+
146
+ const tintRuns = useMemo(() => {
147
+ const runs: { labelId: string | null; points: MetricPoint[] }[] = [];
148
+ segments.forEach((segment) => {
149
+ const current = runs[runs.length - 1];
150
+ if (!current || current.labelId !== segment.labelId) runs.push({ labelId: segment.labelId, points: [segment.a, segment.b] });
151
+ else current.points.push(segment.b);
152
+ });
153
+ return runs;
154
+ }, [segments]);
155
+
156
+ const zoom = useCallback(
157
+ (direction: -1 | 1) => {
158
+ const center = (v0 + v1) / 2;
159
+ const span = (v1 - v0) * (direction < 0 ? 0.62 : 1 / 0.62);
160
+ setViewport(tvClampViewport(center - span / 2, center + span / 2, model, "zoom"));
161
+ },
162
+ [model, v0, v1],
163
+ );
164
+
165
+ const zoomAt = useCallback(
166
+ (anchor: number, scale: number) => {
167
+ const currentSpan = v1 - v0;
168
+ const nextSpan = currentSpan * scale;
169
+ const ratio = Math.max(0, Math.min(1, (anchor - v0) / currentSpan));
170
+ const next0 = anchor - ratio * nextSpan;
171
+ setViewport(tvClampViewport(next0, next0 + nextSpan, model, "zoom"));
172
+ },
173
+ [model, v0, v1],
174
+ );
175
+
176
+ const pan = useCallback(
177
+ (direction: -1 | 1) => {
178
+ const shift = direction * (v1 - v0) * 0.5;
179
+ setViewport(tvClampViewport(v0 + shift, v1 + shift, model, "pan"));
180
+ },
181
+ [model, v0, v1],
182
+ );
183
+
184
+ const reset = useCallback(() => setViewport(defaultVp), [defaultVp]);
185
+ const scrub = useCallback((next0: number, next1: number) => setViewport(tvClampViewport(next0, next1, model, "pan")), [model]);
186
+
187
+ const track = (event: { clientX: number; clientY: number }) => {
188
+ const rect = wrapRef.current?.getBoundingClientRect();
189
+ return rect ? { mx: event.clientX - rect.left, my: event.clientY - rect.top } : { mx: 0, my: 0 };
190
+ };
191
+
192
+ const onPlotDown = (event: ReactPointerEvent<HTMLDivElement>) => {
193
+ event.preventDefault();
194
+ const startX = event.clientX;
195
+ const start: [number, number] = [v0, v1];
196
+ let moved = false;
197
+ dragRef.current = true;
198
+
199
+ const move = (ev: PointerEvent) => {
200
+ const dx = ev.clientX - startX;
201
+ if (Math.abs(dx) > 3) moved = true;
202
+ const dt = -(dx / plotW) * (start[1] - start[0]);
203
+ setViewport(tvClampViewport(start[0] + dt, start[1] + dt, model, "pan"));
204
+ };
205
+
206
+ const up = (ev: PointerEvent) => {
207
+ window.removeEventListener("pointermove", move);
208
+ window.removeEventListener("pointerup", up);
209
+ dragRef.current = false;
210
+ if (!moved && model.samples.length && wrapRef.current) {
211
+ const rect = wrapRef.current.getBoundingClientRect();
212
+ const mx = ev.clientX - rect.left - (D.pad + axisW);
213
+ const point = tvNearest(model.samples, x.invert(mx));
214
+ if (!point) return;
215
+ const idx = model.samples.indexOf(point);
216
+ setSelected((current) => (current?.kind === "sample" && current.id === point.t ? null : { kind: "sample", id: point.t }));
217
+ setFocusIdx(idx);
218
+ }
219
+ };
220
+
221
+ window.addEventListener("pointermove", move);
222
+ window.addEventListener("pointerup", up);
223
+ };
224
+
225
+ const onPlotMove = (event: ReactPointerEvent<HTMLDivElement>) => {
226
+ if (dragRef.current || !model.samples.length || !wrapRef.current) return;
227
+ const rect = wrapRef.current.getBoundingClientRect();
228
+ const mx = event.clientX - rect.left - (D.pad + axisW);
229
+ if (mx < -2 || mx > plotW + 2) {
230
+ setHover(null);
231
+ return;
232
+ }
233
+ const point = tvNearest(model.samples, x.invert(mx));
234
+ if (point) setHover({ kind: "sample", point, ...track(event) });
235
+ };
236
+
237
+ const onPlotWheel = (event: WheelEvent<HTMLDivElement>) => {
238
+ if (isEmpty || !wrapRef.current) return;
239
+ const rect = wrapRef.current.getBoundingClientRect();
240
+ const plotLeft = rect.left + D.pad + axisW;
241
+ const px = event.clientX - plotLeft;
242
+ const overPlot = px >= 0 && px <= plotW;
243
+ if (!overPlot) return;
244
+
245
+ const wantsPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
246
+
247
+ event.preventDefault();
248
+ if (wantsPan) {
249
+ const delta = event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
250
+ const shift = (delta / Math.max(1, plotW)) * (v1 - v0);
251
+ setViewport(tvClampViewport(v0 + shift, v1 + shift, model, "pan"));
252
+ return;
253
+ }
254
+
255
+ const rawScale = Math.exp(event.deltaY * 0.0015);
256
+ const scale = Math.max(0.88, Math.min(1.14, rawScale));
257
+ zoomAt(x.invert(px).getTime(), scale);
258
+ };
259
+
260
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
261
+ if (!model.samples.length) return;
262
+ let idx = focusIdx == null ? model.samples.findIndex((point) => point.t >= v0) : focusIdx;
263
+ if (idx < 0) idx = 0;
264
+
265
+ if (event.key === "ArrowRight") idx = Math.min(model.samples.length - 1, idx + 1);
266
+ else if (event.key === "ArrowLeft") idx = Math.max(0, idx - 1);
267
+ else if (event.key === "Home") idx = 0;
268
+ else if (event.key === "End") idx = model.samples.length - 1;
269
+ else if (event.key === "Enter" || event.key === " ") {
270
+ event.preventDefault();
271
+ const point = model.samples[idx];
272
+ setSelected((current) => (current?.kind === "sample" && current.id === point.t ? null : { kind: "sample", id: point.t }));
273
+ return;
274
+ } else if (event.key === "+" || event.key === "=") {
275
+ event.preventDefault();
276
+ zoom(-1);
277
+ return;
278
+ } else if (event.key === "-") {
279
+ event.preventDefault();
280
+ zoom(1);
281
+ return;
282
+ } else {
283
+ return;
284
+ }
285
+
286
+ event.preventDefault();
287
+ setFocusIdx(idx);
288
+ const point = model.samples[idx];
289
+ if (point.t < v0 || point.t > v1) {
290
+ const span = v1 - v0;
291
+ setViewport(tvClampViewport(point.t - span / 2, point.t + span / 2, model, "pan"));
292
+ }
293
+ setHover({ kind: "sample", point, mx: D.pad + axisW + x(point.t), my: D.pad + plotH / 2 });
294
+ };
295
+
296
+ const activePoint =
297
+ hover?.kind === "sample"
298
+ ? hover.point
299
+ : selected?.kind === "sample"
300
+ ? model.samples.find((point) => point.t === selected.id) || null
301
+ : focusIdx != null
302
+ ? model.samples[focusIdx] || null
303
+ : null;
304
+
305
+ let lastValueLabelX = -1e9;
306
+ const frameLabel = `${fmt.monDay(new Date(v0))} - ${fmt.monDay(new Date(v1))}`;
307
+
308
+ return (
309
+ <div
310
+ ref={wrapRef}
311
+ className="tv-component"
312
+ style={{
313
+ background: "var(--tv-canvas)",
314
+ border: "1px solid var(--tv-line)",
315
+ borderRadius: "var(--tv-r-lg)",
316
+ boxShadow: "var(--tv-shadow-1)",
317
+ padding: D.pad,
318
+ fontFamily: "var(--tv-font)",
319
+ color: "var(--tv-ink)",
320
+ position: "relative",
321
+ width: "100%",
322
+ overflow: "hidden",
323
+ }}
324
+ >
325
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 4, flexWrap: "wrap" }}>
326
+ <div style={{ fontSize: narrow ? 15 : 17, fontWeight: 600, letterSpacing: 0, color: "var(--tv-ink)", lineHeight: 1.25, minWidth: 0 }}>
327
+ {spec.title || dataset.meta?.title || model.name}
328
+ </div>
329
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", whiteSpace: "nowrap", flex: "none" }}>{frameLabel}</div>
330
+ </div>
331
+
332
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 14, flexWrap: "wrap" }}>
333
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-4)" }}>
334
+ {model.name.toLowerCase()} · {model.samples.length} log{model.samples.length === 1 ? "" : "s"}
335
+ {model.unit ? ` · ${model.unit}` : ""}
336
+ </div>
337
+ <MetricControls onZoom={zoom} onPan={pan} onReset={reset} span={spanLabel(v1 - v0)} atFull={atFull} atMin={atMin} isDefault={isDefault} />
338
+ </div>
339
+
340
+ {captionOn && captionPos === "top" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
341
+ {legendOn && legendPos === "top" && (
342
+ <div style={{ paddingBottom: 14 }}>
343
+ <MetricLegend dataset={dataset} cmap={cmap} mode={mode} />
344
+ </div>
345
+ )}
346
+
347
+ <div style={{ display: "flex", gap: D.pad, alignItems: "flex-start" }}>
348
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>
349
+ <div style={{ display: "flex", alignItems: "stretch" }}>
350
+ <div style={{ width: axisW, height: plotH, position: "relative", flex: "none" }}>
351
+ {!isEmpty &&
352
+ yTicks.ticks.map((tick) => (
353
+ <div
354
+ key={tick}
355
+ style={{
356
+ position: "absolute",
357
+ right: 8,
358
+ top: yOf(tick),
359
+ transform: "translateY(-50%)",
360
+ fontFamily: "var(--tv-mono)",
361
+ fontSize: narrow ? 10 : 11,
362
+ color: "var(--tv-ink-4)",
363
+ whiteSpace: "nowrap",
364
+ }}
365
+ >
366
+ {tick}
367
+ </div>
368
+ ))}
369
+ </div>
370
+
371
+ <div style={{ position: "relative", width: plotW, flex: "none" }}>
372
+ <div
373
+ style={{
374
+ position: "relative",
375
+ width: plotW,
376
+ height: plotH,
377
+ overflow: "hidden",
378
+ border: "1px solid var(--tv-line)",
379
+ borderRadius: "var(--tv-r-sm)",
380
+ background: "var(--tv-canvas)",
381
+ }}
382
+ >
383
+ <svg width={plotW} height={plotH} style={{ position: "absolute", inset: 0, pointerEvents: "none", display: "block" }}>
384
+ {!isEmpty &&
385
+ yTicks.ticks.map((tick) => <line key={`y-${tick}`} x1={0} x2={plotW} y1={yOf(tick)} y2={yOf(tick)} stroke="var(--tv-grid)" strokeWidth={1} />)}
386
+ {xTicks.map((tick, index) => (
387
+ <line key={`x-${index}`} x1={x(tick.t)} x2={x(tick.t)} y1={0} y2={plotH} stroke={tick.strong ? "var(--tv-grid-week)" : "var(--tv-grid)"} strokeWidth={1} />
388
+ ))}
389
+
390
+ {!isEmpty && mode === "tint"
391
+ ? tintRuns.map(
392
+ (run, index) =>
393
+ run.labelId &&
394
+ run.points.length > 1 && <path key={`area-${index}`} d={areaPath(run.points)} fill={mix(cmap[run.labelId] || METRIC_INK, 9)} stroke="none" />,
395
+ )
396
+ : !isEmpty && model.samples.length > 1 && <path d={areaPath(model.samples)} fill="rgba(21,24,30,0.045)" stroke="none" />}
397
+
398
+ {!isEmpty && mode === "tint"
399
+ ? segments.map((segment, index) => (
400
+ <line
401
+ key={`segment-${index}`}
402
+ x1={x(segment.a.t)}
403
+ y1={yOf(segment.a.value)}
404
+ x2={x(segment.b.t)}
405
+ y2={yOf(segment.b.value)}
406
+ stroke={segment.labelId ? cmap[segment.labelId] || METRIC_INK : "var(--tv-ink-4)"}
407
+ strokeWidth={D.line}
408
+ strokeLinecap="round"
409
+ />
410
+ ))
411
+ : !isEmpty && model.samples.length > 1 && <path d={linePath(model.samples)} fill="none" stroke={METRIC_INK} strokeWidth={D.line} strokeLinecap="round" strokeLinejoin="round" />}
412
+
413
+ {!isEmpty && showTarget && model.target && model.target.value >= yTicks.lo && model.target.value <= yTicks.hi && (
414
+ <g>
415
+ <line x1={0} x2={plotW} y1={yOf(model.target.value)} y2={yOf(model.target.value)} stroke="var(--tv-ink-3)" strokeWidth={1.4} strokeDasharray="4 3" />
416
+ <rect x={Math.max(4, plotW - 72)} y={yOf(model.target.value) - 9} width={68} height={16} rx={3} fill="#fff" opacity={0.92} />
417
+ <text x={plotW - 7} y={yOf(model.target.value) + 3.5} textAnchor="end" fontFamily="var(--tv-mono)" fontSize={10.5} fill="var(--tv-ink-3)">
418
+ {model.target.label} {valueText(model.target.value)}
419
+ </text>
420
+ </g>
421
+ )}
422
+
423
+ {!isEmpty &&
424
+ visiblePoints &&
425
+ model.samples.map((point) => {
426
+ const px = x(point.t);
427
+ if (px < -6 || px > plotW + 6) return null;
428
+ const state = tvStateAt(model.states, point.t);
429
+ const color = mode === "tint" && state?.labelIds?.[0] ? cmap[state.labelIds[0]] : METRIC_INK;
430
+ return <circle key={point.t} cx={px} cy={yOf(point.value)} r={D.dot} fill={color} stroke="#fff" strokeWidth={1.25} />;
431
+ })}
432
+
433
+ {!isEmpty &&
434
+ showValues &&
435
+ model.samples.map((point) => {
436
+ const px = x(point.t);
437
+ if (px < 8 || px > plotW - 8 || px - lastValueLabelX < 34) return null;
438
+ lastValueLabelX = px;
439
+ return (
440
+ <text key={`value-${point.t}`} x={px} y={yOf(point.value) - D.dot - 4} textAnchor="middle" fontFamily="var(--tv-mono)" fontSize={D.valueLabel} fill="var(--tv-ink-3)">
441
+ {valueText(point.value)}
442
+ </text>
443
+ );
444
+ })}
445
+ </svg>
446
+
447
+ {!isEmpty &&
448
+ showEvents &&
449
+ model.events.map((event) => {
450
+ const px = x(event._t);
451
+ if (px < -2 || px > plotW + 2) return null;
452
+ const hue = showStates && event.labelIds?.[0] ? cmap[event.labelIds[0]] : "var(--tv-ink-3)";
453
+ return (
454
+ <span key={event.id}>
455
+ <span style={{ position: "absolute", left: px, top: 0, bottom: 0, width: 1, background: "var(--tv-grid-week)", pointerEvents: "none" }} />
456
+ <span
457
+ tabIndex={0}
458
+ role="button"
459
+ aria-label={event.title}
460
+ onMouseEnter={(ev) => setHover({ kind: "event", event, ...track(ev) })}
461
+ onMouseMove={(ev) => setHover({ kind: "event", event, ...track(ev) })}
462
+ onMouseLeave={() => setHover(null)}
463
+ onFocus={() => setHover({ kind: "event", event, mx: D.pad + axisW + px, my: 120 })}
464
+ onBlur={() => setHover(null)}
465
+ style={{ position: "absolute", left: px, top: 3, transform: "translateX(-50%)", cursor: "pointer", outline: "none", padding: 3 }}
466
+ >
467
+ <span style={{ display: "block", width: 11, height: 11, transform: "rotate(45deg)", background: "#fff", border: `2px solid ${hue}`, borderRadius: 2 }} />
468
+ </span>
469
+ </span>
470
+ );
471
+ })}
472
+
473
+ {activePoint && !isEmpty && x(activePoint.t) >= -2 && x(activePoint.t) <= plotW + 2 && (
474
+ <ActiveMarker
475
+ x={x(activePoint.t)}
476
+ y={yOf(activePoint.value)}
477
+ color={mode === "tint" && tvStateAt(model.states, activePoint.t)?.labelIds?.[0] ? cmap[tvStateAt(model.states, activePoint.t)!.labelIds![0]] : METRIC_INK}
478
+ selected={selected?.kind === "sample" && selected.id === activePoint.t}
479
+ dot={D.dot}
480
+ />
481
+ )}
482
+
483
+ {!isEmpty && (
484
+ <div
485
+ tabIndex={0}
486
+ role="application"
487
+ aria-label={`${model.name} chart, ${frameLabel}. Arrow keys move between readings; plus and minus zoom. Vertical scroll zooms around the cursor; horizontal or shift-scroll pans.`}
488
+ onPointerDown={onPlotDown}
489
+ onMouseMove={onPlotMove}
490
+ onMouseLeave={() => setHover(null)}
491
+ onWheel={onPlotWheel}
492
+ onKeyDown={onKeyDown}
493
+ style={{ position: "absolute", inset: 0, cursor: "crosshair", outline: "none", touchAction: "pan-y" }}
494
+ />
495
+ )}
496
+
497
+ {isEmpty && (
498
+ <EmptyState
499
+ title="No measurements in this window"
500
+ description="Log a value to start the trend line."
501
+ icon="trend"
502
+ narrow={narrow}
503
+ />
504
+ )}
505
+ </div>
506
+
507
+ <div style={{ position: "relative", width: plotW, height: D.xAxisH, marginTop: 6 }}>
508
+ {xTicks.map((tick, index) => (
509
+ <div key={index} style={{ position: "absolute", left: x(tick.t), top: 0, transform: "translateX(-50%)", textAlign: "center", whiteSpace: "nowrap" }}>
510
+ <div
511
+ style={{
512
+ fontFamily: "var(--tv-mono)",
513
+ fontSize: narrow ? 10.5 : 11.5,
514
+ fontWeight: tick.strong ? 600 : 500,
515
+ color: tick.strong ? "var(--tv-ink-2)" : "var(--tv-ink-4)",
516
+ lineHeight: 1,
517
+ }}
518
+ >
519
+ {tick.label}
520
+ </div>
521
+ </div>
522
+ ))}
523
+ </div>
524
+
525
+ {!isEmpty && mode === "band" && model.states.length > 0 && (
526
+ <MetricBandRow
527
+ states={model.states}
528
+ x={(time) => x(time)}
529
+ plotW={plotW}
530
+ cmap={cmap}
531
+ height={D.bandH}
532
+ narrow={narrow}
533
+ selected={selected?.kind === "state" ? String(selected.id) : null}
534
+ onSelect={(id) => setSelected((current) => (current?.kind === "state" && current.id === id ? null : { kind: "state", id }))}
535
+ onHover={(state, event) =>
536
+ setHover({
537
+ kind: "state",
538
+ state,
539
+ ...(event ? track(event) : { mx: D.pad + axisW + (x(state._s) + x(state._e)) / 2, my: plotH + 100 }),
540
+ })
541
+ }
542
+ onLeave={() => setHover(null)}
543
+ />
544
+ )}
545
+
546
+ {showMinimap && !isEmpty && (
547
+ <div style={{ marginTop: 12 }}>
548
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 9.5, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--tv-ink-4)", marginBottom: 5 }}>
549
+ Full history · drag to move window
550
+ </div>
551
+ <MetricMinimap model={{ ...model, states: showStates ? model.states : [] }} viewport={viewport} cmap={cmap} width={plotW} height={D.miniH} onScrub={scrub} />
552
+ </div>
553
+ )}
554
+ </div>
555
+ </div>
556
+ </div>
557
+
558
+ {legendOn && legendPos === "right" && !narrow && (
559
+ <div style={{ width: legendW, flex: "none", paddingTop: 2 }}>
560
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--tv-ink-4)", marginBottom: 12 }}>Phases</div>
561
+ <MetricLegend dataset={dataset} cmap={cmap} mode={mode} vertical />
562
+ </div>
563
+ )}
564
+ </div>
565
+
566
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 18, flexWrap: "wrap", padding: "14px 0 2px", borderTop: "1px solid var(--tv-line)", marginTop: 14 }}>
567
+ <MetricShapeKey mode={mode} hasTarget={showTarget && !!model.target} hasEvents={showEvents && model.events.length > 0} seriesName={model.name.toLowerCase()} />
568
+ {legendOn && legendPos === "bottom" && <MetricLegend dataset={dataset} cmap={cmap} mode={mode} />}
569
+ </div>
570
+
571
+ {captionOn && captionPos === "bottom" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
572
+
573
+ {hover && !isEmpty && (
574
+ <MetricTooltip
575
+ hover={hover}
576
+ model={model}
577
+ dataset={dataset}
578
+ cmap={cmap}
579
+ showStates={showStates}
580
+ showTarget={showTarget}
581
+ width={width}
582
+ stateAt={(time) => tvStateAt(model.states, time)}
583
+ />
584
+ )}
585
+ </div>
586
+ );
587
+ }
588
+
589
+ function ActiveMarker({ x, y, color, selected, dot }: { x: number; y: number; color: string; selected: boolean; dot: number }) {
590
+ return (
591
+ <>
592
+ <div style={{ position: "absolute", left: x, top: 0, bottom: 0, width: 1, background: "var(--tv-accent)", opacity: 0.5, pointerEvents: "none" }} />
593
+ <div
594
+ style={{
595
+ position: "absolute",
596
+ left: x,
597
+ top: y,
598
+ width: dot * 2 + 4,
599
+ height: dot * 2 + 4,
600
+ transform: "translate(-50%,-50%)",
601
+ borderRadius: "50%",
602
+ background: color,
603
+ border: "2px solid #fff",
604
+ boxShadow: selected ? "var(--tv-focus-ring)" : "0 1px 3px rgba(16,24,40,.25)",
605
+ pointerEvents: "none",
606
+ }}
607
+ />
608
+ </>
609
+ );
610
+ }
611
+
612
+ function MetricLegend({ dataset, cmap, mode, vertical = false }: { dataset: TimeDataset; cmap: Record<string, string>; mode: string; vertical?: boolean }) {
613
+ return (
614
+ <div style={{ display: "flex", flexDirection: vertical ? "column" : "row", flexWrap: "wrap", gap: vertical ? 8 : "8px 16px", alignItems: vertical ? "stretch" : "center" }}>
615
+ {(dataset.labels || []).map((label) => (
616
+ <div key={label.id} style={{ display: "inline-flex", alignItems: "center", gap: 8, minWidth: 0, flex: "none", maxWidth: vertical ? "100%" : "none" }}>
617
+ <span
618
+ style={{
619
+ width: 16,
620
+ height: 9,
621
+ borderRadius: 3,
622
+ background: mix(cmap[label.id], mode === "tint" ? 30 : 22),
623
+ flex: "none",
624
+ boxShadow: `inset 3px 0 0 0 ${cmap[label.id]}, inset 0 0 0 1px ${mix(cmap[label.id], 34)}`,
625
+ }}
626
+ />
627
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: 12.5, color: "var(--tv-ink-2)", fontWeight: 500, lineHeight: 1.3, whiteSpace: vertical ? "normal" : "nowrap" }}>{label.name}</span>
628
+ </div>
629
+ ))}
630
+ </div>
631
+ );
632
+ }
633
+
634
+ function MetricShapeKey({ mode, hasTarget, hasEvents, seriesName }: { mode: string; hasTarget: boolean; hasEvents: boolean; seriesName: string }) {
635
+ return (
636
+ <div style={{ display: "flex", alignItems: "center", gap: 15, flexWrap: "wrap" }}>
637
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--tv-ink-4)" }}>Key</span>
638
+ <KeyItem label={seriesName}>
639
+ <svg width="26" height="12" aria-hidden="true">
640
+ <line x1="1" y1="6" x2="25" y2="6" stroke={mode === "tint" ? "var(--tv-ink-3)" : METRIC_INK} strokeWidth="2" />
641
+ <circle cx="13" cy="6" r="2.6" fill={mode === "tint" ? "var(--tv-ink-3)" : METRIC_INK} />
642
+ </svg>
643
+ </KeyItem>
644
+ {hasTarget && (
645
+ <KeyItem label="goal">
646
+ <svg width="26" height="12" aria-hidden="true">
647
+ <line x1="1" y1="6" x2="25" y2="6" stroke="var(--tv-ink-3)" strokeWidth="1.5" strokeDasharray="4 3" />
648
+ </svg>
649
+ </KeyItem>
650
+ )}
651
+ {mode === "tint" && (
652
+ <KeyItem label="line tinted by phase">
653
+ <svg width="26" height="12" aria-hidden="true">
654
+ <line x1="1" y1="6" x2="13" y2="6" stroke="#2f6fed" strokeWidth="2.4" />
655
+ <line x1="13" y1="6" x2="25" y2="6" stroke="#0f9f6e" strokeWidth="2.4" />
656
+ </svg>
657
+ </KeyItem>
658
+ )}
659
+ {hasEvents && (
660
+ <KeyItem label="milestone">
661
+ <span style={{ width: 11, height: 11, transform: "rotate(45deg)", borderRadius: 2, background: "#fff", border: "2px solid var(--tv-ink-3)" }} />
662
+ </KeyItem>
663
+ )}
664
+ </div>
665
+ );
666
+ }
667
+
668
+ function KeyItem({ label, children }: { label: string; children: ReactNode }) {
669
+ return (
670
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
671
+ {children}
672
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: 11.5, color: "var(--tv-ink-3)" }}>{label}</span>
673
+ </span>
674
+ );
675
+ }
676
+
677
+ function MetricControls({
678
+ onZoom,
679
+ onPan,
680
+ onReset,
681
+ span,
682
+ atFull,
683
+ atMin,
684
+ isDefault,
685
+ }: {
686
+ onZoom: (direction: -1 | 1) => void;
687
+ onPan: (direction: -1 | 1) => void;
688
+ onReset: () => void;
689
+ span: string;
690
+ atFull: boolean;
691
+ atMin: boolean;
692
+ isDefault: boolean;
693
+ }) {
694
+ return (
695
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
696
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-4)", letterSpacing: ".02em", marginRight: 2 }}>{span}</span>
697
+ <div style={{ display: "flex", gap: 4 }}>
698
+ <MetricButton label="‹" title="Pan back" onClick={() => onPan(-1)} />
699
+ <MetricButton label="›" title="Pan forward" onClick={() => onPan(1)} />
700
+ </div>
701
+ <div style={{ display: "flex", gap: 4 }}>
702
+ <MetricButton label="-" title="Zoom out" onClick={() => onZoom(1)} disabled={atFull} />
703
+ <MetricButton label="+" title="Zoom in" onClick={() => onZoom(-1)} disabled={atMin} />
704
+ </div>
705
+ <MetricButton label="Reset" title="Reset to default viewport" onClick={onReset} disabled={isDefault} wide />
706
+ </div>
707
+ );
708
+ }
709
+
710
+ function MetricButton({ label, title, onClick, disabled, wide }: { label: string; title: string; onClick: () => void; disabled?: boolean; wide?: boolean }) {
711
+ return (
712
+ <button
713
+ type="button"
714
+ aria-label={title}
715
+ title={title}
716
+ onClick={onClick}
717
+ disabled={disabled}
718
+ style={{
719
+ appearance: "none",
720
+ height: 28,
721
+ minWidth: wide ? "auto" : 28,
722
+ padding: wide ? "0 10px" : 0,
723
+ display: "inline-flex",
724
+ alignItems: "center",
725
+ justifyContent: "center",
726
+ border: "1px solid var(--tv-line)",
727
+ borderRadius: 7,
728
+ background: disabled ? "var(--tv-rail)" : "#fff",
729
+ color: disabled ? "var(--tv-ink-5)" : "var(--tv-ink-2)",
730
+ cursor: disabled ? "default" : "pointer",
731
+ fontFamily: "var(--tv-mono)",
732
+ fontSize: 12,
733
+ fontWeight: 600,
734
+ boxShadow: disabled ? "none" : "var(--tv-shadow-1)",
735
+ }}
736
+ >
737
+ {label}
738
+ </button>
739
+ );
740
+ }
741
+
742
+ function MetricBandRow({
743
+ states,
744
+ x,
745
+ plotW,
746
+ cmap,
747
+ height,
748
+ narrow,
749
+ selected,
750
+ onSelect,
751
+ onHover,
752
+ onLeave,
753
+ }: {
754
+ states: MetricState[];
755
+ x: (time: number) => number;
756
+ plotW: number;
757
+ cmap: Record<string, string>;
758
+ height: number;
759
+ narrow: boolean;
760
+ selected: string | null;
761
+ onSelect: (id: string) => void;
762
+ onHover: (state: MetricState, event: { clientX: number; clientY: number } | null) => void;
763
+ onLeave: () => void;
764
+ }) {
765
+ return (
766
+ <div style={{ position: "relative", width: plotW, height, marginTop: 8 }}>
767
+ {states.map((state) => {
768
+ const hue = cmap[state.labelIds?.[0] || ""] || "#64748b";
769
+ const raw0 = x(state._s);
770
+ const raw1 = x(state._e);
771
+ const x0 = Math.max(0, raw0);
772
+ const x1 = Math.min(plotW, raw1);
773
+ if (x1 - x0 < 0.5) return null;
774
+ const startsIn = raw0 >= -0.5;
775
+ const isSelected = selected === state.id;
776
+ return (
777
+ <div
778
+ key={state.id}
779
+ role="button"
780
+ tabIndex={0}
781
+ onMouseEnter={(event) => onHover(state, event)}
782
+ onMouseMove={(event) => onHover(state, event)}
783
+ onMouseLeave={onLeave}
784
+ onFocus={() => onHover(state, null)}
785
+ onBlur={onLeave}
786
+ onClick={() => onSelect(state.id)}
787
+ style={{
788
+ position: "absolute",
789
+ left: x0,
790
+ width: x1 - x0,
791
+ top: 0,
792
+ bottom: 0,
793
+ cursor: "pointer",
794
+ outline: "none",
795
+ background: mix(hue, 17),
796
+ borderRadius: 6,
797
+ borderTop: `1px solid ${mix(hue, 32)}`,
798
+ borderBottom: `1px solid ${mix(hue, 32)}`,
799
+ boxShadow: [startsIn ? `inset 3px 0 0 0 ${hue}` : null, isSelected ? "var(--tv-focus-ring)" : null].filter(Boolean).join(", ") || "none",
800
+ display: "flex",
801
+ alignItems: "center",
802
+ gap: 7,
803
+ padding: "0 9px",
804
+ overflow: "hidden",
805
+ }}
806
+ >
807
+ {startsIn && <span style={{ width: 6, height: 6, borderRadius: "50%", background: hue, flex: "none" }} />}
808
+ {x1 - x0 > 52 && (
809
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: narrow ? 11 : 12, fontWeight: 500, color: "var(--tv-ink-2)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
810
+ {state.title}
811
+ </span>
812
+ )}
813
+ </div>
814
+ );
815
+ })}
816
+ </div>
817
+ );
818
+ }
819
+
820
+ function MetricMinimap({
821
+ model,
822
+ viewport,
823
+ cmap,
824
+ width,
825
+ height,
826
+ onScrub,
827
+ }: {
828
+ model: MetricModel;
829
+ viewport: [number, number];
830
+ cmap: Record<string, string>;
831
+ width: number;
832
+ height: number;
833
+ onScrub: (v0: number, v1: number) => void;
834
+ }) {
835
+ const ref = useRef<HTMLDivElement>(null);
836
+ const padY = 7;
837
+ const bandH = 5;
838
+ const lineH = height - bandH - padY * 2;
839
+ const xFull = (time: number) => ((time - model.T0) / Math.max(1, model.T1 - model.T0)) * width;
840
+ const vSpan = Math.max(1, model.vMax - model.vMin);
841
+ const yOf = (value: number) => padY + (1 - (value - model.vMin) / vSpan) * lineH;
842
+ const path = model.samples.map((point, index) => `${index ? "L" : "M"}${xFull(point.t).toFixed(1)} ${yOf(point.value).toFixed(1)}`).join(" ");
843
+ const [w0, w1] = viewport;
844
+ const wx0 = xFull(w0);
845
+ const wx1 = xFull(w1);
846
+
847
+ const timeAt = (clientX: number) => {
848
+ const rect = ref.current?.getBoundingClientRect();
849
+ if (!rect) return model.T0;
850
+ const f = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
851
+ return model.T0 + f * (model.T1 - model.T0);
852
+ };
853
+
854
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
855
+ event.preventDefault();
856
+ const span = w1 - w0;
857
+ const rect = ref.current?.getBoundingClientRect();
858
+ const inside = rect ? event.clientX >= rect.left + wx0 - 6 && event.clientX <= rect.left + wx1 + 6 : false;
859
+ const startCenter = (w0 + w1) / 2;
860
+ const grabOffset = inside ? timeAt(event.clientX) - startCenter : 0;
861
+ const apply = (clientX: number) => {
862
+ const center = timeAt(clientX) - grabOffset;
863
+ onScrub(center - span / 2, center + span / 2);
864
+ };
865
+ apply(event.clientX);
866
+ const move = (ev: PointerEvent) => apply(ev.clientX);
867
+ const up = () => {
868
+ window.removeEventListener("pointermove", move);
869
+ window.removeEventListener("pointerup", up);
870
+ };
871
+ window.addEventListener("pointermove", move);
872
+ window.addEventListener("pointerup", up);
873
+ };
874
+
875
+ return (
876
+ <div
877
+ ref={ref}
878
+ onPointerDown={onPointerDown}
879
+ style={{ position: "relative", width, height, cursor: "ew-resize", background: "var(--tv-frame)", border: "1px solid var(--tv-line)", borderRadius: "var(--tv-r-md)", overflow: "hidden", touchAction: "none" }}
880
+ >
881
+ <svg width={width} height={height} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
882
+ {model.states.map((state) => {
883
+ const hue = cmap[state.labelIds?.[0] || ""] || "#64748b";
884
+ const x0 = Math.max(0, xFull(state._s));
885
+ const x1 = Math.min(width, xFull(state._e));
886
+ if (x1 - x0 < 0.5) return null;
887
+ return <rect key={state.id} x={x0} y={height - bandH} width={x1 - x0} height={bandH} fill={mix(hue, 60)} />;
888
+ })}
889
+ {model.samples.length > 1 && <path d={path} fill="none" stroke="var(--tv-ink-3)" strokeWidth={1.4} strokeLinejoin="round" strokeLinecap="round" />}
890
+ </svg>
891
+ <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, width: Math.max(0, wx0), background: "rgba(245,247,249,0.74)", pointerEvents: "none" }} />
892
+ <div style={{ position: "absolute", top: 0, bottom: 0, left: Math.min(width, wx1), right: 0, background: "rgba(245,247,249,0.74)", pointerEvents: "none" }} />
893
+ <div style={{ position: "absolute", top: 0, bottom: 0, left: wx0, width: Math.max(8, wx1 - wx0), border: "1.5px solid var(--tv-accent)", borderRadius: 5, background: "var(--tv-accent-soft)", pointerEvents: "none" }}>
894
+ <span style={{ position: "absolute", left: -1, top: "50%", transform: "translateY(-50%)", width: 3, height: 16, background: "var(--tv-accent)", borderRadius: 2 }} />
895
+ <span style={{ position: "absolute", right: -1, top: "50%", transform: "translateY(-50%)", width: 3, height: 16, background: "var(--tv-accent)", borderRadius: 2 }} />
896
+ </div>
897
+ </div>
898
+ );
899
+ }
900
+
901
+ function MetricTooltip({
902
+ hover,
903
+ model,
904
+ dataset,
905
+ cmap,
906
+ showStates,
907
+ showTarget,
908
+ width,
909
+ stateAt,
910
+ }: {
911
+ hover: Hover;
912
+ model: MetricModel;
913
+ dataset: TimeDataset;
914
+ cmap: Record<string, string>;
915
+ showStates: boolean;
916
+ showTarget: boolean;
917
+ width: number;
918
+ stateAt: (time: number) => MetricState | null;
919
+ }) {
920
+ const tooltipW = 230;
921
+ const left = Math.max(8, Math.min(width - tooltipW - 8, hover.mx - tooltipW / 2));
922
+ const top = Math.max(8, hover.my - 104);
923
+ let title = "";
924
+ let line = "";
925
+ let kind = "Reading";
926
+ let color = METRIC_INK;
927
+ let body: ReactNode = null;
928
+
929
+ if (hover.kind === "sample") {
930
+ const state = stateAt(hover.point.t);
931
+ color = showStates && state?.labelIds?.[0] ? cmap[state.labelIds[0]] : METRIC_INK;
932
+ const delta = showTarget && model.target ? Math.round((hover.point.value - model.target.value) * 10) / 10 : null;
933
+ body = (
934
+ <>
935
+ <div style={{ display: "flex", alignItems: "baseline", gap: 5, marginBottom: 3 }}>
936
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 21, fontWeight: 600, color: "var(--tv-ink)", lineHeight: 1 }}>{valueText(hover.point.value)}</span>
937
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 12, color: "var(--tv-ink-4)" }}>{model.unit}</span>
938
+ {delta != null && (
939
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 11, marginLeft: "auto", color: delta > 0 ? "#c2410c" : delta < 0 ? "#0f9f6e" : "var(--tv-ink-4)" }}>
940
+ {delta > 0 ? "+" : ""}
941
+ {valueText(delta)} vs goal
942
+ </span>
943
+ )}
944
+ </div>
945
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", lineHeight: 1.45 }}>{fmt.full(new Date(hover.point.t))}</div>
946
+ {showStates && state?.labelIds?.[0] && (
947
+ <div style={{ display: "inline-flex", alignItems: "center", gap: 6, marginTop: 6 }}>
948
+ <span style={{ width: 8, height: 8, borderRadius: 2, background: mix(color, 26), boxShadow: `inset 2px 0 0 0 ${color}`, flex: "none" }} />
949
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: 11.5, color: "var(--tv-ink-2)" }}>{labelName(dataset, state.labelIds[0])}</span>
950
+ </div>
951
+ )}
952
+ </>
953
+ );
954
+ } else if (hover.kind === "state") {
955
+ kind = "Phase";
956
+ title = hover.state.title;
957
+ color = cmap[hover.state.labelIds?.[0] || ""] || "#64748b";
958
+ line = `${fmt.full(new Date(hover.state._s))} - ${fmt.full(new Date(hover.state._e - DAY_MS))}`;
959
+ } else {
960
+ kind = "Milestone";
961
+ title = hover.event.title;
962
+ color = showStates && hover.event.labelIds?.[0] ? cmap[hover.event.labelIds[0]] : "var(--tv-ink-3)";
963
+ line = fmt.full(new Date(hover.event._t));
964
+ }
965
+
966
+ const isEvent = hover.kind === "event";
967
+ return (
968
+ <div data-tv-transient style={{ position: "absolute", left, top, width: tooltipW, pointerEvents: "none", zIndex: 50 }}>
969
+ <div style={{ background: "#fff", border: "1px solid var(--tv-line)", borderRadius: "var(--tv-r-md)", boxShadow: "var(--tv-shadow-pop)", padding: "10px 13px" }}>
970
+ <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
971
+ <span
972
+ style={{
973
+ width: 9,
974
+ height: 9,
975
+ borderRadius: isEvent ? 1 : "50%",
976
+ background: isEvent ? "#fff" : color,
977
+ border: isEvent ? `2px solid ${color}` : "none",
978
+ transform: isEvent ? "rotate(45deg)" : "none",
979
+ flex: "none",
980
+ }}
981
+ />
982
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--tv-ink-4)" }}>{kind}</span>
983
+ </div>
984
+ {body || (
985
+ <>
986
+ <div style={{ fontSize: 14, fontWeight: 600, color: "var(--tv-ink)", marginBottom: 4, lineHeight: 1.3 }}>{title}</div>
987
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", lineHeight: 1.45 }}>{line}</div>
988
+ </>
989
+ )}
990
+ </div>
991
+ </div>
992
+ );
993
+ }