@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,721 @@
1
+ // SpanMatrix.tsx - Timeview presence and continuity matrix.
2
+ //
3
+ // Category rows by day/week columns. Intervals mark bucket presence, adjacent
4
+ // present cells bridge into capsule runs, overlaps add hatch/count encoding,
5
+ // and milestones render as diamonds.
6
+
7
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
8
+ import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
9
+ import { DAY_MS, fmt } from "./core/time";
10
+ import { tvFmtDuration, tvSpanMatrix } from "./core/aggregate";
11
+ import type { SpanMatrixCell, SpanMatrixRow, TimeBucket } from "./core/aggregate";
12
+ import { startOfDayUTC } from "./core/calendar";
13
+ import { colorMap, mix } from "./core/labels";
14
+ import { Caption } from "./shared/Caption";
15
+ import { EmptyState } from "./shared/EmptyState";
16
+ import { useMeasuredWidth } from "./shared/useMeasuredWidth";
17
+ import type { Density, SpanMatrixSpec, TimeDataset } from "./types";
18
+
19
+ export interface SpanMatrixProps {
20
+ dataset: TimeDataset;
21
+ spec: SpanMatrixSpec;
22
+ palette: string[];
23
+ }
24
+
25
+ const SM_SLATE = "#475569";
26
+ const ZOOM_MIN = 0.6;
27
+ const ZOOM_MAX = 2.5;
28
+ const ZOOM_STEP = 0.15;
29
+
30
+ interface DensitySpec {
31
+ pad: number;
32
+ rowH: number;
33
+ barH: number;
34
+ labelW: number;
35
+ cellMin: number;
36
+ head: number;
37
+ cap: number;
38
+ dot: number;
39
+ dia: number;
40
+ num: number;
41
+ }
42
+
43
+ const DENS: Record<Density, DensitySpec> = {
44
+ comfortable: { pad: 22, rowH: 38, barH: 20, labelW: 150, cellMin: 30, head: 30, cap: 3, dot: 6, dia: 11, num: 10 },
45
+ compact: { pad: 16, rowH: 29, barH: 15, labelW: 124, cellMin: 23, head: 26, cap: 3, dot: 5, dia: 9.5, num: 9.5 },
46
+ };
47
+
48
+ type Hover = {
49
+ id: string;
50
+ row: SpanMatrixRow;
51
+ bucket: TimeBucket;
52
+ cell: SpanMatrixCell;
53
+ hue: string;
54
+ mx: number;
55
+ my: number;
56
+ };
57
+
58
+ function cellId(row: SpanMatrixRow, cell: SpanMatrixCell): string {
59
+ return `${row.id}|${cell.bucketKey}`;
60
+ }
61
+
62
+ function todayTime(value: string | null | undefined): number | null {
63
+ if (!value) return null;
64
+ const t = startOfDayUTC(value);
65
+ return Number.isNaN(t) ? null : t;
66
+ }
67
+
68
+ function clampZoom(value: number | null | undefined): number {
69
+ if (typeof value !== "number" || !Number.isFinite(value)) return 1;
70
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value));
71
+ }
72
+
73
+ export function SpanMatrix({ dataset, spec, palette }: SpanMatrixProps) {
74
+ const [wrapRef, w] = useMeasuredWidth<HTMLDivElement>();
75
+ const cellRefs = useRef<Record<string, HTMLDivElement | null>>({});
76
+ const [hover, setHover] = useState<Hover | null>(null);
77
+ const [selected, setSelected] = useState<string | null>(null);
78
+ const [focusRC, setFocusRC] = useState({ r: 0, c: 0 });
79
+ const [localZoom, setLocalZoom] = useState(() => clampZoom(spec.zoom));
80
+
81
+ const density: Density = spec.density || "comfortable";
82
+ const D = DENS[density] || DENS.comfortable;
83
+ const bucketMode = spec.bucket || "day";
84
+ const groupBy = spec.groupBy || "category";
85
+ const showCounts = spec.showCounts !== false;
86
+ const showMarkers = spec.events?.showMarkers !== false;
87
+ const legendPos = spec.legend?.position || "bottom";
88
+ const captionPos = spec.caption?.position || "bottom";
89
+ const captionText = spec.caption?.text || "";
90
+ const captionOn = captionPos !== "off" && !!captionText;
91
+ const legendOn = legendPos !== "off" && (dataset.labels || []).length > 0;
92
+ const narrow = w < 560;
93
+ const zoom = localZoom;
94
+ const cellMin = Math.max(18, Math.round(D.cellMin * zoom));
95
+ const zoomPct = Math.round(zoom * 100);
96
+
97
+ const cmap = useMemo(() => colorMap(dataset, palette), [dataset, palette]);
98
+ const model = useMemo(() => tvSpanMatrix(dataset, { bucket: bucketMode, groupBy }), [dataset, bucketMode, groupBy]);
99
+ const today = todayTime(spec.today);
100
+ const todayCol = today == null ? -1 : model.buckets.findIndex((b) => today >= b.start && today < b.end);
101
+ const isEmpty = model.presentCells === 0 && (dataset.events || []).length === 0;
102
+
103
+ const labelW = narrow ? Math.max(92, D.labelW - 34) : D.labelW;
104
+ const gridCols = `${labelW}px repeat(${model.buckets.length}, minmax(${cellMin}px, 1fr))`;
105
+ const innerMin = labelW + model.buckets.length * cellMin;
106
+ const stickyCol: CSSProperties = { position: "sticky", left: 0, zIndex: 3, background: "var(--tv-canvas)" };
107
+ const hueFor = (row: SpanMatrixRow) => (groupBy === "none" ? SM_SLATE : cmap[row.labelId || ""] || SM_SLATE);
108
+
109
+ useEffect(() => {
110
+ setLocalZoom(clampZoom(spec.zoom));
111
+ }, [spec.zoom]);
112
+
113
+ const changeZoom = (next: number) => setLocalZoom(clampZoom(Number(next.toFixed(2))));
114
+
115
+ const track = (event: { clientX: number; clientY: number }) => {
116
+ const rect = wrapRef.current?.getBoundingClientRect();
117
+ return rect ? { mx: event.clientX - rect.left, my: event.clientY - rect.top } : { mx: 0, my: 0 };
118
+ };
119
+
120
+ const focusCell = (r: number, c: number) => {
121
+ const node = cellRefs.current[`${r}_${c}`];
122
+ if (!node) return;
123
+ setFocusRC({ r, c });
124
+ node.focus();
125
+ };
126
+
127
+ const onKeyNav = (event: KeyboardEvent<HTMLDivElement>, r: number, c: number) => {
128
+ const rowCount = model.rows.length;
129
+ const colCount = model.buckets.length;
130
+ let nr = r;
131
+ let nc = c;
132
+
133
+ if (event.key === "ArrowRight") nc = Math.min(colCount - 1, c + 1);
134
+ else if (event.key === "ArrowLeft") nc = Math.max(0, c - 1);
135
+ else if (event.key === "ArrowDown") nr = Math.min(rowCount - 1, r + 1);
136
+ else if (event.key === "ArrowUp") nr = Math.max(0, r - 1);
137
+ else if (event.key === "Home") nc = 0;
138
+ else if (event.key === "End") nc = colCount - 1;
139
+ else if (event.key === "Enter" || event.key === " ") {
140
+ event.preventDefault();
141
+ const row = model.rows[r];
142
+ const id = cellId(row, row.cells[c]);
143
+ setSelected((value) => (value === id ? null : id));
144
+ return;
145
+ } else {
146
+ return;
147
+ }
148
+
149
+ event.preventDefault();
150
+ focusCell(nr, nc);
151
+ };
152
+
153
+ const Legend = ({ vertical = false }: { vertical?: boolean }) => (
154
+ <div
155
+ style={{
156
+ display: "flex",
157
+ flexDirection: vertical ? "column" : "row",
158
+ flexWrap: "wrap",
159
+ gap: vertical ? 8 : "8px 16px",
160
+ alignItems: vertical ? "stretch" : "center",
161
+ }}
162
+ >
163
+ {(dataset.labels || []).map((label) => (
164
+ <div key={label.id} style={{ display: "inline-flex", alignItems: "center", gap: 8, minWidth: 0, maxWidth: vertical ? "100%" : 240 }}>
165
+ <span
166
+ style={{
167
+ width: 16,
168
+ height: 9,
169
+ borderRadius: 3,
170
+ background: mix(cmap[label.id], 22),
171
+ flex: "none",
172
+ boxShadow: `inset 3px 0 0 0 ${cmap[label.id]}, inset 0 0 0 1px ${mix(cmap[label.id], 34)}`,
173
+ }}
174
+ />
175
+ <span
176
+ style={{
177
+ fontFamily: "var(--tv-font)",
178
+ fontSize: 12.5,
179
+ color: "var(--tv-ink-2)",
180
+ fontWeight: 500,
181
+ lineHeight: 1.3,
182
+ whiteSpace: vertical ? "normal" : "nowrap",
183
+ overflow: "hidden",
184
+ textOverflow: "ellipsis",
185
+ }}
186
+ >
187
+ {label.name}
188
+ </span>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ );
193
+
194
+ const ShapeKey = () => (
195
+ <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
196
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--tv-ink-4)" }}>
197
+ Key
198
+ </span>
199
+ <KeyItem label="active span">
200
+ <span
201
+ style={{
202
+ width: 26,
203
+ height: 11,
204
+ borderRadius: 4,
205
+ background: mix(SM_SLATE, 20),
206
+ boxShadow: `inset 3px 0 0 0 ${SM_SLATE}, inset 0 0 0 1px ${mix(SM_SLATE, 34)}`,
207
+ }}
208
+ />
209
+ </KeyItem>
210
+ <KeyItem label="overlap (2+)">
211
+ <span
212
+ style={{
213
+ width: 26,
214
+ height: 11,
215
+ borderRadius: 4,
216
+ background: `repeating-linear-gradient(135deg, rgba(255,255,255,.55) 0 2px, transparent 2px 4px), ${mix(SM_SLATE, 40)}`,
217
+ boxShadow: `inset 0 0 0 1px ${mix(SM_SLATE, 50)}`,
218
+ }}
219
+ />
220
+ </KeyItem>
221
+ <KeyItem label="milestone">
222
+ <span
223
+ style={{
224
+ width: 11,
225
+ height: 11,
226
+ transform: "rotate(45deg)",
227
+ borderRadius: 2,
228
+ background: "#fff",
229
+ border: `2px solid ${SM_SLATE}`,
230
+ }}
231
+ />
232
+ </KeyItem>
233
+ </div>
234
+ );
235
+
236
+ return (
237
+ <div
238
+ ref={wrapRef}
239
+ className="tv-component"
240
+ style={{
241
+ background: "var(--tv-canvas)",
242
+ border: "1px solid var(--tv-line)",
243
+ borderRadius: "var(--tv-r-lg)",
244
+ boxShadow: "var(--tv-shadow-1)",
245
+ padding: D.pad,
246
+ fontFamily: "var(--tv-font)",
247
+ color: "var(--tv-ink)",
248
+ position: "relative",
249
+ width: "100%",
250
+ overflow: "hidden",
251
+ }}
252
+ >
253
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 6, flexWrap: "wrap" }}>
254
+ <div style={{ fontSize: narrow ? 15 : 17, fontWeight: 600, letterSpacing: 0, color: "var(--tv-ink)", lineHeight: 1.25, minWidth: 0 }}>
255
+ {spec.title || dataset.meta?.title}
256
+ </div>
257
+ <div style={{ display: "flex", alignItems: "center", gap: 10, flex: "none", flexWrap: "wrap", justifyContent: "flex-end" }}>
258
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-4)", whiteSpace: "nowrap", flex: "none" }}>
259
+ {fmt.monDay(model.t0)} - {fmt.monDay(new Date(model.t1 - DAY_MS))}
260
+ </div>
261
+ <div
262
+ data-tv-transient
263
+ style={{
264
+ display: "inline-flex",
265
+ alignItems: "center",
266
+ gap: 2,
267
+ border: "1px solid var(--tv-line)",
268
+ borderRadius: 6,
269
+ background: "var(--tv-rail)",
270
+ padding: 2,
271
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,.8)",
272
+ }}
273
+ >
274
+ <ZoomButton label="Zoom out" disabled={zoom <= ZOOM_MIN + 0.001} onClick={() => changeZoom(zoom - ZOOM_STEP)}>
275
+ -
276
+ </ZoomButton>
277
+ <button
278
+ type="button"
279
+ title="Reset zoom"
280
+ aria-label="Reset zoom"
281
+ onClick={() => changeZoom(1)}
282
+ style={{
283
+ minWidth: 42,
284
+ height: 24,
285
+ border: 0,
286
+ borderRadius: 4,
287
+ background: "transparent",
288
+ color: "var(--tv-ink-3)",
289
+ fontFamily: "var(--tv-mono)",
290
+ fontSize: 10.5,
291
+ fontWeight: 600,
292
+ cursor: "pointer",
293
+ }}
294
+ >
295
+ {zoomPct}%
296
+ </button>
297
+ <ZoomButton label="Zoom in" disabled={zoom >= ZOOM_MAX - 0.001} onClick={() => changeZoom(zoom + ZOOM_STEP)}>
298
+ +
299
+ </ZoomButton>
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", marginBottom: 16 }}>
305
+ interval presence / per {bucketMode} / by {groupBy === "none" ? "total" : "category"}
306
+ </div>
307
+
308
+ {captionOn && captionPos === "top" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
309
+ {legendOn && legendPos === "top" && (
310
+ <div style={{ paddingBottom: 14 }}>
311
+ <Legend />
312
+ </div>
313
+ )}
314
+
315
+ <div style={{ display: "flex", gap: D.pad, alignItems: "flex-start" }}>
316
+ <div
317
+ style={{
318
+ flex: "1 1 auto",
319
+ minWidth: 0,
320
+ position: "relative",
321
+ overflowX: "auto",
322
+ overflowY: "hidden",
323
+ border: "1px solid var(--tv-line)",
324
+ borderRadius: "var(--tv-r-md)",
325
+ }}
326
+ role="grid"
327
+ aria-label={spec.title || dataset.meta?.title}
328
+ >
329
+ <div style={{ display: "grid", gridTemplateColumns: gridCols, minWidth: innerMin }}>
330
+ <div
331
+ style={{
332
+ ...stickyCol,
333
+ height: D.head,
334
+ borderBottom: "1px solid var(--tv-line)",
335
+ display: "flex",
336
+ alignItems: "center",
337
+ paddingLeft: 11,
338
+ }}
339
+ >
340
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--tv-ink-4)" }}>
341
+ {bucketMode === "week" ? "Week" : "Day"}
342
+ </span>
343
+ </div>
344
+
345
+ {model.buckets.map((bucket, ci) => {
346
+ const strong = bucket.mode === "week" || bucket.isMonday;
347
+ const monthStart = bucket.mode === "day" && new Date(bucket.start).getUTCDate() === 1;
348
+ const isToday = ci === todayCol;
349
+ return (
350
+ <div
351
+ key={bucket.key}
352
+ style={{
353
+ height: D.head,
354
+ borderBottom: "1px solid var(--tv-line)",
355
+ borderLeft: `1px solid ${strong ? "var(--tv-grid-week)" : "var(--tv-grid)"}`,
356
+ display: "flex",
357
+ flexDirection: "column",
358
+ alignItems: "center",
359
+ justifyContent: "center",
360
+ background: isToday ? "var(--tv-accent-soft)" : bucket.isWeekend ? "var(--tv-rail)" : "transparent",
361
+ gap: 1,
362
+ overflow: "hidden",
363
+ }}
364
+ >
365
+ <span
366
+ style={{
367
+ fontFamily: "var(--tv-mono)",
368
+ fontSize: 11,
369
+ fontWeight: 600,
370
+ color: isToday ? "var(--tv-accent)" : monthStart ? "var(--tv-accent)" : "var(--tv-ink-2)",
371
+ lineHeight: 1,
372
+ }}
373
+ >
374
+ {bucket.label}
375
+ </span>
376
+ {!narrow && <span style={{ fontFamily: "var(--tv-font)", fontSize: 9, color: "var(--tv-ink-4)", lineHeight: 1 }}>{bucket.sub}</span>}
377
+ </div>
378
+ );
379
+ })}
380
+
381
+ {model.rows.map((row, ri) => {
382
+ const hue = hueFor(row);
383
+ const last = ri === model.rows.length - 1;
384
+ return (
385
+ <Fragment key={row.id}>
386
+ <div
387
+ role="rowheader"
388
+ style={{
389
+ ...stickyCol,
390
+ height: D.rowH,
391
+ display: "flex",
392
+ alignItems: "center",
393
+ gap: 8,
394
+ padding: "0 10px",
395
+ borderBottom: last ? "none" : "1px solid var(--tv-grid)",
396
+ boxShadow: "1px 0 0 var(--tv-line)",
397
+ }}
398
+ >
399
+ <span
400
+ style={{
401
+ width: 14,
402
+ height: 8,
403
+ borderRadius: 2,
404
+ flex: "none",
405
+ background: mix(hue, 22),
406
+ boxShadow: `inset 3px 0 0 0 ${hue}, inset 0 0 0 1px ${mix(hue, 34)}`,
407
+ }}
408
+ />
409
+ <span
410
+ style={{
411
+ fontFamily: "var(--tv-font)",
412
+ fontSize: narrow ? 11.5 : 12.5,
413
+ fontWeight: 500,
414
+ color: "var(--tv-ink-2)",
415
+ whiteSpace: "nowrap",
416
+ overflow: "hidden",
417
+ textOverflow: "ellipsis",
418
+ }}
419
+ >
420
+ {row.name}
421
+ </span>
422
+ </div>
423
+
424
+ {row.cells.map((cell, ci) => {
425
+ const bucket = model.buckets[ci];
426
+ const strong = bucket.mode === "week" || bucket.isMonday;
427
+ const id = cellId(row, cell);
428
+ const hovered = hover?.id === id;
429
+ const isSelected = selected === id;
430
+ const isToday = ci === todayCol;
431
+ const isFocus = focusRC.r === ri && focusRC.c === ci;
432
+ return (
433
+ <div
434
+ key={bucket.key}
435
+ role="gridcell"
436
+ ref={(node) => {
437
+ cellRefs.current[`${ri}_${ci}`] = node;
438
+ }}
439
+ tabIndex={isFocus ? 0 : -1}
440
+ aria-label={`${row.name}, ${bucket.mode === "week" ? "week of " : ""}${fmt.monDay(new Date(bucket.start))}: ${
441
+ cell.present ? `${cell.spanCount} ${cell.spanCount === 1 ? "active span" : "overlapping spans"}` : "no span"
442
+ }${cell.eventCount ? `, ${cell.eventCount} milestone${cell.eventCount === 1 ? "" : "s"}` : ""}`}
443
+ onMouseEnter={(event) => setHover({ id, row, bucket, cell, hue, ...track(event) })}
444
+ onMouseMove={(event) => setHover({ id, row, bucket, cell, hue, ...track(event) })}
445
+ onFocus={() => {
446
+ setFocusRC({ r: ri, c: ci });
447
+ setHover({ id, row, bucket, cell, hue, mx: labelW + ci * cellMin, my: 70 + ri * D.rowH });
448
+ }}
449
+ onMouseLeave={() => setHover(null)}
450
+ onBlur={() => setHover(null)}
451
+ onClick={() => setSelected((value) => (value === id ? null : id))}
452
+ onKeyDown={(event) => onKeyNav(event, ri, ci)}
453
+ style={{
454
+ height: D.rowH,
455
+ position: "relative",
456
+ cursor: "pointer",
457
+ outline: "none",
458
+ borderBottom: last ? "none" : "1px solid var(--tv-grid)",
459
+ borderLeft: `1px solid ${strong ? "var(--tv-grid-week)" : "var(--tv-grid)"}`,
460
+ background: isToday ? "var(--tv-accent-soft)" : bucket.isWeekend ? mix("#f5f7f9", 60) : "transparent",
461
+ boxShadow: isSelected ? "var(--tv-focus-ring)" : "none",
462
+ zIndex: isSelected || hovered ? 5 : "auto",
463
+ transition: "box-shadow var(--tv-fast) var(--tv-ease)",
464
+ }}
465
+ >
466
+ {cell.present && (
467
+ <div
468
+ style={{
469
+ position: "absolute",
470
+ top: (D.rowH - D.barH) / 2,
471
+ height: D.barH,
472
+ left: cell.runStart ? 3 : -1,
473
+ right: cell.runEnd ? 3 : -1,
474
+ background: cell.overlap
475
+ ? `repeating-linear-gradient(135deg, rgba(255,255,255,.5) 0 2.5px, transparent 2.5px 5px), ${mix(hue, 40)}`
476
+ : mix(hue, hovered ? 26 : 19),
477
+ borderTop: `1px solid ${mix(hue, cell.overlap ? 52 : 36)}`,
478
+ borderBottom: `1px solid ${mix(hue, cell.overlap ? 52 : 36)}`,
479
+ borderLeft: cell.runStart ? `1px solid ${mix(hue, 36)}` : "none",
480
+ borderRight: cell.runEnd ? `1px solid ${mix(hue, 36)}` : "none",
481
+ borderTopLeftRadius: cell.runStart ? 6 : 0,
482
+ borderBottomLeftRadius: cell.runStart ? 6 : 0,
483
+ borderTopRightRadius: cell.runEnd ? 6 : 0,
484
+ borderBottomRightRadius: cell.runEnd ? 6 : 0,
485
+ boxShadow: cell.runStart ? `inset ${D.cap}px 0 0 0 ${hue}` : "none",
486
+ display: "flex",
487
+ alignItems: "center",
488
+ justifyContent: cell.runStart ? "flex-start" : "flex-end",
489
+ padding: "0 5px",
490
+ transition: "background var(--tv-fast) var(--tv-ease)",
491
+ }}
492
+ >
493
+ {cell.runStart && (
494
+ <span
495
+ style={{
496
+ width: D.dot,
497
+ height: D.dot,
498
+ borderRadius: "50%",
499
+ background: hue,
500
+ flex: "none",
501
+ marginLeft: 2,
502
+ boxShadow: "0 0 0 1.5px rgba(255,255,255,.7)",
503
+ }}
504
+ />
505
+ )}
506
+ {cell.overlap && showCounts && (
507
+ <span
508
+ style={{
509
+ marginLeft: "auto",
510
+ fontFamily: "var(--tv-mono)",
511
+ fontSize: D.num,
512
+ fontWeight: 600,
513
+ color: mix(hue, 90, "#1d2330"),
514
+ lineHeight: 1,
515
+ background: "rgba(255,255,255,.82)",
516
+ borderRadius: 3,
517
+ padding: "1px 3px",
518
+ }}
519
+ >
520
+ {cell.spanCount}
521
+ </span>
522
+ )}
523
+ </div>
524
+ )}
525
+
526
+ {showMarkers && cell.eventCount > 0 && (
527
+ <span
528
+ style={{
529
+ position: "absolute",
530
+ top: cell.present ? 3 : (D.rowH - D.dia) / 2 - 1,
531
+ left: "50%",
532
+ transform: "translateX(-50%) rotate(45deg)",
533
+ width: D.dia,
534
+ height: D.dia,
535
+ borderRadius: 2,
536
+ background: "#fff",
537
+ border: `2px solid ${hue}`,
538
+ boxShadow: "0 1px 1px rgba(16,24,40,.14)",
539
+ zIndex: 2,
540
+ }}
541
+ />
542
+ )}
543
+
544
+ {isFocus && !isSelected && (
545
+ <span aria-hidden="true" style={{ position: "absolute", inset: 1, borderRadius: 4, pointerEvents: "none", boxShadow: "var(--tv-focus-ring)" }} />
546
+ )}
547
+ </div>
548
+ );
549
+ })}
550
+ </Fragment>
551
+ );
552
+ })}
553
+ </div>
554
+
555
+ {isEmpty && (
556
+ <EmptyState title="No spans in this window" icon="matrix" background narrow={narrow} />
557
+ )}
558
+ </div>
559
+
560
+ {legendOn && legendPos === "right" && !narrow && (
561
+ <div style={{ width: Math.min(220, Math.max(168, w * 0.2)), flex: "none", paddingTop: 4 }}>
562
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--tv-ink-4)", marginBottom: 12 }}>
563
+ Legend
564
+ </div>
565
+ <Legend vertical />
566
+ </div>
567
+ )}
568
+ </div>
569
+
570
+ <div
571
+ style={{
572
+ display: "flex",
573
+ alignItems: "center",
574
+ justifyContent: "space-between",
575
+ gap: 18,
576
+ flexWrap: "wrap",
577
+ padding: "14px 0 2px",
578
+ borderTop: "1px solid var(--tv-line)",
579
+ marginTop: 6,
580
+ }}
581
+ >
582
+ <ShapeKey />
583
+ {legendOn && legendPos === "bottom" && <Legend />}
584
+ </div>
585
+
586
+ {captionOn && captionPos === "bottom" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
587
+
588
+ {hover && (
589
+ <SpanMatrixTooltip
590
+ left={Math.max(8, Math.min(w - 244 - 8, hover.mx - 122))}
591
+ top={Math.max(8, hover.my - 96)}
592
+ width={244}
593
+ hue={hover.hue}
594
+ group={hover.row.name}
595
+ range={
596
+ hover.bucket.mode === "week"
597
+ ? `${fmt.full(new Date(hover.bucket.start))} - ${fmt.full(new Date(hover.bucket.end - DAY_MS))}`
598
+ : fmt.full(new Date(hover.bucket.start))
599
+ }
600
+ cell={hover.cell}
601
+ cmap={cmap}
602
+ />
603
+ )}
604
+ </div>
605
+ );
606
+ }
607
+
608
+ function KeyItem({ label, children }: { label: string; children: ReactNode }) {
609
+ return (
610
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
611
+ {children}
612
+ <span style={{ fontFamily: "var(--tv-font)", fontSize: 11.5, color: "var(--tv-ink-3)" }}>{label}</span>
613
+ </span>
614
+ );
615
+ }
616
+
617
+ function ZoomButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
618
+ return (
619
+ <button
620
+ type="button"
621
+ title={label}
622
+ aria-label={label}
623
+ disabled={disabled}
624
+ onClick={onClick}
625
+ style={{
626
+ width: 24,
627
+ height: 24,
628
+ border: 0,
629
+ borderRadius: 4,
630
+ background: disabled ? "transparent" : "#fff",
631
+ color: disabled ? "var(--tv-ink-5)" : "var(--tv-ink-2)",
632
+ fontFamily: "var(--tv-mono)",
633
+ fontSize: 15,
634
+ fontWeight: 700,
635
+ lineHeight: "22px",
636
+ cursor: disabled ? "default" : "pointer",
637
+ boxShadow: disabled ? "none" : "0 1px 1px rgba(16,24,40,.08)",
638
+ }}
639
+ >
640
+ {children}
641
+ </button>
642
+ );
643
+ }
644
+
645
+ function SpanMatrixTooltip({
646
+ left,
647
+ top,
648
+ width,
649
+ hue,
650
+ group,
651
+ range,
652
+ cell,
653
+ cmap,
654
+ }: {
655
+ left: number;
656
+ top: number;
657
+ width: number;
658
+ hue: string;
659
+ group: string;
660
+ range: string;
661
+ cell: SpanMatrixCell;
662
+ cmap: Record<string, string>;
663
+ }) {
664
+ const intervals = cell.intervals.slice(0, 4);
665
+ const more = cell.intervals.length - intervals.length;
666
+
667
+ return (
668
+ <div data-tv-transient style={{ position: "absolute", left, top, width, pointerEvents: "none", zIndex: 50 }}>
669
+ <div style={{ background: "#fff", border: "1px solid var(--tv-line)", borderRadius: "var(--tv-r-md)", boxShadow: "var(--tv-shadow-pop)", padding: "10px 13px" }}>
670
+ <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
671
+ <span style={{ width: 14, height: 8, borderRadius: 2, background: mix(hue, 22), flex: "none", boxShadow: `inset 3px 0 0 0 ${hue}` }} />
672
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--tv-ink-4)" }}>{group}</span>
673
+ </div>
674
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-3)", lineHeight: 1.45, marginBottom: 6 }}>{range}</div>
675
+ <div style={{ display: "flex", alignItems: "baseline", gap: 7, marginBottom: cell.intervals.length || cell.events.length ? 8 : 0 }}>
676
+ <span style={{ fontSize: 17, fontWeight: 600, color: "var(--tv-ink)", lineHeight: 1 }}>{cell.spanCount}</span>
677
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, color: "var(--tv-ink-4)", textTransform: "uppercase", letterSpacing: ".05em" }}>
678
+ {cell.spanCount === 1 ? "active span" : cell.spanCount === 0 ? "no span" : "overlapping spans"}
679
+ </span>
680
+ </div>
681
+ {(intervals.length > 0 || cell.events.length > 0) && (
682
+ <div style={{ borderTop: "1px solid var(--tv-line)", paddingTop: 7, display: "flex", flexDirection: "column", gap: 4 }}>
683
+ {intervals.map((item) => (
684
+ <div key={item.id} style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
685
+ <span
686
+ style={{
687
+ width: 13,
688
+ height: 7,
689
+ borderRadius: 2,
690
+ flex: "none",
691
+ background: mix(cmap[item.labelId || ""] || hue, 22),
692
+ boxShadow: `inset 2.5px 0 0 0 ${cmap[item.labelId || ""] || hue}`,
693
+ }}
694
+ />
695
+ <span style={{ fontSize: 11.5, color: "var(--tv-ink-2)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.title}</span>
696
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, color: "var(--tv-ink-4)", marginLeft: "auto", flex: "none" }}>{tvFmtDuration(item.overlapMs)}</span>
697
+ </div>
698
+ ))}
699
+ {cell.events.map((event) => (
700
+ <div key={event.id} style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
701
+ <span
702
+ style={{
703
+ width: 8,
704
+ height: 8,
705
+ flex: "none",
706
+ borderRadius: 1,
707
+ transform: "rotate(45deg)",
708
+ background: "#fff",
709
+ border: `1.5px solid ${cmap[event.labelId || ""] || hue}`,
710
+ }}
711
+ />
712
+ <span style={{ fontSize: 11.5, color: "var(--tv-ink-2)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{event.title}</span>
713
+ </div>
714
+ ))}
715
+ {more > 0 && <div style={{ fontFamily: "var(--tv-mono)", fontSize: 10.5, color: "var(--tv-ink-4)" }}>+{more} more</div>}
716
+ </div>
717
+ )}
718
+ </div>
719
+ </div>
720
+ );
721
+ }