@mim/histui 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.
@@ -0,0 +1,2062 @@
1
+ import {
2
+ clamp,
3
+ compactLabel,
4
+ escapeHtml,
5
+ formatExtent,
6
+ formatYear,
7
+ textOf
8
+ } from "./paststruct.js";
9
+
10
+ const TYPE_SHAPES = {
11
+ event: "circle",
12
+ process: "capsule",
13
+ period: "diamond",
14
+ phenomenon: "hex",
15
+ structure: "square"
16
+ };
17
+
18
+ const TYPE_VARIABLES = {
19
+ event: "--type-event",
20
+ process: "--type-process",
21
+ period: "--type-period",
22
+ phenomenon: "--type-phenomenon",
23
+ structure: "--type-structure"
24
+ };
25
+
26
+ export class TimelineView {
27
+ constructor({ stage, canvas, cards, hint, zoomBar, themeRoot, config, t, language, direction, onSelect, onViewportChange }) {
28
+ this.stage = stage;
29
+ this.canvas = canvas;
30
+ this.ctx = canvas.getContext("2d");
31
+ this.cards = cards;
32
+ this.hint = hint;
33
+ this.zoomBar = zoomBar;
34
+ this.themeRoot = themeRoot || stage.closest(".histui-timeline") || document.documentElement;
35
+ this.config = config;
36
+ this.t = t;
37
+ this.language = language;
38
+ this.direction = direction;
39
+ this.onSelect = onSelect;
40
+ this.onViewportChange = onViewportChange;
41
+
42
+ this.records = [];
43
+ this.idMap = new Map();
44
+ this.selectedId = null;
45
+ this.hoveredId = null;
46
+ this.hoveredClusterId = null;
47
+ this.expandedCluster = null;
48
+ this.orientationSetting = config.app?.orientation || "auto";
49
+ this.axisPlacement = {
50
+ horizontal: config.app?.axisPlacement?.horizontal || "center",
51
+ vertical: config.app?.axisPlacement?.vertical || "side-start"
52
+ };
53
+ this.lodEnabled = config.timeline?.lod?.enabled !== false;
54
+ this.explodeEnabled = config.timeline?.explode?.enabled === true;
55
+ this.domain = { start: -100, end: 100 };
56
+ this.extent = { start: -100, end: 100 };
57
+ this.view = { start: -100, end: 100 };
58
+ this.pointer = null;
59
+ this.zoomPointer = null;
60
+ this.kineticVelocity = 0;
61
+ this.wheelVelocity = 0;
62
+ this.animationFrame = 0;
63
+ this.viewportAnimationFrame = 0;
64
+ this.viewportAnimation = null;
65
+ this.motionTimer = 0;
66
+ this.explodeAnimationTimer = 0;
67
+ this.lastFrame = 0;
68
+ this.lastMetrics = null;
69
+ this.lastItems = { all: [], display: [], hidden: [] };
70
+ this.lastClusters = [];
71
+ this.suppressStageClick = false;
72
+ this.clusterTooltip = document.createElement("div");
73
+ this.clusterTooltip.className = "cluster-tooltip";
74
+ this.clusterTooltip.hidden = true;
75
+ this.stage.append(this.clusterTooltip);
76
+ this.stage.classList.toggle("is-explode-mode", this.explodeEnabled);
77
+ this.setupZoomBar();
78
+
79
+ this.boundRender = () => this.render();
80
+ this.boundAnimate = (time) => this.animate(time);
81
+ this.boundAnimateViewport = (time) => this.animateViewport(time);
82
+
83
+ this.stage.addEventListener("wheel", (event) => this.handleWheel(event), { passive: false });
84
+ this.stage.addEventListener("pointerdown", (event) => this.handlePointerDown(event));
85
+ this.stage.addEventListener("pointermove", (event) => this.handlePointerMove(event));
86
+ this.stage.addEventListener("pointerup", (event) => this.handlePointerUp(event));
87
+ this.stage.addEventListener("pointercancel", (event) => this.handlePointerUp(event));
88
+ this.stage.addEventListener("pointerleave", () => this.setHovered(null, { source: "timeline" }));
89
+ this.stage.addEventListener("mousemove", (event) => {
90
+ if (!this.pointer && !event.target.closest("[data-record-id]")) this.handleTimelineHover(event);
91
+ });
92
+ this.stage.addEventListener("mouseleave", () => this.setHovered(null, { source: "timeline" }));
93
+ this.stage.addEventListener("click", (event) => this.handleStageClick(event));
94
+ this.stage.addEventListener("keydown", (event) => this.handleKeydown(event));
95
+ this.cards.addEventListener("pointerdown", (event) => {
96
+ if (event.target.closest("[data-record-id]")) event.stopPropagation();
97
+ });
98
+ this.cards.addEventListener("pointerup", (event) => {
99
+ if (event.target.closest("[data-record-id]")) event.stopPropagation();
100
+ });
101
+ this.cards.addEventListener("pointerover", (event) => {
102
+ const card = event.target.closest("[data-record-id]");
103
+ if (!card) return;
104
+ this.setHovered(card.dataset.recordId, { source: "card" });
105
+ });
106
+ this.cards.addEventListener("mouseover", (event) => {
107
+ const card = event.target.closest("[data-record-id]");
108
+ if (!card) return;
109
+ this.setHovered(card.dataset.recordId, { source: "card" });
110
+ });
111
+ this.cards.addEventListener("pointerout", (event) => {
112
+ const card = event.target.closest("[data-record-id]");
113
+ if (!card || card.contains(event.relatedTarget)) return;
114
+ this.setHovered(null, { source: "card" });
115
+ });
116
+ this.cards.addEventListener("mouseout", (event) => {
117
+ const card = event.target.closest("[data-record-id]");
118
+ if (!card || card.contains(event.relatedTarget)) return;
119
+ this.setHovered(null, { source: "card" });
120
+ });
121
+ this.cards.addEventListener("click", (event) => {
122
+ const card = event.target.closest("[data-record-id]");
123
+ if (!card) return;
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ const record = this.idMap.get(card.dataset.recordId);
127
+ if (record) {
128
+ if (this.expandedCluster && !this.expandedCluster.recordIds.includes(record.id)) {
129
+ this.clearExpandedCluster({ render: false });
130
+ }
131
+ this.select(record.id, true);
132
+ }
133
+ });
134
+
135
+ this.resizeObserver = new ResizeObserver(this.boundRender);
136
+ this.resizeObserver.observe(this.stage);
137
+ if (this.zoomBar) this.resizeObserver.observe(this.zoomBar);
138
+ }
139
+
140
+ setupZoomBar() {
141
+ if (!this.zoomBar) return;
142
+ const navigator = this.config.timeline?.navigator || {};
143
+ if (navigator.enabled === false) {
144
+ this.zoomBar.hidden = true;
145
+ return;
146
+ }
147
+
148
+ this.zoomBar.dataset.zoomControl = "true";
149
+ this.zoomBar.tabIndex = 0;
150
+ this.zoomBar.setAttribute("role", "group");
151
+ this.zoomBar.setAttribute("aria-label", this.t("timelineOverview"));
152
+ this.zoomBar.innerHTML = `
153
+ <canvas class="zoom-bar-canvas" aria-hidden="true"></canvas>
154
+ <div class="zoom-window" data-zoom-role="window" aria-label="${escapeHtml(this.t("zoomWindow"))}">
155
+ <span class="zoom-window-label" aria-hidden="true"></span>
156
+ <button class="zoom-handle zoom-handle-start" type="button" data-zoom-role="handle-start" aria-label="${escapeHtml(this.t("from"))}"></button>
157
+ <button class="zoom-handle zoom-handle-end" type="button" data-zoom-role="handle-end" aria-label="${escapeHtml(this.t("to"))}"></button>
158
+ </div>
159
+ <div class="zoom-selection" aria-label="${escapeHtml(this.t("zoomSelection"))}" hidden></div>
160
+ <div class="zoom-labels" aria-hidden="true">
161
+ <span class="zoom-label-start"></span>
162
+ <span class="zoom-label-end"></span>
163
+ </div>
164
+ `;
165
+ this.zoomCanvas = this.zoomBar.querySelector(".zoom-bar-canvas");
166
+ this.zoomCtx = this.zoomCanvas.getContext("2d");
167
+ this.zoomWindow = this.zoomBar.querySelector(".zoom-window");
168
+ this.zoomWindowLabel = this.zoomBar.querySelector(".zoom-window-label");
169
+ this.zoomSelection = this.zoomBar.querySelector(".zoom-selection");
170
+ this.zoomLabelStart = this.zoomBar.querySelector(".zoom-label-start");
171
+ this.zoomLabelEnd = this.zoomBar.querySelector(".zoom-label-end");
172
+
173
+ this.zoomBar.addEventListener("pointerdown", (event) => this.handleZoomPointerDown(event));
174
+ this.zoomBar.addEventListener("pointermove", (event) => this.handleZoomPointerMove(event));
175
+ this.zoomBar.addEventListener("pointerup", (event) => this.handleZoomPointerUp(event));
176
+ this.zoomBar.addEventListener("pointercancel", (event) => this.handleZoomPointerUp(event));
177
+ this.zoomBar.addEventListener("keydown", (event) => this.handleZoomKeydown(event));
178
+ }
179
+
180
+ setTranslator(t) {
181
+ this.t = t;
182
+ if (this.zoomBar) {
183
+ this.zoomBar.setAttribute("aria-label", this.t("timelineOverview"));
184
+ }
185
+ }
186
+
187
+ setLanguage(language, direction) {
188
+ this.language = language;
189
+ this.direction = direction;
190
+ if (this.zoomBar) {
191
+ this.zoomBar.setAttribute("aria-label", this.t("timelineOverview"));
192
+ this.zoomWindow?.setAttribute("aria-label", this.t("zoomWindow"));
193
+ this.zoomSelection?.setAttribute("aria-label", this.t("zoomSelection"));
194
+ this.zoomBar.querySelector(".zoom-handle-start")?.setAttribute("aria-label", this.t("from"));
195
+ this.zoomBar.querySelector(".zoom-handle-end")?.setAttribute("aria-label", this.t("to"));
196
+ }
197
+ this.render();
198
+ }
199
+
200
+ setOrientationSetting(value) {
201
+ this.orientationSetting = value;
202
+ this.render();
203
+ }
204
+
205
+ setAxisPlacement(orientation, value) {
206
+ this.axisPlacement[orientation] = value;
207
+ this.render();
208
+ }
209
+
210
+ setLodEnabled(value) {
211
+ this.lodEnabled = value;
212
+ this.clearExpandedCluster({ render: false });
213
+ this.render();
214
+ }
215
+
216
+ setExplodeEnabled(value) {
217
+ const nextValue = Boolean(value);
218
+ if (nextValue === this.explodeEnabled) return;
219
+ this.explodeEnabled = nextValue;
220
+ this.clearExpandedCluster({ render: false });
221
+ this.stage.classList.toggle("is-explode-mode", this.explodeEnabled);
222
+ this.stage.classList.add("is-exploding");
223
+
224
+ if (this.explodeAnimationTimer) window.clearTimeout(this.explodeAnimationTimer);
225
+ this.explodeAnimationTimer = window.setTimeout(() => {
226
+ this.explodeAnimationTimer = 0;
227
+ this.stage.classList.remove("is-exploding");
228
+ this.render();
229
+ }, (this.config.timeline?.explode?.animationMs ?? 620) + 180);
230
+
231
+ this.render();
232
+ }
233
+
234
+ setRecords(records, { resetView = false } = {}) {
235
+ this.records = records;
236
+ this.idMap = new Map(records.map((record) => [record.id, record]));
237
+ this.hoveredClusterId = null;
238
+ this.expandedCluster = null;
239
+ this.computeDomain();
240
+ if (resetView || !records.some((record) => record.id === this.selectedId)) {
241
+ this.fit();
242
+ } else {
243
+ this.clampView();
244
+ this.render();
245
+ }
246
+ }
247
+
248
+ select(recordId, emit = false) {
249
+ this.selectedId = recordId;
250
+ this.render();
251
+ if (emit) this.onSelect?.(this.idMap.get(recordId) || null);
252
+ }
253
+
254
+ setHovered(recordId, { source = "timeline" } = {}) {
255
+ const nextId = recordId && this.idMap.has(recordId) ? recordId : null;
256
+ const nextClusterId = null;
257
+ if (nextId === this.hoveredId && nextClusterId === this.hoveredClusterId) return;
258
+ const needsCardRender = source === "timeline" || (nextId && !this.hasRenderedCard(nextId));
259
+ this.hoveredId = nextId;
260
+ this.hoveredClusterId = nextClusterId;
261
+ this.updateHoverCursor();
262
+ this.render({ renderCards: needsCardRender });
263
+ if (!needsCardRender) this.updateCardHighlightClasses();
264
+ }
265
+
266
+ setHoveredCluster(clusterId) {
267
+ const nextId = clusterId && this.lastClusters.some((cluster) => cluster.id === clusterId) ? clusterId : null;
268
+ if (nextId === this.hoveredClusterId && !this.hoveredId) return;
269
+ this.hoveredId = null;
270
+ this.hoveredClusterId = nextId;
271
+ this.updateHoverCursor();
272
+ this.render({ renderCards: false });
273
+ }
274
+
275
+ updateHoverCursor() {
276
+ this.stage.classList.toggle("has-hit-hover", Boolean(this.hoveredId || this.hoveredClusterId));
277
+ }
278
+
279
+ hasRenderedCard(recordId) {
280
+ return [...this.cards.querySelectorAll("[data-record-id]")].some((card) => card.dataset.recordId === recordId);
281
+ }
282
+
283
+ zoomBy(factor) {
284
+ const metrics = this.measure();
285
+ const center = metrics.orientation === "horizontal" ? metrics.width / 2 : metrics.height / 2;
286
+ this.zoomAtPoint(factor, center, metrics);
287
+ }
288
+
289
+ fit({ animate = false } = {}) {
290
+ const span = Math.max(1, this.domain.end - this.domain.start);
291
+ this.setViewRange(this.domain.start, this.domain.end || this.domain.start + span, { animate, motion: false });
292
+ }
293
+
294
+ computeDomain() {
295
+ if (!this.records.length) {
296
+ const now = new Date().getUTCFullYear();
297
+ this.extent = { start: now - 10, end: now + 10 };
298
+ this.domain = { start: now - 10, end: now + 10 };
299
+ return;
300
+ }
301
+
302
+ const starts = this.records.map((record) => record.__meta.start).filter(Number.isFinite);
303
+ const ends = this.records.map((record) => record.__meta.end).filter(Number.isFinite);
304
+ const min = Math.min(...starts, ...ends);
305
+ const max = Math.max(...starts, ...ends);
306
+ const rawSpan = Math.max(1, max - min);
307
+ const paddingRatio = this.config.timeline?.defaultPaddingRatio ?? 0.08;
308
+ const padding = Math.max(rawSpan * paddingRatio, Math.min(25, rawSpan));
309
+ this.extent = {
310
+ start: min,
311
+ end: max || min + rawSpan
312
+ };
313
+ this.domain = {
314
+ start: min - padding,
315
+ end: max + padding
316
+ };
317
+ }
318
+
319
+ handleWheel(event) {
320
+ event.preventDefault();
321
+ const metrics = this.measure();
322
+ const pointer = this.pointerToAxis(event, metrics);
323
+ const delta = normalizeWheelDelta(event);
324
+
325
+ if (event.ctrlKey || event.metaKey || event.altKey) {
326
+ const factor = Math.exp(delta.y * 0.0017);
327
+ this.zoomAtPoint(factor, pointer, metrics);
328
+ return;
329
+ }
330
+
331
+ const axisDelta = metrics.orientation === "horizontal"
332
+ ? Math.abs(delta.x) > Math.abs(delta.y) ? delta.x : delta.y
333
+ : delta.y;
334
+ const contentDelta = -axisDelta;
335
+ this.panByPixels(contentDelta, metrics);
336
+ this.wheelVelocity += contentDelta * 0.12;
337
+ this.startAnimation();
338
+ }
339
+
340
+ handlePointerDown(event) {
341
+ if (event.target.closest("[data-record-id], button, input, select, textarea, a")) return;
342
+ this.stage.setPointerCapture(event.pointerId);
343
+ const metrics = this.measure();
344
+ const axis = this.pointerToAxis(event, metrics);
345
+ this.pointer = {
346
+ id: event.pointerId,
347
+ axis,
348
+ lastAxis: axis,
349
+ lastTime: performance.now(),
350
+ velocity: 0,
351
+ moved: false
352
+ };
353
+ this.kineticVelocity = 0;
354
+ this.stage.classList.add("is-dragging");
355
+ }
356
+
357
+ handlePointerMove(event) {
358
+ if (!this.pointer) {
359
+ if (!event.target.closest("[data-record-id]")) this.handleTimelineHover(event);
360
+ return;
361
+ }
362
+ if (this.pointer.id !== event.pointerId) return;
363
+ const metrics = this.measure();
364
+ const axis = this.pointerToAxis(event, metrics);
365
+ const now = performance.now();
366
+ const delta = axis - this.pointer.lastAxis;
367
+ const elapsed = Math.max(1, now - this.pointer.lastTime);
368
+
369
+ if (Math.abs(delta) > 0.4) {
370
+ this.pointer.moved = true;
371
+ this.panByPixels(delta, metrics);
372
+ this.pointer.velocity = delta / elapsed;
373
+ }
374
+
375
+ this.pointer.lastAxis = axis;
376
+ this.pointer.lastTime = now;
377
+ }
378
+
379
+ handlePointerUp(event) {
380
+ if (!this.pointer || this.pointer.id !== event.pointerId) return;
381
+ this.stage.releasePointerCapture(event.pointerId);
382
+ if (this.pointer.moved) {
383
+ this.suppressStageClick = true;
384
+ window.setTimeout(() => {
385
+ this.suppressStageClick = false;
386
+ }, 0);
387
+ }
388
+ this.kineticVelocity = this.pointer.moved ? this.pointer.velocity : 0;
389
+ this.pointer = null;
390
+ this.stage.classList.remove("is-dragging");
391
+ this.startAnimation();
392
+ }
393
+
394
+ handleStageClick(event) {
395
+ if (this.suppressStageClick) return;
396
+ if (event.target.closest("[data-record-id], button, input, select, textarea, a")) return;
397
+ const hit = this.hitTestEvent(event);
398
+ if (hit?.cluster) {
399
+ this.expandCluster(hit.cluster);
400
+ return;
401
+ }
402
+ if (hit?.record) {
403
+ if (this.expandedCluster && !this.expandedCluster.recordIds.includes(hit.record.id)) {
404
+ this.clearExpandedCluster({ render: false });
405
+ }
406
+ this.select(hit.record.id, true);
407
+ return;
408
+ }
409
+ this.clearExpandedCluster();
410
+ }
411
+
412
+ handleTimelineHover(event) {
413
+ const hit = this.hitTestEvent(event);
414
+ if (hit?.cluster) {
415
+ this.setHoveredCluster(hit.cluster.id);
416
+ return;
417
+ }
418
+ this.setHovered(hit?.record?.id || null, { source: "timeline" });
419
+ }
420
+
421
+ hitTestEvent(event) {
422
+ const rect = this.stage.getBoundingClientRect();
423
+ const point = {
424
+ x: event.clientX - rect.left,
425
+ y: event.clientY - rect.top
426
+ };
427
+ return this.hitTestPoint(point, this.measure());
428
+ }
429
+
430
+ handleKeydown(event) {
431
+ const metrics = this.measure();
432
+ const spanPixels = metrics.axisLength || 1;
433
+ const panStep = spanPixels * 0.08;
434
+
435
+ if (event.key === "+" || event.key === "=") {
436
+ event.preventDefault();
437
+ this.zoomBy(0.72);
438
+ } else if (event.key === "-" || event.key === "_") {
439
+ event.preventDefault();
440
+ this.zoomBy(1.35);
441
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
442
+ event.preventDefault();
443
+ this.panByPixels(panStep, metrics);
444
+ } else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
445
+ event.preventDefault();
446
+ this.panByPixels(-panStep, metrics);
447
+ } else if (event.key === "Home") {
448
+ event.preventDefault();
449
+ this.fit();
450
+ }
451
+ }
452
+
453
+ handleZoomPointerDown(event) {
454
+ if (!this.zoomBar || !this.records.length || event.button !== 0) return;
455
+ event.preventDefault();
456
+ event.stopPropagation();
457
+ this.cancelViewportAnimation();
458
+ this.zoomBar.focus({ preventScroll: true });
459
+ this.zoomBar.setPointerCapture(event.pointerId);
460
+
461
+ const metrics = this.measureZoomBar();
462
+ const year = this.zoomClientToYear(event, metrics);
463
+ const role = event.target.closest("[data-zoom-role]")?.dataset.zoomRole || "select";
464
+ const currentRange = this.getNavigatorViewRange();
465
+ const mode = role === "handle-start"
466
+ ? "start"
467
+ : role === "handle-end"
468
+ ? "end"
469
+ : "select";
470
+
471
+ this.zoomPointer = {
472
+ id: event.pointerId,
473
+ mode,
474
+ startYear: year,
475
+ currentYear: year,
476
+ initialRange: currentRange,
477
+ moved: false
478
+ };
479
+ this.kineticVelocity = 0;
480
+ this.wheelVelocity = 0;
481
+ this.zoomBar.classList.add("is-interacting", mode === "select" ? "is-selecting" : `is-${mode}`);
482
+
483
+ if (mode === "select") this.updateZoomSelection(year, year, metrics);
484
+ else this.hideZoomSelection();
485
+ }
486
+
487
+ handleZoomPointerMove(event) {
488
+ if (!this.zoomPointer || this.zoomPointer.id !== event.pointerId) return;
489
+ event.preventDefault();
490
+ event.stopPropagation();
491
+
492
+ const metrics = this.measureZoomBar();
493
+ const year = this.zoomClientToYear(event, metrics);
494
+ const pointer = this.zoomPointer;
495
+ const bounds = this.getNavigatorDomain();
496
+ const minSpan = this.config.timeline?.minZoomSpanYears || 2;
497
+ pointer.currentYear = year;
498
+ pointer.moved = pointer.moved || Math.abs(this.zoomYearToAxis(year, metrics) - this.zoomYearToAxis(pointer.startYear, metrics)) > 2;
499
+
500
+ if (pointer.mode === "select") {
501
+ this.updateZoomSelection(pointer.startYear, year, metrics);
502
+ return;
503
+ }
504
+
505
+ if (pointer.mode === "pan") {
506
+ const span = pointer.initialRange.end - pointer.initialRange.start;
507
+ const delta = year - pointer.startYear;
508
+ let nextStart = pointer.initialRange.start + delta;
509
+ let nextEnd = pointer.initialRange.end + delta;
510
+ if (span >= bounds.end - bounds.start) {
511
+ nextStart = bounds.start;
512
+ nextEnd = bounds.end;
513
+ } else if (nextStart < bounds.start) {
514
+ nextEnd += bounds.start - nextStart;
515
+ nextStart = bounds.start;
516
+ } else if (nextEnd > bounds.end) {
517
+ nextStart -= nextEnd - bounds.end;
518
+ nextEnd = bounds.end;
519
+ }
520
+ this.setViewRange(nextStart, nextEnd, { clampTo: "navigator" });
521
+ return;
522
+ }
523
+
524
+ if (pointer.mode === "start") {
525
+ const fixedEnd = pointer.initialRange.end;
526
+ const nextStart = clamp(year, bounds.start, fixedEnd - minSpan);
527
+ this.setViewRange(nextStart, fixedEnd, { clampTo: "navigator" });
528
+ return;
529
+ }
530
+
531
+ if (pointer.mode === "end") {
532
+ const fixedStart = pointer.initialRange.start;
533
+ const nextEnd = clamp(year, fixedStart + minSpan, bounds.end);
534
+ this.setViewRange(fixedStart, nextEnd, { clampTo: "navigator" });
535
+ }
536
+ }
537
+
538
+ handleZoomPointerUp(event) {
539
+ if (!this.zoomPointer || this.zoomPointer.id !== event.pointerId) return;
540
+ event.preventDefault();
541
+ event.stopPropagation();
542
+ this.zoomBar.releasePointerCapture(event.pointerId);
543
+
544
+ const pointer = this.zoomPointer;
545
+ const metrics = this.measureZoomBar();
546
+ this.zoomPointer = null;
547
+ this.zoomBar.classList.remove("is-interacting", "is-selecting", "is-start", "is-end", "is-pan");
548
+
549
+ if (pointer.mode === "select") {
550
+ const startPx = this.zoomYearToAxis(pointer.startYear, metrics);
551
+ const endPx = this.zoomYearToAxis(pointer.currentYear, metrics);
552
+ const minPixels = this.config.timeline?.navigator?.minSelectionPixels ?? 10;
553
+ this.hideZoomSelection();
554
+ if (Math.abs(endPx - startPx) >= minPixels) {
555
+ const nextStart = Math.min(pointer.startYear, pointer.currentYear);
556
+ const nextEnd = Math.max(pointer.startYear, pointer.currentYear);
557
+ this.setViewRange(nextStart, nextEnd, { animate: true, clampTo: "navigator" });
558
+ }
559
+ }
560
+ }
561
+
562
+ handleZoomKeydown(event) {
563
+ if (!this.records.length) return;
564
+ const range = this.getNavigatorViewRange();
565
+ const span = range.end - range.start;
566
+ const step = span * (event.shiftKey ? 0.25 : 0.08);
567
+
568
+ if (event.key === "ArrowLeft") {
569
+ event.preventDefault();
570
+ this.setViewRange(range.start - step, range.end - step, { clampTo: "navigator" });
571
+ } else if (event.key === "ArrowRight") {
572
+ event.preventDefault();
573
+ this.setViewRange(range.start + step, range.end + step, { clampTo: "navigator" });
574
+ } else if (event.key === "+" || event.key === "=") {
575
+ event.preventDefault();
576
+ this.zoomNavigatorRange(0.72);
577
+ } else if (event.key === "-" || event.key === "_") {
578
+ event.preventDefault();
579
+ this.zoomNavigatorRange(1.35);
580
+ } else if (event.key === "Home") {
581
+ event.preventDefault();
582
+ this.setViewRange(this.extent.start, this.extent.end, { animate: true, clampTo: "navigator" });
583
+ }
584
+ }
585
+
586
+ startAnimation() {
587
+ if (this.animationFrame) return;
588
+ this.lastFrame = 0;
589
+ this.animationFrame = requestAnimationFrame(this.boundAnimate);
590
+ }
591
+
592
+ markViewportMoving(duration = 180) {
593
+ this.stage.classList.add("is-viewport-moving");
594
+ if (this.motionTimer) window.clearTimeout(this.motionTimer);
595
+ this.motionTimer = window.setTimeout(() => {
596
+ this.motionTimer = 0;
597
+ this.stage.classList.remove("is-viewport-moving");
598
+ }, duration);
599
+ }
600
+
601
+ animate(time) {
602
+ const inertia = this.config.timeline?.inertia || {};
603
+ const enabled = inertia.enabled !== false;
604
+ const friction = inertia.friction ?? 0.92;
605
+ const wheelFriction = inertia.wheelFriction ?? 0.86;
606
+ const minVelocity = inertia.minVelocity ?? 0.02;
607
+ const elapsed = this.lastFrame ? Math.min(34, time - this.lastFrame) : 16;
608
+ this.lastFrame = time;
609
+
610
+ if (enabled && Math.abs(this.kineticVelocity) > minVelocity) {
611
+ this.panByPixels(this.kineticVelocity * elapsed, this.measure());
612
+ this.kineticVelocity *= Math.pow(friction, elapsed / 16);
613
+ } else {
614
+ this.kineticVelocity = 0;
615
+ }
616
+
617
+ if (enabled && Math.abs(this.wheelVelocity) > minVelocity) {
618
+ this.panByPixels(this.wheelVelocity, this.measure());
619
+ this.wheelVelocity *= Math.pow(wheelFriction, elapsed / 16);
620
+ } else {
621
+ this.wheelVelocity = 0;
622
+ }
623
+
624
+ if (Math.abs(this.kineticVelocity) > minVelocity || Math.abs(this.wheelVelocity) > minVelocity) {
625
+ this.animationFrame = requestAnimationFrame(this.boundAnimate);
626
+ } else {
627
+ this.animationFrame = 0;
628
+ }
629
+ }
630
+
631
+ panByPixels(deltaPixels, metrics = this.measure()) {
632
+ this.cancelViewportAnimation();
633
+ this.markViewportMoving();
634
+ const span = this.view.end - this.view.start;
635
+ const years = deltaPixels / Math.max(1, metrics.axisLength) * span;
636
+ this.view.start -= years;
637
+ this.view.end -= years;
638
+ this.clampView();
639
+ this.render();
640
+ }
641
+
642
+ zoomAtPoint(factor, axisPoint, metrics = this.measure()) {
643
+ this.cancelViewportAnimation();
644
+ this.clearExpandedCluster({ render: false });
645
+ const span = this.view.end - this.view.start;
646
+ const minSpan = this.config.timeline?.minZoomSpanYears || 2;
647
+ const domainSpan = Math.max(1, this.domain.end - this.domain.start);
648
+ const maxSpan = domainSpan * (this.config.timeline?.maxZoomMultiplier || 2.5);
649
+ const nextSpan = clamp(span * factor, minSpan, maxSpan);
650
+ const fraction = clamp((axisPoint - metrics.axisStart) / Math.max(1, metrics.axisLength), 0, 1);
651
+ const focusYear = this.view.start + fraction * span;
652
+
653
+ const nextStart = focusYear - fraction * nextSpan;
654
+ this.setViewRange(nextStart, nextStart + nextSpan);
655
+ }
656
+
657
+ zoomNavigatorRange(factor) {
658
+ const range = this.getNavigatorViewRange();
659
+ const center = (range.start + range.end) / 2;
660
+ const nextSpan = (range.end - range.start) * factor;
661
+ this.setViewRange(center - nextSpan / 2, center + nextSpan / 2, { animate: true, clampTo: "navigator" });
662
+ }
663
+
664
+ setViewRange(start, end, { animate = false, clampTo = "domain", motion = true } = {}) {
665
+ const target = this.normalizeViewRange(start, end, clampTo);
666
+ if (animate) {
667
+ this.animateViewTo(target.start, target.end, clampTo);
668
+ return;
669
+ }
670
+
671
+ this.cancelViewportAnimation();
672
+ this.clearExpandedCluster({ render: false });
673
+ if (motion) this.markViewportMoving();
674
+ this.view = target;
675
+ if (clampTo !== "navigator") this.clampView();
676
+ this.render();
677
+ }
678
+
679
+ normalizeViewRange(start, end, clampTo = "domain") {
680
+ const bounds = clampTo === "navigator" ? this.getNavigatorDomain() : this.domain;
681
+ const boundsSpan = Math.max(1, bounds.end - bounds.start);
682
+ const minSpan = Math.min(this.config.timeline?.minZoomSpanYears || 2, boundsSpan);
683
+ let nextStart = Math.min(start, end);
684
+ let nextEnd = Math.max(start, end);
685
+
686
+ if (!Number.isFinite(nextStart) || !Number.isFinite(nextEnd)) {
687
+ nextStart = bounds.start;
688
+ nextEnd = bounds.end;
689
+ }
690
+
691
+ let span = nextEnd - nextStart;
692
+ if (span < minSpan) {
693
+ const center = (nextStart + nextEnd) / 2;
694
+ nextStart = center - minSpan / 2;
695
+ nextEnd = center + minSpan / 2;
696
+ span = minSpan;
697
+ }
698
+
699
+ if (span >= boundsSpan) {
700
+ return { start: bounds.start, end: bounds.end };
701
+ }
702
+
703
+ if (nextStart < bounds.start) {
704
+ nextEnd += bounds.start - nextStart;
705
+ nextStart = bounds.start;
706
+ }
707
+ if (nextEnd > bounds.end) {
708
+ nextStart -= nextEnd - bounds.end;
709
+ nextEnd = bounds.end;
710
+ }
711
+
712
+ return { start: nextStart, end: nextEnd };
713
+ }
714
+
715
+ animateViewTo(start, end, clampTo = "navigator") {
716
+ this.cancelViewportAnimation();
717
+ this.clearExpandedCluster({ render: false });
718
+ this.kineticVelocity = 0;
719
+ this.wheelVelocity = 0;
720
+ const target = this.normalizeViewRange(start, end, clampTo);
721
+ this.markViewportMoving((this.config.timeline?.navigator?.animationMs ?? 420) + 120);
722
+ this.viewportAnimation = {
723
+ from: { ...this.view },
724
+ to: target,
725
+ startTime: 0,
726
+ duration: this.config.timeline?.navigator?.animationMs ?? 420
727
+ };
728
+ this.viewportAnimationFrame = requestAnimationFrame(this.boundAnimateViewport);
729
+ }
730
+
731
+ animateViewport(time) {
732
+ if (!this.viewportAnimation) return;
733
+ if (!this.viewportAnimation.startTime) this.viewportAnimation.startTime = time;
734
+ const { from, to, startTime, duration } = this.viewportAnimation;
735
+ const progress = clamp((time - startTime) / Math.max(1, duration), 0, 1);
736
+ const eased = easeOutCubic(progress);
737
+ this.view = {
738
+ start: from.start + (to.start - from.start) * eased,
739
+ end: from.end + (to.end - from.end) * eased
740
+ };
741
+ this.render();
742
+
743
+ if (progress < 1) {
744
+ this.viewportAnimationFrame = requestAnimationFrame(this.boundAnimateViewport);
745
+ return;
746
+ }
747
+
748
+ this.viewportAnimationFrame = 0;
749
+ this.viewportAnimation = null;
750
+ this.view = to;
751
+ this.render();
752
+ }
753
+
754
+ cancelViewportAnimation() {
755
+ if (this.viewportAnimationFrame) cancelAnimationFrame(this.viewportAnimationFrame);
756
+ this.viewportAnimationFrame = 0;
757
+ this.viewportAnimation = null;
758
+ }
759
+
760
+ clampView() {
761
+ const span = Math.max(0.001, this.view.end - this.view.start);
762
+ const minSpan = this.config.timeline?.minZoomSpanYears || 2;
763
+ const domainSpan = Math.max(1, this.domain.end - this.domain.start);
764
+ const maxSpan = domainSpan * (this.config.timeline?.maxZoomMultiplier || 2.5);
765
+ let nextSpan = clamp(span, minSpan, maxSpan);
766
+ const center = (this.view.start + this.view.end) / 2;
767
+ this.view.start = center - nextSpan / 2;
768
+ this.view.end = center + nextSpan / 2;
769
+
770
+ if (nextSpan >= domainSpan) {
771
+ const domainCenter = (this.domain.start + this.domain.end) / 2;
772
+ this.view.start = domainCenter - nextSpan / 2;
773
+ this.view.end = domainCenter + nextSpan / 2;
774
+ return;
775
+ }
776
+
777
+ if (this.view.start < this.domain.start) {
778
+ this.view.end += this.domain.start - this.view.start;
779
+ this.view.start = this.domain.start;
780
+ }
781
+ if (this.view.end > this.domain.end) {
782
+ this.view.start -= this.view.end - this.domain.end;
783
+ this.view.end = this.domain.end;
784
+ }
785
+ }
786
+
787
+ pointerToAxis(event, metrics) {
788
+ const rect = this.stage.getBoundingClientRect();
789
+ return metrics.orientation === "horizontal" ? event.clientX - rect.left : event.clientY - rect.top;
790
+ }
791
+
792
+ measure() {
793
+ const rect = this.stage.getBoundingClientRect();
794
+ const width = Math.max(1, rect.width);
795
+ const height = Math.max(1, rect.height);
796
+ const orientation = this.orientationSetting === "auto"
797
+ ? width >= height ? "horizontal" : "vertical"
798
+ : this.orientationSetting;
799
+ const margin = orientation === "horizontal"
800
+ ? clamp(width * 0.06, 42, 92)
801
+ : clamp(height * 0.055, 42, 82);
802
+ const axisLength = Math.max(1, (orientation === "horizontal" ? width : height) - margin * 2);
803
+ const placement = this.axisPlacement[orientation] || "center";
804
+ const sideOffset = 74;
805
+ const axisCoordinate = getAxisCoordinate({
806
+ orientation,
807
+ placement,
808
+ width,
809
+ height,
810
+ direction: this.direction,
811
+ sideOffset
812
+ });
813
+
814
+ const metrics = {
815
+ width,
816
+ height,
817
+ orientation,
818
+ placement,
819
+ margin,
820
+ axisStart: margin,
821
+ axisEnd: margin + axisLength,
822
+ axisLength,
823
+ axisCoordinate
824
+ };
825
+ this.lastMetrics = metrics;
826
+ return metrics;
827
+ }
828
+
829
+ yearToAxis(year, metrics) {
830
+ const span = this.view.end - this.view.start;
831
+ return metrics.axisStart + ((year - this.view.start) / span) * metrics.axisLength;
832
+ }
833
+
834
+ render({ renderCards = true } = {}) {
835
+ const metrics = this.measure();
836
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
837
+ const pixelWidth = Math.floor(metrics.width * dpr);
838
+ const pixelHeight = Math.floor(metrics.height * dpr);
839
+
840
+ if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) {
841
+ this.canvas.width = pixelWidth;
842
+ this.canvas.height = pixelHeight;
843
+ this.canvas.style.width = `${metrics.width}px`;
844
+ this.canvas.style.height = `${metrics.height}px`;
845
+ }
846
+
847
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
848
+ this.ctx.clearRect(0, 0, metrics.width, metrics.height);
849
+
850
+ const colors = this.readColors();
851
+ const items = this.computeItems(metrics);
852
+ this.applyExplodeLayout(metrics, items.display);
853
+ this.lastItems = items;
854
+ this.lastClusters = [];
855
+ this.drawGrid(metrics, colors);
856
+ this.drawSpans(metrics, colors, items.all);
857
+ this.drawRelationships(metrics, colors, items.all);
858
+ this.drawCardConnectors(metrics, colors, items.display);
859
+ this.drawMarkers(metrics, colors, items.all, items.display);
860
+ this.drawClusters(metrics, colors, items.hidden);
861
+ this.renderClusterTooltip(metrics);
862
+ this.renderZoomBar(colors);
863
+ if (renderCards) this.renderCards(metrics, items.display);
864
+ else this.updateCardHighlightClasses();
865
+ this.renderHint(metrics, items);
866
+ this.stage.dataset.orientation = metrics.orientation;
867
+
868
+ this.onViewportChange?.({
869
+ orientation: metrics.orientation,
870
+ placement: metrics.placement,
871
+ span: this.view.end - this.view.start,
872
+ visible: items.display.length,
873
+ hidden: items.hidden.length,
874
+ total: this.records.length,
875
+ lod: items.lod
876
+ });
877
+ }
878
+
879
+ renderZoomBar(colors = this.readColors()) {
880
+ if (!this.zoomBar || !this.zoomCanvas || this.zoomBar.hidden) return;
881
+ const metrics = this.measureZoomBar();
882
+ if (metrics.width <= 1 || metrics.height <= 1) return;
883
+
884
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
885
+ const pixelWidth = Math.floor(metrics.width * dpr);
886
+ const pixelHeight = Math.floor(metrics.height * dpr);
887
+ if (this.zoomCanvas.width !== pixelWidth || this.zoomCanvas.height !== pixelHeight) {
888
+ this.zoomCanvas.width = pixelWidth;
889
+ this.zoomCanvas.height = pixelHeight;
890
+ this.zoomCanvas.style.width = `${metrics.width}px`;
891
+ this.zoomCanvas.style.height = `${metrics.height}px`;
892
+ }
893
+
894
+ const ctx = this.zoomCtx;
895
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
896
+ ctx.clearRect(0, 0, metrics.width, metrics.height);
897
+ this.zoomBar.dataset.orientation = "horizontal";
898
+
899
+ this.drawZoomGrid(ctx, metrics, colors);
900
+ this.drawZoomEvents(ctx, metrics, colors);
901
+ this.updateZoomWindow(metrics);
902
+ if (this.zoomPointer?.mode === "select") {
903
+ this.updateZoomSelection(this.zoomPointer.startYear, this.zoomPointer.currentYear, metrics);
904
+ }
905
+
906
+ this.zoomLabelStart.textContent = formatYear(metrics.domain.start, this.language, this.t);
907
+ this.zoomLabelEnd.textContent = formatYear(metrics.domain.end, this.language, this.t);
908
+ }
909
+
910
+ drawZoomGrid(ctx, metrics, colors) {
911
+ const span = metrics.domain.end - metrics.domain.start;
912
+ const step = chooseTickStep(span, metrics.axisLength);
913
+ const baseline = metrics.axisY;
914
+
915
+ ctx.save();
916
+ ctx.strokeStyle = colors.line;
917
+ ctx.lineWidth = 1;
918
+ ctx.globalAlpha = 0.9;
919
+ ctx.beginPath();
920
+ ctx.moveTo(metrics.axisStart, baseline);
921
+ ctx.lineTo(metrics.axisEnd, baseline);
922
+ ctx.stroke();
923
+
924
+ ctx.strokeStyle = colors.grid;
925
+ ctx.globalAlpha = 0.44;
926
+ const first = Math.ceil(metrics.domain.start / step) * step;
927
+ for (let tick = first; tick <= metrics.domain.end + step * 0.5; tick += step) {
928
+ const x = this.zoomYearToAxis(tick, metrics);
929
+ ctx.beginPath();
930
+ ctx.moveTo(x, baseline - 18);
931
+ ctx.lineTo(x, baseline + 18);
932
+ ctx.stroke();
933
+ }
934
+ ctx.restore();
935
+ }
936
+
937
+ drawZoomEvents(ctx, metrics, colors) {
938
+ const range = this.getNavigatorViewRange();
939
+ const viewStart = Math.min(range.start, range.end);
940
+ const viewEnd = Math.max(range.start, range.end);
941
+ const baseline = metrics.axisY;
942
+
943
+ ctx.save();
944
+ const sorted = [...this.records].sort((a, b) => a.__meta.importance - b.__meta.importance);
945
+ for (const record of sorted) {
946
+ const start = clamp(record.__meta.start, metrics.domain.start, metrics.domain.end);
947
+ const end = clamp(record.__meta.end, metrics.domain.start, metrics.domain.end);
948
+ const x = this.zoomYearToAxis(start, metrics);
949
+ const endX = this.zoomYearToAxis(end, metrics);
950
+ const color = this.colorForRecord(record, colors);
951
+ const insideView = record.__meta.end >= viewStart && record.__meta.start <= viewEnd;
952
+ const selected = record.id === this.selectedId;
953
+ const height = clamp(5 + record.__meta.importance * 1.55, 9, 25);
954
+
955
+ if (Math.abs(endX - x) > 2.5) {
956
+ ctx.strokeStyle = color;
957
+ ctx.lineWidth = selected ? 3 : clamp(record.__meta.importance * 0.35, 1.2, 4);
958
+ ctx.globalAlpha = selected ? 0.95 : insideView ? 0.42 : 0.18;
959
+ ctx.lineCap = "round";
960
+ ctx.beginPath();
961
+ ctx.moveTo(x, baseline);
962
+ ctx.lineTo(endX, baseline);
963
+ ctx.stroke();
964
+ }
965
+
966
+ ctx.strokeStyle = selected ? colors.text : color;
967
+ ctx.lineWidth = selected ? 2.4 : clamp(record.__meta.importance * 0.16, 1, 2.2);
968
+ ctx.globalAlpha = selected ? 1 : insideView ? 0.88 : 0.42;
969
+ ctx.shadowColor = selected ? color : "transparent";
970
+ ctx.shadowBlur = selected ? 10 : 0;
971
+ ctx.beginPath();
972
+ ctx.moveTo(x, baseline - height / 2);
973
+ ctx.lineTo(x, baseline + height / 2);
974
+ ctx.stroke();
975
+ }
976
+ ctx.restore();
977
+ }
978
+
979
+ updateZoomWindow(metrics = this.measureZoomBar()) {
980
+ if (!this.zoomWindow) return;
981
+ const range = this.getNavigatorViewRange();
982
+ const left = this.zoomYearToAxis(range.start, metrics);
983
+ const right = this.zoomYearToAxis(range.end, metrics);
984
+ const width = Math.max(12, right - left);
985
+ this.zoomWindow.style.left = `${left}px`;
986
+ this.zoomWindow.style.width = `${width}px`;
987
+ this.zoomWindowLabel.textContent = `${formatYear(range.start, this.language, this.t)} - ${formatYear(range.end, this.language, this.t)}`;
988
+ }
989
+
990
+ updateZoomSelection(startYear, endYear, metrics = this.measureZoomBar()) {
991
+ if (!this.zoomSelection) return;
992
+ const start = this.zoomYearToAxis(startYear, metrics);
993
+ const end = this.zoomYearToAxis(endYear, metrics);
994
+ const left = Math.min(start, end);
995
+ const width = Math.max(1, Math.abs(end - start));
996
+ this.zoomSelection.style.left = `${left}px`;
997
+ this.zoomSelection.style.width = `${width}px`;
998
+ this.zoomSelection.hidden = false;
999
+ }
1000
+
1001
+ hideZoomSelection() {
1002
+ if (this.zoomSelection) this.zoomSelection.hidden = true;
1003
+ }
1004
+
1005
+ measureZoomBar() {
1006
+ const rect = this.zoomBar?.getBoundingClientRect?.() || { width: 1, height: 1 };
1007
+ const width = Math.max(1, rect.width);
1008
+ const height = Math.max(1, rect.height);
1009
+ const inset = clamp(this.config.timeline?.navigator?.trackInsetPx ?? width * 0.035, 12, Math.max(12, width / 3));
1010
+ const axisStart = inset;
1011
+ const axisEnd = Math.max(axisStart + 1, width - inset);
1012
+ const domain = this.getNavigatorDomain();
1013
+ return {
1014
+ width,
1015
+ height,
1016
+ axisStart,
1017
+ axisEnd,
1018
+ axisLength: Math.max(1, axisEnd - axisStart),
1019
+ axisY: clamp(height * 0.48, 26, height - 24),
1020
+ domain
1021
+ };
1022
+ }
1023
+
1024
+ getNavigatorDomain() {
1025
+ const start = Number.isFinite(this.extent.start) ? this.extent.start : this.domain.start;
1026
+ const end = Number.isFinite(this.extent.end) ? this.extent.end : this.domain.end;
1027
+ if (end <= start) return { start, end: start + 1 };
1028
+ return { start, end };
1029
+ }
1030
+
1031
+ getNavigatorViewRange() {
1032
+ const bounds = this.getNavigatorDomain();
1033
+ const start = clamp(this.view.start, bounds.start, bounds.end);
1034
+ const end = clamp(this.view.end, bounds.start, bounds.end);
1035
+ if (end - start >= 1) return { start, end };
1036
+ const center = clamp((this.view.start + this.view.end) / 2, bounds.start, bounds.end);
1037
+ const minSpan = Math.min(this.config.timeline?.minZoomSpanYears || 2, bounds.end - bounds.start);
1038
+ return this.normalizeViewRange(center - minSpan / 2, center + minSpan / 2, "navigator");
1039
+ }
1040
+
1041
+ zoomYearToAxis(year, metrics = this.measureZoomBar()) {
1042
+ const fraction = clamp((year - metrics.domain.start) / Math.max(1, metrics.domain.end - metrics.domain.start), 0, 1);
1043
+ return metrics.axisStart + fraction * metrics.axisLength;
1044
+ }
1045
+
1046
+ zoomAxisToYear(axis, metrics = this.measureZoomBar()) {
1047
+ const fraction = clamp((axis - metrics.axisStart) / Math.max(1, metrics.axisLength), 0, 1);
1048
+ return metrics.domain.start + fraction * (metrics.domain.end - metrics.domain.start);
1049
+ }
1050
+
1051
+ zoomClientToYear(event, metrics = this.measureZoomBar()) {
1052
+ const rect = this.zoomBar.getBoundingClientRect();
1053
+ return this.zoomAxisToYear(event.clientX - rect.left, metrics);
1054
+ }
1055
+
1056
+ computeItems(metrics) {
1057
+ const span = this.view.end - this.view.start;
1058
+ const lod = this.getLod(span);
1059
+ const minSignificance = this.lodEnabled ? lod.minSignificance : 1;
1060
+ const activeClusterIds = this.getActiveClusterRecordIds();
1061
+ const all = this.records
1062
+ .filter((record) => record.__meta.end >= this.view.start && record.__meta.start <= this.view.end)
1063
+ .map((record) => ({
1064
+ record,
1065
+ axis: this.yearToAxis(record.__meta.start, metrics),
1066
+ endAxis: this.yearToAxis(record.__meta.end, metrics),
1067
+ importance: record.__meta.importance,
1068
+ selected: record.id === this.selectedId,
1069
+ hovered: record.id === this.hoveredId,
1070
+ clusterHighlighted: activeClusterIds.has(record.id)
1071
+ }))
1072
+ .filter((item) => item.axis > metrics.axisStart - 80 && item.axis < metrics.axisEnd + 80);
1073
+
1074
+ const spacing = this.cardSpacingFor(lod.labelMode);
1075
+ const occupied = [];
1076
+ const display = [];
1077
+ let hidden = [];
1078
+ const ranked = [...all].sort((a, b) => {
1079
+ if (a.selected !== b.selected) return a.selected ? -1 : 1;
1080
+ if (a.hovered !== b.hovered) return a.hovered ? -1 : 1;
1081
+ if (b.importance !== a.importance) return b.importance - a.importance;
1082
+ return a.record.__meta.start - b.record.__meta.start;
1083
+ });
1084
+
1085
+ for (const item of ranked) {
1086
+ const importantEnough = item.importance >= minSignificance || item.selected;
1087
+ const hasRoom = !occupied.some((axis) => Math.abs(axis - item.axis) < spacing);
1088
+ if (!this.lodEnabled || item.selected || item.hovered || (importantEnough && hasRoom)) {
1089
+ display.push(item);
1090
+ occupied.push(item.axis);
1091
+ } else {
1092
+ hidden.push(item);
1093
+ }
1094
+ }
1095
+
1096
+ if (this.expandedCluster?.recordIds?.length) {
1097
+ const expandedIds = new Set(this.expandedCluster.recordIds);
1098
+ const remainingHidden = [];
1099
+ let expandedOrder = 0;
1100
+ for (const item of hidden) {
1101
+ if (expandedIds.has(item.record.id)) {
1102
+ item.expandedClusterId = this.expandedCluster.id;
1103
+ item.expandedOrder = expandedOrder;
1104
+ item.expandedCount = this.expandedCluster.recordIds.length;
1105
+ expandedOrder += 1;
1106
+ display.push(item);
1107
+ } else {
1108
+ remainingHidden.push(item);
1109
+ }
1110
+ }
1111
+ hidden = remainingHidden;
1112
+ }
1113
+
1114
+ if (this.explodeEnabled) {
1115
+ const capacity = this.explodeCapacity(metrics);
1116
+ const mustShow = display.filter((item) => item.selected || item.hovered);
1117
+ const mustShowIds = new Set(mustShow.map((item) => item.record.id));
1118
+ const rankedDisplay = display
1119
+ .filter((item) => !mustShowIds.has(item.record.id))
1120
+ .sort((a, b) => b.importance - a.importance || a.record.__meta.start - b.record.__meta.start);
1121
+ const keptDisplay = [...mustShow, ...rankedDisplay.slice(0, Math.max(0, capacity - mustShow.length))];
1122
+ const keptIds = new Set(keptDisplay.map((item) => item.record.id));
1123
+ const displayOverflow = display.filter((item) => !keptIds.has(item.record.id));
1124
+ const extraCapacity = Math.max(0, capacity - keptDisplay.length);
1125
+ const extraHidden = hidden
1126
+ .slice()
1127
+ .sort((a, b) => b.importance - a.importance || a.record.__meta.start - b.record.__meta.start)
1128
+ .slice(0, extraCapacity);
1129
+ const extraHiddenIds = new Set(extraHidden.map((item) => item.record.id));
1130
+
1131
+ display.splice(0, display.length, ...keptDisplay, ...extraHidden);
1132
+ hidden = [
1133
+ ...displayOverflow,
1134
+ ...hidden.filter((item) => !extraHiddenIds.has(item.record.id))
1135
+ ];
1136
+ }
1137
+
1138
+ display.sort((a, b) => a.record.__meta.start - b.record.__meta.start);
1139
+ if (this.explodeEnabled) {
1140
+ display.forEach((item, index) => {
1141
+ item.exploded = true;
1142
+ item.explodedIndex = index;
1143
+ item.explodedCount = display.length;
1144
+ });
1145
+ }
1146
+ return { all, display, hidden, lod };
1147
+ }
1148
+
1149
+ explodeCapacity(metrics) {
1150
+ const explode = this.config.timeline?.explode || {};
1151
+ const maxVisible = Math.max(1, explode.maxVisible ?? 34);
1152
+ const minVisible = Math.min(maxVisible, Math.max(1, explode.minVisible ?? 10));
1153
+ const densityPixels = Math.max(4200, explode.densityPixels ?? 8800);
1154
+ const byArea = Math.floor((metrics.width * metrics.height) / densityPixels);
1155
+ const cardWidth = this.explodeCardWidth(metrics);
1156
+ const cardHeight = this.explodeCardHeight(metrics);
1157
+ const lanes = metrics.orientation === "horizontal"
1158
+ ? this.horizontalExplodeLanes(metrics, cardHeight)
1159
+ : this.verticalExplodeLanes(metrics, cardWidth);
1160
+ const perLane = metrics.orientation === "horizontal"
1161
+ ? Math.floor(metrics.width / (cardWidth + 12))
1162
+ : Math.floor(metrics.height / (cardHeight + 12));
1163
+ const laneCapacity = Math.max(1, lanes.length * Math.max(1, perLane));
1164
+ const safeMinimum = Math.min(minVisible, laneCapacity);
1165
+ return clamp(Math.min(byArea, laneCapacity), safeMinimum, maxVisible);
1166
+ }
1167
+
1168
+ getLod(span) {
1169
+ const thresholds = this.config.timeline?.lod?.thresholds || [];
1170
+ const fallback = { spanYears: 0, minSignificance: 1, labelMode: "full" };
1171
+ return thresholds.find((threshold) => span >= threshold.spanYears) || fallback;
1172
+ }
1173
+
1174
+ cardSpacingFor(labelMode) {
1175
+ if (!this.lodEnabled) return 190;
1176
+ if (labelMode === "icon") return 84;
1177
+ if (labelMode === "short") return 128;
1178
+ if (labelMode === "standard") return 172;
1179
+ return 216;
1180
+ }
1181
+
1182
+ drawGrid(metrics, colors) {
1183
+ const ctx = this.ctx;
1184
+ const span = this.view.end - this.view.start;
1185
+ const step = chooseTickStep(span, metrics.axisLength);
1186
+ const minorStep = step / 5;
1187
+ const axis = metrics.axisCoordinate;
1188
+
1189
+ ctx.save();
1190
+ ctx.lineWidth = 1;
1191
+ ctx.font = "11px var(--mono-font)";
1192
+ ctx.textBaseline = metrics.orientation === "horizontal" ? "top" : "middle";
1193
+
1194
+ drawTicks(ctx, metrics, this.view, minorStep, colors.grid, 0.28, null);
1195
+ drawTicks(ctx, metrics, this.view, step, colors.grid, 0.55, (tick, axisPosition) => {
1196
+ ctx.save();
1197
+ ctx.fillStyle = colors.muted;
1198
+ ctx.globalAlpha = 0.82;
1199
+ const label = formatYear(tick, this.language, this.t);
1200
+ if (metrics.orientation === "horizontal") {
1201
+ const labelY = axis + (metrics.placement === "side-end" ? -28 : 18);
1202
+ ctx.textAlign = "center";
1203
+ ctx.fillText(label, axisPosition, labelY);
1204
+ } else {
1205
+ const labelX = axis + (metrics.placement === "side-end" ? -18 : 18) * (this.direction === "rtl" ? -1 : 1);
1206
+ ctx.textAlign = labelX < axis ? "right" : "left";
1207
+ ctx.fillText(label, labelX, axisPosition);
1208
+ }
1209
+ ctx.restore();
1210
+ });
1211
+
1212
+ ctx.strokeStyle = colors.line;
1213
+ ctx.globalAlpha = 0.95;
1214
+ ctx.lineWidth = 1.4;
1215
+ ctx.beginPath();
1216
+ if (metrics.orientation === "horizontal") {
1217
+ ctx.moveTo(metrics.axisStart, axis);
1218
+ ctx.lineTo(metrics.axisEnd, axis);
1219
+ } else {
1220
+ ctx.moveTo(axis, metrics.axisStart);
1221
+ ctx.lineTo(axis, metrics.axisEnd);
1222
+ }
1223
+ ctx.stroke();
1224
+ ctx.restore();
1225
+ }
1226
+
1227
+ drawSpans(metrics, colors, items) {
1228
+ const ctx = this.ctx;
1229
+ const axis = metrics.axisCoordinate;
1230
+ ctx.save();
1231
+ for (const item of sortHighlightLast(items)) {
1232
+ const durationPixels = Math.abs(item.endAxis - item.axis);
1233
+ if (durationPixels < 6) continue;
1234
+ const color = this.colorForRecord(item.record, colors);
1235
+ const highlighted = item.selected || item.hovered || item.clusterHighlighted;
1236
+ ctx.strokeStyle = color;
1237
+ ctx.lineWidth = highlighted
1238
+ ? clamp(item.importance * 0.9, 5, 13)
1239
+ : clamp(item.importance * 0.65, 3, 9);
1240
+ ctx.globalAlpha = item.selected ? 0.88 : highlighted ? 0.82 : (this.hoveredId || this.hoveredClusterId) ? 0.16 : 0.34;
1241
+ ctx.lineCap = "round";
1242
+ ctx.shadowColor = highlighted ? color : "transparent";
1243
+ ctx.shadowBlur = highlighted ? 18 : 0;
1244
+ ctx.beginPath();
1245
+ if (metrics.orientation === "horizontal") {
1246
+ ctx.moveTo(item.axis, axis);
1247
+ ctx.lineTo(item.endAxis, axis);
1248
+ } else {
1249
+ ctx.moveTo(axis, item.axis);
1250
+ ctx.lineTo(axis, item.endAxis);
1251
+ }
1252
+ ctx.stroke();
1253
+ }
1254
+ ctx.restore();
1255
+ }
1256
+
1257
+ drawRelationships(metrics, colors, items) {
1258
+ const ctx = this.ctx;
1259
+ const axis = metrics.axisCoordinate;
1260
+ const visibleById = new Map(items.map((item) => [item.record.id, item]));
1261
+ ctx.save();
1262
+ ctx.strokeStyle = colors.accent4;
1263
+ ctx.lineWidth = 0.9;
1264
+ ctx.globalAlpha = 0.2;
1265
+
1266
+ for (const item of sortHighlightLast(items)) {
1267
+ for (const relationship of item.record.relationships || []) {
1268
+ const target = visibleById.get(relationship.target);
1269
+ if (!target) continue;
1270
+ const highlighted = item.record.id === this.hoveredId ||
1271
+ target.record.id === this.hoveredId ||
1272
+ item.clusterHighlighted ||
1273
+ target.clusterHighlighted;
1274
+ const distance = Math.abs(target.axis - item.axis);
1275
+ if (distance < 18) continue;
1276
+ const bow = clamp(distance * 0.16, 22, 82);
1277
+ ctx.strokeStyle = highlighted ? this.colorForRecord(item.record, colors) : colors.accent4;
1278
+ ctx.lineWidth = highlighted ? 1.8 : 0.9;
1279
+ ctx.globalAlpha = highlighted ? 0.62 : (this.hoveredId || this.hoveredClusterId) ? 0.1 : 0.2;
1280
+ ctx.beginPath();
1281
+ if (metrics.orientation === "horizontal") {
1282
+ const direction = target.axis > item.axis ? 1 : -1;
1283
+ const y = axis + (item.importance % 2 ? -bow : bow);
1284
+ ctx.moveTo(item.axis, axis);
1285
+ ctx.quadraticCurveTo((item.axis + target.axis) / 2, y, target.axis - direction * 4, axis);
1286
+ } else {
1287
+ const direction = target.axis > item.axis ? 1 : -1;
1288
+ const x = axis + (item.importance % 2 ? -bow : bow);
1289
+ ctx.moveTo(axis, item.axis);
1290
+ ctx.quadraticCurveTo(x, (item.axis + target.axis) / 2, axis, target.axis - direction * 4);
1291
+ }
1292
+ ctx.stroke();
1293
+ }
1294
+ }
1295
+ ctx.restore();
1296
+ }
1297
+
1298
+ drawCardConnectors(metrics, colors, displayItems) {
1299
+ if (!displayItems.length) return;
1300
+ const ctx = this.ctx;
1301
+ const mode = this.getLod(this.view.end - this.view.start).labelMode;
1302
+ const compact = this.lodEnabled && (mode === "icon" || mode === "short");
1303
+
1304
+ ctx.save();
1305
+ ctx.lineCap = "round";
1306
+ ctx.lineJoin = "round";
1307
+
1308
+ sortHighlightLast(displayItems).forEach((item) => {
1309
+ const index = displayItems.indexOf(item);
1310
+ const placement = this.cardPlacement(metrics, item, index, compact);
1311
+ const color = this.colorForRecord(item.record, colors);
1312
+ const markerSize = clamp(4 + item.importance * 0.75, 7, 14);
1313
+ const marker = metrics.orientation === "horizontal"
1314
+ ? { x: item.axis, y: metrics.axisCoordinate }
1315
+ : { x: metrics.axisCoordinate, y: item.axis };
1316
+ const path = connectorPath(metrics, marker, placement, markerSize);
1317
+ const highlighted = item.selected || item.hovered || item.clusterHighlighted;
1318
+ const exploded = this.explodeEnabled && item.exploded;
1319
+
1320
+ ctx.strokeStyle = color;
1321
+ ctx.lineWidth = highlighted ? 2.8 : exploded ? 1.55 : 1.35;
1322
+ ctx.globalAlpha = item.selected ? 0.9 : highlighted ? 0.86 : (this.hoveredId || this.hoveredClusterId) ? 0.18 : exploded ? 0.58 : 0.5;
1323
+ ctx.shadowColor = highlighted ? color : "transparent";
1324
+ ctx.shadowBlur = highlighted ? 16 : 0;
1325
+ ctx.setLineDash(item.record.__meta.temporalUncertainty ? [5, 5] : exploded ? [7, 5] : []);
1326
+ ctx.beginPath();
1327
+ ctx.moveTo(path.start.x, path.start.y);
1328
+ for (const point of path.midpoints) ctx.lineTo(point.x, point.y);
1329
+ ctx.lineTo(path.end.x, path.end.y);
1330
+ ctx.stroke();
1331
+
1332
+ ctx.setLineDash([]);
1333
+ ctx.fillStyle = color;
1334
+ ctx.globalAlpha = highlighted ? 0.98 : (this.hoveredId || this.hoveredClusterId) ? 0.3 : 0.68;
1335
+ ctx.beginPath();
1336
+ ctx.arc(path.end.x, path.end.y, item.selected ? 3.6 : 2.8, 0, Math.PI * 2);
1337
+ ctx.fill();
1338
+ });
1339
+
1340
+ ctx.restore();
1341
+ }
1342
+
1343
+ drawMarkers(metrics, colors, all, display) {
1344
+ const ctx = this.ctx;
1345
+ const axis = metrics.axisCoordinate;
1346
+ const displayed = new Set(display.map((item) => item.record.id));
1347
+
1348
+ ctx.save();
1349
+ for (const item of sortHighlightLast(all)) {
1350
+ const color = this.colorForRecord(item.record, colors);
1351
+ const highlighted = item.selected || item.hovered || item.clusterHighlighted;
1352
+ const size = clamp(4 + item.importance * 0.75, 7, 14) + (highlighted ? 3 : 0);
1353
+ const x = metrics.orientation === "horizontal" ? item.axis : axis;
1354
+ const y = metrics.orientation === "horizontal" ? axis : item.axis;
1355
+ ctx.globalAlpha = highlighted ? 1 : displayed.has(item.record.id) ? ((this.hoveredId || this.hoveredClusterId) ? 0.42 : 0.96) : ((this.hoveredId || this.hoveredClusterId) ? 0.16 : 0.38);
1356
+ drawMarker(ctx, x, y, size, TYPE_SHAPES[item.record.recordType] || "circle", color, highlighted);
1357
+ }
1358
+ ctx.restore();
1359
+ }
1360
+
1361
+ drawClusters(metrics, colors, hidden) {
1362
+ this.lastClusters = [];
1363
+ if (!hidden.length || !this.lodEnabled) return;
1364
+ const clusters = this.buildClusters(metrics, hidden);
1365
+ this.lastClusters = clusters;
1366
+
1367
+ const ctx = this.ctx;
1368
+ ctx.save();
1369
+ ctx.font = "11px var(--mono-font)";
1370
+ ctx.textAlign = "center";
1371
+ ctx.textBaseline = "middle";
1372
+
1373
+ for (const cluster of clusters) {
1374
+ const highlighted = cluster.id === this.hoveredClusterId || cluster.id === this.expandedCluster?.id;
1375
+ const anchor = metrics.orientation === "horizontal"
1376
+ ? { x: cluster.axis, y: metrics.axisCoordinate }
1377
+ : { x: metrics.axisCoordinate, y: cluster.axis };
1378
+ const edge = metrics.orientation === "horizontal"
1379
+ ? { x: cluster.x + cluster.width / 2, y: cluster.side > 0 ? cluster.y : cluster.y + cluster.height }
1380
+ : { x: cluster.side > 0 ? cluster.x : cluster.x + cluster.width, y: cluster.y + cluster.height / 2 };
1381
+
1382
+ ctx.strokeStyle = highlighted ? colors.accent2 : colors.line;
1383
+ ctx.lineWidth = highlighted ? 1.4 : 0.9;
1384
+ ctx.globalAlpha = highlighted ? 0.78 : 0.42;
1385
+ ctx.setLineDash(highlighted ? [] : [3, 5]);
1386
+ ctx.beginPath();
1387
+ ctx.moveTo(anchor.x, anchor.y);
1388
+ ctx.lineTo(edge.x, edge.y);
1389
+ ctx.stroke();
1390
+ ctx.setLineDash([]);
1391
+
1392
+ ctx.fillStyle = highlighted ? colorMixFallback(colors.accent2, colors.surfaceRaised) : colors.surfaceRaised;
1393
+ ctx.strokeStyle = highlighted ? colors.accent2 : colors.line;
1394
+ ctx.lineWidth = highlighted ? 2 : 1;
1395
+ ctx.globalAlpha = highlighted ? 1 : this.hoveredClusterId ? 0.45 : 0.9;
1396
+ ctx.shadowColor = highlighted ? colors.accent2 : "transparent";
1397
+ ctx.shadowBlur = highlighted ? 18 : 0;
1398
+ ctx.beginPath();
1399
+ roundedRect(ctx, cluster.x, cluster.y, cluster.width, cluster.height, 8);
1400
+ ctx.fill();
1401
+ ctx.stroke();
1402
+ ctx.fillStyle = highlighted ? colors.background : colors.accent2;
1403
+ ctx.globalAlpha = 1;
1404
+ ctx.fillText(cluster.label, cluster.x + cluster.width / 2, cluster.y + cluster.height / 2 + 0.5);
1405
+ }
1406
+ ctx.restore();
1407
+ }
1408
+
1409
+ buildClusters(metrics, hidden) {
1410
+ const buckets = new Map();
1411
+ for (const item of hidden) {
1412
+ const key = Math.round(item.axis / 96);
1413
+ const bucket = buckets.get(key) || { key, count: 0, axis: 0, maxImportance: 0, items: [] };
1414
+ bucket.count += 1;
1415
+ bucket.axis += item.axis;
1416
+ bucket.maxImportance = Math.max(bucket.maxImportance, item.importance);
1417
+ bucket.items.push(item);
1418
+ buckets.set(key, bucket);
1419
+ }
1420
+
1421
+ return [...buckets.values()].map((bucket) => {
1422
+ const axis = bucket.axis / bucket.count;
1423
+ const label = `+${bucket.count}`;
1424
+ const width = 28 + label.length * 5;
1425
+ const height = 22;
1426
+ const side = clusterSideFor(metrics, bucket.key);
1427
+ const offset = 28 + bucket.maxImportance * 1.35;
1428
+ const x = metrics.orientation === "horizontal" ? axis - width / 2 : metrics.axisCoordinate + side * offset - width / 2;
1429
+ const y = metrics.orientation === "horizontal" ? metrics.axisCoordinate + side * offset - height / 2 : axis - height / 2;
1430
+ const recordIds = bucket.items.map((item) => item.record.id).sort();
1431
+ return {
1432
+ id: `cluster:${bucket.key}:${recordIds.join("|")}`,
1433
+ key: bucket.key,
1434
+ label,
1435
+ count: bucket.count,
1436
+ axis,
1437
+ x,
1438
+ y,
1439
+ width,
1440
+ height,
1441
+ side,
1442
+ maxImportance: bucket.maxImportance,
1443
+ items: bucket.items,
1444
+ recordIds,
1445
+ bbox: {
1446
+ left: x - 8,
1447
+ right: x + width + 8,
1448
+ top: y - 8,
1449
+ bottom: y + height + 8
1450
+ }
1451
+ };
1452
+ });
1453
+ }
1454
+
1455
+ renderClusterTooltip(metrics) {
1456
+ const cluster = this.lastClusters.find((entry) => entry.id === this.hoveredClusterId);
1457
+ if (!cluster) {
1458
+ this.clusterTooltip.hidden = true;
1459
+ return;
1460
+ }
1461
+
1462
+ const fallback = this.records[0]?.__meta?.fallbackLanguage || "en";
1463
+ const titles = cluster.items
1464
+ .slice()
1465
+ .sort((a, b) => b.importance - a.importance || a.record.__meta.start - b.record.__meta.start)
1466
+ .slice(0, 3)
1467
+ .map((item) => textOf(item.record.label, this.language, item.record.__meta.fallbackLanguage || fallback));
1468
+ const extra = Math.max(0, cluster.count - titles.length);
1469
+ const titleList = titles.map((title) => `<li>${escapeHtml(title)}</li>`).join("");
1470
+ const more = extra ? `<li>${escapeHtml(`+${extra}`)}</li>` : "";
1471
+
1472
+ this.clusterTooltip.innerHTML = `
1473
+ <strong>${escapeHtml(cluster.label)} ${escapeHtml(this.t("hiddenEvents"))}</strong>
1474
+ <ul>${titleList}${more}</ul>
1475
+ <span>${escapeHtml(this.t("clusterHint"))}</span>
1476
+ `;
1477
+
1478
+ const centerX = cluster.x + cluster.width / 2;
1479
+ const centerY = cluster.y + cluster.height / 2;
1480
+ const left = clamp(centerX, 118, Math.max(118, metrics.width - 118));
1481
+ const placeBelow = centerY < 118;
1482
+ const top = placeBelow ? centerY + 28 : centerY - 18;
1483
+ this.clusterTooltip.style.left = `${left}px`;
1484
+ this.clusterTooltip.style.top = `${top}px`;
1485
+ this.clusterTooltip.dataset.placement = placeBelow ? "below" : "above";
1486
+ this.clusterTooltip.hidden = false;
1487
+ }
1488
+
1489
+ renderCards(metrics, displayItems) {
1490
+ const axis = metrics.axisCoordinate;
1491
+ const mode = this.getLod(this.view.end - this.view.start).labelMode;
1492
+ const compact = this.lodEnabled && (mode === "icon" || mode === "short");
1493
+ const explodeAnimationMs = this.config.timeline?.explode?.animationMs ?? 620;
1494
+ const html = displayItems.map((item, index) => {
1495
+ const record = item.record;
1496
+ const title = textOf(record.label, this.language, record.__meta.fallbackLanguage);
1497
+ const description = textOf(record.description, this.language, record.__meta.fallbackLanguage);
1498
+ const date = formatExtent(record.__meta.preferred, this.language, record.__meta.fallbackLanguage, this.t);
1499
+ const colorVar = TYPE_VARIABLES[record.recordType] || "--accent";
1500
+ const placement = this.cardPlacement(metrics, item, index, compact);
1501
+ const selected = item.selected ? " is-selected" : "";
1502
+ const hovered = item.hovered ? " is-hovered" : "";
1503
+ const expanded = item.expandedClusterId ? " is-cluster-expanded" : "";
1504
+ const exploded = item.exploded ? " is-exploded" : "";
1505
+ const exploding = item.exploded && this.stage.classList.contains("is-exploding") ? " is-exploding-card" : "";
1506
+ const motion = this.stage.classList.contains("is-viewport-moving") || this.stage.classList.contains("is-dragging")
1507
+ ? " is-motion-card"
1508
+ : "";
1509
+ const cardMode = item.exploded ? "short" : mode;
1510
+ const compactCard = item.exploded || compact;
1511
+ const descriptionHtml = compactCard ? "" : `<p>${escapeHtml(description)}</p>`;
1512
+ const mediaBadge = record.__meta.hasMedia ? `<span class="card-chip">${escapeHtml(this.t("media"))}</span>` : "";
1513
+ const uncertainty = record.__meta.temporalUncertainty ? `<span class="card-chip">${escapeHtml(record.__meta.confidence)}</span>` : "";
1514
+ const emoji = record.emoji ? `<span class="card-emoji" aria-hidden="true">${escapeHtml(record.emoji)}</span>` : "";
1515
+ const zIndex = 10 + record.__meta.importance + (item.expandedClusterId ? 36 : 0) + (item.exploded ? 22 + (item.explodeDepth || 0) * 3 : 0);
1516
+ return `
1517
+ <button class="event-card mode-${escapeHtml(cardMode)} type-${escapeHtml(record.recordType)}${selected}${hovered}${expanded}${exploded}${exploding}${motion}" data-record-id="${escapeHtml(record.id)}" style="--x:${placement.x}px;--y:${placement.y}px;--shift-x:${placement.shiftX};--shift-y:${placement.shiftY};--record-color:var(${colorVar});--card-z:${escapeHtml(String(zIndex))};--card-width:${placement.width}px;--card-max-height:${placement.height}px;--explode-from-x:${placement.explodeFromX || 0}px;--explode-from-y:${placement.explodeFromY || 0}px;--explode-over-x:${placement.explodeOverX || 0}px;--explode-over-y:${placement.explodeOverY || 0}px;--explode-delay:${placement.explodeDelay || 0}ms;--explode-duration:${explodeAnimationMs}ms;">
1518
+ <span class="card-date">${escapeHtml(date)}</span>
1519
+ <span class="card-title">${emoji}<span>${escapeHtml(title)}</span></span>
1520
+ <span class="card-meta">
1521
+ <span>${escapeHtml(compactLabel(record.recordType))}</span>
1522
+ <span>${escapeHtml(String(record.__meta.importance))}/10</span>
1523
+ ${mediaBadge}
1524
+ ${uncertainty}
1525
+ </span>
1526
+ ${descriptionHtml}
1527
+ </button>
1528
+ `;
1529
+ }).join("");
1530
+ this.cards.innerHTML = html;
1531
+ }
1532
+
1533
+ applyExplodeLayout(metrics, displayItems) {
1534
+ for (const item of displayItems) {
1535
+ item.explodePlacement = null;
1536
+ item.explodeDepth = 0;
1537
+ }
1538
+ if (!this.explodeEnabled || !displayItems.length) return;
1539
+
1540
+ const cardWidth = this.explodeCardWidth(metrics);
1541
+ const cardHeight = this.explodeCardHeight(metrics);
1542
+ const lanes = metrics.orientation === "horizontal"
1543
+ ? this.horizontalExplodeLanes(metrics, cardHeight)
1544
+ : this.verticalExplodeLanes(metrics, cardWidth);
1545
+ if (!lanes.length) return;
1546
+
1547
+ const sorted = [...displayItems].sort((a, b) => a.record.__meta.start - b.record.__meta.start || b.importance - a.importance);
1548
+ const axisMin = metrics.orientation === "horizontal" ? cardWidth / 2 + 14 : cardHeight / 2 + 14;
1549
+ const axisMax = metrics.orientation === "horizontal" ? metrics.width - cardWidth / 2 - 14 : metrics.height - cardHeight / 2 - 14;
1550
+ const intervalSize = metrics.orientation === "horizontal" ? cardWidth : cardHeight;
1551
+ const markerFor = (item) => metrics.orientation === "horizontal"
1552
+ ? { x: item.axis, y: metrics.axisCoordinate }
1553
+ : { x: metrics.axisCoordinate, y: item.axis };
1554
+
1555
+ sorted.forEach((item, index) => {
1556
+ const base = clamp(item.axis, axisMin, axisMax);
1557
+ const slotCount = Math.max(2, Math.floor((axisMax - axisMin) / (intervalSize + 10)) + 1);
1558
+ const slotOffsets = Array.from({ length: slotCount }, (_, slotIndex) => {
1559
+ const slotCenter = axisMin + ((axisMax - axisMin) * slotIndex) / Math.max(1, slotCount - 1);
1560
+ return slotCenter - base;
1561
+ }).sort((a, b) => Math.abs(a) - Math.abs(b));
1562
+ const offsets = slotOffsets;
1563
+ const laneStart = index % lanes.length;
1564
+ const orderedLanes = [...lanes.slice(laneStart), ...lanes.slice(0, laneStart)];
1565
+ let best = null;
1566
+
1567
+ for (const lane of orderedLanes) {
1568
+ for (const offset of offsets) {
1569
+ const center = clamp(base + offset, axisMin, axisMax);
1570
+ const start = center - intervalSize / 2;
1571
+ const end = center + intervalSize / 2;
1572
+ const overlap = intervalOverlap(lane.occupied, start, end);
1573
+ const score = overlap * 240 + lane.occupied.length * 18 + lane.depth * 9 + Math.abs(center - base) * 0.12;
1574
+ if (!best || score < best.score) {
1575
+ best = { lane, center, start, end, score };
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ if (!best) return;
1581
+ best.lane.occupied.push([best.start, best.end]);
1582
+ const marker = markerFor(item);
1583
+ const x = metrics.orientation === "horizontal" ? best.center : best.lane.coordinate;
1584
+ const y = metrics.orientation === "horizontal" ? best.lane.coordinate : best.center;
1585
+ const fromX = marker.x - x;
1586
+ const fromY = marker.y - y;
1587
+ item.explodeDepth = best.lane.depth;
1588
+ item.explodePlacement = {
1589
+ x,
1590
+ y,
1591
+ width: cardWidth,
1592
+ height: cardHeight,
1593
+ side: best.lane.side,
1594
+ shiftX: metrics.orientation === "horizontal" ? "-50%" : best.lane.side > 0 ? "0%" : "-100%",
1595
+ shiftY: "-50%",
1596
+ explodeFromX: Math.round(fromX),
1597
+ explodeFromY: Math.round(fromY),
1598
+ explodeOverX: Math.round(-fromX * 0.045),
1599
+ explodeOverY: Math.round(-fromY * 0.045),
1600
+ explodeDelay: Math.min(360, index * 18)
1601
+ };
1602
+ });
1603
+ }
1604
+
1605
+ horizontalExplodeLanes(metrics, cardHeight) {
1606
+ const explode = this.config.timeline?.explode || {};
1607
+ const maxLayers = Math.max(1, Math.round(explode.layers ?? 6));
1608
+ const lanes = [];
1609
+ const firstOffset = Math.max(46, cardHeight * 0.6);
1610
+ const step = cardHeight;
1611
+ const canPlace = (y) => y >= cardHeight / 2 + 6 && y <= metrics.height - cardHeight / 2 - 6;
1612
+ const pushLane = (side, depth) => {
1613
+ const y = metrics.axisCoordinate + side * (firstOffset + (depth - 1) * step);
1614
+ if (!canPlace(y)) return;
1615
+ lanes.push({ side, depth, coordinate: y, occupied: [] });
1616
+ };
1617
+
1618
+ for (let depth = 1; depth <= maxLayers; depth += 1) {
1619
+ if (metrics.placement === "center") {
1620
+ pushLane(depth % 2 ? -1 : 1, depth);
1621
+ pushLane(depth % 2 ? 1 : -1, depth);
1622
+ } else {
1623
+ pushLane(metrics.placement === "side-end" ? -1 : 1, depth);
1624
+ }
1625
+ }
1626
+
1627
+ return lanes;
1628
+ }
1629
+
1630
+ verticalExplodeLanes(metrics, cardWidth) {
1631
+ const explode = this.config.timeline?.explode || {};
1632
+ const maxLayers = Math.max(1, Math.round(explode.layers ?? 6));
1633
+ const lanes = [];
1634
+ const gap = 18;
1635
+ const firstOffset = Math.max(76, cardWidth * 0.42);
1636
+ const step = cardWidth + gap;
1637
+ const contentSide = metrics.axisCoordinate < metrics.width / 2 ? 1 : -1;
1638
+ const canPlace = (x, side) => side > 0
1639
+ ? x >= 14 && x + cardWidth <= metrics.width - 14
1640
+ : x <= metrics.width - 14 && x - cardWidth >= 14;
1641
+ const pushLane = (side, depth) => {
1642
+ const x = metrics.axisCoordinate + side * (firstOffset + (depth - 1) * step);
1643
+ if (!canPlace(x, side)) return;
1644
+ lanes.push({ side, depth, coordinate: x, occupied: [] });
1645
+ };
1646
+
1647
+ for (let depth = 1; depth <= maxLayers; depth += 1) {
1648
+ if (metrics.placement === "center") {
1649
+ pushLane(depth % 2 ? -1 : 1, depth);
1650
+ pushLane(depth % 2 ? 1 : -1, depth);
1651
+ } else {
1652
+ pushLane(contentSide, depth);
1653
+ }
1654
+ }
1655
+
1656
+ return lanes;
1657
+ }
1658
+
1659
+ explodeCardWidth(metrics) {
1660
+ if (metrics.orientation === "horizontal") return clamp(metrics.width * 0.16, 136, 190);
1661
+ return clamp(metrics.width * 0.22, 120, 176);
1662
+ }
1663
+
1664
+ explodeCardHeight(metrics) {
1665
+ return metrics.orientation === "horizontal" ? 78 : 76;
1666
+ }
1667
+
1668
+ updateCardHighlightClasses() {
1669
+ const cards = this.cards.querySelectorAll("[data-record-id]");
1670
+ for (const card of cards) {
1671
+ const highlighted = card.dataset.recordId === this.hoveredId;
1672
+ card.classList.toggle("is-hovered", highlighted);
1673
+ }
1674
+ }
1675
+
1676
+ cardPlacement(metrics, item, index, compact) {
1677
+ if (item.explodePlacement) return item.explodePlacement;
1678
+ const offset = compact ? 88 : 128;
1679
+ const cardWidth = Math.min(compact ? 206 : 284, Math.max(152, metrics.width - 32));
1680
+ const cardHeight = compact ? 104 : 188;
1681
+ const expandedNudge = Number.isFinite(item.expandedOrder)
1682
+ ? (item.expandedOrder - ((item.expandedCount || 1) - 1) / 2) * 42
1683
+ : 0;
1684
+ if (metrics.orientation === "horizontal") {
1685
+ let side = 1;
1686
+ if (metrics.placement === "center") side = index % 2 === 0 ? -1 : 1;
1687
+ if (metrics.placement === "side-start") side = 1;
1688
+ if (metrics.placement === "side-end") side = -1;
1689
+ const x = clamp(item.axis + expandedNudge, cardWidth / 2 + 14, metrics.width - cardWidth / 2 - 14);
1690
+ const y = clamp(metrics.axisCoordinate + side * offset, 62, metrics.height - 62);
1691
+ return { x, y, width: cardWidth, height: cardHeight, side, shiftX: "-50%", shiftY: "-50%" };
1692
+ }
1693
+
1694
+ let side = this.direction === "rtl" ? -1 : 1;
1695
+ if (metrics.placement === "center") side = index % 2 === 0 ? -1 : 1;
1696
+ if (metrics.placement === "side-start") side = metrics.axisCoordinate < metrics.width / 2 ? 1 : -1;
1697
+ if (metrics.placement === "side-end") side = metrics.axisCoordinate < metrics.width / 2 ? 1 : -1;
1698
+ const desiredX = metrics.axisCoordinate + side * offset;
1699
+ const x = side > 0
1700
+ ? clamp(desiredX, 16, metrics.width - cardWidth - 16)
1701
+ : clamp(desiredX, cardWidth + 16, metrics.width - 16);
1702
+ const y = clamp(item.axis + expandedNudge, 48, metrics.height - 48);
1703
+ return {
1704
+ x,
1705
+ y,
1706
+ width: cardWidth,
1707
+ height: cardHeight,
1708
+ side,
1709
+ shiftX: side > 0 ? "0%" : "-100%",
1710
+ shiftY: "-50%"
1711
+ };
1712
+ }
1713
+
1714
+ renderHint(metrics, items) {
1715
+ const span = Math.max(1, Math.round(this.view.end - this.view.start));
1716
+ const status = this.t("statusReady", {
1717
+ visible: items.display.length,
1718
+ hidden: items.hidden.length
1719
+ }) + ` · ${this.t("zoomLevel", { span })}`;
1720
+ this.hint.textContent = this.expandedCluster
1721
+ ? `${this.t("clusterExpanded", { count: this.expandedCluster.recordIds.length })} · ${status}`
1722
+ : status;
1723
+ }
1724
+
1725
+ readColors() {
1726
+ const styles = getComputedStyle(this.themeRoot || document.documentElement);
1727
+ return {
1728
+ background: styles.getPropertyValue("--background").trim(),
1729
+ text: styles.getPropertyValue("--text").trim(),
1730
+ muted: styles.getPropertyValue("--muted").trim(),
1731
+ line: styles.getPropertyValue("--line").trim(),
1732
+ grid: styles.getPropertyValue("--grid").trim(),
1733
+ surfaceRaised: styles.getPropertyValue("--surface-raised").trim(),
1734
+ accent: styles.getPropertyValue("--accent").trim(),
1735
+ accent2: styles.getPropertyValue("--accent2").trim(),
1736
+ accent3: styles.getPropertyValue("--accent3").trim(),
1737
+ accent4: styles.getPropertyValue("--accent4").trim(),
1738
+ event: styles.getPropertyValue("--type-event").trim(),
1739
+ process: styles.getPropertyValue("--type-process").trim(),
1740
+ period: styles.getPropertyValue("--type-period").trim(),
1741
+ phenomenon: styles.getPropertyValue("--type-phenomenon").trim(),
1742
+ structure: styles.getPropertyValue("--type-structure").trim()
1743
+ };
1744
+ }
1745
+
1746
+ colorForRecord(record, colors) {
1747
+ return colors[record.recordType] || colors.accent;
1748
+ }
1749
+
1750
+ getActiveClusterRecordIds() {
1751
+ const ids = new Set(this.expandedCluster?.recordIds || []);
1752
+ const cluster = this.lastClusters.find((entry) => entry.id === this.hoveredClusterId);
1753
+ if (cluster) {
1754
+ for (const id of cluster.recordIds) ids.add(id);
1755
+ }
1756
+ return ids;
1757
+ }
1758
+
1759
+ hitTestPoint(point, metrics) {
1760
+ for (const cluster of this.lastClusters || []) {
1761
+ if (
1762
+ point.x >= cluster.bbox.left &&
1763
+ point.x <= cluster.bbox.right &&
1764
+ point.y >= cluster.bbox.top &&
1765
+ point.y <= cluster.bbox.bottom
1766
+ ) {
1767
+ return { cluster };
1768
+ }
1769
+ }
1770
+
1771
+ const items = this.lastItems?.all?.length ? this.lastItems.all : this.computeItems(metrics).all;
1772
+ const candidates = [];
1773
+ for (const item of items) {
1774
+ const marker = metrics.orientation === "horizontal"
1775
+ ? { x: item.axis, y: metrics.axisCoordinate }
1776
+ : { x: metrics.axisCoordinate, y: item.axis };
1777
+ const markerDistance = distance(point, marker);
1778
+ const markerRadius = clamp(4 + item.importance * 0.75, 9, 17) + 8;
1779
+ if (markerDistance <= markerRadius) {
1780
+ candidates.push({ item, distance: markerDistance, kind: "marker" });
1781
+ continue;
1782
+ }
1783
+
1784
+ const spanDistance = metrics.orientation === "horizontal"
1785
+ ? distanceToSegment(point, { x: item.axis, y: metrics.axisCoordinate }, { x: item.endAxis, y: metrics.axisCoordinate })
1786
+ : distanceToSegment(point, { x: metrics.axisCoordinate, y: item.axis }, { x: metrics.axisCoordinate, y: item.endAxis });
1787
+ const durationPixels = Math.abs(item.endAxis - item.axis);
1788
+ if (durationPixels >= 6 && spanDistance <= 10) {
1789
+ candidates.push({ item, distance: spanDistance + 4, kind: "span" });
1790
+ }
1791
+ }
1792
+
1793
+ const displayItems = this.lastItems?.display || [];
1794
+ const mode = this.getLod(this.view.end - this.view.start).labelMode;
1795
+ const compact = this.lodEnabled && (mode === "icon" || mode === "short");
1796
+ displayItems.forEach((item, index) => {
1797
+ const marker = metrics.orientation === "horizontal"
1798
+ ? { x: item.axis, y: metrics.axisCoordinate }
1799
+ : { x: metrics.axisCoordinate, y: item.axis };
1800
+ const placement = this.cardPlacement(metrics, item, index, compact);
1801
+ const markerSize = clamp(4 + item.importance * 0.75, 7, 14);
1802
+ const path = connectorPath(metrics, marker, placement, markerSize);
1803
+ const points = [path.start, ...path.midpoints, path.end];
1804
+ let connectorDistance = Infinity;
1805
+ for (let index = 0; index < points.length - 1; index += 1) {
1806
+ connectorDistance = Math.min(connectorDistance, distanceToSegment(point, points[index], points[index + 1]));
1807
+ }
1808
+ if (connectorDistance <= 8) candidates.push({ item, distance: connectorDistance + 2, kind: "connector" });
1809
+ });
1810
+
1811
+ candidates.sort((a, b) => a.distance - b.distance || b.item.importance - a.item.importance);
1812
+ const hit = candidates[0]?.item;
1813
+ return hit ? { record: hit.record, item: hit } : null;
1814
+ }
1815
+
1816
+ expandCluster(cluster) {
1817
+ this.expandedCluster = {
1818
+ id: cluster.id,
1819
+ recordIds: cluster.recordIds
1820
+ };
1821
+ this.hoveredClusterId = null;
1822
+ this.updateHoverCursor();
1823
+ this.render();
1824
+ }
1825
+
1826
+ clearExpandedCluster({ render = true } = {}) {
1827
+ if (!this.expandedCluster) return;
1828
+ this.expandedCluster = null;
1829
+ if (render) this.render();
1830
+ }
1831
+
1832
+ destroy() {
1833
+ this.resizeObserver?.disconnect();
1834
+ if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
1835
+ if (this.viewportAnimationFrame) cancelAnimationFrame(this.viewportAnimationFrame);
1836
+ if (this.motionTimer) window.clearTimeout(this.motionTimer);
1837
+ if (this.explodeAnimationTimer) window.clearTimeout(this.explodeAnimationTimer);
1838
+ this.clusterTooltip?.remove();
1839
+ this.animationFrame = 0;
1840
+ this.viewportAnimationFrame = 0;
1841
+ this.motionTimer = 0;
1842
+ this.explodeAnimationTimer = 0;
1843
+ }
1844
+ }
1845
+
1846
+ function getAxisCoordinate({ orientation, placement, width, height, direction, sideOffset }) {
1847
+ if (orientation === "horizontal") {
1848
+ if (placement === "side-start") return sideOffset;
1849
+ if (placement === "side-end") return height - sideOffset;
1850
+ return height / 2;
1851
+ }
1852
+
1853
+ if (placement === "side-start") return direction === "rtl" ? width - sideOffset : sideOffset;
1854
+ if (placement === "side-end") return direction === "rtl" ? sideOffset : width - sideOffset;
1855
+ return width / 2;
1856
+ }
1857
+
1858
+ function clusterSideFor(metrics, key) {
1859
+ if (metrics.orientation === "horizontal") {
1860
+ if (metrics.placement === "side-start") return 1;
1861
+ if (metrics.placement === "side-end") return -1;
1862
+ return key % 2 === 0 ? -1 : 1;
1863
+ }
1864
+
1865
+ if (metrics.placement === "center") return key % 2 === 0 ? -1 : 1;
1866
+ return metrics.axisCoordinate < metrics.width / 2 ? 1 : -1;
1867
+ }
1868
+
1869
+ function sortHighlightLast(items) {
1870
+ return [...items].sort((a, b) => {
1871
+ const aHighlighted = a.selected || a.hovered || a.clusterHighlighted;
1872
+ const bHighlighted = b.selected || b.hovered || b.clusterHighlighted;
1873
+ if (aHighlighted !== bHighlighted) return aHighlighted ? 1 : -1;
1874
+ return a.importance - b.importance;
1875
+ });
1876
+ }
1877
+
1878
+ function intervalOverlap(intervals, start, end) {
1879
+ return intervals.reduce((total, interval) => {
1880
+ return total + Math.max(0, Math.min(end, interval[1]) - Math.max(start, interval[0]));
1881
+ }, 0);
1882
+ }
1883
+
1884
+ function distance(a, b) {
1885
+ return Math.hypot(a.x - b.x, a.y - b.y);
1886
+ }
1887
+
1888
+ function distanceToSegment(point, start, end) {
1889
+ const dx = end.x - start.x;
1890
+ const dy = end.y - start.y;
1891
+ const lengthSquared = dx * dx + dy * dy;
1892
+ if (lengthSquared === 0) return distance(point, start);
1893
+ const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, 0, 1);
1894
+ return distance(point, {
1895
+ x: start.x + t * dx,
1896
+ y: start.y + t * dy
1897
+ });
1898
+ }
1899
+
1900
+ function colorMixFallback(primary, fallback) {
1901
+ return primary || fallback;
1902
+ }
1903
+
1904
+ function easeOutCubic(value) {
1905
+ return 1 - Math.pow(1 - value, 3);
1906
+ }
1907
+
1908
+ function normalizeWheelDelta(event) {
1909
+ const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1;
1910
+ return {
1911
+ x: event.deltaX * multiplier,
1912
+ y: event.deltaY * multiplier
1913
+ };
1914
+ }
1915
+
1916
+ function chooseTickStep(span, pixels) {
1917
+ const target = Math.max(1, pixels / 115);
1918
+ const raw = Math.max(0.0001, span / target);
1919
+ const power = 10 ** Math.floor(Math.log10(raw));
1920
+ const multiples = [1, 2, 5, 10];
1921
+ return multiples.find((multiple) => raw <= multiple * power) * power;
1922
+ }
1923
+
1924
+ function drawTicks(ctx, metrics, view, step, color, alpha, labeler) {
1925
+ if (!Number.isFinite(step) || step <= 0) return;
1926
+ const first = Math.ceil(view.start / step) * step;
1927
+ const axis = metrics.axisCoordinate;
1928
+ ctx.save();
1929
+ ctx.strokeStyle = color;
1930
+ ctx.globalAlpha = alpha;
1931
+ ctx.lineWidth = 1;
1932
+
1933
+ for (let tick = first; tick <= view.end + step * 0.5; tick += step) {
1934
+ const axisPosition = metrics.axisStart + ((tick - view.start) / (view.end - view.start)) * metrics.axisLength;
1935
+ if (axisPosition < metrics.axisStart - 2 || axisPosition > metrics.axisEnd + 2) continue;
1936
+ ctx.beginPath();
1937
+ if (metrics.orientation === "horizontal") {
1938
+ ctx.moveTo(axisPosition, 0);
1939
+ ctx.lineTo(axisPosition, metrics.height);
1940
+ } else {
1941
+ ctx.moveTo(0, axisPosition);
1942
+ ctx.lineTo(metrics.width, axisPosition);
1943
+ }
1944
+ ctx.stroke();
1945
+
1946
+ if (labeler) {
1947
+ ctx.globalAlpha = 1;
1948
+ const tickLength = 8;
1949
+ ctx.beginPath();
1950
+ if (metrics.orientation === "horizontal") {
1951
+ ctx.moveTo(axisPosition, axis - tickLength);
1952
+ ctx.lineTo(axisPosition, axis + tickLength);
1953
+ } else {
1954
+ ctx.moveTo(axis - tickLength, axisPosition);
1955
+ ctx.lineTo(axis + tickLength, axisPosition);
1956
+ }
1957
+ ctx.stroke();
1958
+ labeler(Math.round(tick), axisPosition);
1959
+ ctx.globalAlpha = alpha;
1960
+ }
1961
+ }
1962
+ ctx.restore();
1963
+ }
1964
+
1965
+ function drawMarker(ctx, x, y, size, shape, color, selected) {
1966
+ ctx.save();
1967
+ ctx.fillStyle = color;
1968
+ ctx.strokeStyle = selected ? "#ffffff" : color;
1969
+ ctx.lineWidth = selected ? 2.4 : 1;
1970
+ ctx.shadowColor = selected ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.25)";
1971
+ ctx.shadowBlur = selected ? 18 : 8;
1972
+ ctx.beginPath();
1973
+
1974
+ if (shape === "square") {
1975
+ roundedRect(ctx, x - size, y - size, size * 2, size * 2, 3);
1976
+ } else if (shape === "diamond") {
1977
+ ctx.moveTo(x, y - size * 1.25);
1978
+ ctx.lineTo(x + size * 1.25, y);
1979
+ ctx.lineTo(x, y + size * 1.25);
1980
+ ctx.lineTo(x - size * 1.25, y);
1981
+ ctx.closePath();
1982
+ } else if (shape === "hex") {
1983
+ for (let index = 0; index < 6; index += 1) {
1984
+ const angle = Math.PI / 6 + index * Math.PI / 3;
1985
+ const px = x + Math.cos(angle) * size * 1.15;
1986
+ const py = y + Math.sin(angle) * size * 1.15;
1987
+ if (index === 0) ctx.moveTo(px, py);
1988
+ else ctx.lineTo(px, py);
1989
+ }
1990
+ ctx.closePath();
1991
+ } else if (shape === "capsule") {
1992
+ roundedRect(ctx, x - size * 1.45, y - size * 0.78, size * 2.9, size * 1.56, size);
1993
+ } else {
1994
+ ctx.arc(x, y, size, 0, Math.PI * 2);
1995
+ }
1996
+
1997
+ ctx.fill();
1998
+ ctx.stroke();
1999
+ ctx.restore();
2000
+ }
2001
+
2002
+ function connectorPath(metrics, marker, placement, markerSize) {
2003
+ const inset = 18;
2004
+ const side = placement.side || 1;
2005
+
2006
+ if (metrics.orientation === "horizontal") {
2007
+ const cardLeft = placement.x - placement.width / 2;
2008
+ const cardRight = placement.x + placement.width / 2;
2009
+ const end = {
2010
+ x: clamp(marker.x, cardLeft + inset, cardRight - inset),
2011
+ y: placement.y - side * (placement.height / 2 + 1)
2012
+ };
2013
+ const start = {
2014
+ x: marker.x,
2015
+ y: marker.y + side * (markerSize + 6)
2016
+ };
2017
+ const jointY = start.y + side * Math.max(18, Math.min(44, Math.abs(end.y - start.y) * 0.48));
2018
+ return {
2019
+ start,
2020
+ end,
2021
+ midpoints: [
2022
+ { x: start.x, y: jointY },
2023
+ { x: end.x, y: jointY }
2024
+ ]
2025
+ };
2026
+ }
2027
+
2028
+ const cardLeft = side > 0 ? placement.x : placement.x - placement.width;
2029
+ const cardRight = side > 0 ? placement.x + placement.width : placement.x;
2030
+ const cardTop = placement.y - placement.height / 2;
2031
+ const cardBottom = placement.y + placement.height / 2;
2032
+ const end = {
2033
+ x: side > 0 ? cardLeft - 1 : cardRight + 1,
2034
+ y: clamp(marker.y, cardTop + inset, cardBottom - inset)
2035
+ };
2036
+ const start = {
2037
+ x: marker.x + side * (markerSize + 6),
2038
+ y: marker.y
2039
+ };
2040
+ const jointX = start.x + side * Math.max(18, Math.min(44, Math.abs(end.x - start.x) * 0.48));
2041
+ return {
2042
+ start,
2043
+ end,
2044
+ midpoints: [
2045
+ { x: jointX, y: start.y },
2046
+ { x: jointX, y: end.y }
2047
+ ]
2048
+ };
2049
+ }
2050
+
2051
+ function roundedRect(ctx, x, y, width, height, radius) {
2052
+ const safeRadius = Math.min(radius, width / 2, height / 2);
2053
+ ctx.moveTo(x + safeRadius, y);
2054
+ ctx.lineTo(x + width - safeRadius, y);
2055
+ ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
2056
+ ctx.lineTo(x + width, y + height - safeRadius);
2057
+ ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
2058
+ ctx.lineTo(x + safeRadius, y + height);
2059
+ ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
2060
+ ctx.lineTo(x, y + safeRadius);
2061
+ ctx.quadraticCurveTo(x, y, x + safeRadius, y);
2062
+ }