@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,620 @@
1
+ // BandedTimeline.tsx — the first Timeview visualizer (React / HTML renderer).
2
+ //
3
+ // Renders a horizontal day axis, instant-event milestones, and colored
4
+ // interval bands in stacked lanes. Pure presentational; every option arrives
5
+ // through `spec`. The only internal state is hover + selection.
6
+ //
7
+ // Ported pixel-for-pixel from the design handoff's `timeview.jsx`.
8
+
9
+ import { useState, useMemo, Fragment } from "react";
10
+ import { DAY_MS, domainOf, tvScale, tvDays, tvWeeks, fmt } from "./core/time";
11
+ import { packLanes, overlapSpans } from "./core/intervals";
12
+ import { colorMap, labelName, mix } from "./core/labels";
13
+ import { Caption } from "./shared/Caption";
14
+ import { EmptyState } from "./shared/EmptyState";
15
+ import { Legend } from "./shared/Legend";
16
+ import { Tooltip } from "./shared/Tooltip";
17
+ import { useMeasuredWidth } from "./shared/useMeasuredWidth";
18
+ import type {
19
+ TimeDataset,
20
+ TimeEvent,
21
+ TimeInterval,
22
+ BandedTimelineSpec,
23
+ Density,
24
+ OverlapMode,
25
+ } from "./types";
26
+
27
+ export interface BandedTimelineProps {
28
+ /** schema `timeview.dataset.v1` */
29
+ dataset: TimeDataset;
30
+ /** view-specific projection (title, density, overlap mode, legend, caption…) */
31
+ spec: BandedTimelineSpec;
32
+ /** colors mapped to `dataset.labels` by index */
33
+ palette: string[];
34
+ }
35
+
36
+ // ── derived model shapes ────────────────────────────────────────────
37
+ type ModelInterval = TimeInterval & { _s: number; _e: number; _lane: number };
38
+ type ModelEvent = TimeEvent & { _t: number };
39
+
40
+ interface Model {
41
+ t0: number;
42
+ t1: number;
43
+ intervals: ModelInterval[];
44
+ events: ModelEvent[];
45
+ laneCount: number;
46
+ spans: [number, number][];
47
+ }
48
+
49
+ type Hover =
50
+ | { kind: "interval"; id: string; iv: ModelInterval; c: string; mx: number; my: number; sticky?: boolean }
51
+ | { kind: "event"; id: string; ev: ModelEvent; c: string; mx: number; my: number; sticky?: boolean };
52
+
53
+ function buildModel(dataset: TimeDataset, overlapMode: OverlapMode): Model {
54
+ const [t0, t1] = domainOf(dataset);
55
+ const intervals: ModelInterval[] = (dataset.intervals || []).map((iv) => ({
56
+ ...iv,
57
+ _s: new Date(iv.range.start).getTime(),
58
+ _e: new Date(iv.range.end).getTime(),
59
+ _lane: 0,
60
+ }));
61
+ let laneCount: number;
62
+ if (overlapMode === "lanes") {
63
+ intervals.sort((a, b) => a._s - b._s).forEach((iv, i) => (iv._lane = i));
64
+ laneCount = intervals.length;
65
+ } else if (overlapMode === "layered") {
66
+ intervals.forEach((iv) => (iv._lane = 0));
67
+ laneCount = 1;
68
+ } else {
69
+ laneCount = packLanes(intervals); // packed + hatched
70
+ }
71
+ const events: ModelEvent[] = (dataset.events || []).map((e) => ({ ...e, _t: new Date(e.at).getTime() }));
72
+ return { t0, t1, intervals, events, laneCount, spans: overlapMode === "hatched" ? overlapSpans(intervals) : [] };
73
+ }
74
+
75
+ // ── density presets ─────────────────────────────────────────────────
76
+ interface DensitySpec {
77
+ laneH: number;
78
+ gap: number;
79
+ pad: number;
80
+ axisH: number;
81
+ weekH: number;
82
+ railH: number;
83
+ band: number;
84
+ dot: number;
85
+ layeredH: number;
86
+ }
87
+ const DENS: Record<Density, DensitySpec> = {
88
+ comfortable: { laneH: 44, gap: 8, pad: 24, axisH: 46, weekH: 24, railH: 30, band: 14, dot: 8, layeredH: 120 },
89
+ compact: { laneH: 30, gap: 5, pad: 16, axisH: 38, weekH: 20, railH: 24, band: 12.5, dot: 7, layeredH: 92 },
90
+ };
91
+
92
+ // ── the component ───────────────────────────────────────────────────
93
+ export function BandedTimeline({ dataset, spec, palette }: BandedTimelineProps) {
94
+ const [wrapRef, w] = useMeasuredWidth<HTMLDivElement>();
95
+ const [hover, setHover] = useState<Hover | null>(null);
96
+ const [sel, setSel] = useState<string | null>(null);
97
+
98
+ const density: Density = spec.density || "comfortable";
99
+ const D = DENS[density] || DENS.comfortable;
100
+ const overlapMode = spec.overlapMode || "lanes";
101
+ const showEventLabels = spec.events?.showLabels !== false;
102
+ const legendPos = spec.legend?.position || "bottom"; // top|bottom|right|off
103
+ const captionPos = spec.caption?.position || "bottom"; // top|bottom|off
104
+ const captionText = spec.caption?.text || "";
105
+ const legendOn = legendPos !== "off" && (dataset.labels || []).length > 0;
106
+ const captionOn = captionPos !== "off" && !!captionText;
107
+
108
+ const track = (e: { clientX: number; clientY: number }) => {
109
+ const r = wrapRef.current!.getBoundingClientRect();
110
+ return { mx: e.clientX - r.left, my: e.clientY - r.top };
111
+ };
112
+
113
+ const model = useMemo(() => buildModel(dataset, overlapMode), [dataset, overlapMode]);
114
+ const cmap = useMemo(() => colorMap(dataset, palette), [dataset, palette]);
115
+ const isEmpty = model.intervals.length === 0 && model.events.length === 0;
116
+
117
+ // plot width = container minus frame padding (and right legend if any)
118
+ const legendW = legendPos === "right" ? Math.min(230, Math.max(170, w * 0.22)) : 0;
119
+ const plotW = Math.max(120, w - D.pad * 2 - (legendW ? legendW + D.pad : 0));
120
+ const x = tvScale(model.t0, model.t1, plotW);
121
+ const days = tvDays(model.t0, model.t1).filter((d) => d.getTime() < model.t1);
122
+ const weeks = tvWeeks(model.t0, model.t1);
123
+ const pxPerDay = plotW / Math.max(1, days.length);
124
+ const dayStep = pxPerDay >= 30 ? 1 : pxPerDay >= 18 ? 2 : pxPerDay >= 11 ? 3 : 7;
125
+ const narrow = pxPerDay < 22;
126
+
127
+ // layered mode: stagger small label chips so they don't collide
128
+ const layeredLabels = useMemo(() => {
129
+ if (overlapMode !== "layered") return { rows: {} as Record<string, number>, count: 1 };
130
+ const rows: Record<string, number> = {};
131
+ const rowRight: number[] = [];
132
+ [...model.intervals]
133
+ .sort((a, b) => a._s - b._s)
134
+ .forEach((iv) => {
135
+ const lx = x(iv._s) + 6;
136
+ const estW = 26 + iv.title.length * 6.6;
137
+ let r = rowRight.findIndex((right) => lx >= right + 8);
138
+ if (r === -1) r = rowRight.length;
139
+ rowRight[r] = lx + estW;
140
+ rows[iv.id] = r;
141
+ });
142
+ return { rows, count: Math.max(1, rowRight.length) };
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps
144
+ }, [overlapMode, model, plotW]);
145
+
146
+ const lanesH = isEmpty
147
+ ? 150
148
+ : overlapMode === "layered"
149
+ ? Math.max(96, layeredLabels.count * 26 + 26)
150
+ : Math.max(D.laneH, model.laneCount * D.laneH);
151
+ const plotH = lanesH + D.gap;
152
+
153
+ // ── sub-renders ───────────────────────────────────────────────────
154
+ // gridlines + day labels + week strip
155
+ const Axis = () => (
156
+ <>
157
+ {/* week strip */}
158
+ <div style={{ position: "relative", height: D.weekH, marginBottom: 4 }}>
159
+ {weeks.map((wk, i) => {
160
+ const x0 = Math.max(0, x(wk.start));
161
+ const x1 = Math.min(plotW, x(wk.end));
162
+ if (x1 - x0 < 4) return null;
163
+ const labelStart = wk.start.getTime() < model.t0 ? new Date(model.t0) : wk.start;
164
+ return (
165
+ <div
166
+ key={i}
167
+ style={{
168
+ position: "absolute",
169
+ left: x0,
170
+ width: x1 - x0,
171
+ top: 0,
172
+ bottom: 0,
173
+ background: i % 2 ? "var(--tv-rail-2)" : "var(--tv-rail)",
174
+ borderRadius: 5,
175
+ display: "flex",
176
+ alignItems: "center",
177
+ paddingLeft: 9,
178
+ overflow: "hidden",
179
+ }}
180
+ >
181
+ <span
182
+ style={{
183
+ fontFamily: "var(--tv-mono)",
184
+ fontSize: 10.5,
185
+ fontWeight: 500,
186
+ color: "var(--tv-ink-3)",
187
+ letterSpacing: ".02em",
188
+ whiteSpace: "nowrap",
189
+ }}
190
+ >
191
+ {x1 - x0 > 78 && !narrow ? `Week of ${fmt.monDay(labelStart)}` : fmt.monDay(labelStart)}
192
+ </span>
193
+ </div>
194
+ );
195
+ })}
196
+ </div>
197
+ {/* day ticks */}
198
+ <div style={{ position: "relative", height: D.axisH }}>
199
+ {days.map((d, i) => {
200
+ const px = x(d);
201
+ const isMon = (d.getUTCDay() + 6) % 7 === 0;
202
+ const show = i % dayStep === 0;
203
+ return (
204
+ <div key={i} style={{ position: "absolute", left: px, top: 0 }}>
205
+ <div style={{ width: 1, height: 7, background: isMon ? "var(--tv-grid-week)" : "var(--tv-grid)" }} />
206
+ {show && (
207
+ <div style={{ position: "absolute", left: 0, top: 9, transform: "translateX(-50%)", textAlign: "center" }}>
208
+ <div
209
+ style={{
210
+ fontFamily: "var(--tv-mono)",
211
+ fontSize: narrow ? 11 : 12.5,
212
+ fontWeight: 600,
213
+ color: "var(--tv-ink-2)",
214
+ lineHeight: 1,
215
+ }}
216
+ >
217
+ {fmt.day(d)}
218
+ </div>
219
+ {!narrow && (
220
+ <div
221
+ style={{
222
+ fontFamily: "var(--tv-font)",
223
+ fontSize: 10,
224
+ color: "var(--tv-ink-4)",
225
+ marginTop: 3,
226
+ lineHeight: 1,
227
+ }}
228
+ >
229
+ {fmt.dow(d)}
230
+ </div>
231
+ )}
232
+ </div>
233
+ )}
234
+ </div>
235
+ );
236
+ })}
237
+ </div>
238
+ </>
239
+ );
240
+
241
+ return (
242
+ <div
243
+ ref={wrapRef}
244
+ className="tv-component"
245
+ style={{
246
+ background: "var(--tv-canvas)",
247
+ border: "1px solid var(--tv-line)",
248
+ borderRadius: "var(--tv-r-lg)",
249
+ boxShadow: "var(--tv-shadow-1)",
250
+ padding: D.pad,
251
+ fontFamily: "var(--tv-font)",
252
+ color: "var(--tv-ink)",
253
+ position: "relative",
254
+ width: "100%",
255
+ overflow: "hidden",
256
+ }}
257
+ >
258
+ {/* title row */}
259
+ <div
260
+ style={{
261
+ display: "flex",
262
+ alignItems: "baseline",
263
+ justifyContent: "space-between",
264
+ gap: 12,
265
+ marginBottom: captionPos === "top" || legendPos === "top" ? 14 : 16,
266
+ flexWrap: "wrap",
267
+ }}
268
+ >
269
+ <div style={{ minWidth: 0 }}>
270
+ <div
271
+ style={{
272
+ fontSize: narrow ? 15 : 17,
273
+ fontWeight: 600,
274
+ letterSpacing: "-0.01em",
275
+ color: "var(--tv-ink)",
276
+ lineHeight: 1.25,
277
+ }}
278
+ >
279
+ {spec.title || dataset.meta?.title}
280
+ </div>
281
+ </div>
282
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-4)", whiteSpace: "nowrap", flex: "none" }}>
283
+ {fmt.monDay(model.t0)} – {fmt.monDay(new Date(model.t1 - DAY_MS))}
284
+ </div>
285
+ </div>
286
+
287
+ {captionOn && captionPos === "top" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
288
+ {legendOn && legendPos === "top" && <Legend labels={dataset.labels || []} colors={cmap} position={legendPos} />}
289
+
290
+ <div style={{ display: "flex", gap: D.pad, alignItems: "stretch" }}>
291
+ {/* PLOT */}
292
+ <div style={{ width: plotW, flex: "none" }}>
293
+ <Axis />
294
+
295
+ {/* plot body */}
296
+ <div
297
+ style={{ position: "relative", width: plotW, height: plotH, marginTop: 6 }}
298
+ onMouseLeave={() => setHover((h) => (h && h.sticky ? h : null))}
299
+ >
300
+ {/* day gridlines */}
301
+ {days.map((d, i) => {
302
+ const isMon = (d.getUTCDay() + 6) % 7 === 0;
303
+ return (
304
+ <div
305
+ key={i}
306
+ style={{
307
+ position: "absolute",
308
+ left: x(d),
309
+ top: 0,
310
+ bottom: 0,
311
+ width: 1,
312
+ background: isMon ? "var(--tv-grid-week)" : "var(--tv-grid)",
313
+ }}
314
+ />
315
+ );
316
+ })}
317
+ <div style={{ position: "absolute", left: plotW - 0.5, top: 0, bottom: 0, width: 1, background: "var(--tv-grid-week)" }} />
318
+
319
+ {/* hatched overlap zones */}
320
+ {model.spans.map(([s, e], i) => (
321
+ <div
322
+ key={"sp" + i}
323
+ style={{
324
+ position: "absolute",
325
+ left: x(s),
326
+ width: x(e) - x(s),
327
+ top: 0,
328
+ bottom: 0,
329
+ background: "repeating-linear-gradient(135deg, rgba(21,24,30,0.05) 0 5px, transparent 5px 11px)",
330
+ borderLeft: "1px dashed var(--tv-ink-5)",
331
+ borderRight: "1px dashed var(--tv-ink-5)",
332
+ pointerEvents: "none",
333
+ }}
334
+ />
335
+ ))}
336
+
337
+ {/* event hairlines */}
338
+ {model.events.map((ev) => (
339
+ <div
340
+ key={"hl" + ev.id}
341
+ style={{
342
+ position: "absolute",
343
+ left: x(ev._t),
344
+ top: -2,
345
+ bottom: 0,
346
+ width: 1,
347
+ background: "var(--tv-ink-5)",
348
+ pointerEvents: "none",
349
+ }}
350
+ />
351
+ ))}
352
+
353
+ {/* interval bands */}
354
+ {model.intervals.map((iv) => {
355
+ const c = cmap[iv.labelIds?.[0] ?? ""] || "#64748b";
356
+ const left = x(iv._s);
357
+ const wid = Math.max(3, x(iv._e) - x(iv._s));
358
+ const layered = overlapMode === "layered";
359
+ const top = layered ? 0 : iv._lane * D.laneH;
360
+ const h = layered ? lanesH : D.laneH - D.gap;
361
+ const hovered = hover?.id === iv.id;
362
+ const selected = sel === iv.id;
363
+ const tiny = wid < 54;
364
+ const boxShadow =
365
+ [
366
+ layered ? null : `inset 3px 0 0 0 ${c}`,
367
+ selected ? "var(--tv-focus-ring)" : hovered ? "var(--tv-shadow-pop)" : null,
368
+ ]
369
+ .filter(Boolean)
370
+ .join(", ") || "none";
371
+ return (
372
+ <div
373
+ key={iv.id}
374
+ tabIndex={0}
375
+ role="button"
376
+ onMouseEnter={(e) => setHover({ kind: "interval", id: iv.id, iv, c, ...track(e) })}
377
+ onMouseMove={(e) => setHover({ kind: "interval", id: iv.id, iv, c, ...track(e) })}
378
+ onFocus={() =>
379
+ setHover({ kind: "interval", id: iv.id, iv, c, mx: D.pad + Math.min(left + 90, left + wid / 2), my: 96 })
380
+ }
381
+ onBlur={() => setHover(null)}
382
+ onClick={() => setSel((s) => (s === iv.id ? null : iv.id))}
383
+ style={{
384
+ position: "absolute",
385
+ left,
386
+ width: wid,
387
+ top,
388
+ height: h,
389
+ background: layered ? mix(c, 30, "#ffffff") : mix(c, 13, "#ffffff"),
390
+ opacity: layered ? 0.62 : 1,
391
+ mixBlendMode: layered ? "multiply" : "normal",
392
+ border: `1px solid ${hovered || selected ? c : mix(c, 34, "#ffffff")}`,
393
+ borderRadius: "var(--tv-r-sm)",
394
+ cursor: "pointer",
395
+ outline: "none",
396
+ boxShadow,
397
+ transition:
398
+ "box-shadow var(--tv-fast) var(--tv-ease), border-color var(--tv-fast) var(--tv-ease), background var(--tv-fast)",
399
+ display: "flex",
400
+ alignItems: "center",
401
+ gap: 7,
402
+ padding: layered ? "0 8px" : "0 9px",
403
+ overflow: "hidden",
404
+ zIndex: hovered || selected ? 5 : 1,
405
+ }}
406
+ >
407
+ {!layered && <span style={{ width: D.dot, height: D.dot, borderRadius: "50%", background: c, flex: "none" }} />}
408
+ {!layered && !tiny && (
409
+ <span
410
+ style={{
411
+ fontSize: D.band,
412
+ fontWeight: 500,
413
+ color: "var(--tv-ink-2)",
414
+ whiteSpace: "nowrap",
415
+ overflow: "hidden",
416
+ textOverflow: "ellipsis",
417
+ flex: "1 1 auto",
418
+ minWidth: 0,
419
+ }}
420
+ >
421
+ {iv.title}
422
+ </span>
423
+ )}
424
+ {!layered && !tiny && !narrow && wid > 130 && (
425
+ <span
426
+ style={{
427
+ fontFamily: "var(--tv-mono)",
428
+ fontSize: 10.5,
429
+ color: "var(--tv-ink-4)",
430
+ flex: "none",
431
+ whiteSpace: "nowrap",
432
+ letterSpacing: ".01em",
433
+ }}
434
+ >
435
+ {fmt.span(iv._s, iv._e)}
436
+ </span>
437
+ )}
438
+ </div>
439
+ );
440
+ })}
441
+
442
+ {/* layered mode: staggered label chips (direct labels survive without color) */}
443
+ {overlapMode === "layered" &&
444
+ model.intervals.map((iv) => {
445
+ const c = cmap[iv.labelIds?.[0] ?? ""] || "#64748b";
446
+ const lx = x(iv._s);
447
+ const r = layeredLabels.rows[iv.id] || 0;
448
+ return (
449
+ <div
450
+ key={"ll" + iv.id}
451
+ style={{
452
+ position: "absolute",
453
+ left: lx + 5,
454
+ top: 8 + r * 26,
455
+ display: "inline-flex",
456
+ alignItems: "center",
457
+ gap: 6,
458
+ padding: "2px 8px 2px 6px",
459
+ background: "rgba(255,255,255,0.92)",
460
+ border: "1px solid var(--tv-line)",
461
+ borderRadius: "var(--tv-r-pill)",
462
+ boxShadow: "var(--tv-shadow-1)",
463
+ pointerEvents: "none",
464
+ maxWidth: Math.max(60, plotW - lx - 8),
465
+ }}
466
+ >
467
+ <span style={{ width: 7, height: 7, borderRadius: "50%", background: c, flex: "none" }} />
468
+ <span
469
+ style={{
470
+ fontSize: 11.5,
471
+ fontWeight: 500,
472
+ color: "var(--tv-ink-2)",
473
+ whiteSpace: "nowrap",
474
+ overflow: "hidden",
475
+ textOverflow: "ellipsis",
476
+ }}
477
+ >
478
+ {iv.title}
479
+ </span>
480
+ </div>
481
+ );
482
+ })}
483
+
484
+ {/* empty state - centered in the plot body */}
485
+ {isEmpty && (
486
+ <EmptyState
487
+ title="No events or intervals in this window"
488
+ description="Add intervals or milestones to see them on the timeline."
489
+ icon="calendar"
490
+ narrow={narrow}
491
+ />
492
+ )}
493
+ </div>
494
+
495
+ {model.events.length > 0 && (
496
+ <div
497
+ style={{
498
+ position: "relative",
499
+ width: plotW,
500
+ height: showEventLabels ? D.railH + 14 : D.railH,
501
+ marginTop: 8,
502
+ borderTop: "1px solid var(--tv-line)",
503
+ paddingTop: 10,
504
+ }}
505
+ >
506
+ {model.events.map((ev) => {
507
+ const c = cmap[ev.labelIds?.[0] ?? ""] || "var(--tv-ink-2)";
508
+ const px = x(ev._t);
509
+ const hovered = hover?.id === ev.id;
510
+ const lx = Math.max(46, Math.min(plotW - 46, px));
511
+ const on = (e: { clientX: number; clientY: number } | null, extra?: { mx: number; my: number }) =>
512
+ setHover({ kind: "event", id: ev.id, ev, c, ...(extra || track(e!)) });
513
+ return (
514
+ <Fragment key={ev.id}>
515
+ <div
516
+ tabIndex={0}
517
+ onMouseEnter={(e) => on(e)}
518
+ onMouseMove={(e) => on(e)}
519
+ onFocus={() => on(null, { mx: D.pad + px, my: plotH + 120 })}
520
+ onBlur={() => setHover(null)}
521
+ style={{
522
+ position: "absolute",
523
+ left: px,
524
+ top: 0,
525
+ transform: "translateX(-50%)",
526
+ cursor: "pointer",
527
+ outline: "none",
528
+ padding: 3,
529
+ }}
530
+ >
531
+ <span
532
+ style={{
533
+ display: "block",
534
+ width: 11,
535
+ height: 11,
536
+ transform: "rotate(45deg)",
537
+ background: "#fff",
538
+ border: `2px solid ${c}`,
539
+ borderRadius: 2,
540
+ boxShadow: hovered ? `0 0 0 4px ${mix(c, 18, "#ffffff")}` : "none",
541
+ transition: "box-shadow var(--tv-fast)",
542
+ }}
543
+ />
544
+ </div>
545
+ {showEventLabels && (
546
+ <span
547
+ style={{
548
+ position: "absolute",
549
+ left: lx,
550
+ top: 22,
551
+ transform: "translateX(-50%)",
552
+ fontSize: 11,
553
+ fontWeight: 500,
554
+ color: hovered ? "var(--tv-ink)" : "var(--tv-ink-2)",
555
+ whiteSpace: "nowrap",
556
+ textAlign: "center",
557
+ pointerEvents: "none",
558
+ }}
559
+ >
560
+ {ev.title}
561
+ </span>
562
+ )}
563
+ </Fragment>
564
+ );
565
+ })}
566
+ </div>
567
+ )}
568
+ </div>
569
+
570
+ {/* right legend */}
571
+ {legendOn && legendPos === "right" && (
572
+ <div style={{ width: legendW, flex: "none", paddingTop: D.weekH + 6 }}>
573
+ <div
574
+ style={{
575
+ fontFamily: "var(--tv-mono)",
576
+ fontSize: 10,
577
+ letterSpacing: ".08em",
578
+ textTransform: "uppercase",
579
+ color: "var(--tv-ink-4)",
580
+ marginBottom: 12,
581
+ }}
582
+ >
583
+ Legend
584
+ </div>
585
+ <Legend labels={dataset.labels || []} colors={cmap} position={legendPos} vertical />
586
+ </div>
587
+ )}
588
+ </div>
589
+
590
+ {legendOn && legendPos === "bottom" && <Legend labels={dataset.labels || []} colors={cmap} position={legendPos} />}
591
+ {captionOn && captionPos === "bottom" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
592
+
593
+ {/* tooltip — follows cursor, clamped to component */}
594
+ {hover &&
595
+ (() => {
596
+ const isEvent = hover.kind === "event";
597
+ const src = isEvent ? hover.ev : hover.iv;
598
+ const line = isEvent ? fmt.full(hover.ev._t) : `${fmt.full(hover.iv._s)} → ${fmt.full(new Date(hover.iv._e))}`;
599
+ const sub = isEvent ? fmt.time(hover.ev._t) || "All-day milestone" : fmt.span(hover.iv._s, hover.iv._e);
600
+ const TW = 234;
601
+ const left = Math.max(8, Math.min(w - TW - 8, hover.mx - TW / 2));
602
+ const top = Math.max(8, hover.my - 96);
603
+ return (
604
+ <Tooltip
605
+ left={left}
606
+ top={top}
607
+ width={TW}
608
+ color={hover.c}
609
+ label={labelName(dataset, src.labelIds?.[0])}
610
+ title={src.title}
611
+ line={line}
612
+ sub={sub}
613
+ kindLabel={isEvent ? "Milestone" : "Interval"}
614
+ isEvent={isEvent}
615
+ />
616
+ );
617
+ })()}
618
+ </div>
619
+ );
620
+ }