@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.
- package/README.md +167 -0
- package/package.json +35 -0
- package/src/analytics.js +25 -0
- package/src/default-config.js +120 -0
- package/src/filters.js +61 -0
- package/src/i18n.js +184 -0
- package/src/index.d.ts +137 -0
- package/src/index.js +457 -0
- package/src/paststruct.js +270 -0
- package/src/styles.css +780 -0
- package/src/theme.js +27 -0
- package/src/timeline-view.js +2062 -0
|
@@ -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
|
+
}
|