@event-timeline/core 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/dist/index.js ADDED
@@ -0,0 +1,2614 @@
1
+ // src/layout/byFirstEvent.ts
2
+ var byFirstEvent = {
3
+ order(elements, events) {
4
+ const firstEventTime = /* @__PURE__ */ new Map();
5
+ const note = (id, time) => {
6
+ const current = firstEventTime.get(id);
7
+ if (current === void 0 || time < current) {
8
+ firstEventTime.set(id, time);
9
+ }
10
+ };
11
+ for (const event of events) {
12
+ note(event.sourceId, event.time);
13
+ if (event.targetId !== event.sourceId) note(event.targetId, event.time);
14
+ }
15
+ return elements.map((element, index) => ({
16
+ id: element.id,
17
+ index,
18
+ key: firstEventTime.get(element.id)
19
+ })).sort((a, b) => {
20
+ if (a.key !== void 0 && b.key !== void 0) {
21
+ return a.key !== b.key ? a.key - b.key : a.index - b.index;
22
+ }
23
+ if (a.key !== void 0) return -1;
24
+ if (b.key !== void 0) return 1;
25
+ return a.index - b.index;
26
+ }).map((entry) => entry.id);
27
+ }
28
+ };
29
+
30
+ // src/layout/stableAppend.ts
31
+ function stableAppend(priorOrder, elements) {
32
+ const current = new Set(elements.map((element) => element.id));
33
+ const placed = /* @__PURE__ */ new Set();
34
+ const order = [];
35
+ for (const id of priorOrder) {
36
+ if (current.has(id) && !placed.has(id)) {
37
+ order.push(id);
38
+ placed.add(id);
39
+ }
40
+ }
41
+ for (const element of elements) {
42
+ if (!placed.has(element.id)) {
43
+ order.push(element.id);
44
+ placed.add(element.id);
45
+ }
46
+ }
47
+ return order;
48
+ }
49
+
50
+ // src/cluster/strategies.ts
51
+ var SEP = "\0";
52
+ var MS_PER_DAY = 864e5;
53
+ function pixelColumn(event, ctx) {
54
+ return Math.floor(ctx.timeToScreenX(event.time) / ctx.minSeparationPx);
55
+ }
56
+ function elementPairKey(event) {
57
+ return `${event.sourceId}${SEP}${event.targetId}`;
58
+ }
59
+ var byPixelColumn = {
60
+ key: (event, ctx) => String(pixelColumn(event, ctx))
61
+ };
62
+ var byElementPair = {
63
+ key: (event) => elementPairKey(event)
64
+ };
65
+ var byDay = {
66
+ key: (event) => String(Math.floor(event.time / MS_PER_DAY))
67
+ };
68
+ var bySourceTargetColumn = {
69
+ key: (event, ctx) => `${elementPairKey(event)}${SEP}${pixelColumn(event, ctx)}`
70
+ };
71
+
72
+ // src/layout/positions.ts
73
+ function computeRowLayout(orderedIds, metrics) {
74
+ const rowIndexById = /* @__PURE__ */ new Map();
75
+ orderedIds.forEach((id, index) => rowIndexById.set(id, index));
76
+ const rowCount = orderedIds.length;
77
+ return {
78
+ orderedIds,
79
+ rowIndexById,
80
+ rowCount,
81
+ contentHeight: rowCount * (metrics.rowHeight + metrics.rowGap)
82
+ };
83
+ }
84
+
85
+ // src/model/diagnostics.ts
86
+ function createDefaultDiagnosticSink() {
87
+ const env = globalThis.process?.env;
88
+ if (env?.NODE_ENV === "production") {
89
+ return () => {
90
+ };
91
+ }
92
+ return (d) => {
93
+ const id = d.id !== void 0 ? ` (${d.id})` : "";
94
+ console.warn(`[timeline] dropped ${d.kind}${id}: ${d.reason}`);
95
+ };
96
+ }
97
+
98
+ // src/model/extent.ts
99
+ function widenExtent(current, t) {
100
+ if (current === void 0) {
101
+ return { minTime: t, maxTime: t };
102
+ }
103
+ return {
104
+ minTime: t < current.minTime ? t : current.minTime,
105
+ maxTime: t > current.maxTime ? t : current.maxTime
106
+ };
107
+ }
108
+ function dataExtent(extents) {
109
+ let result = null;
110
+ for (const extent of extents.values()) {
111
+ result = result === null ? { minTime: extent.minTime, maxTime: extent.maxTime } : {
112
+ minTime: Math.min(result.minTime, extent.minTime),
113
+ maxTime: Math.max(result.maxTime, extent.maxTime)
114
+ };
115
+ }
116
+ return result;
117
+ }
118
+
119
+ // src/model/temporalIndex.ts
120
+ function lowerBound(events, t) {
121
+ let lo = 0;
122
+ let hi = events.length;
123
+ while (lo < hi) {
124
+ const mid = lo + hi >>> 1;
125
+ if (events[mid].time < t) lo = mid + 1;
126
+ else hi = mid;
127
+ }
128
+ return lo;
129
+ }
130
+ function upperBound(events, t) {
131
+ let lo = 0;
132
+ let hi = events.length;
133
+ while (lo < hi) {
134
+ const mid = lo + hi >>> 1;
135
+ if (events[mid].time <= t) lo = mid + 1;
136
+ else hi = mid;
137
+ }
138
+ return lo;
139
+ }
140
+ function buildBuckets(sorted) {
141
+ const buckets = /* @__PURE__ */ new Map();
142
+ const add = (id, index) => {
143
+ const bucket = buckets.get(id);
144
+ if (bucket === void 0) buckets.set(id, [index]);
145
+ else bucket.push(index);
146
+ };
147
+ for (let i = 0; i < sorted.length; i++) {
148
+ const event = sorted[i];
149
+ add(event.sourceId, i);
150
+ if (event.targetId !== event.sourceId) add(event.targetId, i);
151
+ }
152
+ return buckets;
153
+ }
154
+
155
+ // src/model/store.ts
156
+ var DataStore = class {
157
+ onDiagnostic;
158
+ elements = [];
159
+ elementById = /* @__PURE__ */ new Map();
160
+ /** Events sorted ascending by `time` (DESIGN.md §5); see {@link reindex}. */
161
+ events = [];
162
+ eventById = /* @__PURE__ */ new Map();
163
+ extents = /* @__PURE__ */ new Map();
164
+ /** Element id → indices into {@link events}, in time order (§5). */
165
+ eventIndicesByElement = /* @__PURE__ */ new Map();
166
+ /** Per-event insertion order, the stable tiebreak for equal-time events. */
167
+ seq = /* @__PURE__ */ new Map();
168
+ /**
169
+ * Monotonic source for {@link seq}. Never reset except by {@link clear}, so
170
+ * an event inserted after a removal still sorts *after* survivors at the same
171
+ * time — using `events.length` would recycle a live seq and corrupt the order.
172
+ */
173
+ seqCounter = 0;
174
+ constructor(options = {}) {
175
+ this.onDiagnostic = options.onDiagnostic ?? createDefaultDiagnosticSink();
176
+ }
177
+ /**
178
+ * Full replace: clear, then apply the whole dataset through the same insert
179
+ * routines used by (future) incremental updates (§5). Elements are inserted
180
+ * before events so edges can reference any element in the batch.
181
+ */
182
+ setData(data) {
183
+ this.clear();
184
+ let added = 0;
185
+ for (const element of data.elements) {
186
+ if (this.insertElement(element)) added++;
187
+ }
188
+ for (const event of data.events) {
189
+ if (this.insertEvent(event)) added++;
190
+ }
191
+ this.reindex();
192
+ return { added, removed: 0, updated: 0 };
193
+ }
194
+ /**
195
+ * Incrementally insert events (DESIGN.md §10). Each row runs through the same
196
+ * validated {@link insertEvent} as `setData` (lenient: invalid rows drop), then
197
+ * a single {@link reindex} re-sorts and rebuilds buckets. Appending a batch and
198
+ * re-sorting the mostly-sorted array is the §10 "single merge" in practice — no
199
+ * parallel index builder (the §5 single-build-path invariant).
200
+ */
201
+ addEvents(events) {
202
+ let added = 0;
203
+ for (const event of events) {
204
+ if (this.insertEvent(event)) added++;
205
+ }
206
+ if (added > 0) this.reindex();
207
+ return { added, removed: 0, updated: 0 };
208
+ }
209
+ /**
210
+ * Incrementally insert elements with stable-append semantics (DESIGN.md §10):
211
+ * appended in input order, existing elements untouched. Duplicates drop
212
+ * leniently. Events are unchanged, so no reindex is needed.
213
+ */
214
+ addElements(elements) {
215
+ let added = 0;
216
+ for (const element of elements) {
217
+ if (this.insertElement(element)) added++;
218
+ }
219
+ return { added, removed: 0, updated: 0 };
220
+ }
221
+ /**
222
+ * Remove events by id (DESIGN.md §10). Unknown ids are ignored (lenient).
223
+ * Extents shrink, so the touched elements' extents are recomputed from their
224
+ * surviving events after the buckets are rebuilt by {@link reindex}.
225
+ */
226
+ removeEvents(ids) {
227
+ const affected = /* @__PURE__ */ new Set();
228
+ let removed = 0;
229
+ for (const id of ids) {
230
+ const event = this.eventById.get(id);
231
+ if (event === void 0) continue;
232
+ this.eventById.delete(id);
233
+ this.seq.delete(event);
234
+ affected.add(event.sourceId);
235
+ affected.add(event.targetId);
236
+ removed++;
237
+ }
238
+ if (removed === 0) return { added: 0, removed: 0, updated: 0 };
239
+ this.events = this.events.filter((e) => this.eventById.has(e.id));
240
+ this.reindex();
241
+ for (const id of affected) this.recomputeExtentFor(id);
242
+ return { added: 0, removed, updated: 0 };
243
+ }
244
+ /**
245
+ * Patch an element in place (DESIGN.md §10): style/label/icon/order overrides
246
+ * without a full rebuild. The `id` is immutable. Unknown ids are a lenient
247
+ * no-op. Extents are event-derived, so they are unaffected.
248
+ *
249
+ * Note: patching `order` only changes row position under the `explicit`
250
+ * layout with `streamingLayout: 'full'`; the default stable-append streaming
251
+ * layout deliberately never reshuffles existing rows (§7).
252
+ */
253
+ updateElement(id, patch) {
254
+ const existing = this.elementById.get(id);
255
+ if (existing === void 0) return { added: 0, removed: 0, updated: 0 };
256
+ const slot = this.elements.indexOf(existing);
257
+ if (slot === -1) return { added: 0, removed: 0, updated: 0 };
258
+ const updated = { ...existing, ...patch, id };
259
+ this.elements[slot] = updated;
260
+ this.elementById.set(id, updated);
261
+ return { added: 0, removed: 0, updated: 1 };
262
+ }
263
+ /**
264
+ * Recompute one element's extent from its surviving events (§5). Reads the
265
+ * rebuilt bucket; an event-less element has no extent and is removed.
266
+ */
267
+ recomputeExtentFor(id) {
268
+ const indices = this.eventIndicesByElement.get(id);
269
+ if (indices === void 0 || indices.length === 0) {
270
+ this.extents.delete(id);
271
+ return;
272
+ }
273
+ let extent;
274
+ for (const index of indices) {
275
+ const event = this.events[index];
276
+ extent = widenExtent(extent, event.time);
277
+ }
278
+ if (extent !== void 0) this.extents.set(id, extent);
279
+ }
280
+ clear() {
281
+ this.elements = [];
282
+ this.elementById.clear();
283
+ this.events = [];
284
+ this.eventById.clear();
285
+ this.extents.clear();
286
+ this.eventIndicesByElement.clear();
287
+ this.seq.clear();
288
+ this.seqCounter = 0;
289
+ }
290
+ /**
291
+ * Finalise the derived event indexes from the validated events (§5): sort by
292
+ * `time` (stable on the insertion `seq`, so equal-time order is deterministic
293
+ * across engines), then build the per-element buckets in one pass. This is the
294
+ * single index-build path — the future incremental `addEvents` (§10) re-enters
295
+ * here rather than maintaining a parallel builder.
296
+ */
297
+ reindex() {
298
+ this.events.sort((a, b) => {
299
+ if (a.time !== b.time) return a.time - b.time;
300
+ return (this.seq.get(a) ?? 0) - (this.seq.get(b) ?? 0);
301
+ });
302
+ this.eventIndicesByElement = buildBuckets(this.events);
303
+ }
304
+ insertElement(element) {
305
+ if (this.elementById.has(element.id)) {
306
+ this.onDiagnostic({
307
+ code: "duplicate-id",
308
+ kind: "element",
309
+ id: element.id,
310
+ reason: "duplicate element id"
311
+ });
312
+ return false;
313
+ }
314
+ this.elements.push(element);
315
+ this.elementById.set(element.id, element);
316
+ return true;
317
+ }
318
+ insertEvent(event) {
319
+ if (!Number.isFinite(event.time)) {
320
+ this.onDiagnostic({
321
+ code: "invalid-time",
322
+ kind: "event",
323
+ id: event.id,
324
+ reason: "time is not a finite number"
325
+ });
326
+ return false;
327
+ }
328
+ if (this.eventById.has(event.id)) {
329
+ this.onDiagnostic({
330
+ code: "duplicate-id",
331
+ kind: "event",
332
+ id: event.id,
333
+ reason: "duplicate event id"
334
+ });
335
+ return false;
336
+ }
337
+ if (!this.elementById.has(event.sourceId) || !this.elementById.has(event.targetId)) {
338
+ this.onDiagnostic({
339
+ code: "dangling-edge",
340
+ kind: "event",
341
+ id: event.id,
342
+ reason: "sourceId or targetId references an unknown element"
343
+ });
344
+ return false;
345
+ }
346
+ this.seq.set(event, this.seqCounter++);
347
+ this.events.push(event);
348
+ this.eventById.set(event.id, event);
349
+ this.extents.set(
350
+ event.sourceId,
351
+ widenExtent(this.extents.get(event.sourceId), event.time)
352
+ );
353
+ if (event.targetId !== event.sourceId) {
354
+ this.extents.set(
355
+ event.targetId,
356
+ widenExtent(this.extents.get(event.targetId), event.time)
357
+ );
358
+ }
359
+ return true;
360
+ }
361
+ getElements() {
362
+ return this.elements;
363
+ }
364
+ getEvents() {
365
+ return this.events;
366
+ }
367
+ /**
368
+ * The events whose `time` falls in `[tStart, tEnd]` (inclusive), via binary
369
+ * search over the sorted array — O(log n + k) (§5). Used by the renderer for
370
+ * horizontal culling instead of a linear scan.
371
+ */
372
+ getEventsInRange(tStart, tEnd) {
373
+ if (tEnd < tStart) return [];
374
+ const lo = lowerBound(this.events, tStart);
375
+ const hi = upperBound(this.events, tEnd);
376
+ return this.events.slice(lo, hi);
377
+ }
378
+ /**
379
+ * Indices into {@link getEvents} of the events touching `id`, in time order
380
+ * (§5). Empty for an unknown or event-less element.
381
+ */
382
+ getEventIndicesForElement(id) {
383
+ return this.eventIndicesByElement.get(id) ?? [];
384
+ }
385
+ getElementById(id) {
386
+ return this.elementById.get(id);
387
+ }
388
+ getExtent(id) {
389
+ return this.extents.get(id);
390
+ }
391
+ getExtents() {
392
+ return this.extents;
393
+ }
394
+ /** Overall extent across all elements, or `null` when empty (§3.1). */
395
+ getDataExtent() {
396
+ return dataExtent(this.extents);
397
+ }
398
+ };
399
+
400
+ // src/cluster/cluster.ts
401
+ function clusterEvents(events, strategy, ctx) {
402
+ const groups = /* @__PURE__ */ new Map();
403
+ for (const event of events) {
404
+ const key = strategy.key(event, ctx);
405
+ const group = groups.get(key);
406
+ if (group === void 0) groups.set(key, [event]);
407
+ else group.push(event);
408
+ }
409
+ const clusters = [];
410
+ for (const group of groups.values()) {
411
+ let run = [];
412
+ let prevX = 0;
413
+ for (const event of group) {
414
+ const x = ctx.timeToScreenX(event.time);
415
+ if (run.length === 0 || x - prevX <= ctx.minSeparationPx) {
416
+ run.push(event);
417
+ } else {
418
+ clusters.push({ events: run });
419
+ run = [event];
420
+ }
421
+ prevX = x;
422
+ }
423
+ if (run.length > 0) clusters.push({ events: run });
424
+ }
425
+ return clusters;
426
+ }
427
+
428
+ // src/render/batch.ts
429
+ function lineBatch(map, color, width) {
430
+ const key = `${color}|${width}`;
431
+ let batch = map.get(key);
432
+ if (batch === void 0) {
433
+ batch = { color, width, segments: [] };
434
+ map.set(key, batch);
435
+ }
436
+ return batch;
437
+ }
438
+ function nodeBatch(map, color, radius) {
439
+ const key = `${color}|${radius}`;
440
+ let batch = map.get(key);
441
+ if (batch === void 0) {
442
+ batch = { color, radius, items: [] };
443
+ map.set(key, batch);
444
+ }
445
+ return batch;
446
+ }
447
+ function arrowheadBatch(map, color, size) {
448
+ const key = `${color}|${size}`;
449
+ let batch = map.get(key);
450
+ if (batch === void 0) {
451
+ batch = { color, size, items: [] };
452
+ map.set(key, batch);
453
+ }
454
+ return batch;
455
+ }
456
+ function labelBatch(map, color, font, align = "left", baseline = "middle") {
457
+ const key = `${color}|${font}|${align}|${baseline}`;
458
+ let batch = map.get(key);
459
+ if (batch === void 0) {
460
+ batch = { color, font, baseline, align, items: [] };
461
+ map.set(key, batch);
462
+ }
463
+ return batch;
464
+ }
465
+
466
+ // src/render/riser.ts
467
+ var HOP_ARC_STEPS = 6;
468
+ function buildRiserSegments(x, yFrom, yTo, crossings, hopRadius) {
469
+ if (crossings.length === 0 || hopRadius <= 0) {
470
+ return [{ x1: x, y1: yFrom, x2: x, y2: yTo }];
471
+ }
472
+ const dir = yTo >= yFrom ? 1 : -1;
473
+ const ordered = [...crossings].sort((a, b) => dir * (a - b));
474
+ const points = [{ x, y: yFrom }];
475
+ for (const cy of ordered) {
476
+ const entryY = cy - dir * hopRadius;
477
+ const exitY = cy + dir * hopRadius;
478
+ points.push({ x, y: entryY });
479
+ for (let s = 1; s < HOP_ARC_STEPS; s++) {
480
+ const theta = Math.PI * s / HOP_ARC_STEPS;
481
+ points.push({
482
+ x: x + hopRadius * Math.sin(theta),
483
+ y: cy - dir * hopRadius * Math.cos(theta)
484
+ });
485
+ }
486
+ points.push({ x, y: exitY });
487
+ }
488
+ points.push({ x, y: yTo });
489
+ const segments = [];
490
+ for (let i = 1; i < points.length; i++) {
491
+ const a = points[i - 1];
492
+ const b = points[i];
493
+ if (a === void 0 || b === void 0) continue;
494
+ segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
495
+ }
496
+ return segments;
497
+ }
498
+
499
+ // src/render/arrows.ts
500
+ var SUB_LANE_STEP_PX = 2;
501
+ var SUB_LANE_MAX = 4;
502
+ var EVENT_LABEL_GAP_PX = 4;
503
+ var HORIZONTAL_MARGIN_PX = 48;
504
+ var CLUSTER_MARKER_BONUS_PX = 2;
505
+ var HOP_RADIUS_PX = 4;
506
+ var MAX_HOP_SPAN_ROWS = 24;
507
+ var ANGLE_DOWN = Math.PI / 2;
508
+ var ANGLE_UP = -Math.PI / 2;
509
+ function subLaneOffset(k) {
510
+ return Math.min(k, SUB_LANE_MAX) * SUB_LANE_STEP_PX;
511
+ }
512
+ function buildArrows(ctx) {
513
+ const { camera, viewport, lod, clustering } = ctx;
514
+ const batches = {
515
+ lines: /* @__PURE__ */ new Map(),
516
+ nodes: /* @__PURE__ */ new Map(),
517
+ arrowheads: /* @__PURE__ */ new Map(),
518
+ labels: /* @__PURE__ */ new Map(),
519
+ targets: [],
520
+ columnCounts: /* @__PURE__ */ new Map()
521
+ };
522
+ const tStart = camera.screenXToTime(-HORIZONTAL_MARGIN_PX);
523
+ const tEnd = camera.screenXToTime(viewport.cssWidth + HORIZONTAL_MARGIN_PX);
524
+ const windowEvents = ctx.data.getEventsInRange(tStart, tEnd);
525
+ const visible = [];
526
+ for (const event of windowEvents) {
527
+ const rows = rowSpan(ctx, event);
528
+ if (rows === null) continue;
529
+ if (rows.hi < ctx.band.firstRow || rows.lo > ctx.band.lastRow) continue;
530
+ visible.push(event);
531
+ }
532
+ const labelsOn = camera.state.pxPerMs >= lod.eventLabelMinPxPerMs;
533
+ if (clustering.enabled) {
534
+ const clusterCtx = {
535
+ minSeparationPx: clustering.minSeparationPx,
536
+ timeToScreenX: (t) => camera.timeToScreenX(t)
537
+ };
538
+ for (const cluster of clusterEvents(
539
+ visible,
540
+ clustering.strategy,
541
+ clusterCtx
542
+ )) {
543
+ const [first] = cluster.events;
544
+ if (cluster.events.length === 1 && first !== void 0) {
545
+ addSingleArrow(batches, ctx, first, labelsOn);
546
+ } else {
547
+ addCluster(batches, ctx, cluster.events);
548
+ }
549
+ }
550
+ } else {
551
+ for (const event of visible) addSingleArrow(batches, ctx, event, labelsOn);
552
+ }
553
+ return {
554
+ lines: [...batches.lines.values()],
555
+ nodes: [...batches.nodes.values()],
556
+ arrowheads: [...batches.arrowheads.values()],
557
+ labels: [...batches.labels.values()],
558
+ targets: batches.targets
559
+ };
560
+ }
561
+ function rowSpan(ctx, event) {
562
+ const sourceRow = ctx.rowLayout.rowIndexById.get(event.sourceId);
563
+ const targetRow = ctx.rowLayout.rowIndexById.get(event.targetId);
564
+ if (sourceRow === void 0 || targetRow === void 0) return null;
565
+ return {
566
+ lo: Math.min(sourceRow, targetRow),
567
+ hi: Math.max(sourceRow, targetRow),
568
+ sourceRow,
569
+ targetRow
570
+ };
571
+ }
572
+ function addSingleArrow(batches, ctx, event, labelsOn) {
573
+ const rows = rowSpan(ctx, event);
574
+ if (rows === null) return;
575
+ const { camera, theme } = ctx;
576
+ const halfRow = theme.rowHeight / 2;
577
+ const baseX = camera.timeToScreenX(event.time);
578
+ const column = Math.round(baseX);
579
+ const k = batches.columnCounts.get(column) ?? 0;
580
+ batches.columnCounts.set(column, k + 1);
581
+ const x = baseX + subLaneOffset(k);
582
+ const style = ctx.resolveEventStyle(event);
583
+ const color = style.arrowColor;
584
+ const width = style.arrowWidth;
585
+ const size = style.arrowheadSize;
586
+ const radius = style.nodeRadius;
587
+ const labelText = labelsOn ? ctx.formatEventLabel(event) : void 0;
588
+ if (event.sourceId === event.targetId) {
589
+ const y = camera.rowToScreenY(rows.sourceRow) + halfRow;
590
+ addSelfLoop(batches, { color, width, size, radius }, x, y, event);
591
+ if (labelText) {
592
+ pushEventLabel(batches.labels, labelText, style, {
593
+ x: x + size * 2 + EVENT_LABEL_GAP_PX,
594
+ y
595
+ });
596
+ }
597
+ return;
598
+ }
599
+ const ySource = camera.rowToScreenY(rows.sourceRow) + halfRow;
600
+ const yTargetCenter = camera.rowToScreenY(rows.targetRow) + halfRow;
601
+ const dir = yTargetCenter > ySource ? 1 : -1;
602
+ const targetEl = ctx.data.getElementById(event.targetId);
603
+ const targetLineWidth = targetEl ? ctx.resolveElementStyle(targetEl).lineWidth : theme.element.lineWidth;
604
+ const nearEdge = yTargetCenter - dir * (targetLineWidth / 2);
605
+ const arrowheadAnchorY = nearEdge - dir * size;
606
+ const crossings = intermediaryCrossings(ctx, rows, event.time);
607
+ const segments = buildRiserSegments(
608
+ x,
609
+ ySource,
610
+ arrowheadAnchorY,
611
+ crossings,
612
+ HOP_RADIUS_PX
613
+ );
614
+ lineBatch(batches.lines, color, width).segments.push(...segments);
615
+ const nodes = nodeBatch(batches.nodes, color, radius);
616
+ nodes.items.push({ x, y: ySource });
617
+ nodes.items.push({ x, y: yTargetCenter });
618
+ arrowheadBatch(batches.arrowheads, color, size).items.push({
619
+ x,
620
+ y: arrowheadAnchorY,
621
+ angle: dir > 0 ? ANGLE_DOWN : ANGLE_UP
622
+ });
623
+ batches.targets.push({
624
+ kind: "event",
625
+ event,
626
+ segments,
627
+ arrowhead: { x, y: arrowheadAnchorY },
628
+ node: { x, y: ySource },
629
+ targetNode: { x, y: yTargetCenter },
630
+ width,
631
+ arrowheadSize: size
632
+ });
633
+ if (labelText) {
634
+ pushEventLabel(batches.labels, labelText, style, {
635
+ x: x + width / 2 + EVENT_LABEL_GAP_PX,
636
+ y: (ySource + yTargetCenter) / 2
637
+ });
638
+ }
639
+ }
640
+ function intermediaryCrossings(ctx, rows, time) {
641
+ if (rows.hi - rows.lo <= 1 || rows.hi - rows.lo > MAX_HOP_SPAN_ROWS)
642
+ return [];
643
+ const { rowLayout, data, camera, theme } = ctx;
644
+ const halfRow = theme.rowHeight / 2;
645
+ const ys = [];
646
+ for (let r = rows.lo + 1; r < rows.hi; r++) {
647
+ const id = rowLayout.orderedIds[r];
648
+ if (id === void 0) continue;
649
+ const extent = data.getExtent(id);
650
+ if (extent === void 0) continue;
651
+ if (time < extent.minTime || time > extent.maxTime) continue;
652
+ ys.push(camera.rowToScreenY(r) + halfRow);
653
+ }
654
+ return ys;
655
+ }
656
+ function addCluster(batches, ctx, events) {
657
+ const { camera, theme } = ctx;
658
+ const halfRow = theme.rowHeight / 2;
659
+ let minRow = Infinity;
660
+ let maxRow = -Infinity;
661
+ let sumX = 0;
662
+ let placed = 0;
663
+ for (const event of events) {
664
+ const rows = rowSpan(ctx, event);
665
+ if (rows === null) continue;
666
+ minRow = Math.min(minRow, rows.lo);
667
+ maxRow = Math.max(maxRow, rows.hi);
668
+ sumX += camera.timeToScreenX(event.time);
669
+ placed += 1;
670
+ }
671
+ if (placed === 0) return;
672
+ const x = sumX / placed;
673
+ const yTop = camera.rowToScreenY(minRow) + halfRow;
674
+ const yBot = camera.rowToScreenY(maxRow) + halfRow;
675
+ const yMid = (yTop + yBot) / 2;
676
+ const color = theme.clusterColor;
677
+ const width = theme.event.arrowWidth;
678
+ const markerRadius = theme.event.nodeRadius + CLUSTER_MARKER_BONUS_PX;
679
+ const segments = yTop !== yBot ? [{ x1: x, y1: yTop, x2: x, y2: yBot }] : [];
680
+ if (segments.length > 0) {
681
+ lineBatch(batches.lines, color, width).segments.push(...segments);
682
+ }
683
+ nodeBatch(batches.nodes, color, markerRadius).items.push({ x, y: yMid });
684
+ labelBatch(batches.labels, color, theme.event.labelFont).items.push({
685
+ x: x + markerRadius + EVENT_LABEL_GAP_PX,
686
+ y: yMid,
687
+ text: `\xD7${events.length}`
688
+ });
689
+ batches.targets.push({
690
+ kind: "cluster",
691
+ events,
692
+ segments,
693
+ marker: { x, y: yMid },
694
+ markerSize: markerRadius,
695
+ width
696
+ });
697
+ }
698
+ function addSelfLoop(batches, style, x, y, event) {
699
+ const loop = style.size * 2;
700
+ const top = y - loop;
701
+ const segments = [
702
+ { x1: x, y1: y, x2: x, y2: top },
703
+ { x1: x, y1: top, x2: x + loop, y2: top },
704
+ { x1: x + loop, y1: top, x2: x + loop, y2: y }
705
+ ];
706
+ lineBatch(batches.lines, style.color, style.width).segments.push(...segments);
707
+ nodeBatch(batches.nodes, style.color, style.radius).items.push({ x, y });
708
+ arrowheadBatch(batches.arrowheads, style.color, style.size).items.push({
709
+ x: x + loop,
710
+ y,
711
+ angle: ANGLE_DOWN
712
+ });
713
+ batches.targets.push({
714
+ kind: "event",
715
+ event,
716
+ segments,
717
+ arrowhead: { x: x + loop, y },
718
+ node: { x, y },
719
+ width: style.width,
720
+ arrowheadSize: style.size
721
+ });
722
+ }
723
+ function pushEventLabel(labels, text, style, at) {
724
+ labelBatch(labels, style.labelColor, style.labelFont).items.push({
725
+ x: at.x,
726
+ y: at.y,
727
+ text
728
+ });
729
+ }
730
+
731
+ // src/render/canvas2d.ts
732
+ var Canvas2DRenderer = class {
733
+ constructor(baseCanvas, overlayCanvas, background) {
734
+ this.baseCanvas = baseCanvas;
735
+ this.overlayCanvas = overlayCanvas;
736
+ this.background = background;
737
+ this.baseCtx = getContext(baseCanvas);
738
+ this.overlayCtx = getContext(overlayCanvas);
739
+ this.current = this.baseCtx;
740
+ }
741
+ baseCanvas;
742
+ overlayCanvas;
743
+ background;
744
+ baseCtx;
745
+ overlayCtx;
746
+ current;
747
+ setBackground(background) {
748
+ this.background = background;
749
+ }
750
+ resize(deviceWidth, deviceHeight) {
751
+ this.baseCanvas.width = deviceWidth;
752
+ this.baseCanvas.height = deviceHeight;
753
+ this.overlayCanvas.width = deviceWidth;
754
+ this.overlayCanvas.height = deviceHeight;
755
+ }
756
+ beginFrame(layer) {
757
+ if (layer === "base") {
758
+ this.current = this.baseCtx;
759
+ this.baseCtx.fillStyle = this.background;
760
+ this.baseCtx.fillRect(
761
+ 0,
762
+ 0,
763
+ this.baseCanvas.width,
764
+ this.baseCanvas.height
765
+ );
766
+ } else {
767
+ this.current = this.overlayCtx;
768
+ this.overlayCtx.clearRect(
769
+ 0,
770
+ 0,
771
+ this.overlayCanvas.width,
772
+ this.overlayCanvas.height
773
+ );
774
+ }
775
+ }
776
+ fillRect(x, y, width, height, color) {
777
+ const ctx = this.current;
778
+ ctx.fillStyle = color;
779
+ ctx.fillRect(x, y, width, height);
780
+ }
781
+ drawLines(batch) {
782
+ if (batch.segments.length === 0) return;
783
+ const ctx = this.current;
784
+ ctx.strokeStyle = batch.color;
785
+ ctx.lineWidth = batch.width;
786
+ ctx.beginPath();
787
+ for (const s of batch.segments) {
788
+ ctx.moveTo(s.x1, s.y1);
789
+ ctx.lineTo(s.x2, s.y2);
790
+ }
791
+ ctx.stroke();
792
+ }
793
+ drawText(batch) {
794
+ if (batch.items.length === 0) return;
795
+ const ctx = this.current;
796
+ ctx.fillStyle = batch.color;
797
+ ctx.font = batch.font;
798
+ ctx.textBaseline = batch.baseline;
799
+ ctx.textAlign = batch.align;
800
+ for (const item of batch.items) {
801
+ ctx.fillText(item.text, item.x, item.y);
802
+ }
803
+ }
804
+ measureText(text, font) {
805
+ this.baseCtx.font = font;
806
+ return { width: this.baseCtx.measureText(text).width };
807
+ }
808
+ drawArrowheads(batch) {
809
+ if (batch.items.length === 0) return;
810
+ const ctx = this.current;
811
+ const { size } = batch;
812
+ ctx.fillStyle = batch.color;
813
+ ctx.beginPath();
814
+ for (const { x, y, angle } of batch.items) {
815
+ const cos = Math.cos(angle);
816
+ const sin = Math.sin(angle);
817
+ const tipX = x + size * cos;
818
+ const tipY = y + size * sin;
819
+ const bx = -0.4 * size;
820
+ const wy = 0.6 * size;
821
+ const x1 = x + bx * cos - wy * sin;
822
+ const y1 = y + bx * sin + wy * cos;
823
+ const x2 = x + bx * cos + wy * sin;
824
+ const y2 = y + bx * sin - wy * cos;
825
+ ctx.moveTo(tipX, tipY);
826
+ ctx.lineTo(x1, y1);
827
+ ctx.lineTo(x2, y2);
828
+ ctx.closePath();
829
+ }
830
+ ctx.fill();
831
+ }
832
+ drawNodes(batch) {
833
+ if (batch.items.length === 0) return;
834
+ const ctx = this.current;
835
+ ctx.fillStyle = batch.color;
836
+ ctx.beginPath();
837
+ for (const { x, y } of batch.items) {
838
+ ctx.moveTo(x + batch.radius, y);
839
+ ctx.arc(x, y, batch.radius, 0, Math.PI * 2);
840
+ }
841
+ ctx.fill();
842
+ }
843
+ drawImages(batch) {
844
+ const ctx = this.current;
845
+ for (const { x, y, size, image } of batch.items) {
846
+ try {
847
+ ctx.drawImage(image, x, y, size, size);
848
+ } catch {
849
+ }
850
+ }
851
+ }
852
+ endFrame(_layer) {
853
+ }
854
+ };
855
+ function getContext(canvas) {
856
+ const ctx = canvas.getContext("2d");
857
+ if (ctx === null) {
858
+ throw new Error("Canvas2DRenderer: failed to acquire a 2D context");
859
+ }
860
+ return ctx;
861
+ }
862
+
863
+ // src/render/device.ts
864
+ function snapDevice(cssPx, dpr) {
865
+ return Math.round(cssPx * dpr);
866
+ }
867
+ function hairlineOffset(deviceWidthPx) {
868
+ return deviceWidthPx % 2 === 1 ? 0.5 : 0;
869
+ }
870
+ function scaleFontToDevice(font, dpr) {
871
+ return font.replace(
872
+ /(\d*\.?\d+)px/,
873
+ (_, size) => `${Number(size) * dpr}px`
874
+ );
875
+ }
876
+
877
+ // src/render/elementLines.ts
878
+ var LINE_LEAD_PAD_PX = 12;
879
+ var MARKER_GAP_PX = 8;
880
+ var STACK_GAP_PX = 2;
881
+ var FALLBACK_LABEL_PX = 12;
882
+ var FIT_LABEL_BUDGET_PX = 96;
883
+ function markerReserveLeftPx(theme) {
884
+ const markerContentW = Math.max(theme.element.iconSize, FIT_LABEL_BUDGET_PX);
885
+ return theme.labelPadding + markerContentW + MARKER_GAP_PX + LINE_LEAD_PAD_PX;
886
+ }
887
+ function fontPx(font) {
888
+ const match = /(\d+(?:\.\d+)?)px/.exec(font);
889
+ return match ? Number(match[1]) : FALLBACK_LABEL_PX;
890
+ }
891
+ function buildElementLines(ctx) {
892
+ const { camera, rowLayout, data, viewport, theme, band, resolveIcon } = ctx;
893
+ const rowHeight = theme.rowHeight;
894
+ const lines = /* @__PURE__ */ new Map();
895
+ const labels = /* @__PURE__ */ new Map();
896
+ const icons = { items: [] };
897
+ const targets = [];
898
+ for (const id of rowLayout.orderedIds) {
899
+ const extent = data.getExtent(id);
900
+ if (extent === void 0) continue;
901
+ const rowIndex = rowLayout.rowIndexById.get(id);
902
+ if (rowIndex === void 0) continue;
903
+ if (rowIndex < band.firstRow || rowIndex > band.lastRow) continue;
904
+ const lineStartX = camera.timeToScreenX(extent.minTime);
905
+ const lineEndX = camera.timeToScreenX(extent.maxTime);
906
+ if (lineEndX <= 0 || lineStartX - LINE_LEAD_PAD_PX > viewport.cssWidth) {
907
+ continue;
908
+ }
909
+ const rowCenterY = camera.rowToScreenY(rowIndex) + rowHeight / 2;
910
+ const element = data.getElementById(id);
911
+ const style = element ? ctx.resolveElementStyle(element) : theme.element;
912
+ const lineColor = style.lineColor;
913
+ const lineWidth = style.lineWidth;
914
+ const x1 = lineStartX - LINE_LEAD_PAD_PX;
915
+ lineBatch(lines, lineColor, lineWidth).segments.push({
916
+ x1,
917
+ y1: rowCenterY,
918
+ x2: lineEndX,
919
+ y2: rowCenterY
920
+ });
921
+ if (element !== void 0) {
922
+ targets.push({
923
+ kind: "element",
924
+ element,
925
+ y: rowCenterY,
926
+ x1,
927
+ x2: lineEndX,
928
+ width: lineWidth
929
+ });
930
+ }
931
+ const iconSize = style.iconSize;
932
+ const icon = element?.icon;
933
+ const text = element ? ctx.formatElementLabel(element) : id;
934
+ const labelPx = fontPx(style.labelFont);
935
+ if (icon !== void 0) {
936
+ const markerHalfW = iconSize / 2;
937
+ const markerCenterX = Math.max(
938
+ x1 - MARKER_GAP_PX - markerHalfW,
939
+ theme.labelPadding + markerHalfW
940
+ );
941
+ const totalH = iconSize + STACK_GAP_PX + labelPx;
942
+ const top = rowCenterY - totalH / 2;
943
+ const iconCenterY = top + iconSize / 2;
944
+ const labelY = top + iconSize + STACK_GAP_PX + labelPx / 2;
945
+ const resolved = resolveIcon(icon);
946
+ if (resolved.kind === "image") {
947
+ icons.items.push({
948
+ x: markerCenterX - iconSize / 2,
949
+ y: top,
950
+ size: iconSize,
951
+ image: resolved.image
952
+ });
953
+ } else if (resolved.kind === "text") {
954
+ const iconFont = `${iconSize}px sans-serif`;
955
+ labelBatch(labels, style.labelColor, iconFont, "center").items.push({
956
+ x: markerCenterX,
957
+ y: iconCenterY,
958
+ text: resolved.text
959
+ });
960
+ }
961
+ labelBatch(
962
+ labels,
963
+ style.labelColor,
964
+ style.labelFont,
965
+ "center"
966
+ ).items.push({ x: markerCenterX, y: labelY, text });
967
+ } else {
968
+ const labelRightX = x1 - MARKER_GAP_PX;
969
+ if (labelRightX >= theme.labelPadding) {
970
+ labelBatch(
971
+ labels,
972
+ style.labelColor,
973
+ style.labelFont,
974
+ "right"
975
+ ).items.push({ x: labelRightX, y: rowCenterY, text });
976
+ } else {
977
+ labelBatch(
978
+ labels,
979
+ style.labelColor,
980
+ style.labelFont,
981
+ "left"
982
+ ).items.push({ x: theme.labelPadding, y: rowCenterY, text });
983
+ }
984
+ }
985
+ }
986
+ return {
987
+ lines: [...lines.values()],
988
+ labels: [...labels.values()],
989
+ icons,
990
+ targets
991
+ };
992
+ }
993
+
994
+ // src/render/iconCache.ts
995
+ function isIconUrl(icon) {
996
+ return /^(https?:|data:|blob:|\/|\.\/|\.\.\/)/.test(icon) || /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i.test(icon);
997
+ }
998
+ var IconCache = class {
999
+ constructor(onLoad) {
1000
+ this.onLoad = onLoad;
1001
+ }
1002
+ onLoad;
1003
+ // `null` = loading or failed; `undefined` (absent) = never requested.
1004
+ cache = /* @__PURE__ */ new Map();
1005
+ /**
1006
+ * The ready image for `url`, or `undefined` while it is still loading (or
1007
+ * failed). The first request for a URL kicks off the load.
1008
+ */
1009
+ get(url) {
1010
+ const hit = this.cache.get(url);
1011
+ if (hit === void 0) {
1012
+ this.load(url);
1013
+ return void 0;
1014
+ }
1015
+ if (hit === null) return void 0;
1016
+ return hit.complete && hit.naturalWidth > 0 ? hit : void 0;
1017
+ }
1018
+ clear() {
1019
+ this.cache.clear();
1020
+ }
1021
+ load(url) {
1022
+ if (typeof Image === "undefined") {
1023
+ this.cache.set(url, null);
1024
+ return;
1025
+ }
1026
+ this.cache.set(url, null);
1027
+ const image = new Image();
1028
+ image.onload = () => {
1029
+ this.cache.set(url, image);
1030
+ this.onLoad();
1031
+ };
1032
+ image.onerror = () => {
1033
+ };
1034
+ image.src = url;
1035
+ }
1036
+ };
1037
+
1038
+ // src/render/rowBand.ts
1039
+ function visibleRowBand(camera, viewport, rowCount, headerHeight) {
1040
+ if (rowCount === 0) return { firstRow: 0, lastRow: -1 };
1041
+ const top = Math.floor(camera.screenYToRow(headerHeight)) - 1;
1042
+ const bottom = Math.ceil(camera.screenYToRow(viewport.cssHeight)) + 1;
1043
+ return {
1044
+ firstRow: Math.max(0, top),
1045
+ lastRow: Math.min(rowCount - 1, bottom)
1046
+ };
1047
+ }
1048
+
1049
+ // src/render/snap.ts
1050
+ function snapLines(spec, dpr) {
1051
+ const width = Math.max(1, snapDevice(spec.width, dpr));
1052
+ const off = hairlineOffset(width);
1053
+ return {
1054
+ color: spec.color,
1055
+ width,
1056
+ segments: spec.segments.map((s) => ({
1057
+ x1: snapDevice(s.x1, dpr) + off,
1058
+ y1: snapDevice(s.y1, dpr) + off,
1059
+ x2: snapDevice(s.x2, dpr) + off,
1060
+ y2: snapDevice(s.y2, dpr) + off
1061
+ }))
1062
+ };
1063
+ }
1064
+ function snapNodes(spec, dpr) {
1065
+ return {
1066
+ color: spec.color,
1067
+ radius: Math.max(1, snapDevice(spec.radius, dpr)),
1068
+ items: spec.items.map((i) => ({
1069
+ x: snapDevice(i.x, dpr),
1070
+ y: snapDevice(i.y, dpr)
1071
+ }))
1072
+ };
1073
+ }
1074
+ function snapArrowheads(spec, dpr) {
1075
+ return {
1076
+ color: spec.color,
1077
+ size: Math.max(1, snapDevice(spec.size, dpr)),
1078
+ items: spec.items.map((i) => ({
1079
+ x: snapDevice(i.x, dpr),
1080
+ y: snapDevice(i.y, dpr),
1081
+ angle: i.angle
1082
+ // direction is dpr-invariant
1083
+ }))
1084
+ };
1085
+ }
1086
+ function snapLabels(spec, dpr) {
1087
+ return {
1088
+ color: spec.color,
1089
+ font: scaleFontToDevice(spec.font, dpr),
1090
+ baseline: spec.baseline,
1091
+ align: spec.align,
1092
+ items: spec.items.map((i) => ({
1093
+ x: snapDevice(i.x, dpr),
1094
+ y: snapDevice(i.y, dpr),
1095
+ text: i.text
1096
+ }))
1097
+ };
1098
+ }
1099
+ function snapIcons(spec, dpr) {
1100
+ return {
1101
+ items: spec.items.map((i) => ({
1102
+ x: snapDevice(i.x, dpr),
1103
+ y: snapDevice(i.y, dpr),
1104
+ size: snapDevice(i.size, dpr),
1105
+ image: i.image
1106
+ }))
1107
+ };
1108
+ }
1109
+
1110
+ // src/scale/camera.ts
1111
+ var DAY_MS = 864e5;
1112
+ var DEFAULT_WINDOW_MS = 30 * DAY_MS;
1113
+ var MAX_DAY_WIDTH_PX = 4e3;
1114
+ function usableFitWidth(viewport, inset) {
1115
+ const left = inset.left ?? 0;
1116
+ const right = inset.right ?? 0;
1117
+ return Math.max(1, viewport.cssWidth - left - right);
1118
+ }
1119
+ var DEFAULT_STATE = {
1120
+ originTime: 0,
1121
+ scrollY: 0,
1122
+ pxPerMs: 1
1123
+ };
1124
+ var Camera = class {
1125
+ constructor(metrics, initial = {}) {
1126
+ this.metrics = metrics;
1127
+ this._state = { ...DEFAULT_STATE, ...initial };
1128
+ }
1129
+ metrics;
1130
+ _state;
1131
+ get state() {
1132
+ return this._state;
1133
+ }
1134
+ // --- Transforms (the only place this arithmetic exists) ---
1135
+ timeToScreenX(t) {
1136
+ return (t - this._state.originTime) * this._state.pxPerMs;
1137
+ }
1138
+ screenXToTime(x) {
1139
+ return this._state.originTime + x / this._state.pxPerMs;
1140
+ }
1141
+ rowToScreenY(row) {
1142
+ const { headerHeight, rowHeight, rowGap } = this.metrics;
1143
+ return headerHeight + row * (rowHeight + rowGap) - this._state.scrollY;
1144
+ }
1145
+ screenYToRow(y) {
1146
+ const { headerHeight, rowHeight, rowGap } = this.metrics;
1147
+ return (y + this._state.scrollY - headerHeight) / (rowHeight + rowGap);
1148
+ }
1149
+ // --- Mutations ---
1150
+ setOriginTime(t) {
1151
+ this._state.originTime = t;
1152
+ }
1153
+ setScrollY(y) {
1154
+ this._state.scrollY = y;
1155
+ }
1156
+ setPxPerMs(pxPerMs, bounds) {
1157
+ this._state.pxPerMs = Math.min(
1158
+ bounds.maxPxPerMs,
1159
+ Math.max(bounds.minPxPerMs, pxPerMs)
1160
+ );
1161
+ }
1162
+ /**
1163
+ * Zoom about the cursor (DESIGN.md §3): scale `pxPerMs` by `factor`, keeping
1164
+ * the time under `cursorScreenX` fixed on screen. The cursor time is captured
1165
+ * *before* the zoom change, then `originTime` is recomputed afterwards so the
1166
+ * same instant lands under the same screen x. Reuses the existing transforms
1167
+ * and clamping — no new `pxPerMs` arithmetic leaks out of the Camera.
1168
+ */
1169
+ zoomAboutCursor(factor, cursorScreenX, bounds) {
1170
+ const tCursor = this.screenXToTime(cursorScreenX);
1171
+ this.setPxPerMs(this._state.pxPerMs * factor, bounds);
1172
+ this._state.originTime = tCursor - cursorScreenX / this._state.pxPerMs;
1173
+ }
1174
+ /**
1175
+ * Pan by a pixel delta. Dragging right (`dxCss > 0`) reveals earlier time at
1176
+ * the left edge, so `originTime` decreases. This is the only place a pixel
1177
+ * delta is converted to a time delta (`dxCss / pxPerMs`).
1178
+ */
1179
+ panByPixels(dxCss, dyCss) {
1180
+ this._state.originTime -= dxCss / this._state.pxPerMs;
1181
+ this._state.scrollY -= dyCss;
1182
+ }
1183
+ /** Clamp `scrollY` so vertical scroll cannot overscroll the content. */
1184
+ clampScrollY(contentHeight, viewport) {
1185
+ const visibleBody = viewport.cssHeight - this.metrics.headerHeight;
1186
+ const max = Math.max(0, contentHeight - visibleBody);
1187
+ this._state.scrollY = Math.min(max, Math.max(0, this._state.scrollY));
1188
+ }
1189
+ };
1190
+ function effectiveExtent(extent, now) {
1191
+ if (extent === null) {
1192
+ return {
1193
+ minTime: now - DEFAULT_WINDOW_MS / 2,
1194
+ maxTime: now + DEFAULT_WINDOW_MS / 2
1195
+ };
1196
+ }
1197
+ if (extent.maxTime - extent.minTime <= 0) {
1198
+ const center = extent.minTime;
1199
+ return {
1200
+ minTime: center - DEFAULT_WINDOW_MS / 2,
1201
+ maxTime: center + DEFAULT_WINDOW_MS / 2
1202
+ };
1203
+ }
1204
+ return extent;
1205
+ }
1206
+ function computeZoomBounds(extent, viewport, now, inset = {}) {
1207
+ const eff = effectiveExtent(extent, now);
1208
+ const span = eff.maxTime - eff.minTime;
1209
+ const maxPxPerMs = MAX_DAY_WIDTH_PX / DAY_MS;
1210
+ const minPxPerMs = usableFitWidth(viewport, inset) / span;
1211
+ return { minPxPerMs, maxPxPerMs };
1212
+ }
1213
+ function fit(camera, extent, viewport, now, inset = {}) {
1214
+ const eff = effectiveExtent(extent, now);
1215
+ const span = eff.maxTime - eff.minTime;
1216
+ camera.setPxPerMs(
1217
+ usableFitWidth(viewport, inset) / span,
1218
+ computeZoomBounds(extent, viewport, now, inset)
1219
+ );
1220
+ const left = inset.left ?? 0;
1221
+ camera.setOriginTime(eff.minTime - left / camera.state.pxPerMs);
1222
+ camera.setScrollY(0);
1223
+ }
1224
+
1225
+ // src/scale/ticks.ts
1226
+ var STEPS = [
1227
+ { level: "day", n: 1, approxMs: DAY_MS },
1228
+ { level: "day", n: 2, approxMs: 2 * DAY_MS },
1229
+ { level: "day", n: 5, approxMs: 5 * DAY_MS },
1230
+ { level: "day", n: 10, approxMs: 10 * DAY_MS },
1231
+ { level: "month", n: 1, approxMs: 30 * DAY_MS },
1232
+ { level: "month", n: 3, approxMs: 91 * DAY_MS },
1233
+ { level: "year", n: 1, approxMs: 365 * DAY_MS },
1234
+ { level: "year", n: 2, approxMs: 730 * DAY_MS },
1235
+ { level: "year", n: 5, approxMs: 1826 * DAY_MS },
1236
+ { level: "year", n: 10, approxMs: 3652 * DAY_MS }
1237
+ ];
1238
+ function pickStep(pxPerMs, minLabelGapPx) {
1239
+ for (const step of STEPS) {
1240
+ if (step.approxMs * pxPerMs >= minLabelGapPx) return step;
1241
+ }
1242
+ return STEPS[STEPS.length - 1];
1243
+ }
1244
+ function coarserStep(minor) {
1245
+ switch (minor.level) {
1246
+ case "day":
1247
+ return { level: "month", n: 1, approxMs: 30 * DAY_MS };
1248
+ case "month":
1249
+ return { level: "year", n: 1, approxMs: 365 * DAY_MS };
1250
+ case "year":
1251
+ return { level: "year", n: minor.n * 10, approxMs: minor.approxMs * 10 };
1252
+ }
1253
+ }
1254
+ function utcYear(t) {
1255
+ return new Date(t).getUTCFullYear();
1256
+ }
1257
+ function utcMonth(t) {
1258
+ return new Date(t).getUTCMonth();
1259
+ }
1260
+ function daysInUtcMonth(year, month) {
1261
+ return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
1262
+ }
1263
+ var formatterCache = /* @__PURE__ */ new Map();
1264
+ function defaultFormat(date, level) {
1265
+ let fmt = formatterCache.get(level);
1266
+ if (fmt === void 0) {
1267
+ const options = level === "year" ? { timeZone: "UTC", year: "numeric" } : level === "month" ? { timeZone: "UTC", month: "short", year: "numeric" } : { timeZone: "UTC", day: "numeric" };
1268
+ fmt = new Intl.DateTimeFormat("en-US", options);
1269
+ formatterCache.set(level, fmt);
1270
+ }
1271
+ return fmt.format(date);
1272
+ }
1273
+ function makeTick(time, level, format) {
1274
+ return { time, level, label: format(new Date(time), level) };
1275
+ }
1276
+ function enumerate(tStart, tEnd, step, format) {
1277
+ const ticks = [];
1278
+ if (step.level === "day") {
1279
+ let monthStart = Date.UTC(utcYear(tStart), utcMonth(tStart), 1);
1280
+ while (monthStart <= tEnd) {
1281
+ const year2 = utcYear(monthStart);
1282
+ const month = utcMonth(monthStart);
1283
+ const dim = daysInUtcMonth(year2, month);
1284
+ for (let day = 1; day <= dim; day += step.n) {
1285
+ const t2 = Date.UTC(year2, month, day);
1286
+ if (t2 >= tStart && t2 <= tEnd) ticks.push(makeTick(t2, "day", format));
1287
+ }
1288
+ monthStart = Date.UTC(year2, month + 1, 1);
1289
+ }
1290
+ return ticks;
1291
+ }
1292
+ if (step.level === "month") {
1293
+ const startMonth = Math.floor(utcMonth(tStart) / step.n) * step.n;
1294
+ let year2 = utcYear(tStart);
1295
+ let month = startMonth;
1296
+ let t2 = Date.UTC(year2, month, 1);
1297
+ while (t2 <= tEnd) {
1298
+ if (t2 >= tStart) ticks.push(makeTick(t2, "month", format));
1299
+ month += step.n;
1300
+ t2 = Date.UTC(year2, month, 1);
1301
+ year2 = utcYear(t2);
1302
+ month = utcMonth(t2);
1303
+ }
1304
+ return ticks;
1305
+ }
1306
+ let year = Math.floor(utcYear(tStart) / step.n) * step.n;
1307
+ let t = Date.UTC(year, 0, 1);
1308
+ while (t <= tEnd) {
1309
+ if (t >= tStart) ticks.push(makeTick(t, "year", format));
1310
+ year += step.n;
1311
+ t = Date.UTC(year, 0, 1);
1312
+ }
1313
+ return ticks;
1314
+ }
1315
+ function thinByPixels(ticks, pxPerMs, minGapPx) {
1316
+ const kept = [];
1317
+ let lastX = -Infinity;
1318
+ for (const tick of ticks) {
1319
+ const x = tick.time * pxPerMs;
1320
+ if (x - lastX >= minGapPx) {
1321
+ kept.push(tick);
1322
+ lastX = x;
1323
+ }
1324
+ }
1325
+ return kept;
1326
+ }
1327
+ function generateTicks(tStart, tEnd, pxPerMs, minLabelGapPx, formatTick = defaultFormat) {
1328
+ if (tEnd <= tStart || !Number.isFinite(pxPerMs) || pxPerMs <= 0) {
1329
+ return { major: [], minor: [] };
1330
+ }
1331
+ const minor = pickStep(pxPerMs, minLabelGapPx);
1332
+ const major = coarserStep(minor);
1333
+ return {
1334
+ major: thinByPixels(
1335
+ enumerate(tStart, tEnd, major, formatTick),
1336
+ pxPerMs,
1337
+ minLabelGapPx
1338
+ ),
1339
+ minor: thinByPixels(
1340
+ enumerate(tStart, tEnd, minor, formatTick),
1341
+ pxPerMs,
1342
+ minLabelGapPx
1343
+ )
1344
+ };
1345
+ }
1346
+
1347
+ // src/render/resolveStyle.ts
1348
+ function overlayStyle(base, ...partials) {
1349
+ const out = { ...base };
1350
+ for (const partial of partials) {
1351
+ if (partial === void 0) continue;
1352
+ for (const key of Object.keys(partial)) {
1353
+ const value = partial[key];
1354
+ if (value !== void 0) out[key] = value;
1355
+ }
1356
+ }
1357
+ return out;
1358
+ }
1359
+
1360
+ // src/interaction/wheel.ts
1361
+ var ZOOM_DELTA_PER_DOUBLING = 200;
1362
+ function interpretWheel(e, cursorX) {
1363
+ if (e.ctrlKey || e.metaKey) {
1364
+ const factor = Math.pow(2, -e.deltaY / ZOOM_DELTA_PER_DOUBLING);
1365
+ return { kind: "zoom", factor, cursorX };
1366
+ }
1367
+ if (e.shiftKey) {
1368
+ return { kind: "pan", dxCss: -e.deltaY };
1369
+ }
1370
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
1371
+ return { kind: "pan", dxCss: -e.deltaX };
1372
+ }
1373
+ return { kind: "scroll", dyCss: e.deltaY };
1374
+ }
1375
+
1376
+ // src/interaction/pointer.ts
1377
+ var DRAG_THRESHOLD_PX = 4;
1378
+ var PointerController = class {
1379
+ constructor(canvas, host) {
1380
+ this.canvas = canvas;
1381
+ this.host = host;
1382
+ canvas.addEventListener("pointerdown", this.onPointerDown);
1383
+ canvas.addEventListener("pointermove", this.onPointerMove);
1384
+ canvas.addEventListener("pointerup", this.onPointerUp);
1385
+ canvas.addEventListener("pointercancel", this.onPointerCancel);
1386
+ canvas.addEventListener("pointerleave", this.onPointerLeave);
1387
+ canvas.addEventListener("wheel", this.onWheel, { passive: false });
1388
+ }
1389
+ canvas;
1390
+ host;
1391
+ state = "idle";
1392
+ activePointer = null;
1393
+ lastX = 0;
1394
+ lastY = 0;
1395
+ downX = 0;
1396
+ downY = 0;
1397
+ /**
1398
+ * Convert a client position to canvas-local CSS px. Reads the bounding rect
1399
+ * once per call (one layout flush) — hover runs on every move, so the two
1400
+ * coords must not each force a separate reflow.
1401
+ */
1402
+ local(clientX, clientY) {
1403
+ const rect = this.canvas.getBoundingClientRect();
1404
+ return { x: clientX - rect.left, y: clientY - rect.top };
1405
+ }
1406
+ onPointerDown = (e) => {
1407
+ if (this.state !== "idle") return;
1408
+ this.state = "down";
1409
+ this.activePointer = e.pointerId;
1410
+ this.lastX = e.clientX;
1411
+ this.lastY = e.clientY;
1412
+ this.downX = e.clientX;
1413
+ this.downY = e.clientY;
1414
+ this.canvas.setPointerCapture(e.pointerId);
1415
+ };
1416
+ onPointerMove = (e) => {
1417
+ if (e.pointerId !== this.activePointer) {
1418
+ if (this.state === "idle") {
1419
+ const { x, y } = this.local(e.clientX, e.clientY);
1420
+ this.host.hoverAt(x, y);
1421
+ }
1422
+ return;
1423
+ }
1424
+ if (this.state === "down") {
1425
+ const movedFar = Math.abs(e.clientX - this.downX) > DRAG_THRESHOLD_PX || Math.abs(e.clientY - this.downY) > DRAG_THRESHOLD_PX;
1426
+ if (!movedFar) return;
1427
+ this.state = "panning";
1428
+ this.host.leave();
1429
+ }
1430
+ if (this.state === "panning") {
1431
+ const dx = e.clientX - this.lastX;
1432
+ const dy = e.clientY - this.lastY;
1433
+ this.lastX = e.clientX;
1434
+ this.lastY = e.clientY;
1435
+ if (dx !== 0 || dy !== 0) this.host.panBy(dx, dy);
1436
+ }
1437
+ };
1438
+ onPointerUp = (e) => {
1439
+ if (e.pointerId !== this.activePointer) return;
1440
+ if (this.state === "down") {
1441
+ const { x, y } = this.local(e.clientX, e.clientY);
1442
+ this.host.clickAt(x, y, {
1443
+ ctrlOrMeta: e.ctrlKey || e.metaKey,
1444
+ shift: e.shiftKey
1445
+ });
1446
+ }
1447
+ this.release(e.pointerId);
1448
+ };
1449
+ onPointerCancel = (e) => {
1450
+ if (e.pointerId !== this.activePointer) return;
1451
+ this.release(e.pointerId);
1452
+ };
1453
+ onPointerLeave = () => {
1454
+ if (this.state === "idle") this.host.leave();
1455
+ };
1456
+ onWheel = (e) => {
1457
+ const intent = interpretWheel(e, this.local(e.clientX, e.clientY).x);
1458
+ switch (intent.kind) {
1459
+ case "zoom":
1460
+ e.preventDefault();
1461
+ this.host.zoomBy(intent.factor, intent.cursorX);
1462
+ break;
1463
+ case "pan":
1464
+ e.preventDefault();
1465
+ this.host.panBy(intent.dxCss, 0);
1466
+ break;
1467
+ case "scroll":
1468
+ e.preventDefault();
1469
+ this.host.scrollBy(intent.dyCss);
1470
+ break;
1471
+ }
1472
+ };
1473
+ release(pointerId) {
1474
+ this.state = "idle";
1475
+ this.activePointer = null;
1476
+ if (this.canvas.hasPointerCapture(pointerId)) {
1477
+ this.canvas.releasePointerCapture(pointerId);
1478
+ }
1479
+ }
1480
+ dispose() {
1481
+ this.canvas.removeEventListener("pointerdown", this.onPointerDown);
1482
+ this.canvas.removeEventListener("pointermove", this.onPointerMove);
1483
+ this.canvas.removeEventListener("pointerup", this.onPointerUp);
1484
+ this.canvas.removeEventListener("pointercancel", this.onPointerCancel);
1485
+ this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
1486
+ this.canvas.removeEventListener("wheel", this.onWheel);
1487
+ }
1488
+ };
1489
+
1490
+ // src/interaction/hitGrid.ts
1491
+ var CELL_PX = 64;
1492
+ var BBOX_PAD_PX = 6;
1493
+ function boundsOf(target) {
1494
+ if (target.kind === "element") {
1495
+ const pad2 = BBOX_PAD_PX + target.width;
1496
+ return {
1497
+ minX: Math.min(target.x1, target.x2) - pad2,
1498
+ maxX: Math.max(target.x1, target.x2) + pad2,
1499
+ minY: target.y - pad2,
1500
+ maxY: target.y + pad2
1501
+ };
1502
+ }
1503
+ let minX = Infinity;
1504
+ let minY = Infinity;
1505
+ let maxX = -Infinity;
1506
+ let maxY = -Infinity;
1507
+ const consume = (x, y) => {
1508
+ if (x < minX) minX = x;
1509
+ if (x > maxX) maxX = x;
1510
+ if (y < minY) minY = y;
1511
+ if (y > maxY) maxY = y;
1512
+ };
1513
+ for (const s of target.segments) {
1514
+ consume(s.x1, s.y1);
1515
+ consume(s.x2, s.y2);
1516
+ }
1517
+ if (target.kind === "cluster") {
1518
+ consume(target.marker.x, target.marker.y);
1519
+ const pad2 = BBOX_PAD_PX + Math.max(target.width, target.markerSize);
1520
+ return {
1521
+ minX: minX - pad2,
1522
+ minY: minY - pad2,
1523
+ maxX: maxX + pad2,
1524
+ maxY: maxY + pad2
1525
+ };
1526
+ }
1527
+ if (target.arrowhead) consume(target.arrowhead.x, target.arrowhead.y);
1528
+ if (target.node) consume(target.node.x, target.node.y);
1529
+ const pad = BBOX_PAD_PX + Math.max(target.width, target.arrowheadSize);
1530
+ return {
1531
+ minX: minX - pad,
1532
+ minY: minY - pad,
1533
+ maxX: maxX + pad,
1534
+ maxY: maxY + pad
1535
+ };
1536
+ }
1537
+ var HitGrid = class _HitGrid {
1538
+ constructor(cellSize = CELL_PX) {
1539
+ this.cellSize = cellSize;
1540
+ }
1541
+ cellSize;
1542
+ cells = /* @__PURE__ */ new Map();
1543
+ static key(cx, cy) {
1544
+ return `${cx},${cy}`;
1545
+ }
1546
+ /** Register a target in every cell its padded bounding box overlaps. */
1547
+ insert(target) {
1548
+ const b = boundsOf(target);
1549
+ const cx0 = Math.floor(b.minX / this.cellSize);
1550
+ const cx1 = Math.floor(b.maxX / this.cellSize);
1551
+ const cy0 = Math.floor(b.minY / this.cellSize);
1552
+ const cy1 = Math.floor(b.maxY / this.cellSize);
1553
+ for (let cx = cx0; cx <= cx1; cx++) {
1554
+ for (let cy = cy0; cy <= cy1; cy++) {
1555
+ const key = _HitGrid.key(cx, cy);
1556
+ let bucket = this.cells.get(key);
1557
+ if (bucket === void 0) {
1558
+ bucket = [];
1559
+ this.cells.set(key, bucket);
1560
+ }
1561
+ bucket.push(target);
1562
+ }
1563
+ }
1564
+ }
1565
+ /** Candidates whose cell contains the screen point (x,y), in insertion order. */
1566
+ query(x, y) {
1567
+ const key = _HitGrid.key(
1568
+ Math.floor(x / this.cellSize),
1569
+ Math.floor(y / this.cellSize)
1570
+ );
1571
+ return this.cells.get(key) ?? [];
1572
+ }
1573
+ };
1574
+ function buildHitGrid(targets) {
1575
+ const grid = new HitGrid();
1576
+ for (const t of targets) grid.insert(t);
1577
+ return grid;
1578
+ }
1579
+
1580
+ // src/interaction/geometry.ts
1581
+ function distSqToSegment(px, py, x1, y1, x2, y2) {
1582
+ const dx = x2 - x1;
1583
+ const dy = y2 - y1;
1584
+ const lenSq = dx * dx + dy * dy;
1585
+ const t = lenSq === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / lenSq;
1586
+ const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
1587
+ const cx = x1 + clamped * dx;
1588
+ const cy = y1 + clamped * dy;
1589
+ const ex = px - cx;
1590
+ const ey = py - cy;
1591
+ return ex * ex + ey * ey;
1592
+ }
1593
+ function inSquare(px, py, cx, cy, half) {
1594
+ return Math.abs(px - cx) <= half && Math.abs(py - cy) <= half;
1595
+ }
1596
+
1597
+ // src/interaction/hitTest.ts
1598
+ var HIT_TOLERANCE_PX = 5;
1599
+ function hitTest(grid, x, y, tolerance = HIT_TOLERANCE_PX) {
1600
+ let bestTop = null;
1601
+ let bestElement = null;
1602
+ for (const target of grid.query(x, y)) {
1603
+ if (target.kind === "element") {
1604
+ const distSq2 = distSqToSegment(
1605
+ x,
1606
+ y,
1607
+ target.x1,
1608
+ target.y,
1609
+ target.x2,
1610
+ target.y
1611
+ );
1612
+ const tol2 = tolerance + target.width / 2;
1613
+ if (distSq2 <= tol2 * tol2 && (bestElement === null || distSq2 < bestElement.distSq)) {
1614
+ bestElement = { target, distSq: distSq2 };
1615
+ }
1616
+ continue;
1617
+ }
1618
+ let distSq = Infinity;
1619
+ for (const s of target.segments) {
1620
+ distSq = Math.min(distSq, distSqToSegment(x, y, s.x1, s.y1, s.x2, s.y2));
1621
+ }
1622
+ if (target.kind === "event") {
1623
+ if (target.arrowhead !== void 0 && inSquare(
1624
+ x,
1625
+ y,
1626
+ target.arrowhead.x,
1627
+ target.arrowhead.y,
1628
+ target.arrowheadSize
1629
+ )) {
1630
+ distSq = 0;
1631
+ }
1632
+ if (target.targetNode !== void 0 && inSquare(
1633
+ x,
1634
+ y,
1635
+ target.targetNode.x,
1636
+ target.targetNode.y,
1637
+ target.arrowheadSize
1638
+ )) {
1639
+ distSq = 0;
1640
+ }
1641
+ } else if (inSquare(x, y, target.marker.x, target.marker.y, target.markerSize)) {
1642
+ distSq = 0;
1643
+ }
1644
+ const tol = tolerance + target.width / 2;
1645
+ if (distSq <= tol * tol && (bestTop === null || distSq < bestTop.distSq)) {
1646
+ bestTop = { target, distSq };
1647
+ }
1648
+ }
1649
+ if (bestTop !== null) {
1650
+ const target = bestTop.target;
1651
+ if (target.kind === "event") return { kind: "event", event: target.event };
1652
+ if (target.kind === "cluster") {
1653
+ return {
1654
+ kind: "cluster",
1655
+ events: target.events,
1656
+ count: target.events.length
1657
+ };
1658
+ }
1659
+ }
1660
+ if (bestElement !== null && bestElement.target.kind === "element") {
1661
+ return { kind: "element", element: bestElement.target.element };
1662
+ }
1663
+ return null;
1664
+ }
1665
+
1666
+ // src/interaction/selection.ts
1667
+ function nextSelection(current, hitId2, mods, multiSelect) {
1668
+ const next = new Set(current);
1669
+ if (multiSelect && mods.ctrlOrMeta) {
1670
+ if (hitId2 === null) {
1671
+ return { ids: [...current], changed: false };
1672
+ }
1673
+ if (next.has(hitId2)) next.delete(hitId2);
1674
+ else next.add(hitId2);
1675
+ } else {
1676
+ next.clear();
1677
+ if (hitId2 !== null) next.add(hitId2);
1678
+ }
1679
+ return { ids: [...next], changed: !sameSet(current, next) };
1680
+ }
1681
+ function sameSet(a, b) {
1682
+ if (a.size !== b.size) return false;
1683
+ for (const id of a) if (!b.has(id)) return false;
1684
+ return true;
1685
+ }
1686
+
1687
+ // src/engine/debounce.ts
1688
+ function debounce(fn, delayMs) {
1689
+ let handle = null;
1690
+ let pendingArgs = null;
1691
+ const run = () => {
1692
+ handle = null;
1693
+ if (pendingArgs === null) return;
1694
+ const args = pendingArgs;
1695
+ pendingArgs = null;
1696
+ fn(...args);
1697
+ };
1698
+ const debounced = ((...args) => {
1699
+ pendingArgs = args;
1700
+ if (handle !== null) clearTimeout(handle);
1701
+ handle = setTimeout(run, delayMs);
1702
+ });
1703
+ debounced.cancel = () => {
1704
+ if (handle !== null) {
1705
+ clearTimeout(handle);
1706
+ handle = null;
1707
+ }
1708
+ pendingArgs = null;
1709
+ };
1710
+ debounced.flush = () => {
1711
+ if (handle !== null) {
1712
+ clearTimeout(handle);
1713
+ run();
1714
+ }
1715
+ };
1716
+ return debounced;
1717
+ }
1718
+
1719
+ // src/engine/emitter.ts
1720
+ var TypedEmitter = class {
1721
+ listeners = /* @__PURE__ */ new Map();
1722
+ on(key, fn) {
1723
+ let set = this.listeners.get(key);
1724
+ if (set === void 0) {
1725
+ set = /* @__PURE__ */ new Set();
1726
+ this.listeners.set(key, set);
1727
+ }
1728
+ set.add(fn);
1729
+ return () => this.off(key, fn);
1730
+ }
1731
+ off(key, fn) {
1732
+ this.listeners.get(key)?.delete(fn);
1733
+ }
1734
+ emit(key, payload) {
1735
+ const set = this.listeners.get(key);
1736
+ if (set === void 0) return;
1737
+ for (const fn of set) fn(payload);
1738
+ }
1739
+ clear() {
1740
+ this.listeners.clear();
1741
+ }
1742
+ };
1743
+
1744
+ // src/engine/scheduler.ts
1745
+ var FrameScheduler = class {
1746
+ constructor(render) {
1747
+ this.render = render;
1748
+ }
1749
+ render;
1750
+ dirty = { base: false, overlay: false };
1751
+ frameHandle = null;
1752
+ disposed = false;
1753
+ markDirty(layer) {
1754
+ if (this.disposed) return;
1755
+ this.dirty[layer] = true;
1756
+ if (this.frameHandle === null) {
1757
+ this.frameHandle = requestAnimationFrame(() => this.tick());
1758
+ }
1759
+ }
1760
+ tick() {
1761
+ this.frameHandle = null;
1762
+ if (this.disposed) return;
1763
+ const dirty = this.dirty;
1764
+ this.dirty = { base: false, overlay: false };
1765
+ if (dirty.base || dirty.overlay) {
1766
+ this.render(dirty);
1767
+ }
1768
+ }
1769
+ dispose() {
1770
+ this.disposed = true;
1771
+ if (this.frameHandle !== null) {
1772
+ cancelAnimationFrame(this.frameHandle);
1773
+ this.frameHandle = null;
1774
+ }
1775
+ }
1776
+ };
1777
+
1778
+ // src/engine/theme.ts
1779
+ var defaultTheme = {
1780
+ background: "#161b22",
1781
+ gridlineColor: "rgba(255, 255, 255, 0.08)",
1782
+ headerTextColor: "#9aa4b2",
1783
+ headerFont: "12px system-ui, sans-serif",
1784
+ headerHeight: 44,
1785
+ rowHeight: 28,
1786
+ rowGap: 6,
1787
+ labelPadding: 8,
1788
+ hoverColor: "#ffd166",
1789
+ selectionColor: "#4fc3f7",
1790
+ clusterColor: "#d29922",
1791
+ element: {
1792
+ lineColor: "#5aa0ff",
1793
+ lineWidth: 2,
1794
+ labelColor: "#e6e6e6",
1795
+ labelFont: "12px system-ui, sans-serif",
1796
+ iconSize: 16
1797
+ },
1798
+ event: {
1799
+ arrowColor: "#8b949e",
1800
+ arrowWidth: 1,
1801
+ arrowheadSize: 6,
1802
+ labelColor: "#c9d1d9",
1803
+ labelFont: "11px system-ui, sans-serif",
1804
+ nodeRadius: 3
1805
+ }
1806
+ };
1807
+ function resolveTheme(partial) {
1808
+ if (partial === void 0) return defaultTheme;
1809
+ return {
1810
+ ...defaultTheme,
1811
+ ...partial,
1812
+ element: { ...defaultTheme.element, ...partial.element },
1813
+ event: { ...defaultTheme.event, ...partial.event }
1814
+ };
1815
+ }
1816
+
1817
+ // src/engine/timeline.ts
1818
+ var MIN_LABEL_GAP_PX = 64;
1819
+ var MS_PER_DAY2 = 864e5;
1820
+ var DEFAULT_CLUSTER_MIN_SEPARATION_PX = 12;
1821
+ var DEFAULT_EVENT_LABEL_MIN_PX_PER_DAY = 40;
1822
+ var DEFAULT_LAYOUT_DEBOUNCE_MS = 120;
1823
+ var TimelineEngine = class {
1824
+ theme;
1825
+ metrics;
1826
+ store;
1827
+ camera;
1828
+ renderer;
1829
+ scheduler;
1830
+ emitter = new TypedEmitter();
1831
+ pointer;
1832
+ multiSelect;
1833
+ layoutStrategy;
1834
+ /** Incremental re-layout policy (DESIGN.md §7, §10). */
1835
+ streamingLayout;
1836
+ /**
1837
+ * Coalesced incremental re-layout (DESIGN.md §7). Streaming mutations
1838
+ * (`addEvents`/`removeEvents`, §10) call {@link scheduleLayoutRecompute} so a
1839
+ * burst re-runs layout once; `setData` bypasses this and recomputes inline.
1840
+ */
1841
+ debouncedRecomputeLayout;
1842
+ /** Resolved per-frame LOD/clustering config (DESIGN.md §6). */
1843
+ lod;
1844
+ clustering;
1845
+ /**
1846
+ * §11 style/label resolution, bound once from the theme + host resolvers. The
1847
+ * builders receive these via `BuildContext`, so resolution lives in one place.
1848
+ */
1849
+ resolveElementStyle;
1850
+ resolveEventStyle;
1851
+ formatElementLabel;
1852
+ formatEventLabel;
1853
+ tickFormatter;
1854
+ /** Optional per-frame timing sink (DESIGN.md §13); undefined disables timing. */
1855
+ onFrameStats;
1856
+ iconCache = new IconCache(
1857
+ () => this.scheduler.markDirty("base")
1858
+ );
1859
+ /**
1860
+ * Resolve an element icon for the current frame (§1, §11): a preloaded image
1861
+ * is used directly when decoded, a URL is loaded/cached (skipped until ready),
1862
+ * and any other string (emoji/glyph) is drawn as text by the builder.
1863
+ */
1864
+ resolveIcon = (icon) => {
1865
+ if (typeof icon !== "string") {
1866
+ return icon.complete && icon.naturalWidth > 0 ? { kind: "image", image: icon } : { kind: "none" };
1867
+ }
1868
+ if (isIconUrl(icon)) {
1869
+ const image = this.iconCache.get(icon);
1870
+ return image ? { kind: "image", image } : { kind: "none" };
1871
+ }
1872
+ return { kind: "text", text: icon };
1873
+ };
1874
+ viewport = { cssWidth: 0, cssHeight: 0 };
1875
+ dpr = 1;
1876
+ rowLayout = computeRowLayout([], {
1877
+ rowHeight: 0,
1878
+ rowGap: 0
1879
+ });
1880
+ hasFitted = false;
1881
+ disposed = false;
1882
+ // --- Interaction state (Phase 3, §9) ---
1883
+ /** Hit grid + targets rebuilt each base draw; reused during pure hover (§5). */
1884
+ hitGrid = null;
1885
+ frameTargets = [];
1886
+ /** Latest cursor position pending a (frame-coalesced) hit-test, or null. */
1887
+ pendingPointer = null;
1888
+ hovered = null;
1889
+ selectedIds = /* @__PURE__ */ new Set();
1890
+ constructor(baseCanvas, overlayCanvas, options = {}) {
1891
+ this.theme = resolveTheme(options.theme);
1892
+ this.metrics = {
1893
+ headerHeight: this.theme.headerHeight,
1894
+ rowHeight: this.theme.rowHeight,
1895
+ rowGap: this.theme.rowGap
1896
+ };
1897
+ this.layoutStrategy = options.layout ?? byFirstEvent;
1898
+ this.streamingLayout = options.streamingLayout ?? "stable-append";
1899
+ this.debouncedRecomputeLayout = debounce(() => {
1900
+ if (this.disposed) return;
1901
+ this.recomputeLayout("full");
1902
+ this.scheduler.markDirty("base");
1903
+ this.emitViewportChange();
1904
+ }, options.layoutDebounceMs ?? DEFAULT_LAYOUT_DEBOUNCE_MS);
1905
+ this.multiSelect = options.multiSelect ?? false;
1906
+ this.clustering = {
1907
+ enabled: options.clustering?.enabled ?? true,
1908
+ strategy: options.clustering?.strategy ?? bySourceTargetColumn,
1909
+ minSeparationPx: options.clustering?.minSeparationPx ?? DEFAULT_CLUSTER_MIN_SEPARATION_PX
1910
+ };
1911
+ this.lod = {
1912
+ eventLabelMinPxPerMs: options.lod?.eventLabelMinPxPerMs ?? DEFAULT_EVENT_LABEL_MIN_PX_PER_DAY / MS_PER_DAY2
1913
+ };
1914
+ const elementResolver = options.styleResolvers?.element;
1915
+ this.resolveElementStyle = (element) => overlayStyle(
1916
+ this.theme.element,
1917
+ elementResolver?.(element),
1918
+ element.style
1919
+ );
1920
+ const eventResolver = options.styleResolvers?.event;
1921
+ this.resolveEventStyle = (event) => overlayStyle(this.theme.event, eventResolver?.(event), event.style);
1922
+ this.formatElementLabel = options.formatters?.elementLabel ?? ((element) => element.label ?? element.id);
1923
+ this.formatEventLabel = options.formatters?.eventLabel ?? ((event) => event.label);
1924
+ this.tickFormatter = options.formatters?.tickLabel;
1925
+ this.onFrameStats = options.onFrameStats;
1926
+ this.store = new DataStore({ onDiagnostic: options.onDiagnostic });
1927
+ this.camera = new Camera(this.metrics);
1928
+ this.renderer = new Canvas2DRenderer(
1929
+ baseCanvas,
1930
+ overlayCanvas,
1931
+ this.theme.background
1932
+ );
1933
+ this.scheduler = new FrameScheduler((dirty) => this.drawFrame(dirty));
1934
+ this.pointer = new PointerController(overlayCanvas, this.pointerHost());
1935
+ queueMicrotask(() => {
1936
+ if (!this.disposed) this.emitter.emit("ready", void 0);
1937
+ });
1938
+ }
1939
+ // --- Public API ---
1940
+ setData(data) {
1941
+ const counts = this.store.setData(data);
1942
+ this.recomputeLayout("full");
1943
+ this.maybeFit();
1944
+ this.emitter.emit("dataChange", counts);
1945
+ this.scheduler.markDirty("base");
1946
+ this.emitViewportChange();
1947
+ }
1948
+ /** Incrementally insert events without a full rebuild (DESIGN.md §10). */
1949
+ addEvents(events) {
1950
+ this.applyMutation(this.store.addEvents(events));
1951
+ }
1952
+ /** Remove events by id (DESIGN.md §10). */
1953
+ removeEvents(ids) {
1954
+ this.applyMutation(this.store.removeEvents(ids));
1955
+ }
1956
+ /** Append elements with stable-append layout (DESIGN.md §7, §10). */
1957
+ addElements(elements) {
1958
+ this.applyMutation(this.store.addElements(elements));
1959
+ }
1960
+ /** Patch an element's label/icon/style/order in place (DESIGN.md §10). */
1961
+ updateElement(id, patch) {
1962
+ this.applyMutation(this.store.updateElement(id, patch));
1963
+ }
1964
+ /**
1965
+ * Shared tail for the incremental mutations (§10): a no-op change emits and
1966
+ * redraws nothing; otherwise re-layout (stable-append by default, §7), emit
1967
+ * `dataChange`, and mark the base dirty. Streaming never refits — that would
1968
+ * jerk the camera mid-stream.
1969
+ */
1970
+ applyMutation(counts) {
1971
+ if (counts.added === 0 && counts.removed === 0 && counts.updated === 0) {
1972
+ return;
1973
+ }
1974
+ this.scheduleLayoutRecompute();
1975
+ this.emitter.emit("dataChange", counts);
1976
+ this.scheduler.markDirty("base");
1977
+ this.emitViewportChange();
1978
+ }
1979
+ resize(cssWidth, cssHeight, dpr) {
1980
+ this.viewport = { cssWidth, cssHeight };
1981
+ this.dpr = dpr;
1982
+ this.renderer.resize(snapDevice(cssWidth, dpr), snapDevice(cssHeight, dpr));
1983
+ this.camera.setPxPerMs(this.camera.state.pxPerMs, this.zoomBounds());
1984
+ this.maybeFit();
1985
+ this.camera.clampScrollY(this.rowLayout.contentHeight, this.viewport);
1986
+ this.scheduler.markDirty("base");
1987
+ this.emitViewportChange();
1988
+ }
1989
+ fit() {
1990
+ fit(
1991
+ this.camera,
1992
+ this.store.getDataExtent(),
1993
+ this.viewport,
1994
+ this.now(),
1995
+ this.fitInset()
1996
+ );
1997
+ this.hasFitted = true;
1998
+ this.scheduler.markDirty("base");
1999
+ this.emitViewportChange();
2000
+ }
2001
+ panToTime(t) {
2002
+ this.camera.setOriginTime(t);
2003
+ this.scheduler.markDirty("base");
2004
+ this.emitViewportChange();
2005
+ }
2006
+ /** Frame a time range across the viewport width (DESIGN.md §12). */
2007
+ zoomToRange(start, end) {
2008
+ const span = end - start;
2009
+ if (!(span > 0) || this.viewport.cssWidth <= 0) return;
2010
+ this.camera.setOriginTime(start);
2011
+ this.camera.setPxPerMs(this.viewport.cssWidth / span, this.zoomBounds());
2012
+ this.scheduler.markDirty("base");
2013
+ this.emitViewportChange();
2014
+ }
2015
+ /**
2016
+ * Replace the selection imperatively (DESIGN.md §9, §12). Ids are the
2017
+ * `kind:id` composites produced by hit-testing (see {@link hitId}), so an
2018
+ * element and an event sharing a raw id stay distinct. A no-op replacement
2019
+ * emits nothing and skips the overlay redraw.
2020
+ */
2021
+ select(ids) {
2022
+ const next = new Set(ids);
2023
+ if (sameSet(this.selectedIds, next)) return;
2024
+ this.selectedIds.clear();
2025
+ for (const id of next) this.selectedIds.add(id);
2026
+ this.emitter.emit("selectionChange", { ids: [...this.selectedIds] });
2027
+ this.scheduler.markDirty("overlay");
2028
+ }
2029
+ getViewport() {
2030
+ const { scrollY, pxPerMs } = this.camera.state;
2031
+ const timeRange = [
2032
+ this.camera.screenXToTime(0),
2033
+ this.camera.screenXToTime(this.viewport.cssWidth)
2034
+ ];
2035
+ const top = Math.max(
2036
+ 0,
2037
+ Math.floor(this.camera.screenYToRow(this.metrics.headerHeight))
2038
+ );
2039
+ const bottom = Math.min(
2040
+ this.rowLayout.rowCount,
2041
+ Math.ceil(this.camera.screenYToRow(this.viewport.cssHeight))
2042
+ );
2043
+ return {
2044
+ timeRange,
2045
+ scrollY,
2046
+ pxPerMs,
2047
+ visibleRowRange: [top, Math.max(top, bottom)]
2048
+ };
2049
+ }
2050
+ on(key, fn) {
2051
+ return this.emitter.on(key, fn);
2052
+ }
2053
+ off(key, fn) {
2054
+ this.emitter.off(key, fn);
2055
+ }
2056
+ dispose() {
2057
+ if (this.disposed) return;
2058
+ this.disposed = true;
2059
+ this.debouncedRecomputeLayout.cancel();
2060
+ this.scheduler.dispose();
2061
+ this.pointer.dispose();
2062
+ this.emitter.clear();
2063
+ }
2064
+ // --- Internals ---
2065
+ now() {
2066
+ return Date.now();
2067
+ }
2068
+ zoomBounds() {
2069
+ return computeZoomBounds(
2070
+ this.store.getDataExtent(),
2071
+ this.viewport,
2072
+ this.now(),
2073
+ this.fitInset()
2074
+ );
2075
+ }
2076
+ /**
2077
+ * Edge space reserved when fitting (§3): a left gutter so the leftmost line's
2078
+ * start marker (icon + label, §1) clears the viewport edge instead of being
2079
+ * clipped. Derived from the theme so host themes scale it. With no elements
2080
+ * there are no markers, so nothing is reserved (the empty-state window stays
2081
+ * centred on `now`, §3.1).
2082
+ */
2083
+ fitInset() {
2084
+ if (this.store.getElements().length === 0) return {};
2085
+ return { left: markerReserveLeftPx(this.theme) };
2086
+ }
2087
+ /**
2088
+ * Recompute row order + positions (DESIGN.md §7). `'full'` runs the configured
2089
+ * strategy (used by `setData` and the opt-in streaming relayout); `'stable-append'`
2090
+ * keeps existing rows and appends new elements (the streaming default, §10).
2091
+ */
2092
+ recomputeLayout(mode = "full") {
2093
+ const orderedIds = mode === "full" ? this.layoutStrategy.order(
2094
+ this.store.getElements(),
2095
+ this.store.getEvents()
2096
+ ) : stableAppend(this.rowLayout.orderedIds, this.store.getElements());
2097
+ this.rowLayout = computeRowLayout(orderedIds, this.metrics);
2098
+ this.camera.clampScrollY(this.rowLayout.contentHeight, this.viewport);
2099
+ }
2100
+ /**
2101
+ * Apply layout after an *incremental* mutation (DESIGN.md §7, §10). By default
2102
+ * (`streamingLayout: 'stable-append'`) this runs synchronously so new rows and
2103
+ * extents appear at once with no reshuffle; under `'full'` a burst is coalesced
2104
+ * into one trailing strategy recompute via the debounced seam. Full `setData`
2105
+ * bypasses this and recomputes synchronously so `fit`/scroll-clamp see
2106
+ * `contentHeight` immediately.
2107
+ */
2108
+ scheduleLayoutRecompute() {
2109
+ if (this.streamingLayout === "full") {
2110
+ this.debouncedRecomputeLayout();
2111
+ } else {
2112
+ this.recomputeLayout("stable-append");
2113
+ }
2114
+ }
2115
+ /**
2116
+ * Frame the view before the first explicit fit. With data, fit to it once and
2117
+ * latch (`hasFitted`). Without data, frame the default window on `now` (§3.1)
2118
+ * but stay un-latched, so real data still auto-fits when it arrives.
2119
+ */
2120
+ maybeFit() {
2121
+ if (this.hasFitted || this.viewport.cssWidth <= 0) return;
2122
+ if (this.store.getDataExtent() !== null) {
2123
+ this.fit();
2124
+ return;
2125
+ }
2126
+ fit(this.camera, null, this.viewport, this.now(), this.fitInset());
2127
+ this.scheduler.markDirty("base");
2128
+ this.emitViewportChange();
2129
+ }
2130
+ /** Mark the base layer dirty and emit the new viewport (any camera change). */
2131
+ onCameraChange() {
2132
+ this.scheduler.markDirty("base");
2133
+ this.emitViewportChange();
2134
+ }
2135
+ /**
2136
+ * The semantic-intent sink for the pointer controller (§9). The controller
2137
+ * does no math; every camera mutation and hit-test is applied here so the
2138
+ * Camera stays the single source of coordinate truth (§3).
2139
+ */
2140
+ pointerHost() {
2141
+ return {
2142
+ panBy: (dx, dy) => {
2143
+ this.camera.panByPixels(dx, dy);
2144
+ this.camera.clampScrollY(this.rowLayout.contentHeight, this.viewport);
2145
+ this.onCameraChange();
2146
+ },
2147
+ zoomBy: (factor, cursorX) => {
2148
+ this.camera.zoomAboutCursor(factor, cursorX, this.zoomBounds());
2149
+ this.onCameraChange();
2150
+ },
2151
+ scrollBy: (dy) => {
2152
+ this.camera.panByPixels(0, -dy);
2153
+ this.camera.clampScrollY(this.rowLayout.contentHeight, this.viewport);
2154
+ this.onCameraChange();
2155
+ },
2156
+ hoverAt: (x, y) => {
2157
+ this.pendingPointer = { x, y };
2158
+ this.scheduler.markDirty("overlay");
2159
+ },
2160
+ clickAt: (x, y, mods) => this.onClick(x, y, mods),
2161
+ leave: () => {
2162
+ this.pendingPointer = null;
2163
+ if (this.hovered !== null) {
2164
+ this.hovered = null;
2165
+ this.emitter.emit("hover", null);
2166
+ this.scheduler.markDirty("overlay");
2167
+ }
2168
+ }
2169
+ };
2170
+ }
2171
+ /** Resolve a click against the current hit grid: emit click + update selection. */
2172
+ onClick(x, y, mods) {
2173
+ const hit = this.hitGrid ? hitTest(this.hitGrid, x, y) : null;
2174
+ if (hit !== null) this.emitter.emit("click", { hit, modifiers: mods });
2175
+ if (hit?.kind === "cluster") {
2176
+ this.expandCluster(hit.events);
2177
+ return;
2178
+ }
2179
+ const result = nextSelection(
2180
+ this.selectedIds,
2181
+ hit !== null ? hitId(hit) : null,
2182
+ mods,
2183
+ this.multiSelect
2184
+ );
2185
+ if (result.changed) {
2186
+ this.selectedIds.clear();
2187
+ for (const id of result.ids) this.selectedIds.add(id);
2188
+ this.emitter.emit("selectionChange", { ids: result.ids });
2189
+ this.scheduler.markDirty("overlay");
2190
+ }
2191
+ }
2192
+ /** Zoom to a clicked cluster's member time span, expanding it (§6). */
2193
+ expandCluster(events) {
2194
+ let min = Infinity;
2195
+ let max = -Infinity;
2196
+ for (const event of events) {
2197
+ if (event.time < min) min = event.time;
2198
+ if (event.time > max) max = event.time;
2199
+ }
2200
+ if (Number.isFinite(min)) this.zoomToRange(min, max);
2201
+ }
2202
+ emitViewportChange() {
2203
+ if (this.viewport.cssWidth <= 0 || !(this.camera.state.pxPerMs > 0)) return;
2204
+ this.emitter.emit("viewportChange", this.getViewport());
2205
+ }
2206
+ drawFrame(dirty) {
2207
+ if (this.disposed) return;
2208
+ if (dirty.base) this.timed("base", this.drawBase);
2209
+ if (dirty.base || dirty.overlay) this.timed("overlay", this.drawOverlay);
2210
+ }
2211
+ /**
2212
+ * Run one layer's draw, reporting its wall-clock cost to `onFrameStats` when a
2213
+ * sink is set (DESIGN.md §13). With no sink the draw runs directly — no
2214
+ * `performance.now()` calls, no allocation — so timing is truly opt-in.
2215
+ */
2216
+ timed(layer, draw) {
2217
+ if (this.onFrameStats === void 0) {
2218
+ draw.call(this);
2219
+ return;
2220
+ }
2221
+ const start = performance.now();
2222
+ draw.call(this);
2223
+ this.onFrameStats({ layer, durationMs: performance.now() - start });
2224
+ }
2225
+ drawBase() {
2226
+ const { cssWidth, cssHeight } = this.viewport;
2227
+ const { headerHeight } = this.metrics;
2228
+ const dpr = this.dpr;
2229
+ this.renderer.beginFrame("base");
2230
+ const ticks = generateTicks(
2231
+ this.camera.screenXToTime(0),
2232
+ this.camera.screenXToTime(cssWidth),
2233
+ this.camera.state.pxPerMs,
2234
+ MIN_LABEL_GAP_PX,
2235
+ this.tickFormatter
2236
+ );
2237
+ const gridWidth = Math.max(1, snapDevice(1, dpr));
2238
+ const gridSegments = ticks.major.map((tick) => {
2239
+ const x = snapDevice(this.camera.timeToScreenX(tick.time), dpr) + hairlineOffset(gridWidth);
2240
+ return {
2241
+ x1: x,
2242
+ y1: snapDevice(headerHeight, dpr),
2243
+ x2: x,
2244
+ y2: snapDevice(cssHeight, dpr)
2245
+ };
2246
+ });
2247
+ this.renderer.drawLines({
2248
+ color: this.theme.gridlineColor,
2249
+ width: gridWidth,
2250
+ segments: gridSegments
2251
+ });
2252
+ const ctx = {
2253
+ camera: this.camera,
2254
+ rowLayout: this.rowLayout,
2255
+ data: this.store,
2256
+ viewport: this.viewport,
2257
+ theme: this.theme,
2258
+ band: visibleRowBand(
2259
+ this.camera,
2260
+ this.viewport,
2261
+ this.rowLayout.rowCount,
2262
+ headerHeight
2263
+ ),
2264
+ resolveIcon: this.resolveIcon,
2265
+ lod: this.lod,
2266
+ clustering: this.clustering,
2267
+ resolveElementStyle: this.resolveElementStyle,
2268
+ resolveEventStyle: this.resolveEventStyle,
2269
+ formatElementLabel: this.formatElementLabel,
2270
+ formatEventLabel: this.formatEventLabel
2271
+ };
2272
+ const elements = buildElementLines(ctx);
2273
+ const arrows = buildArrows(ctx);
2274
+ this.frameTargets = [...elements.targets, ...arrows.targets];
2275
+ this.hitGrid = buildHitGrid(this.frameTargets);
2276
+ for (const spec of elements.lines) {
2277
+ this.renderer.drawLines(snapLines(spec, dpr));
2278
+ }
2279
+ for (const spec of arrows.lines) {
2280
+ this.renderer.drawLines(snapLines(spec, dpr));
2281
+ }
2282
+ for (const spec of arrows.nodes) {
2283
+ this.renderer.drawNodes(snapNodes(spec, dpr));
2284
+ }
2285
+ for (const spec of arrows.arrowheads) {
2286
+ this.renderer.drawArrowheads(snapArrowheads(spec, dpr));
2287
+ }
2288
+ for (const spec of arrows.labels) {
2289
+ this.renderer.drawText(snapLabels(spec, dpr));
2290
+ }
2291
+ for (const spec of elements.labels) {
2292
+ this.renderer.drawText(snapLabels(spec, dpr));
2293
+ }
2294
+ this.renderer.drawImages(snapIcons(elements.icons, dpr));
2295
+ this.renderer.fillRect(
2296
+ 0,
2297
+ 0,
2298
+ snapDevice(cssWidth, dpr),
2299
+ snapDevice(headerHeight, dpr),
2300
+ this.theme.background
2301
+ );
2302
+ const baselineY = snapDevice(headerHeight, dpr) + hairlineOffset(gridWidth);
2303
+ this.renderer.drawLines({
2304
+ color: this.theme.gridlineColor,
2305
+ width: gridWidth,
2306
+ segments: [
2307
+ { x1: 0, y1: baselineY, x2: snapDevice(cssWidth, dpr), y2: baselineY }
2308
+ ]
2309
+ });
2310
+ const headerFont = scaleFontToDevice(this.theme.headerFont, dpr);
2311
+ this.renderer.drawText({
2312
+ color: this.theme.headerTextColor,
2313
+ font: headerFont,
2314
+ baseline: "middle",
2315
+ align: "center",
2316
+ items: ticks.major.map((tick) => ({
2317
+ x: snapDevice(this.camera.timeToScreenX(tick.time), dpr),
2318
+ y: snapDevice(headerHeight * 0.32, dpr),
2319
+ text: tick.label
2320
+ }))
2321
+ });
2322
+ this.renderer.drawText({
2323
+ color: this.theme.headerTextColor,
2324
+ font: headerFont,
2325
+ baseline: "middle",
2326
+ align: "center",
2327
+ items: ticks.minor.map((tick) => ({
2328
+ x: snapDevice(this.camera.timeToScreenX(tick.time), dpr),
2329
+ y: snapDevice(headerHeight * 0.72, dpr),
2330
+ text: tick.label
2331
+ }))
2332
+ });
2333
+ this.renderer.endFrame("base");
2334
+ }
2335
+ /**
2336
+ * Overlay pass (DESIGN.md §4, §9): resolve the pending hover against the hit
2337
+ * grid (emitting `hover` only on change), then redraw the hovered and selected
2338
+ * primitives in the highlight/selection colours. It reuses this frame's
2339
+ * `frameTargets` geometry — no recompute — and never touches the base layer,
2340
+ * so hover/selection stay independent of the expensive base redraw.
2341
+ */
2342
+ drawOverlay() {
2343
+ this.resolveHover();
2344
+ this.renderer.beginFrame("overlay");
2345
+ const dpr = this.dpr;
2346
+ const hoveredKey = this.hovered !== null ? hitId(this.hovered) : null;
2347
+ const hoveredElementId = this.hovered?.kind === "element" ? this.hovered.element.id : null;
2348
+ const selectedElementIds = elementIdsFromKeys(this.selectedIds);
2349
+ for (const target of this.frameTargets) {
2350
+ const color = this.highlightColorFor(
2351
+ target,
2352
+ hoveredKey,
2353
+ hoveredElementId,
2354
+ selectedElementIds
2355
+ );
2356
+ if (color !== null) this.drawHighlight(target, color, dpr);
2357
+ }
2358
+ this.renderer.endFrame("overlay");
2359
+ }
2360
+ /**
2361
+ * Overlay colour for a target, or null when it should not highlight. A target
2362
+ * highlights when it is itself hovered/selected, or — for an event/cluster —
2363
+ * when one of its endpoints is a hovered/selected element (§9). Hover wins the
2364
+ * colour over selection.
2365
+ */
2366
+ highlightColorFor(target, hoveredKey, hoveredElementId, selectedElementIds) {
2367
+ const key = targetKey(target);
2368
+ const hovered = key === hoveredKey || hoveredElementId !== null && touchesElement(target, hoveredElementId);
2369
+ if (hovered) return this.theme.hoverColor;
2370
+ const selected = this.selectedIds.has(key) || touchesAnyElement(target, selectedElementIds);
2371
+ if (selected) return this.theme.selectionColor;
2372
+ return null;
2373
+ }
2374
+ /** Run the coalesced hit-test for the latest cursor position and emit on change. */
2375
+ resolveHover() {
2376
+ if (this.pendingPointer === null || this.hitGrid === null) return;
2377
+ const hit = hitTest(
2378
+ this.hitGrid,
2379
+ this.pendingPointer.x,
2380
+ this.pendingPointer.y
2381
+ );
2382
+ const nextKey = hit !== null ? hitId(hit) : null;
2383
+ const prevKey = this.hovered !== null ? hitId(this.hovered) : null;
2384
+ if (nextKey !== prevKey) {
2385
+ this.hovered = hit;
2386
+ this.emitter.emit("hover", hit);
2387
+ }
2388
+ }
2389
+ /** Redraw one target's geometry in `color`, slightly thickened, on the overlay. */
2390
+ drawHighlight(target, color, dpr) {
2391
+ if (target.kind === "element") {
2392
+ this.renderer.drawLines(
2393
+ snapLines(
2394
+ {
2395
+ color,
2396
+ width: target.width + 2,
2397
+ segments: [
2398
+ { x1: target.x1, y1: target.y, x2: target.x2, y2: target.y }
2399
+ ]
2400
+ },
2401
+ dpr
2402
+ )
2403
+ );
2404
+ return;
2405
+ }
2406
+ if (target.kind === "cluster") {
2407
+ if (target.segments.length > 0) {
2408
+ this.renderer.drawLines(
2409
+ snapLines(
2410
+ { color, width: target.width + 2, segments: target.segments },
2411
+ dpr
2412
+ )
2413
+ );
2414
+ }
2415
+ this.renderer.drawNodes(
2416
+ snapNodes(
2417
+ { color, radius: target.markerSize + 1, items: [target.marker] },
2418
+ dpr
2419
+ )
2420
+ );
2421
+ return;
2422
+ }
2423
+ this.renderer.drawLines(
2424
+ snapLines(
2425
+ { color, width: target.width + 2, segments: target.segments },
2426
+ dpr
2427
+ )
2428
+ );
2429
+ const dots = [target.node, target.targetNode].filter(
2430
+ (n) => n !== void 0
2431
+ );
2432
+ if (dots.length > 0) {
2433
+ this.renderer.drawNodes(
2434
+ snapNodes(
2435
+ { color, radius: this.theme.event.nodeRadius + 1, items: dots },
2436
+ dpr
2437
+ )
2438
+ );
2439
+ }
2440
+ if (target.arrowhead !== void 0) {
2441
+ this.renderer.drawArrowheads(
2442
+ snapArrowheads(
2443
+ {
2444
+ color,
2445
+ size: target.arrowheadSize + 1,
2446
+ items: [{ ...target.arrowhead, angle: arrowheadAngle(target) }]
2447
+ },
2448
+ dpr
2449
+ )
2450
+ );
2451
+ }
2452
+ }
2453
+ };
2454
+ function hitId(hit) {
2455
+ switch (hit.kind) {
2456
+ case "element":
2457
+ return `element:${hit.element.id}`;
2458
+ case "event":
2459
+ return `event:${hit.event.id}`;
2460
+ case "cluster":
2461
+ return `cluster:${hit.events.map((e) => e.id).join(",")}`;
2462
+ }
2463
+ }
2464
+ function targetKey(target) {
2465
+ switch (target.kind) {
2466
+ case "element":
2467
+ return `element:${target.element.id}`;
2468
+ case "event":
2469
+ return `event:${target.event.id}`;
2470
+ case "cluster":
2471
+ return `cluster:${target.events.map((e) => e.id).join(",")}`;
2472
+ }
2473
+ }
2474
+ var ELEMENT_KEY_PREFIX = "element:";
2475
+ function elementIdsFromKeys(keys) {
2476
+ const ids = /* @__PURE__ */ new Set();
2477
+ for (const key of keys) {
2478
+ if (key.startsWith(ELEMENT_KEY_PREFIX)) {
2479
+ ids.add(key.slice(ELEMENT_KEY_PREFIX.length));
2480
+ }
2481
+ }
2482
+ return ids;
2483
+ }
2484
+ function targetElementIds(target) {
2485
+ switch (target.kind) {
2486
+ case "element":
2487
+ return [];
2488
+ case "event":
2489
+ return [target.event.sourceId, target.event.targetId];
2490
+ case "cluster":
2491
+ return target.events.flatMap((e) => [e.sourceId, e.targetId]);
2492
+ }
2493
+ }
2494
+ function touchesElement(target, elementId) {
2495
+ return targetElementIds(target).includes(elementId);
2496
+ }
2497
+ function touchesAnyElement(target, ids) {
2498
+ return ids.size > 0 && targetElementIds(target).some((id) => ids.has(id));
2499
+ }
2500
+ function arrowheadAngle(target) {
2501
+ const last = target.segments[target.segments.length - 1];
2502
+ if (last === void 0 || target.arrowhead === void 0) return Math.PI / 2;
2503
+ return target.arrowhead.y >= last.y1 ? Math.PI / 2 : -Math.PI / 2;
2504
+ }
2505
+
2506
+ // src/layout/barycenter.ts
2507
+ var MAX_SWEEPS = 8;
2508
+ var NO_NEIGHBOURS = /* @__PURE__ */ new Map();
2509
+ function buildAdjacency(ids, events) {
2510
+ const adjacency = /* @__PURE__ */ new Map();
2511
+ for (const id of ids) adjacency.set(id, /* @__PURE__ */ new Map());
2512
+ const link = (a, b) => {
2513
+ const neighbours = adjacency.get(a);
2514
+ if (neighbours === void 0) return;
2515
+ neighbours.set(b, (neighbours.get(b) ?? 0) + 1);
2516
+ };
2517
+ for (const { sourceId, targetId } of events) {
2518
+ if (sourceId === targetId) continue;
2519
+ link(sourceId, targetId);
2520
+ link(targetId, sourceId);
2521
+ }
2522
+ return adjacency;
2523
+ }
2524
+ function barycenterOf(neighbours, rowIndexById) {
2525
+ let weightedSum = 0;
2526
+ let weight = 0;
2527
+ for (const [neighbourId, w] of neighbours) {
2528
+ const row = rowIndexById.get(neighbourId);
2529
+ if (row === void 0) continue;
2530
+ weightedSum += row * w;
2531
+ weight += w;
2532
+ }
2533
+ return weight === 0 ? null : weightedSum / weight;
2534
+ }
2535
+ function totalEdgeSpan(order, adjacency) {
2536
+ const rowIndexById = new Map(order.map((id, index) => [id, index]));
2537
+ let span = 0;
2538
+ for (const [id, neighbours] of adjacency) {
2539
+ const row = rowIndexById.get(id);
2540
+ if (row === void 0) continue;
2541
+ for (const [neighbourId, weight] of neighbours) {
2542
+ const neighbourRow = rowIndexById.get(neighbourId);
2543
+ if (neighbourRow === void 0) continue;
2544
+ span += Math.abs(row - neighbourRow) * weight;
2545
+ }
2546
+ }
2547
+ return span;
2548
+ }
2549
+ var barycenter = {
2550
+ order(elements, events) {
2551
+ const seed = byFirstEvent.order(elements, events);
2552
+ if (seed.length < 3) return seed;
2553
+ const adjacency = buildAdjacency(seed, events);
2554
+ let order = seed;
2555
+ let best = seed;
2556
+ let bestSpan = totalEdgeSpan(seed, adjacency);
2557
+ let twoBackKey = "";
2558
+ let previousKey = seed.join(" ");
2559
+ for (let sweep = 0; sweep < MAX_SWEEPS; sweep++) {
2560
+ const rowIndexById = new Map(order.map((id, index) => [id, index]));
2561
+ const ranked = order.map((id, index) => {
2562
+ const key2 = barycenterOf(
2563
+ adjacency.get(id) ?? NO_NEIGHBOURS,
2564
+ rowIndexById
2565
+ );
2566
+ return { id, index, key: key2 ?? index };
2567
+ });
2568
+ ranked.sort(
2569
+ (a, b) => a.key !== b.key ? a.key - b.key : a.index - b.index
2570
+ );
2571
+ order = ranked.map((entry) => entry.id);
2572
+ const span = totalEdgeSpan(order, adjacency);
2573
+ if (span < bestSpan) {
2574
+ best = order;
2575
+ bestSpan = span;
2576
+ }
2577
+ const key = order.join(" ");
2578
+ if (key === previousKey || key === twoBackKey) break;
2579
+ twoBackKey = previousKey;
2580
+ previousKey = key;
2581
+ }
2582
+ return best;
2583
+ }
2584
+ };
2585
+
2586
+ // src/layout/explicit.ts
2587
+ var explicit = {
2588
+ order(elements, events) {
2589
+ const pinned = [];
2590
+ const unpinned = [];
2591
+ for (let index = 0; index < elements.length; index++) {
2592
+ const element = elements[index];
2593
+ if (Number.isFinite(element.order)) {
2594
+ pinned.push({ id: element.id, order: element.order, index });
2595
+ } else {
2596
+ unpinned.push(element);
2597
+ }
2598
+ }
2599
+ pinned.sort(
2600
+ (a, b) => a.order !== b.order ? a.order - b.order : a.index - b.index
2601
+ );
2602
+ return [
2603
+ ...pinned.map((entry) => entry.id),
2604
+ ...byFirstEvent.order(unpinned, events)
2605
+ ];
2606
+ }
2607
+ };
2608
+
2609
+ // src/index.ts
2610
+ var VERSION = "0.0.0";
2611
+
2612
+ export { TimelineEngine, VERSION, barycenter, byDay, byElementPair, byFirstEvent, byPixelColumn, bySourceTargetColumn, defaultTheme, explicit, overlayStyle };
2613
+ //# sourceMappingURL=index.js.map
2614
+ //# sourceMappingURL=index.js.map