@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,496 @@
1
+ // LaneCalendar.tsx — week-grid projection of a TimeDataset.
2
+ //
3
+ // Weeks are rows, days are columns. Intervals render as chips spanning day
4
+ // cells and split across week boundaries; milestones render as hollow diamonds.
5
+
6
+ import { useMemo, useState } from "react";
7
+ import { DAY_MS, domainOf, fmt } from "./core/time";
8
+ import { packLanes } from "./core/intervals";
9
+ import { startOfDayUTC, tvDayCell, tvGridWeeks, tvSplitInterval } from "./core/calendar";
10
+ import type { CalendarIntervalSegment, CalendarWeek } from "./core/calendar";
11
+ import { colorMap, labelName, mix } from "./core/labels";
12
+ import { Caption } from "./shared/Caption";
13
+ import { EmptyState } from "./shared/EmptyState";
14
+ import { Legend } from "./shared/Legend";
15
+ import { Tooltip } from "./shared/Tooltip";
16
+ import { useMeasuredWidth } from "./shared/useMeasuredWidth";
17
+ import type { Density, LaneCalendarSpec, LaneMode, TimeDataset, TimeEvent, TimeInterval } from "./types";
18
+
19
+ export interface LaneCalendarProps {
20
+ /** schema `timeview.dataset.v1` */
21
+ dataset: TimeDataset;
22
+ /** calendar projection options (lanes, density, legend, caption, today...) */
23
+ spec: LaneCalendarSpec;
24
+ /** colors mapped to `dataset.labels` by index */
25
+ palette: string[];
26
+ }
27
+
28
+ const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
29
+ const DOW_SHORT = ["M", "T", "W", "T", "F", "S", "S"];
30
+
31
+ interface DensitySpec {
32
+ pad: number;
33
+ headH: number;
34
+ laneH: number;
35
+ milestoneH: number;
36
+ vgap: number;
37
+ chip: number;
38
+ dot: number;
39
+ diamond: number;
40
+ }
41
+
42
+ const DENS: Record<Density, DensitySpec> = {
43
+ comfortable: { pad: 22, headH: 30, laneH: 26, milestoneH: 26, vgap: 5, chip: 12.5, dot: 7, diamond: 11 },
44
+ compact: { pad: 16, headH: 25, laneH: 21, milestoneH: 22, vgap: 4, chip: 11.5, dot: 6, diamond: 10 },
45
+ };
46
+
47
+ type ModelInterval = TimeInterval & { _s: number; _e: number; _lane: number };
48
+ type ModelEvent = TimeEvent & { _t: number };
49
+ type Segment = CalendarIntervalSegment & { iv: ModelInterval };
50
+ type WeekModel = { week: CalendarWeek; segs: Segment[]; events: { ev: ModelEvent; col: number }[]; maxLane: number };
51
+
52
+ interface Model {
53
+ t0: number;
54
+ t1: number;
55
+ weeks: CalendarWeek[];
56
+ perWeek: WeekModel[];
57
+ intervals: ModelInterval[];
58
+ events: ModelEvent[];
59
+ laneCount: number;
60
+ }
61
+
62
+ type Hover =
63
+ | { kind: "interval"; id: string; iv: ModelInterval; c: string; mx: number; my: number }
64
+ | { kind: "event"; id: string; ev: ModelEvent; c: string; mx: number; my: number };
65
+
66
+ function resolveToday(today: LaneCalendarSpec["today"]): number | null {
67
+ if (today == null) return null;
68
+ if (today === "auto") {
69
+ const now = new Date();
70
+ return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
71
+ }
72
+ return startOfDayUTC(today);
73
+ }
74
+
75
+ function buildModel(dataset: TimeDataset, laneMode: LaneMode): Model {
76
+ const [t0, t1] = domainOf(dataset);
77
+ const weeks = tvGridWeeks(t0, t1);
78
+ const intervals: ModelInterval[] = (dataset.intervals || []).map((iv) => ({
79
+ ...iv,
80
+ _s: new Date(iv.range.start).getTime(),
81
+ _e: new Date(iv.range.end).getTime(),
82
+ _lane: 0,
83
+ }));
84
+
85
+ let laneCount: number;
86
+ if (laneMode === "category") {
87
+ const idx: Record<string, number> = {};
88
+ (dataset.labels || []).forEach((l, i) => {
89
+ idx[l.id] = i;
90
+ });
91
+ intervals.forEach((iv) => {
92
+ iv._lane = idx[iv.labelIds?.[0] || ""] ?? 0;
93
+ });
94
+ laneCount = Math.max(1, (dataset.labels || []).length);
95
+ } else {
96
+ laneCount = Math.max(1, packLanes(intervals));
97
+ }
98
+
99
+ const events: ModelEvent[] = (dataset.events || []).map((e) => ({ ...e, _t: new Date(e.at).getTime() }));
100
+ const perWeek: WeekModel[] = weeks.map((week) => ({ week, segs: [], events: [], maxLane: -1 }));
101
+
102
+ intervals.forEach((iv) => {
103
+ tvSplitInterval(iv._s, iv._e, weeks).forEach((seg) => {
104
+ perWeek[seg.wi].segs.push({ ...seg, iv });
105
+ perWeek[seg.wi].maxLane = Math.max(perWeek[seg.wi].maxLane, iv._lane);
106
+ });
107
+ });
108
+
109
+ events.forEach((ev) => {
110
+ const cell = tvDayCell(ev._t, weeks);
111
+ if (cell) perWeek[cell.wi].events.push({ ev, col: cell.col });
112
+ });
113
+
114
+ return { t0, t1, weeks, perWeek, intervals, events, laneCount };
115
+ }
116
+
117
+ export function LaneCalendar({ dataset, spec, palette }: LaneCalendarProps) {
118
+ const [wrapRef, w] = useMeasuredWidth<HTMLDivElement>();
119
+ const [hover, setHover] = useState<Hover | null>(null);
120
+ const [sel, setSel] = useState<string | null>(null);
121
+
122
+ const density: Density = spec.density || "comfortable";
123
+ const D = DENS[density] || DENS.comfortable;
124
+ const laneMode: LaneMode = spec.laneMode || "packed";
125
+ const showLabels = spec.events?.showLabels !== false;
126
+ const rawLegendPos = spec.legend?.position || "bottom";
127
+ const captionPos = spec.caption?.position || "bottom";
128
+ const today = resolveToday(spec.today);
129
+ const narrow = w < 560;
130
+ const ultra = w < 430;
131
+ const legendPos = rawLegendPos === "right" && narrow ? "bottom" : rawLegendPos;
132
+ const legendOn = legendPos !== "off" && (dataset.labels || []).length > 0;
133
+ const captionText = spec.caption?.text || "";
134
+ const captionOn = captionPos !== "off" && !!captionText;
135
+
136
+ const model = useMemo(() => buildModel(dataset, laneMode), [dataset, laneMode]);
137
+ const cmap = useMemo(() => colorMap(dataset, palette), [dataset, palette]);
138
+ const isEmpty = model.intervals.length === 0 && model.events.length === 0;
139
+
140
+ const legendW = legendPos === "right" ? Math.min(220, Math.max(168, w * 0.2)) : 0;
141
+ const gridW = Math.max(120, w - D.pad * 2 - (legendW ? legendW + D.pad : 0));
142
+ const colW = gridW / 7;
143
+ const inDomain = (d: Date) => {
144
+ const t = startOfDayUTC(d);
145
+ return t >= model.t0 && t < model.t1;
146
+ };
147
+
148
+ const track = (e: { clientX: number; clientY: number }) => {
149
+ const r = wrapRef.current!.getBoundingClientRect();
150
+ return { mx: e.clientX - r.left, my: e.clientY - r.top };
151
+ };
152
+
153
+ const Weekday = () => (
154
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", width: gridW, borderBottom: "1px solid var(--tv-grid-week)", marginBottom: 2 }}>
155
+ {DOW.map((d, i) => (
156
+ <div
157
+ key={i}
158
+ style={{
159
+ fontFamily: "var(--tv-mono)",
160
+ fontSize: ultra ? 10 : 11,
161
+ fontWeight: 600,
162
+ letterSpacing: ".04em",
163
+ color: i >= 5 ? "var(--tv-ink-4)" : "var(--tv-ink-3)",
164
+ textTransform: "uppercase",
165
+ padding: "0 0 7px 7px",
166
+ textAlign: "left",
167
+ }}
168
+ >
169
+ {ultra ? DOW_SHORT[i] : d}
170
+ </div>
171
+ ))}
172
+ </div>
173
+ );
174
+
175
+ const WeekRow = ({ pw, last }: { pw: WeekModel; last: boolean }) => {
176
+ const { week, segs, events, maxLane } = pw;
177
+ const laneRows = Math.max(0, maxLane + 1);
178
+ const hasMilestones = events.length > 0;
179
+ const bodyH = (hasMilestones ? D.milestoneH : 0) + laneRows * D.laneH + (laneRows || hasMilestones ? D.vgap : 0);
180
+
181
+ return (
182
+ <div style={{ position: "relative", width: gridW, borderBottom: last ? "none" : "1px solid var(--tv-grid-week)" }}>
183
+ <div style={{ position: "absolute", inset: 0, display: "grid", gridTemplateColumns: "repeat(7, 1fr)", pointerEvents: "none" }}>
184
+ {week.days.map((d, i) => {
185
+ const out = !inDomain(d);
186
+ return (
187
+ <div
188
+ key={i}
189
+ style={{
190
+ borderLeft: i === 0 ? "none" : "1px solid var(--tv-grid)",
191
+ background: out ? "transparent" : i >= 5 ? "var(--tv-rail)" : "transparent",
192
+ }}
193
+ />
194
+ );
195
+ })}
196
+ </div>
197
+
198
+ <div style={{ position: "relative", display: "grid", gridTemplateColumns: "repeat(7, 1fr)", height: D.headH, alignItems: "start" }}>
199
+ {week.days.map((d, i) => {
200
+ const out = !inDomain(d);
201
+ const isToday = today != null && startOfDayUTC(d) === today;
202
+ const dayNum = d.getUTCDate();
203
+ const showMonth = dayNum === 1 || (week.index === 0 && i === 0);
204
+ return (
205
+ <div key={i} style={{ display: "flex", alignItems: "baseline", gap: 5, padding: "6px 0 0 7px", minWidth: 0 }}>
206
+ {showMonth && !ultra && (
207
+ <span
208
+ style={{
209
+ fontFamily: "var(--tv-mono)",
210
+ fontSize: 10,
211
+ fontWeight: 600,
212
+ color: out ? "var(--tv-ink-5)" : "var(--tv-accent)",
213
+ letterSpacing: ".02em",
214
+ }}
215
+ >
216
+ {fmt.mon(d)}
217
+ </span>
218
+ )}
219
+ <span
220
+ style={{
221
+ fontFamily: "var(--tv-mono)",
222
+ fontSize: ultra ? 11.5 : 12.5,
223
+ fontWeight: 600,
224
+ lineHeight: 1,
225
+ letterSpacing: ".01em",
226
+ color: out ? "var(--tv-ink-5)" : isToday ? "#fff" : "var(--tv-ink-2)",
227
+ background: isToday ? "var(--tv-accent)" : "transparent",
228
+ borderRadius: "var(--tv-r-pill)",
229
+ padding: isToday ? "3px 6px" : 0,
230
+ margin: isToday ? "-3px 0" : 0,
231
+ boxShadow: isToday ? "0 1px 2px rgba(79,70,229,.35)" : "none",
232
+ }}
233
+ >
234
+ {dayNum}
235
+ </span>
236
+ </div>
237
+ );
238
+ })}
239
+ </div>
240
+
241
+ {bodyH > 0 && (
242
+ <div style={{ position: "relative", width: gridW, height: bodyH, marginBottom: D.vgap }}>
243
+ {hasMilestones &&
244
+ events.map(({ ev, col }) => {
245
+ const c = cmap[ev.labelIds?.[0] || ""] || "var(--tv-ink-2)";
246
+ const hovered = hover?.id === ev.id;
247
+ const selected = sel === ev.id;
248
+ const left = col * colW;
249
+ return (
250
+ <div
251
+ key={"m" + ev.id}
252
+ tabIndex={0}
253
+ role="button"
254
+ aria-label={`Milestone: ${ev.title}, ${fmt.full(ev._t)}`}
255
+ onMouseEnter={(e) => setHover({ kind: "event", id: ev.id, ev, c, ...track(e) })}
256
+ onMouseMove={(e) => setHover({ kind: "event", id: ev.id, ev, c, ...track(e) })}
257
+ onFocus={() => setHover({ kind: "event", id: ev.id, ev, c, mx: left + 60, my: 70 })}
258
+ onMouseLeave={() => setHover(null)}
259
+ onBlur={() => setHover(null)}
260
+ onClick={() => setSel((s) => (s === ev.id ? null : ev.id))}
261
+ style={{
262
+ position: "absolute",
263
+ left,
264
+ top: 0,
265
+ width: colW,
266
+ height: D.milestoneH,
267
+ display: "flex",
268
+ alignItems: "center",
269
+ gap: 6,
270
+ padding: "0 4px 0 7px",
271
+ cursor: "pointer",
272
+ outline: "none",
273
+ borderRadius: "var(--tv-r-sm)",
274
+ boxShadow: selected ? "var(--tv-focus-ring)" : "none",
275
+ minWidth: 0,
276
+ }}
277
+ >
278
+ <span
279
+ style={{
280
+ width: D.diamond,
281
+ height: D.diamond,
282
+ transform: "rotate(45deg)",
283
+ flex: "none",
284
+ background: "#fff",
285
+ border: `2px solid ${c}`,
286
+ borderRadius: 2,
287
+ boxShadow: hovered ? `0 0 0 4px ${mix(c, 18)}` : "0 1px 1px rgba(16,24,40,.12)",
288
+ transition: "box-shadow var(--tv-fast) var(--tv-ease)",
289
+ }}
290
+ />
291
+ {showLabels && !ultra && (
292
+ <span
293
+ style={{
294
+ fontSize: D.chip - 0.5,
295
+ fontWeight: 500,
296
+ color: "var(--tv-ink-2)",
297
+ whiteSpace: "nowrap",
298
+ overflow: "hidden",
299
+ textOverflow: "ellipsis",
300
+ flex: "1 1 auto",
301
+ minWidth: 0,
302
+ }}
303
+ >
304
+ {ev.title}
305
+ </span>
306
+ )}
307
+ </div>
308
+ );
309
+ })}
310
+
311
+ {segs.map((seg, i) => {
312
+ const iv = seg.iv;
313
+ const c = cmap[iv.labelIds?.[0] || ""] || "#64748b";
314
+ const left = seg.c0 * colW + 2;
315
+ const width = Math.max(10, (seg.c1 - seg.c0 + 1) * colW - 4);
316
+ const top = (hasMilestones ? D.milestoneH : 0) + iv._lane * D.laneH;
317
+ const h = D.laneH - 3;
318
+ const hovered = hover?.id === iv.id;
319
+ const selected = sel === iv.id;
320
+ const tiny = width < 46 || ultra;
321
+ const contL = !seg.startsHere;
322
+ const contR = !seg.endsHere;
323
+ const boxShadow =
324
+ [
325
+ seg.startsHere ? `inset 3px 0 0 0 ${c}` : null,
326
+ selected ? "var(--tv-focus-ring)" : hovered ? "var(--tv-shadow-pop)" : null,
327
+ ]
328
+ .filter(Boolean)
329
+ .join(", ") || "none";
330
+
331
+ return (
332
+ <div
333
+ key={iv.id + "-" + i}
334
+ tabIndex={0}
335
+ role="button"
336
+ aria-label={`${iv.title}, ${fmt.full(iv._s)} to ${fmt.full(new Date(iv._e))}`}
337
+ onMouseEnter={(e) => setHover({ kind: "interval", id: iv.id, iv, c, ...track(e) })}
338
+ onMouseMove={(e) => setHover({ kind: "interval", id: iv.id, iv, c, ...track(e) })}
339
+ onFocus={() => setHover({ kind: "interval", id: iv.id, iv, c, mx: left + Math.min(90, width / 2), my: top + 150 })}
340
+ onMouseLeave={() => setHover(null)}
341
+ onBlur={() => setHover(null)}
342
+ onClick={() => setSel((s) => (s === iv.id ? null : iv.id))}
343
+ style={{
344
+ position: "absolute",
345
+ left,
346
+ width,
347
+ top,
348
+ height: h,
349
+ background: mix(c, 13),
350
+ display: "flex",
351
+ alignItems: "center",
352
+ gap: 6,
353
+ padding: `0 ${tiny ? 5 : 8}px 0 ${seg.startsHere ? (tiny ? 7 : 9) : 6}px`,
354
+ border: `1px solid ${hovered || selected ? c : mix(c, 34)}`,
355
+ borderTopLeftRadius: contL ? 0 : "var(--tv-r-sm)",
356
+ borderBottomLeftRadius: contL ? 0 : "var(--tv-r-sm)",
357
+ borderTopRightRadius: contR ? 0 : "var(--tv-r-sm)",
358
+ borderBottomRightRadius: contR ? 0 : "var(--tv-r-sm)",
359
+ borderLeftStyle: contL ? "dashed" : "solid",
360
+ borderRightStyle: contR ? "dashed" : "solid",
361
+ boxShadow,
362
+ cursor: "pointer",
363
+ outline: "none",
364
+ overflow: "hidden",
365
+ transition: "box-shadow var(--tv-fast) var(--tv-ease), border-color var(--tv-fast) var(--tv-ease)",
366
+ zIndex: hovered || selected ? 6 : 2,
367
+ minWidth: 0,
368
+ }}
369
+ >
370
+ {seg.startsHere && !tiny && <span style={{ width: D.dot, height: D.dot, borderRadius: "50%", background: c, flex: "none" }} />}
371
+ {!tiny && (
372
+ <span
373
+ style={{
374
+ fontSize: D.chip,
375
+ fontWeight: 500,
376
+ color: "var(--tv-ink-2)",
377
+ whiteSpace: "nowrap",
378
+ overflow: "hidden",
379
+ textOverflow: "ellipsis",
380
+ flex: "1 1 auto",
381
+ minWidth: 0,
382
+ }}
383
+ >
384
+ {iv.title}
385
+ </span>
386
+ )}
387
+ {seg.startsHere && !tiny && !narrow && width > 150 && (
388
+ <span style={{ fontFamily: "var(--tv-mono)", fontSize: 10, color: "var(--tv-ink-4)", flex: "none", whiteSpace: "nowrap" }}>
389
+ {fmt.span(iv._s, iv._e)}
390
+ </span>
391
+ )}
392
+ </div>
393
+ );
394
+ })}
395
+ </div>
396
+ )}
397
+ </div>
398
+ );
399
+ };
400
+
401
+ return (
402
+ <div
403
+ ref={wrapRef}
404
+ className="tv-component"
405
+ style={{
406
+ background: "var(--tv-canvas)",
407
+ border: "1px solid var(--tv-line)",
408
+ borderRadius: "var(--tv-r-lg)",
409
+ boxShadow: "var(--tv-shadow-1)",
410
+ padding: D.pad,
411
+ fontFamily: "var(--tv-font)",
412
+ color: "var(--tv-ink)",
413
+ position: "relative",
414
+ width: "100%",
415
+ overflow: "hidden",
416
+ }}
417
+ >
418
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 14, flexWrap: "wrap" }}>
419
+ <div style={{ fontSize: narrow ? 15 : 17, fontWeight: 600, letterSpacing: "-0.01em", color: "var(--tv-ink)", lineHeight: 1.25, minWidth: 0 }}>
420
+ {spec.title || dataset.meta?.title}
421
+ </div>
422
+ <div style={{ fontFamily: "var(--tv-mono)", fontSize: 11, color: "var(--tv-ink-4)", whiteSpace: "nowrap", flex: "none" }}>
423
+ {fmt.monDay(model.t0)} – {fmt.monDay(new Date(model.t1 - DAY_MS))}
424
+ </div>
425
+ </div>
426
+
427
+ {captionOn && captionPos === "top" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
428
+ {legendOn && legendPos === "top" && <Legend labels={dataset.labels || []} colors={cmap} position={legendPos} />}
429
+
430
+ <div style={{ display: "flex", gap: D.pad, alignItems: "flex-start" }}>
431
+ <div style={{ width: gridW, flex: "none" }}>
432
+ {!isEmpty && <Weekday />}
433
+ {isEmpty ? (
434
+ <EmptyState
435
+ title="No events or intervals in this window"
436
+ description="Add intervals or milestones to populate the calendar."
437
+ icon="calendar"
438
+ variant="panel"
439
+ bordered
440
+ height={200}
441
+ narrow={narrow}
442
+ />
443
+ ) : (
444
+ model.perWeek.map((pw, i) => <WeekRow key={i} pw={pw} last={i === model.perWeek.length - 1} />)
445
+ )}
446
+ </div>
447
+
448
+ {legendOn && legendPos === "right" && (
449
+ <div style={{ width: legendW, flex: "none", paddingTop: 4 }}>
450
+ <div
451
+ style={{
452
+ fontFamily: "var(--tv-mono)",
453
+ fontSize: 10,
454
+ letterSpacing: ".08em",
455
+ textTransform: "uppercase",
456
+ color: "var(--tv-ink-4)",
457
+ marginBottom: 12,
458
+ }}
459
+ >
460
+ Legend
461
+ </div>
462
+ <Legend labels={dataset.labels || []} colors={cmap} position={legendPos} vertical />
463
+ </div>
464
+ )}
465
+ </div>
466
+
467
+ {legendOn && legendPos === "bottom" && <Legend labels={dataset.labels || []} colors={cmap} position={legendPos} />}
468
+ {captionOn && captionPos === "bottom" && <Caption position={captionPos} text={captionText} narrow={narrow} />}
469
+
470
+ {hover &&
471
+ (() => {
472
+ const isEvent = hover.kind === "event";
473
+ const src = isEvent ? hover.ev : hover.iv;
474
+ const line = isEvent ? fmt.full(hover.ev._t) : `${fmt.full(hover.iv._s)} → ${fmt.full(new Date(hover.iv._e))}`;
475
+ const sub = isEvent ? fmt.time(hover.ev._t) || "All-day milestone" : fmt.span(hover.iv._s, hover.iv._e);
476
+ const TW = 234;
477
+ const left = Math.max(8, Math.min(w - TW - 8, hover.mx - TW / 2));
478
+ const top = Math.max(8, hover.my - 96);
479
+ return (
480
+ <Tooltip
481
+ left={left}
482
+ top={top}
483
+ width={TW}
484
+ color={hover.c}
485
+ label={labelName(dataset, src.labelIds?.[0])}
486
+ title={src.title}
487
+ line={line}
488
+ sub={sub}
489
+ kindLabel={isEvent ? "Milestone" : "Interval"}
490
+ isEvent={isEvent}
491
+ />
492
+ );
493
+ })()}
494
+ </div>
495
+ );
496
+ }