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