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