@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/cli/timeview.js +6710 -0
- package/dist/timeview.cjs +1 -0
- package/dist/timeview.js +5667 -0
- package/dist/tokens.css +67 -0
- package/dist/types/timeview/BandedTimeline.d.ts +11 -0
- package/dist/types/timeview/BandedTimeline.d.ts.map +1 -0
- package/dist/types/timeview/DensityHeatmap.d.ts +11 -0
- package/dist/types/timeview/DensityHeatmap.d.ts.map +1 -0
- package/dist/types/timeview/LaneCalendar.d.ts +11 -0
- package/dist/types/timeview/LaneCalendar.d.ts.map +1 -0
- package/dist/types/timeview/MetricTimeline.d.ts +8 -0
- package/dist/types/timeview/MetricTimeline.d.ts.map +1 -0
- package/dist/types/timeview/SpanMatrix.d.ts +8 -0
- package/dist/types/timeview/SpanMatrix.d.ts.map +1 -0
- package/dist/types/timeview/config.d.ts +22 -0
- package/dist/types/timeview/config.d.ts.map +1 -0
- package/dist/types/timeview/core/aggregate.d.ts +113 -0
- package/dist/types/timeview/core/aggregate.d.ts.map +1 -0
- package/dist/types/timeview/core/calendar.d.ts +27 -0
- package/dist/types/timeview/core/calendar.d.ts.map +1 -0
- package/dist/types/timeview/core/intervals.d.ts +8 -0
- package/dist/types/timeview/core/intervals.d.ts.map +1 -0
- package/dist/types/timeview/core/labels.d.ts +5 -0
- package/dist/types/timeview/core/labels.d.ts.map +1 -0
- package/dist/types/timeview/core/metric.d.ts +58 -0
- package/dist/types/timeview/core/metric.d.ts.map +1 -0
- package/dist/types/timeview/core/time.d.ts +22 -0
- package/dist/types/timeview/core/time.d.ts.map +1 -0
- package/dist/types/timeview/dashboard.d.ts +17 -0
- package/dist/types/timeview/dashboard.d.ts.map +1 -0
- package/dist/types/timeview/data.d.ts +21 -0
- package/dist/types/timeview/data.d.ts.map +1 -0
- package/dist/types/timeview/export.d.ts +14 -0
- package/dist/types/timeview/export.d.ts.map +1 -0
- package/dist/types/timeview/index.d.ts +28 -0
- package/dist/types/timeview/index.d.ts.map +1 -0
- package/dist/types/timeview/registry.d.ts +285 -0
- package/dist/types/timeview/registry.d.ts.map +1 -0
- package/dist/types/timeview/shared/Caption.d.ts +9 -0
- package/dist/types/timeview/shared/Caption.d.ts.map +1 -0
- package/dist/types/timeview/shared/EmptyState.d.ts +16 -0
- package/dist/types/timeview/shared/EmptyState.d.ts.map +1 -0
- package/dist/types/timeview/shared/Legend.d.ts +10 -0
- package/dist/types/timeview/shared/Legend.d.ts.map +1 -0
- package/dist/types/timeview/shared/Tooltip.d.ts +15 -0
- package/dist/types/timeview/shared/Tooltip.d.ts.map +1 -0
- package/dist/types/timeview/shared/useMeasuredWidth.d.ts +2 -0
- package/dist/types/timeview/shared/useMeasuredWidth.d.ts.map +1 -0
- package/dist/types/timeview/types.d.ts +158 -0
- package/dist/types/timeview/types.d.ts.map +1 -0
- package/docs/AGENT-USAGE.md +93 -0
- package/docs/COMPATIBILITY.md +134 -0
- package/docs/STUDIO.md +41 -0
- package/examples/README.md +21 -0
- package/examples/configs/bandedTimeline.json +31 -0
- package/examples/configs/densityHeatmap.json +33 -0
- package/examples/configs/laneCalendar.json +31 -0
- package/examples/configs/metricTimeline.json +51 -0
- package/examples/configs/spanMatrix.json +31 -0
- package/package.json +94 -0
- package/render.html +12 -0
- package/src/render.tsx +67 -0
- package/src/styles/tokens.css +67 -0
- package/src/timeview/BandedTimeline.tsx +620 -0
- package/src/timeview/DensityHeatmap.tsx +513 -0
- package/src/timeview/LaneCalendar.tsx +496 -0
- package/src/timeview/MetricTimeline.tsx +993 -0
- package/src/timeview/SpanMatrix.tsx +721 -0
- package/src/timeview/config.ts +399 -0
- package/src/timeview/core/aggregate.ts +317 -0
- package/src/timeview/core/calendar.ts +81 -0
- package/src/timeview/core/intervals.ts +52 -0
- package/src/timeview/core/labels.ts +19 -0
- package/src/timeview/core/metric.ts +263 -0
- package/src/timeview/core/time.ts +103 -0
- package/src/timeview/dashboard.ts +80 -0
- package/src/timeview/data.ts +242 -0
- package/src/timeview/export.ts +48 -0
- package/src/timeview/index.ts +106 -0
- package/src/timeview/registry.ts +207 -0
- package/src/timeview/shared/Caption.tsx +40 -0
- package/src/timeview/shared/EmptyState.tsx +90 -0
- package/src/timeview/shared/Legend.tsx +67 -0
- package/src/timeview/shared/Tooltip.tsx +59 -0
- package/src/timeview/shared/useMeasuredWidth.ts +21 -0
- package/src/timeview/types.ts +159 -0
- package/vite.config.ts +11 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// aggregate.ts — shared bucketing + aggregation for summary visualizers.
|
|
2
|
+
//
|
|
3
|
+
// Turns a TimeDataset into a {groups × buckets} matrix. Rendering components
|
|
4
|
+
// can consume this directly without mutating source events or intervals.
|
|
5
|
+
|
|
6
|
+
import { DAY_MS, domainOf, fmt, tvWeeks } from "./time";
|
|
7
|
+
import type { HeatmapGroupBy, HeatmapMeasure, TimeBucketMode, TimeDataset } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface TimeBucket {
|
|
10
|
+
key: string;
|
|
11
|
+
index: number;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
label: string;
|
|
15
|
+
sub: string;
|
|
16
|
+
range: [number, number];
|
|
17
|
+
mode: TimeBucketMode;
|
|
18
|
+
isMonday?: boolean;
|
|
19
|
+
isWeekend?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AggregateGroup {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
labelId: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AggregateItem =
|
|
29
|
+
| { id: string; kind: "interval"; title: string; overlapMs: number; labelId?: string }
|
|
30
|
+
| { id: string; kind: "event"; title: string; labelId?: string };
|
|
31
|
+
|
|
32
|
+
export interface AggregateCell {
|
|
33
|
+
count: number;
|
|
34
|
+
durationMs: number;
|
|
35
|
+
items: AggregateItem[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AggregateOptions {
|
|
39
|
+
bucket?: TimeBucketMode;
|
|
40
|
+
measure?: HeatmapMeasure;
|
|
41
|
+
groupBy?: HeatmapGroupBy;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AggregateMatrix {
|
|
45
|
+
t0: number;
|
|
46
|
+
t1: number;
|
|
47
|
+
buckets: TimeBucket[];
|
|
48
|
+
groups: AggregateGroup[];
|
|
49
|
+
cells: Record<string, Record<string, AggregateCell>>;
|
|
50
|
+
max: number;
|
|
51
|
+
totals: Record<string, { value: number; count: number; durationMs: number }>;
|
|
52
|
+
totalMax: number;
|
|
53
|
+
measure: HeatmapMeasure;
|
|
54
|
+
bucket: TimeBucketMode;
|
|
55
|
+
groupBy: HeatmapGroupBy;
|
|
56
|
+
value: (cell: AggregateCell | undefined) => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type SpanMatrixIntervalItem = Extract<AggregateItem, { kind: "interval" }>;
|
|
60
|
+
export type SpanMatrixEventItem = Extract<AggregateItem, { kind: "event" }>;
|
|
61
|
+
|
|
62
|
+
export interface SpanMatrixCell {
|
|
63
|
+
bucketKey: string;
|
|
64
|
+
index: number;
|
|
65
|
+
spanCount: number;
|
|
66
|
+
present: boolean;
|
|
67
|
+
overlap: boolean;
|
|
68
|
+
runStart: boolean;
|
|
69
|
+
runEnd: boolean;
|
|
70
|
+
eventCount: number;
|
|
71
|
+
intervals: SpanMatrixIntervalItem[];
|
|
72
|
+
events: SpanMatrixEventItem[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SpanMatrixRun {
|
|
76
|
+
c0: number;
|
|
77
|
+
c1: number;
|
|
78
|
+
cells: SpanMatrixCell[];
|
|
79
|
+
peak: number;
|
|
80
|
+
title: string;
|
|
81
|
+
labelId: string | null;
|
|
82
|
+
span: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SpanMatrixRow {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
labelId: string | null;
|
|
89
|
+
cells: SpanMatrixCell[];
|
|
90
|
+
runs: SpanMatrixRun[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SpanMatrixOptions {
|
|
94
|
+
bucket?: TimeBucketMode;
|
|
95
|
+
groupBy?: HeatmapGroupBy;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SpanMatrixModel {
|
|
99
|
+
t0: number;
|
|
100
|
+
t1: number;
|
|
101
|
+
buckets: TimeBucket[];
|
|
102
|
+
groups: AggregateGroup[];
|
|
103
|
+
rows: SpanMatrixRow[];
|
|
104
|
+
maxOverlap: number;
|
|
105
|
+
presentCells: number;
|
|
106
|
+
bucket: TimeBucketMode;
|
|
107
|
+
groupBy: HeatmapGroupBy;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function asDate(t: number | string | Date): Date {
|
|
111
|
+
return t instanceof Date ? t : new Date(t);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function startOfDayUTC(t: number | string | Date): number {
|
|
115
|
+
const d = asDate(t);
|
|
116
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Half-open [start,end) bucket windows across the dataset domain. */
|
|
120
|
+
export function tvBuckets(t0: number | string | Date, t1: number | string | Date, mode: TimeBucketMode = "day"): TimeBucket[] {
|
|
121
|
+
const start = asDate(t0).getTime();
|
|
122
|
+
const end = asDate(t1).getTime();
|
|
123
|
+
|
|
124
|
+
if (mode === "week") {
|
|
125
|
+
return tvWeeks(start, end).map((w, i) => ({
|
|
126
|
+
key: "w" + i,
|
|
127
|
+
index: i,
|
|
128
|
+
start: w.start.getTime(),
|
|
129
|
+
end: w.end.getTime(),
|
|
130
|
+
label: String(fmt.day(w.start)),
|
|
131
|
+
sub: fmt.mon(w.start),
|
|
132
|
+
range: [w.start.getTime(), w.end.getTime() - DAY_MS],
|
|
133
|
+
mode: "week",
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const out: TimeBucket[] = [];
|
|
138
|
+
let d = startOfDayUTC(start);
|
|
139
|
+
let i = 0;
|
|
140
|
+
while (d < end) {
|
|
141
|
+
const dt = new Date(d);
|
|
142
|
+
out.push({
|
|
143
|
+
key: "d" + d,
|
|
144
|
+
index: i++,
|
|
145
|
+
start: d,
|
|
146
|
+
end: d + DAY_MS,
|
|
147
|
+
label: String(fmt.day(dt)),
|
|
148
|
+
sub: fmt.dow(dt),
|
|
149
|
+
range: [d, d],
|
|
150
|
+
mode: "day",
|
|
151
|
+
isMonday: (dt.getUTCDay() + 6) % 7 === 0,
|
|
152
|
+
isWeekend: dt.getUTCDay() === 0 || dt.getUTCDay() === 6,
|
|
153
|
+
});
|
|
154
|
+
d += DAY_MS;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function tvAggregate(dataset: TimeDataset, opts: AggregateOptions = {}): AggregateMatrix {
|
|
160
|
+
const bucket = opts.bucket || "day";
|
|
161
|
+
const measure = opts.measure || "count";
|
|
162
|
+
const groupBy = opts.groupBy || "category";
|
|
163
|
+
|
|
164
|
+
const [t0, t1] = domainOf(dataset);
|
|
165
|
+
const buckets = tvBuckets(t0, t1, bucket);
|
|
166
|
+
const groups: AggregateGroup[] =
|
|
167
|
+
groupBy === "category"
|
|
168
|
+
? (dataset.labels || []).map((l) => ({ id: l.id, name: l.name, labelId: l.id }))
|
|
169
|
+
: [{ id: "__all", name: "All activity", labelId: null }];
|
|
170
|
+
const groupOf = (labelId: string | undefined) => (groupBy === "category" ? labelId : "__all");
|
|
171
|
+
|
|
172
|
+
const cells: AggregateMatrix["cells"] = {};
|
|
173
|
+
groups.forEach((g) => {
|
|
174
|
+
cells[g.id] = {};
|
|
175
|
+
buckets.forEach((b) => {
|
|
176
|
+
cells[g.id][b.key] = { count: 0, durationMs: 0, items: [] };
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
(dataset.intervals || []).forEach((iv) => {
|
|
181
|
+
const s = new Date(iv.range.start).getTime();
|
|
182
|
+
const e = new Date(iv.range.end).getTime();
|
|
183
|
+
const labelId = iv.labelIds?.[0];
|
|
184
|
+
const gid = groupOf(labelId);
|
|
185
|
+
if (!gid || !cells[gid]) return;
|
|
186
|
+
|
|
187
|
+
buckets.forEach((b) => {
|
|
188
|
+
const overlapMs = Math.min(e, b.end) - Math.max(s, b.start);
|
|
189
|
+
if (overlapMs <= 0) return;
|
|
190
|
+
const cell = cells[gid][b.key];
|
|
191
|
+
cell.durationMs += overlapMs;
|
|
192
|
+
cell.count += 1;
|
|
193
|
+
cell.items.push({ id: iv.id, kind: "interval", title: iv.title, overlapMs, labelId });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
(dataset.events || []).forEach((ev) => {
|
|
198
|
+
const t = new Date(ev.at).getTime();
|
|
199
|
+
const labelId = ev.labelIds?.[0];
|
|
200
|
+
const gid = groupOf(labelId);
|
|
201
|
+
if (!gid || !cells[gid]) return;
|
|
202
|
+
const b = buckets.find((candidate) => t >= candidate.start && t < candidate.end);
|
|
203
|
+
if (!b) return;
|
|
204
|
+
const cell = cells[gid][b.key];
|
|
205
|
+
cell.count += 1;
|
|
206
|
+
cell.items.push({ id: ev.id, kind: "event", title: ev.title, labelId });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const value = (cell: AggregateCell | undefined) => (cell ? (measure === "duration" ? cell.durationMs : cell.count) : 0);
|
|
210
|
+
let max = 0;
|
|
211
|
+
const totals: AggregateMatrix["totals"] = {};
|
|
212
|
+
|
|
213
|
+
buckets.forEach((b) => {
|
|
214
|
+
let sum = 0;
|
|
215
|
+
let count = 0;
|
|
216
|
+
let durationMs = 0;
|
|
217
|
+
groups.forEach((g) => {
|
|
218
|
+
const cell = cells[g.id][b.key];
|
|
219
|
+
max = Math.max(max, value(cell));
|
|
220
|
+
sum += value(cell);
|
|
221
|
+
count += cell.count;
|
|
222
|
+
durationMs += cell.durationMs;
|
|
223
|
+
});
|
|
224
|
+
totals[b.key] = { value: sum, count, durationMs };
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
let totalMax = 0;
|
|
228
|
+
buckets.forEach((b) => {
|
|
229
|
+
totalMax = Math.max(totalMax, totals[b.key].value);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return { t0, t1, buckets, groups, cells, max, totals, totalMax, measure, bucket, groupBy, value };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function tvSpanMatrix(dataset: TimeDataset, opts: SpanMatrixOptions = {}): SpanMatrixModel {
|
|
236
|
+
const bucket = opts.bucket || "day";
|
|
237
|
+
const groupBy = opts.groupBy || "category";
|
|
238
|
+
const agg = tvAggregate(dataset, { bucket, measure: "count", groupBy });
|
|
239
|
+
|
|
240
|
+
let maxOverlap = 0;
|
|
241
|
+
let presentCells = 0;
|
|
242
|
+
const rows = agg.groups.map((g) => {
|
|
243
|
+
const cells = agg.buckets.map((b, i) => {
|
|
244
|
+
const items = agg.cells[g.id][b.key].items;
|
|
245
|
+
const intervals = items.filter((it): it is SpanMatrixIntervalItem => it.kind === "interval");
|
|
246
|
+
const events = items.filter((it): it is SpanMatrixEventItem => it.kind === "event");
|
|
247
|
+
maxOverlap = Math.max(maxOverlap, intervals.length);
|
|
248
|
+
if (intervals.length > 0) presentCells += 1;
|
|
249
|
+
return {
|
|
250
|
+
bucketKey: b.key,
|
|
251
|
+
index: i,
|
|
252
|
+
spanCount: intervals.length,
|
|
253
|
+
present: intervals.length > 0,
|
|
254
|
+
overlap: intervals.length >= 2,
|
|
255
|
+
runStart: false,
|
|
256
|
+
runEnd: false,
|
|
257
|
+
eventCount: events.length,
|
|
258
|
+
intervals,
|
|
259
|
+
events,
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
cells.forEach((cell, i) => {
|
|
264
|
+
const prev = cells[i - 1];
|
|
265
|
+
const next = cells[i + 1];
|
|
266
|
+
cell.runStart = cell.present && !(prev && prev.present);
|
|
267
|
+
cell.runEnd = cell.present && !(next && next.present);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const runs: SpanMatrixRun[] = [];
|
|
271
|
+
let cur: SpanMatrixRun | null = null;
|
|
272
|
+
cells.forEach((cell, i) => {
|
|
273
|
+
if (cell.present) {
|
|
274
|
+
if (!cur) {
|
|
275
|
+
cur = { c0: i, c1: i, cells: [cell], peak: cell.spanCount, title: "", labelId: g.labelId, span: 1 };
|
|
276
|
+
runs.push(cur);
|
|
277
|
+
} else {
|
|
278
|
+
cur.c1 = i;
|
|
279
|
+
cur.cells.push(cell);
|
|
280
|
+
cur.peak = Math.max(cur.peak, cell.spanCount);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
cur = null;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
runs.forEach((run) => {
|
|
288
|
+
const start = cells[run.c0];
|
|
289
|
+
const primary = [...start.intervals].sort((a, b) => (b.overlapMs || 0) - (a.overlapMs || 0))[0];
|
|
290
|
+
run.title = primary ? primary.title : "";
|
|
291
|
+
run.labelId = g.labelId;
|
|
292
|
+
run.span = run.c1 - run.c0 + 1;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { id: g.id, name: g.name, labelId: g.labelId, cells, runs };
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
t0: agg.t0,
|
|
300
|
+
t1: agg.t1,
|
|
301
|
+
buckets: agg.buckets,
|
|
302
|
+
groups: agg.groups,
|
|
303
|
+
rows,
|
|
304
|
+
maxOverlap,
|
|
305
|
+
presentCells,
|
|
306
|
+
bucket,
|
|
307
|
+
groupBy,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function tvFmtDuration(ms: number): string {
|
|
312
|
+
if (!ms || ms <= 0) return "0h";
|
|
313
|
+
const hours = ms / 3600000;
|
|
314
|
+
if (hours < 24) return Math.round(hours) + "h";
|
|
315
|
+
const days = hours / 24;
|
|
316
|
+
return (days >= 9.95 ? Math.round(days) : days.toFixed(1)) + "d";
|
|
317
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// calendar.ts — shared week/day projection helpers.
|
|
2
|
+
|
|
3
|
+
import { DAY_MS, tvWeeks } from "./time";
|
|
4
|
+
import type { TimeInput } from "../types";
|
|
5
|
+
|
|
6
|
+
export interface CalendarWeek {
|
|
7
|
+
index: number;
|
|
8
|
+
start: Date;
|
|
9
|
+
end: Date;
|
|
10
|
+
days: Date[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CalendarCell {
|
|
14
|
+
wi: number;
|
|
15
|
+
col: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CalendarIntervalSegment {
|
|
19
|
+
wi: number;
|
|
20
|
+
c0: number;
|
|
21
|
+
c1: number;
|
|
22
|
+
startsHere: boolean;
|
|
23
|
+
endsHere: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asDate(d: TimeInput): Date {
|
|
27
|
+
return d instanceof Date ? d : new Date(d);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function startOfDayUTC(d: TimeInput): number {
|
|
31
|
+
const x = asDate(d);
|
|
32
|
+
return Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Mon-start weeks covering [t0,t1], each with 7 concrete UTC day dates. */
|
|
36
|
+
export function tvGridWeeks(t0: TimeInput, t1: TimeInput): CalendarWeek[] {
|
|
37
|
+
return tvWeeks(t0, t1).map((w, index) => {
|
|
38
|
+
const days: Date[] = [];
|
|
39
|
+
for (let k = 0; k < 7; k++) days.push(new Date(w.start.getTime() + k * DAY_MS));
|
|
40
|
+
return { index, start: w.start, end: w.end, days };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Which {week,row col 0..6} a single instant lands in. */
|
|
45
|
+
export function tvDayCell(t: TimeInput, weeks: CalendarWeek[]): CalendarCell | null {
|
|
46
|
+
const day = startOfDayUTC(t);
|
|
47
|
+
for (const w of weeks) {
|
|
48
|
+
const ws = startOfDayUTC(w.start);
|
|
49
|
+
const col = Math.round((day - ws) / DAY_MS);
|
|
50
|
+
if (col >= 0 && col < 7) return { wi: w.index, col };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Split an interval into inclusive day-column segments for each week row it touches. */
|
|
56
|
+
export function tvSplitInterval(s: TimeInput, e: TimeInput, weeks: CalendarWeek[]): CalendarIntervalSegment[] {
|
|
57
|
+
const sDay = startOfDayUTC(s);
|
|
58
|
+
const eDay = startOfDayUTC(e);
|
|
59
|
+
const segments: CalendarIntervalSegment[] = [];
|
|
60
|
+
|
|
61
|
+
weeks.forEach((w) => {
|
|
62
|
+
const ws = startOfDayUTC(w.start);
|
|
63
|
+
const weLast = ws + 6 * DAY_MS;
|
|
64
|
+
const segStart = Math.max(sDay, ws);
|
|
65
|
+
const segEnd = Math.min(eDay, weLast);
|
|
66
|
+
if (segStart > segEnd) return;
|
|
67
|
+
segments.push({
|
|
68
|
+
wi: w.index,
|
|
69
|
+
c0: Math.round((segStart - ws) / DAY_MS),
|
|
70
|
+
c1: Math.round((segEnd - ws) / DAY_MS),
|
|
71
|
+
startsHere: sDay >= ws && sDay <= weLast,
|
|
72
|
+
endsHere: eDay >= ws && eDay <= weLast,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return segments;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function tvSameUTCDay(a: TimeInput, b: TimeInput): boolean {
|
|
80
|
+
return startOfDayUTC(a) === startOfDayUTC(b);
|
|
81
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// intervals.ts — shared interval geometry helpers.
|
|
2
|
+
|
|
3
|
+
export interface PackedInterval {
|
|
4
|
+
_s: number;
|
|
5
|
+
_e: number;
|
|
6
|
+
_lane: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Greedy lane packing — intervals that don't overlap share a lane.
|
|
10
|
+
export function packLanes<T extends PackedInterval>(intervals: T[]): number {
|
|
11
|
+
const sorted = [...intervals].sort((a, b) => a._s - b._s);
|
|
12
|
+
const laneEnds: number[] = [];
|
|
13
|
+
sorted.forEach((iv) => {
|
|
14
|
+
let placed = -1;
|
|
15
|
+
for (let i = 0; i < laneEnds.length; i++) {
|
|
16
|
+
if (iv._s >= laneEnds[i]) {
|
|
17
|
+
placed = i;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (placed === -1) {
|
|
22
|
+
placed = laneEnds.length;
|
|
23
|
+
laneEnds.push(0);
|
|
24
|
+
}
|
|
25
|
+
laneEnds[placed] = iv._e;
|
|
26
|
+
iv._lane = placed;
|
|
27
|
+
});
|
|
28
|
+
return laneEnds.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Compute spans where >= 2 intervals are simultaneously active.
|
|
32
|
+
export function overlapSpans<T extends Pick<PackedInterval, "_s" | "_e">>(intervals: T[]): [number, number][] {
|
|
33
|
+
const pts: [number, number][] = [];
|
|
34
|
+
intervals.forEach((iv) => {
|
|
35
|
+
pts.push([iv._s, 1]);
|
|
36
|
+
pts.push([iv._e, -1]);
|
|
37
|
+
});
|
|
38
|
+
pts.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
39
|
+
const spans: [number, number][] = [];
|
|
40
|
+
let depth = 0;
|
|
41
|
+
let openAt: number | null = null;
|
|
42
|
+
pts.forEach(([t, d]) => {
|
|
43
|
+
const prev = depth;
|
|
44
|
+
depth += d;
|
|
45
|
+
if (prev < 2 && depth >= 2) openAt = t;
|
|
46
|
+
if (prev >= 2 && depth < 2 && openAt != null) {
|
|
47
|
+
spans.push([openAt, t]);
|
|
48
|
+
openAt = null;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return spans;
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// labels.ts — shared label and palette helpers.
|
|
2
|
+
|
|
3
|
+
import type { TimeDataset } from "../types";
|
|
4
|
+
|
|
5
|
+
export function colorMap(dataset: TimeDataset, palette: string[]): Record<string, string> {
|
|
6
|
+
const m: Record<string, string> = {};
|
|
7
|
+
(dataset.labels || []).forEach((l, i) => {
|
|
8
|
+
m[l.id] = palette[i % palette.length];
|
|
9
|
+
});
|
|
10
|
+
return m;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mix(hex: string, pct: number, base = "#ffffff"): string {
|
|
14
|
+
return `color-mix(in srgb, ${hex} ${pct}%, ${base})`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function labelName(dataset: TimeDataset, id: string | undefined): string {
|
|
18
|
+
return (dataset.labels || []).find((l) => l.id === id)?.name || id || "";
|
|
19
|
+
}
|