@opendata-ai/openchart-vanilla 8.4.1 → 8.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/chunk-2OJZBMXM.js +377 -0
- package/dist/chunk-2OJZBMXM.js.map +1 -0
- package/dist/{chunk-5A2NLYUW.js → chunk-5KV3ZBBL.js} +26 -376
- package/dist/chunk-5KV3ZBBL.js.map +1 -0
- package/dist/chunk-NG7CVCI2.js +58 -0
- package/dist/chunk-NG7CVCI2.js.map +1 -0
- package/dist/{chunk-3UIUANPI.js → chunk-UAK6HWOC.js} +2 -2
- package/dist/chunk-WOLX5EZT.js +3577 -0
- package/dist/chunk-WOLX5EZT.js.map +1 -0
- package/dist/{chunk-KANNWAEA.js → chunk-XGII4ZRT.js} +4 -58
- package/dist/chunk-XGII4ZRT.js.map +1 -0
- package/dist/graph-3d/index.d.ts +272 -0
- package/dist/graph-3d/index.js +1556 -0
- package/dist/graph-3d/index.js.map +1 -0
- package/dist/index.d.ts +4 -252
- package/dist/index.js +24 -3468
- package/dist/index.js.map +1 -1
- package/dist/renderer-registry-B5tW6yAZ.d.ts +376 -0
- package/dist/static.js +3 -2
- package/dist/static.js.map +1 -1
- package/dist/story/index.js +8 -5
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +28 -6
- package/dist/chunk-5A2NLYUW.js.map +0 -1
- package/dist/chunk-KANNWAEA.js.map +0 -1
- /package/dist/{chunk-3UIUANPI.js.map → chunk-UAK6HWOC.js.map} +0 -0
|
@@ -0,0 +1,3577 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnimationScheduler,
|
|
3
|
+
SpatialIndex,
|
|
4
|
+
createTooltipManager,
|
|
5
|
+
createTween,
|
|
6
|
+
observeResize,
|
|
7
|
+
prefersReducedMotion,
|
|
8
|
+
resolveDarkMode,
|
|
9
|
+
resolveEase
|
|
10
|
+
} from "./chunk-2OJZBMXM.js";
|
|
11
|
+
import {
|
|
12
|
+
resolvedSurface
|
|
13
|
+
} from "./chunk-NG7CVCI2.js";
|
|
14
|
+
|
|
15
|
+
// src/graph/renderer-registry.ts
|
|
16
|
+
var registry = /* @__PURE__ */ new Map();
|
|
17
|
+
function registerGraphRenderer(dimensions, factory) {
|
|
18
|
+
registry.set(dimensions, factory);
|
|
19
|
+
}
|
|
20
|
+
function getGraphRenderer(dimensions) {
|
|
21
|
+
return registry.get(dimensions);
|
|
22
|
+
}
|
|
23
|
+
var GRAPH_3D_NOT_REGISTERED_ERROR = 'createGraph: dimensions: 3 requires import "@opendata-ai/openchart-vanilla/graph-3d"';
|
|
24
|
+
|
|
25
|
+
// src/graph-mount.ts
|
|
26
|
+
import { buildEdgeTooltip, compileGraph } from "@opendata-ai/openchart-engine";
|
|
27
|
+
|
|
28
|
+
// src/graph/camera.ts
|
|
29
|
+
import { interpolateZoom } from "d3-interpolate";
|
|
30
|
+
|
|
31
|
+
// src/graph/zoom.ts
|
|
32
|
+
var ZoomTransform = class _ZoomTransform {
|
|
33
|
+
constructor(x, y, k) {
|
|
34
|
+
this.x = x;
|
|
35
|
+
this.y = y;
|
|
36
|
+
this.k = k;
|
|
37
|
+
}
|
|
38
|
+
/** Convert screen coordinates to graph coordinates. */
|
|
39
|
+
screenToGraph(sx, sy) {
|
|
40
|
+
return {
|
|
41
|
+
x: (sx - this.x) / this.k,
|
|
42
|
+
y: (sy - this.y) / this.k
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Convert graph coordinates to screen coordinates. */
|
|
46
|
+
graphToScreen(gx, gy) {
|
|
47
|
+
return {
|
|
48
|
+
x: gx * this.k + this.x,
|
|
49
|
+
y: gy * this.k + this.y
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Zoom to a target scale, keeping the given screen-space pivot
|
|
54
|
+
* point fixed (content under the cursor stays under the cursor).
|
|
55
|
+
*/
|
|
56
|
+
zoomAt(targetK, pivotX, pivotY) {
|
|
57
|
+
const gx = (pivotX - this.x) / this.k;
|
|
58
|
+
const gy = (pivotY - this.y) / this.k;
|
|
59
|
+
return new _ZoomTransform(pivotX - gx * targetK, pivotY - gy * targetK, targetK);
|
|
60
|
+
}
|
|
61
|
+
/** Pan by a screen-space delta. */
|
|
62
|
+
pan(dx, dy) {
|
|
63
|
+
return new _ZoomTransform(this.x + dx, this.y + dy, this.k);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compute a transform that fits all nodes within the given canvas
|
|
67
|
+
* dimensions with the specified padding.
|
|
68
|
+
*
|
|
69
|
+
* Returns the transform and the ideal content height (in screen pixels)
|
|
70
|
+
* so callers can shrink the canvas to eliminate dead space.
|
|
71
|
+
*/
|
|
72
|
+
static fitBounds(nodes, canvasW, canvasH, padding = 40, opts) {
|
|
73
|
+
if (nodes.length === 0) {
|
|
74
|
+
return { transform: _ZoomTransform.identity(), contentHeight: canvasH };
|
|
75
|
+
}
|
|
76
|
+
const insetTop = Math.min(Math.max(0, opts?.insetTop ?? 0), canvasH * 0.4);
|
|
77
|
+
let minX = Infinity;
|
|
78
|
+
let minY = Infinity;
|
|
79
|
+
let maxX = -Infinity;
|
|
80
|
+
let maxY = -Infinity;
|
|
81
|
+
for (const n of nodes) {
|
|
82
|
+
const r = n.radius;
|
|
83
|
+
if (n.x - r < minX) minX = n.x - r;
|
|
84
|
+
if (n.y - r < minY) minY = n.y - r;
|
|
85
|
+
if (n.x + r > maxX) maxX = n.x + r;
|
|
86
|
+
if (n.y + r > maxY) maxY = n.y + r;
|
|
87
|
+
}
|
|
88
|
+
let graphW = maxX - minX;
|
|
89
|
+
let graphH = maxY - minY;
|
|
90
|
+
if (graphW === 0 && graphH === 0) {
|
|
91
|
+
return {
|
|
92
|
+
transform: new _ZoomTransform(
|
|
93
|
+
canvasW / 2 - minX,
|
|
94
|
+
insetTop + (canvasH - insetTop) / 2 - minY,
|
|
95
|
+
1
|
|
96
|
+
),
|
|
97
|
+
contentHeight: padding * 2 + insetTop
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (opts?.spread !== false && nodes.length > 50) {
|
|
101
|
+
const spread = 1 + Math.sqrt(nodes.length) / 120;
|
|
102
|
+
const cx2 = (minX + maxX) / 2;
|
|
103
|
+
const cy2 = (minY + maxY) / 2;
|
|
104
|
+
graphW *= spread;
|
|
105
|
+
graphH *= spread;
|
|
106
|
+
minX = cx2 - graphW / 2;
|
|
107
|
+
maxX = cx2 + graphW / 2;
|
|
108
|
+
minY = cy2 - graphH / 2;
|
|
109
|
+
maxY = cy2 + graphH / 2;
|
|
110
|
+
}
|
|
111
|
+
const availW = canvasW - padding * 2;
|
|
112
|
+
const availH = canvasH - insetTop - padding * 2;
|
|
113
|
+
const k = Math.min(1, availW / graphW, availH / graphH);
|
|
114
|
+
const cx = (minX + maxX) / 2;
|
|
115
|
+
const cy = (minY + maxY) / 2;
|
|
116
|
+
const tx = canvasW / 2 - cx * k;
|
|
117
|
+
const ty = insetTop + (canvasH - insetTop) / 2 - cy * k;
|
|
118
|
+
const contentHeight = graphH * k + padding * 2 + insetTop;
|
|
119
|
+
return {
|
|
120
|
+
transform: new _ZoomTransform(tx, ty, k),
|
|
121
|
+
contentHeight
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Identity transform (no pan, no zoom). */
|
|
125
|
+
static identity() {
|
|
126
|
+
return new _ZoomTransform(0, 0, 1);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// src/graph/camera.ts
|
|
131
|
+
var K_MIN = 0.05;
|
|
132
|
+
var K_MAX = 15;
|
|
133
|
+
var AUTO_MIN_MS = 400;
|
|
134
|
+
var AUTO_MAX_MS = 600;
|
|
135
|
+
var AUTO_SCALE = 0.6;
|
|
136
|
+
function transformToView(t, viewport) {
|
|
137
|
+
const cx = (viewport.width / 2 - t.x) / t.k;
|
|
138
|
+
const cy = (viewport.height / 2 - t.y) / t.k;
|
|
139
|
+
const width = viewport.width / t.k;
|
|
140
|
+
return [cx, cy, width];
|
|
141
|
+
}
|
|
142
|
+
function viewToTransform(view, viewport) {
|
|
143
|
+
const [cx, cy, width] = view;
|
|
144
|
+
const k = clampK(viewport.width / width);
|
|
145
|
+
const x = viewport.width / 2 - cx * k;
|
|
146
|
+
const y = viewport.height / 2 - cy * k;
|
|
147
|
+
return new ZoomTransform(x, y, k);
|
|
148
|
+
}
|
|
149
|
+
function clampK(k) {
|
|
150
|
+
if (!Number.isFinite(k) || k <= 0) return K_MIN;
|
|
151
|
+
return Math.min(K_MAX, Math.max(K_MIN, k));
|
|
152
|
+
}
|
|
153
|
+
function createCameraFlight(inputs) {
|
|
154
|
+
const { from, viewport, apply, onDone, opts } = inputs;
|
|
155
|
+
const ease = resolveEase(opts?.ease ?? "smooth");
|
|
156
|
+
const isProvider = typeof inputs.to === "function";
|
|
157
|
+
const fromView = transformToView(from, viewport);
|
|
158
|
+
function targetView() {
|
|
159
|
+
const to = isProvider ? inputs.to() : inputs.to;
|
|
160
|
+
return transformToView(to, viewport);
|
|
161
|
+
}
|
|
162
|
+
let interp = interpolateZoom(fromView, targetView());
|
|
163
|
+
const resolvedDuration = resolveDuration(opts?.duration, interp.duration);
|
|
164
|
+
let startTime = null;
|
|
165
|
+
let finished = false;
|
|
166
|
+
function applyAt(t) {
|
|
167
|
+
if (isProvider) interp = interpolateZoom(fromView, targetView());
|
|
168
|
+
const view = interp(t);
|
|
169
|
+
if (view.some((v) => !Number.isFinite(v))) {
|
|
170
|
+
apply(viewToTransform(targetView(), viewport));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
apply(viewToTransform(view, viewport));
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
tick(now) {
|
|
177
|
+
if (finished) return false;
|
|
178
|
+
if (startTime === null) startTime = now;
|
|
179
|
+
const raw = resolvedDuration <= 0 ? 1 : Math.min(1, (now - startTime) / resolvedDuration);
|
|
180
|
+
applyAt(ease(raw));
|
|
181
|
+
if (raw >= 1) {
|
|
182
|
+
finished = true;
|
|
183
|
+
onDone?.();
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
},
|
|
188
|
+
finish() {
|
|
189
|
+
if (finished) return;
|
|
190
|
+
finished = true;
|
|
191
|
+
applyAt(1);
|
|
192
|
+
onDone?.();
|
|
193
|
+
},
|
|
194
|
+
cancel() {
|
|
195
|
+
finished = true;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function createCameraFollow(inputs) {
|
|
200
|
+
let finished = false;
|
|
201
|
+
return {
|
|
202
|
+
tick() {
|
|
203
|
+
if (finished) return false;
|
|
204
|
+
if (!inputs.isActive()) {
|
|
205
|
+
finished = true;
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
inputs.apply(inputs.target());
|
|
209
|
+
return true;
|
|
210
|
+
},
|
|
211
|
+
finish() {
|
|
212
|
+
finished = true;
|
|
213
|
+
},
|
|
214
|
+
cancel() {
|
|
215
|
+
finished = true;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function resolveDuration(duration, interpDuration) {
|
|
220
|
+
if (typeof duration === "number") return Math.max(1, duration);
|
|
221
|
+
const scaled = interpDuration * AUTO_SCALE;
|
|
222
|
+
return Math.max(AUTO_MIN_MS, Math.min(AUTO_MAX_MS, Math.max(1, scaled)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/graph/canvas-renderer.ts
|
|
226
|
+
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from "@opendata-ai/openchart-core";
|
|
227
|
+
|
|
228
|
+
// src/graph/entrance.ts
|
|
229
|
+
function nodeEnterProgress(globalT, index, total, buckets = 8) {
|
|
230
|
+
const g = globalT <= 0 ? 0 : globalT >= 1 ? 1 : globalT;
|
|
231
|
+
const start = total > 0 ? index / total * 0.4 : 0;
|
|
232
|
+
const local = (g - start) / 0.6;
|
|
233
|
+
const clamped = local <= 0 ? 0 : local >= 1 ? 1 : local;
|
|
234
|
+
const b = Math.max(1, Math.floor(buckets));
|
|
235
|
+
return Math.round(clamped * b) / b;
|
|
236
|
+
}
|
|
237
|
+
var ENTRANCE_STAGGER_MAX_NODES = 3e3;
|
|
238
|
+
var ENTRANCE_DRIFT_PX = 16;
|
|
239
|
+
function centroid(nodes) {
|
|
240
|
+
let cx = 0;
|
|
241
|
+
let cy = 0;
|
|
242
|
+
for (const n of nodes) {
|
|
243
|
+
cx += n.x;
|
|
244
|
+
cy += n.y;
|
|
245
|
+
}
|
|
246
|
+
cx /= nodes.length;
|
|
247
|
+
cy /= nodes.length;
|
|
248
|
+
return { cx, cy };
|
|
249
|
+
}
|
|
250
|
+
function hashId(id) {
|
|
251
|
+
let h = 2166136261;
|
|
252
|
+
for (let i = 0; i < id.length; i++) {
|
|
253
|
+
h ^= id.charCodeAt(i);
|
|
254
|
+
h = Math.imul(h, 16777619);
|
|
255
|
+
}
|
|
256
|
+
return h >>> 0;
|
|
257
|
+
}
|
|
258
|
+
function entranceOrder(nodes) {
|
|
259
|
+
const rank = /* @__PURE__ */ new Map();
|
|
260
|
+
if (nodes.length === 0) return rank;
|
|
261
|
+
const sorted = [...nodes].sort((a, b) => {
|
|
262
|
+
const ha = hashId(a.id);
|
|
263
|
+
const hb = hashId(b.id);
|
|
264
|
+
return ha - hb || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
265
|
+
});
|
|
266
|
+
for (let i = 0; i < sorted.length; i++) rank.set(sorted[i].id, i);
|
|
267
|
+
return rank;
|
|
268
|
+
}
|
|
269
|
+
function entranceOffsets(nodes, dist = ENTRANCE_DRIFT_PX) {
|
|
270
|
+
const offsets = /* @__PURE__ */ new Map();
|
|
271
|
+
if (nodes.length === 0) return offsets;
|
|
272
|
+
const { cx, cy } = centroid(nodes);
|
|
273
|
+
for (const n of nodes) {
|
|
274
|
+
const dx = n.x - cx;
|
|
275
|
+
const dy = n.y - cy;
|
|
276
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
277
|
+
if (len < 1e-6) {
|
|
278
|
+
offsets.set(n.id, { x: 0, y: -dist });
|
|
279
|
+
} else {
|
|
280
|
+
offsets.set(n.id, { x: dx / len * dist, y: dy / len * dist });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return offsets;
|
|
284
|
+
}
|
|
285
|
+
function popScale(t) {
|
|
286
|
+
if (t <= 0) return 0;
|
|
287
|
+
if (t >= 1) return 1;
|
|
288
|
+
const c1 = 1.70158;
|
|
289
|
+
const c3 = c1 + 1;
|
|
290
|
+
const u = t - 1;
|
|
291
|
+
return 1 + c3 * u * u * u + c1 * u * u;
|
|
292
|
+
}
|
|
293
|
+
function popAlpha(t) {
|
|
294
|
+
const a = t / 0.6;
|
|
295
|
+
return a >= 1 ? 1 : a <= 0 ? 0 : a;
|
|
296
|
+
}
|
|
297
|
+
function driftFactor(t) {
|
|
298
|
+
const u = 1 - (t <= 0 ? 0 : t >= 1 ? 1 : t);
|
|
299
|
+
return u * u;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/graph/canvas-renderer.ts
|
|
303
|
+
var LABEL_FONT_MIN = 9;
|
|
304
|
+
var LABEL_FONT_MAX = 12;
|
|
305
|
+
var EDGE_ALPHA_DEFAULT_LIGHT = 0.3;
|
|
306
|
+
var EDGE_ALPHA_DEFAULT_DARK = 0.25;
|
|
307
|
+
var EDGE_ALPHA_CONNECTED = 1;
|
|
308
|
+
var SEARCH_NON_MATCH_ALPHA = 0.25;
|
|
309
|
+
var DEFAULT_DIM_OPACITY = 0.3;
|
|
310
|
+
var LABEL_BUDGET_MIN = 12;
|
|
311
|
+
var LABEL_BUDGET_MAX = 80;
|
|
312
|
+
var LABEL_BUDGET_PER_ZOOM = 30;
|
|
313
|
+
var CROSSFADE_MAX_EDGES = 2e4;
|
|
314
|
+
function edgeTier(edge, focus) {
|
|
315
|
+
if (!focus.hasActive) return "default";
|
|
316
|
+
return focus.connected.has(edge.source) && focus.connected.has(edge.target) ? "connected" : "dimmed";
|
|
317
|
+
}
|
|
318
|
+
function nodeTier(node, focus, exemptIds) {
|
|
319
|
+
if (!focus.hasActive) return "default";
|
|
320
|
+
if (exemptIds?.has(node.id)) return "connected";
|
|
321
|
+
return focus.connected.has(node.id) ? "connected" : "dimmed";
|
|
322
|
+
}
|
|
323
|
+
function edgeTierAlpha(tier, dimOpacity, defaultAlpha) {
|
|
324
|
+
switch (tier) {
|
|
325
|
+
case "connected":
|
|
326
|
+
return EDGE_ALPHA_CONNECTED;
|
|
327
|
+
case "dimmed":
|
|
328
|
+
return dimOpacity / 3;
|
|
329
|
+
default:
|
|
330
|
+
return defaultAlpha;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function nodeTierAlpha(tier, dimOpacity) {
|
|
334
|
+
switch (tier) {
|
|
335
|
+
case "dimmed":
|
|
336
|
+
return dimOpacity;
|
|
337
|
+
default:
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function lerp(a, b, t) {
|
|
342
|
+
return a + (b - a) * t;
|
|
343
|
+
}
|
|
344
|
+
var ZERO_SHIFT = { x: 0, y: 0 };
|
|
345
|
+
function makeEntranceReveal(entrance, total) {
|
|
346
|
+
const g = entrance.t;
|
|
347
|
+
const { stagger, order, offsets } = entrance;
|
|
348
|
+
const rankOf = (node) => order?.get(node.id) ?? node.index;
|
|
349
|
+
const nodeT = (node) => stagger ? nodeEnterProgress(g, rankOf(node), total) : g;
|
|
350
|
+
const edgeAlpha = Math.max(0, (g - 0.3) / 0.7);
|
|
351
|
+
const globalRamp = 0.6 + 0.4 * g;
|
|
352
|
+
return {
|
|
353
|
+
nodeAlpha: (node) => stagger ? popAlpha(nodeT(node)) : globalRamp,
|
|
354
|
+
nodeScale: (node) => stagger ? popScale(nodeT(node)) : globalRamp,
|
|
355
|
+
shift: (id) => {
|
|
356
|
+
if (!stagger || !offsets || !order) return ZERO_SHIFT;
|
|
357
|
+
const off = offsets.get(id);
|
|
358
|
+
if (!off) return ZERO_SHIFT;
|
|
359
|
+
const f = driftFactor(nodeEnterProgress(g, order.get(id) ?? 0, total));
|
|
360
|
+
return f > 0 ? { x: off.x * f, y: off.y * f } : ZERO_SHIFT;
|
|
361
|
+
},
|
|
362
|
+
edgeAlpha,
|
|
363
|
+
labelAlpha: g
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function deriveFocus(hoveredNodeId, selectedNodeIds, adjacencyMap) {
|
|
367
|
+
const hasActive = hoveredNodeId !== null || selectedNodeIds.size > 0;
|
|
368
|
+
const connected = /* @__PURE__ */ new Set();
|
|
369
|
+
if (hasActive) {
|
|
370
|
+
const active = /* @__PURE__ */ new Set();
|
|
371
|
+
if (hoveredNodeId) active.add(hoveredNodeId);
|
|
372
|
+
for (const id of selectedNodeIds) active.add(id);
|
|
373
|
+
for (const id of active) {
|
|
374
|
+
connected.add(id);
|
|
375
|
+
const neighbors = adjacencyMap.get(id);
|
|
376
|
+
if (neighbors) for (const nid of neighbors) connected.add(nid);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { hasActive, connected, searchMatches: null, selected: selectedNodeIds };
|
|
380
|
+
}
|
|
381
|
+
var GLOW_NODE_THRESHOLD = 2e3;
|
|
382
|
+
var GLOW_RADIUS_MULTIPLIER = 1.3;
|
|
383
|
+
var GLOW_ALPHA = 0.1;
|
|
384
|
+
var CULL_MARGIN = 50;
|
|
385
|
+
var TWO_PI = Math.PI * 2;
|
|
386
|
+
var MIN_SCREEN_RADIUS = 2.5;
|
|
387
|
+
function labelBudget(zoom) {
|
|
388
|
+
const raw = Math.round(zoom * LABEL_BUDGET_PER_ZOOM);
|
|
389
|
+
return Math.max(LABEL_BUDGET_MIN, Math.min(LABEL_BUDGET_MAX, raw));
|
|
390
|
+
}
|
|
391
|
+
function boxesOverlap(a, b) {
|
|
392
|
+
return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
|
393
|
+
}
|
|
394
|
+
function visibleRect(canvasWidth, canvasHeight, transform, margin = CULL_MARGIN) {
|
|
395
|
+
const { x, y, k } = transform;
|
|
396
|
+
return {
|
|
397
|
+
minX: (-x - margin) / k,
|
|
398
|
+
minY: (-y - margin) / k,
|
|
399
|
+
maxX: (canvasWidth - x + margin) / k,
|
|
400
|
+
maxY: (canvasHeight - y + margin) / k
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function nodeInView(node, rect) {
|
|
404
|
+
return node.x + node.radius >= rect.minX && node.x - node.radius <= rect.maxX && node.y + node.radius >= rect.minY && node.y - node.radius <= rect.maxY;
|
|
405
|
+
}
|
|
406
|
+
function edgeInView(edge, rect) {
|
|
407
|
+
return edge.sourceX >= rect.minX && edge.sourceX <= rect.maxX && edge.sourceY >= rect.minY && edge.sourceY <= rect.maxY || edge.targetX >= rect.minX && edge.targetX <= rect.maxX && edge.targetY >= rect.minY && edge.targetY <= rect.maxY;
|
|
408
|
+
}
|
|
409
|
+
var DASH_PATTERNS = {
|
|
410
|
+
solid: [],
|
|
411
|
+
dashed: [6, 4],
|
|
412
|
+
dotted: [2, 3]
|
|
413
|
+
};
|
|
414
|
+
var GraphCanvasRenderer = class {
|
|
415
|
+
canvas;
|
|
416
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
|
|
417
|
+
ctx;
|
|
418
|
+
dpr;
|
|
419
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
|
|
420
|
+
cssWidth = 0;
|
|
421
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
|
|
422
|
+
cssHeight = 0;
|
|
423
|
+
constructor(canvas) {
|
|
424
|
+
this.canvas = canvas;
|
|
425
|
+
this.ctx = canvas.getContext("2d");
|
|
426
|
+
this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
427
|
+
}
|
|
428
|
+
/** Update canvas dimensions with DPR scaling. CSS size stays at css values. */
|
|
429
|
+
resize(width, height) {
|
|
430
|
+
this.cssWidth = width;
|
|
431
|
+
this.cssHeight = height;
|
|
432
|
+
this.canvas.width = width * this.dpr;
|
|
433
|
+
this.canvas.height = height * this.dpr;
|
|
434
|
+
}
|
|
435
|
+
/** Clear canvas and render the full graph state. */
|
|
436
|
+
render(state) {
|
|
437
|
+
const { ctx, dpr, cssWidth, cssHeight } = this;
|
|
438
|
+
const {
|
|
439
|
+
nodes,
|
|
440
|
+
edges,
|
|
441
|
+
transform,
|
|
442
|
+
hoveredNodeId,
|
|
443
|
+
hoveredEdgeId,
|
|
444
|
+
selectedNodeIds,
|
|
445
|
+
adjacencyMap,
|
|
446
|
+
theme,
|
|
447
|
+
searchMatches,
|
|
448
|
+
isGesturing
|
|
449
|
+
} = state;
|
|
450
|
+
const dimOpacity = state.dimOpacity ?? DEFAULT_DIM_OPACITY;
|
|
451
|
+
const nextFocus = state.focus?.next ?? deriveFocus(hoveredNodeId, selectedNodeIds, adjacencyMap);
|
|
452
|
+
const crossfade = state.focus && state.focus.t < 1 && !isGesturing ? state.focus : null;
|
|
453
|
+
const entrance = state.entrance && state.entrance.t < 1 ? makeEntranceReveal(state.entrance, nodes.length) : null;
|
|
454
|
+
const enterAlpha = state.enterAlpha ?? null;
|
|
455
|
+
const enterAlphaFor = (id) => enterAlpha?.get(id) ?? 1;
|
|
456
|
+
const rect = visibleRect(cssWidth, cssHeight, transform);
|
|
457
|
+
const visibleNodes = nodes.filter((n) => nodeInView(n, rect));
|
|
458
|
+
const visibleEdges = edges.filter((e) => edgeInView(e, rect));
|
|
459
|
+
const isDark = theme.isDark;
|
|
460
|
+
const edgeAlphaDefault = isDark ? EDGE_ALPHA_DEFAULT_DARK : EDGE_ALPHA_DEFAULT_LIGHT;
|
|
461
|
+
const showGlow = isDark && !isGesturing && visibleNodes.length < GLOW_NODE_THRESHOLD;
|
|
462
|
+
const minRadius = MIN_SCREEN_RADIUS / transform.k;
|
|
463
|
+
ctx.save();
|
|
464
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
465
|
+
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
466
|
+
if (theme.colors.background !== "transparent") {
|
|
467
|
+
ctx.fillStyle = theme.colors.background;
|
|
468
|
+
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
469
|
+
}
|
|
470
|
+
ctx.translate(transform.x, transform.y);
|
|
471
|
+
ctx.scale(transform.k, transform.k);
|
|
472
|
+
if (state.exiting && state.exiting.alpha > 0) {
|
|
473
|
+
this.drawGhosts(ctx, state.exiting, rect, edgeAlphaDefault);
|
|
474
|
+
}
|
|
475
|
+
if (crossfade && visibleEdges.length <= CROSSFADE_MAX_EDGES) {
|
|
476
|
+
this.drawEdgesCrossfade(
|
|
477
|
+
ctx,
|
|
478
|
+
visibleEdges,
|
|
479
|
+
crossfade.prev,
|
|
480
|
+
crossfade.next,
|
|
481
|
+
crossfade.t,
|
|
482
|
+
dimOpacity,
|
|
483
|
+
edgeAlphaDefault,
|
|
484
|
+
isGesturing ? null : searchMatches,
|
|
485
|
+
hoveredEdgeId,
|
|
486
|
+
entrance,
|
|
487
|
+
enterAlphaFor
|
|
488
|
+
);
|
|
489
|
+
} else {
|
|
490
|
+
this.drawEdgesBatched(
|
|
491
|
+
ctx,
|
|
492
|
+
visibleEdges,
|
|
493
|
+
nextFocus,
|
|
494
|
+
dimOpacity,
|
|
495
|
+
edgeAlphaDefault,
|
|
496
|
+
isGesturing ? null : searchMatches,
|
|
497
|
+
hoveredEdgeId,
|
|
498
|
+
entrance,
|
|
499
|
+
enterAlphaFor
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
this.drawNodesBatched(
|
|
503
|
+
ctx,
|
|
504
|
+
visibleNodes,
|
|
505
|
+
hoveredNodeId,
|
|
506
|
+
selectedNodeIds,
|
|
507
|
+
isGesturing ? null : searchMatches,
|
|
508
|
+
showGlow,
|
|
509
|
+
theme,
|
|
510
|
+
minRadius,
|
|
511
|
+
nextFocus,
|
|
512
|
+
dimOpacity,
|
|
513
|
+
state.exemptIds,
|
|
514
|
+
crossfade,
|
|
515
|
+
state.hoverRadiusScale,
|
|
516
|
+
entrance,
|
|
517
|
+
enterAlphaFor
|
|
518
|
+
);
|
|
519
|
+
if (!isGesturing) {
|
|
520
|
+
this.drawLabels(
|
|
521
|
+
ctx,
|
|
522
|
+
visibleNodes,
|
|
523
|
+
hoveredNodeId,
|
|
524
|
+
selectedNodeIds,
|
|
525
|
+
searchMatches,
|
|
526
|
+
transform.k,
|
|
527
|
+
theme,
|
|
528
|
+
entrance,
|
|
529
|
+
enterAlphaFor
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
ctx.restore();
|
|
533
|
+
if (state.watermark) {
|
|
534
|
+
this.drawBrand(ctx, cssWidth, cssHeight, theme);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// -------------------------------------------------------------------------
|
|
538
|
+
// Brand rendering
|
|
539
|
+
// -------------------------------------------------------------------------
|
|
540
|
+
drawBrand(ctx, w, h, theme) {
|
|
541
|
+
if (w < BRAND_MIN_WIDTH) return;
|
|
542
|
+
const { dpr } = this;
|
|
543
|
+
ctx.save();
|
|
544
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
545
|
+
const padding = theme.spacing.padding;
|
|
546
|
+
const x = w - padding;
|
|
547
|
+
const y = h - padding;
|
|
548
|
+
ctx.font = `600 ${BRAND_FONT_SIZE}px ${theme.fonts.family}`;
|
|
549
|
+
ctx.fillStyle = theme.colors.axis;
|
|
550
|
+
ctx.globalAlpha = 0.55;
|
|
551
|
+
ctx.textAlign = "right";
|
|
552
|
+
ctx.textBaseline = "alphabetic";
|
|
553
|
+
ctx.fillText("OpenData", x, y);
|
|
554
|
+
ctx.restore();
|
|
555
|
+
}
|
|
556
|
+
// -------------------------------------------------------------------------
|
|
557
|
+
// Batched edge drawing
|
|
558
|
+
// -------------------------------------------------------------------------
|
|
559
|
+
drawEdgesBatched(ctx, edges, focus, dimOpacity, edgeAlphaDefault, searchMatches, hoveredEdgeId, entrance, enterAlphaFor) {
|
|
560
|
+
const buckets = {
|
|
561
|
+
dimmed: [],
|
|
562
|
+
default: [],
|
|
563
|
+
connected: []
|
|
564
|
+
};
|
|
565
|
+
let hoveredEdge = null;
|
|
566
|
+
for (const edge of edges) {
|
|
567
|
+
const edgeId = `${edge.source}->${edge.target}`;
|
|
568
|
+
if (edgeId === hoveredEdgeId) {
|
|
569
|
+
hoveredEdge = edge;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
buckets[edgeTier(edge, focus)].push(edge);
|
|
573
|
+
}
|
|
574
|
+
const ea = entrance ? entrance.edgeAlpha : 1;
|
|
575
|
+
this.drawEdgeGroupBatched(
|
|
576
|
+
ctx,
|
|
577
|
+
buckets.dimmed,
|
|
578
|
+
edgeTierAlpha("dimmed", dimOpacity, edgeAlphaDefault) * ea,
|
|
579
|
+
searchMatches,
|
|
580
|
+
enterAlphaFor
|
|
581
|
+
);
|
|
582
|
+
this.drawEdgeGroupBatched(
|
|
583
|
+
ctx,
|
|
584
|
+
buckets.default,
|
|
585
|
+
edgeAlphaDefault * ea,
|
|
586
|
+
searchMatches,
|
|
587
|
+
enterAlphaFor
|
|
588
|
+
);
|
|
589
|
+
this.drawEdgeGroupBatched(
|
|
590
|
+
ctx,
|
|
591
|
+
buckets.connected,
|
|
592
|
+
EDGE_ALPHA_CONNECTED * ea,
|
|
593
|
+
searchMatches,
|
|
594
|
+
enterAlphaFor
|
|
595
|
+
);
|
|
596
|
+
this.drawHoveredEdge(ctx, hoveredEdge);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Crossfade edges between a prev and next focus state. Each edge is classified
|
|
600
|
+
* under BOTH snapshots → at most 9 (prevTier × nextTier) buckets, each drawn
|
|
601
|
+
* batched at `lerp(edgeTierAlpha[prev], edgeTierAlpha[next], t)`. Preserves the
|
|
602
|
+
* per-group style batching within each bucket.
|
|
603
|
+
*/
|
|
604
|
+
drawEdgesCrossfade(ctx, edges, prev, next, t, dimOpacity, edgeAlphaDefault, searchMatches, hoveredEdgeId, entrance, enterAlphaFor) {
|
|
605
|
+
const ea = entrance ? entrance.edgeAlpha : 1;
|
|
606
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
607
|
+
let hoveredEdge = null;
|
|
608
|
+
for (const edge of edges) {
|
|
609
|
+
const edgeId = `${edge.source}->${edge.target}`;
|
|
610
|
+
if (edgeId === hoveredEdgeId) {
|
|
611
|
+
hoveredEdge = edge;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const key = `${edgeTier(edge, prev)}|${edgeTier(edge, next)}`;
|
|
615
|
+
let bucket = buckets.get(key);
|
|
616
|
+
if (!bucket) {
|
|
617
|
+
bucket = [];
|
|
618
|
+
buckets.set(key, bucket);
|
|
619
|
+
}
|
|
620
|
+
bucket.push(edge);
|
|
621
|
+
}
|
|
622
|
+
const ordered = [...buckets.entries()].map(([key, bucket]) => {
|
|
623
|
+
const [prevTier, nextTier] = key.split("|");
|
|
624
|
+
const alpha = lerp(
|
|
625
|
+
edgeTierAlpha(prevTier, dimOpacity, edgeAlphaDefault),
|
|
626
|
+
edgeTierAlpha(nextTier, dimOpacity, edgeAlphaDefault),
|
|
627
|
+
t
|
|
628
|
+
) * ea;
|
|
629
|
+
return { alpha, bucket };
|
|
630
|
+
}).sort((a, b) => a.alpha - b.alpha);
|
|
631
|
+
for (const { alpha, bucket } of ordered) {
|
|
632
|
+
this.drawEdgeGroupBatched(ctx, bucket, alpha, searchMatches, enterAlphaFor);
|
|
633
|
+
}
|
|
634
|
+
this.drawHoveredEdge(ctx, hoveredEdge);
|
|
635
|
+
}
|
|
636
|
+
/** Draw the hovered edge on top with a thickened highlight stroke. */
|
|
637
|
+
drawHoveredEdge(ctx, hoveredEdge) {
|
|
638
|
+
if (!hoveredEdge) return;
|
|
639
|
+
const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
|
|
640
|
+
ctx.setLineDash(dash);
|
|
641
|
+
ctx.strokeStyle = hoveredEdge.stroke;
|
|
642
|
+
ctx.lineWidth = hoveredEdge.strokeWidth * 2;
|
|
643
|
+
ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
|
|
644
|
+
ctx.beginPath();
|
|
645
|
+
ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
|
|
646
|
+
ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
|
|
647
|
+
ctx.stroke();
|
|
648
|
+
ctx.setLineDash([]);
|
|
649
|
+
ctx.globalAlpha = 1;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
|
|
653
|
+
* When search is inactive, all edges of the same style are drawn in a single path.
|
|
654
|
+
* When search is active, edges split by search-match status for alpha dimming.
|
|
655
|
+
*/
|
|
656
|
+
drawEdgeGroupBatched(ctx, edges, alpha, searchMatches, enterAlphaFor) {
|
|
657
|
+
if (edges.length === 0) return;
|
|
658
|
+
const groups = /* @__PURE__ */ new Map();
|
|
659
|
+
for (const edge of edges) {
|
|
660
|
+
const rawEnter = Math.min(enterAlphaFor(edge.source), enterAlphaFor(edge.target));
|
|
661
|
+
const enter = Math.round(rawEnter * 8) / 8;
|
|
662
|
+
const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}|${enter}`;
|
|
663
|
+
let group = groups.get(key);
|
|
664
|
+
if (!group) {
|
|
665
|
+
group = { edges: [], enter };
|
|
666
|
+
groups.set(key, group);
|
|
667
|
+
}
|
|
668
|
+
group.edges.push(edge);
|
|
669
|
+
}
|
|
670
|
+
for (const [, { edges: group, enter }] of groups) {
|
|
671
|
+
const sample = group[0];
|
|
672
|
+
const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
|
|
673
|
+
ctx.setLineDash(dash);
|
|
674
|
+
ctx.strokeStyle = sample.stroke;
|
|
675
|
+
ctx.lineWidth = sample.strokeWidth;
|
|
676
|
+
const groupAlpha = alpha * enter;
|
|
677
|
+
if (!searchMatches) {
|
|
678
|
+
ctx.globalAlpha = groupAlpha;
|
|
679
|
+
ctx.beginPath();
|
|
680
|
+
for (const edge of group) {
|
|
681
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
682
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
683
|
+
}
|
|
684
|
+
ctx.stroke();
|
|
685
|
+
} else {
|
|
686
|
+
ctx.globalAlpha = groupAlpha;
|
|
687
|
+
ctx.beginPath();
|
|
688
|
+
let hasMatched = false;
|
|
689
|
+
const nonMatchPath = [];
|
|
690
|
+
for (const edge of group) {
|
|
691
|
+
const srcMatch = searchMatches.has(edge.source);
|
|
692
|
+
const tgtMatch = searchMatches.has(edge.target);
|
|
693
|
+
if (srcMatch || tgtMatch) {
|
|
694
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
695
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
696
|
+
hasMatched = true;
|
|
697
|
+
} else {
|
|
698
|
+
nonMatchPath.push(edge);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (hasMatched) ctx.stroke();
|
|
702
|
+
if (nonMatchPath.length > 0) {
|
|
703
|
+
ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * groupAlpha;
|
|
704
|
+
ctx.beginPath();
|
|
705
|
+
for (const edge of nonMatchPath) {
|
|
706
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
707
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
708
|
+
}
|
|
709
|
+
ctx.stroke();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
ctx.setLineDash([]);
|
|
714
|
+
ctx.globalAlpha = 1;
|
|
715
|
+
}
|
|
716
|
+
// -------------------------------------------------------------------------
|
|
717
|
+
// Batched node drawing
|
|
718
|
+
// -------------------------------------------------------------------------
|
|
719
|
+
drawNodesBatched(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, showGlow, theme, minRadius, nextFocus, dimOpacity, exemptIds, crossfade, hoverRadiusScale, entrance, enterAlphaFor) {
|
|
720
|
+
const focusAlpha = (node) => {
|
|
721
|
+
if (crossfade) {
|
|
722
|
+
return lerp(
|
|
723
|
+
nodeTierAlpha(nodeTier(node, crossfade.prev, exemptIds), dimOpacity),
|
|
724
|
+
nodeTierAlpha(nodeTier(node, crossfade.next, exemptIds), dimOpacity),
|
|
725
|
+
crossfade.t
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
return nodeTierAlpha(nodeTier(node, nextFocus, exemptIds), dimOpacity);
|
|
729
|
+
};
|
|
730
|
+
const searchAlpha = (node) => searchMatches !== null && !searchMatches.has(node.id) ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
731
|
+
const entranceAlpha = (node) => entrance ? entrance.nodeAlpha(node) : 1;
|
|
732
|
+
const entranceScale = (node) => entrance ? entrance.nodeScale(node) : 1;
|
|
733
|
+
const entranceShift = (node) => entrance ? entrance.shift(node.id) : ZERO_SHIFT;
|
|
734
|
+
const effectiveAlpha = (node) => focusAlpha(node) * searchAlpha(node) * entranceAlpha(node) * enterAlphaFor(node.id);
|
|
735
|
+
const bulkNodes = [];
|
|
736
|
+
const specialNodes = [];
|
|
737
|
+
for (const node of nodes) {
|
|
738
|
+
if (node.id === hoveredNodeId || selectedNodeIds.has(node.id) || (hoverRadiusScale?.has(node.id) ?? false)) {
|
|
739
|
+
specialNodes.push(node);
|
|
740
|
+
} else {
|
|
741
|
+
bulkNodes.push(node);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const r = (node) => Math.max(node.radius, minRadius) * entranceScale(node);
|
|
745
|
+
if (showGlow && !entrance) {
|
|
746
|
+
this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
|
|
747
|
+
}
|
|
748
|
+
const fillGroups = /* @__PURE__ */ new Map();
|
|
749
|
+
for (const node of bulkNodes) {
|
|
750
|
+
const alpha = effectiveAlpha(node);
|
|
751
|
+
const key = `${node.fill}|${alpha.toFixed(3)}`;
|
|
752
|
+
let group = fillGroups.get(key);
|
|
753
|
+
if (!group) {
|
|
754
|
+
group = { fill: node.fill, alpha, nodes: [] };
|
|
755
|
+
fillGroups.set(key, group);
|
|
756
|
+
}
|
|
757
|
+
group.nodes.push(node);
|
|
758
|
+
}
|
|
759
|
+
for (const { fill, alpha, nodes: group } of fillGroups.values()) {
|
|
760
|
+
ctx.fillStyle = fill;
|
|
761
|
+
ctx.globalAlpha = alpha;
|
|
762
|
+
ctx.beginPath();
|
|
763
|
+
for (const node of group) {
|
|
764
|
+
const nr = r(node);
|
|
765
|
+
if (nr <= 0) continue;
|
|
766
|
+
const s = entranceShift(node);
|
|
767
|
+
ctx.moveTo(node.x + s.x + nr, node.y + s.y);
|
|
768
|
+
ctx.arc(node.x + s.x, node.y + s.y, nr, 0, TWO_PI);
|
|
769
|
+
}
|
|
770
|
+
ctx.fill();
|
|
771
|
+
}
|
|
772
|
+
const strokeGroups = /* @__PURE__ */ new Map();
|
|
773
|
+
for (const node of bulkNodes) {
|
|
774
|
+
const alpha = effectiveAlpha(node);
|
|
775
|
+
const key = `${node.stroke}|${node.strokeWidth}|${alpha.toFixed(3)}`;
|
|
776
|
+
let group = strokeGroups.get(key);
|
|
777
|
+
if (!group) {
|
|
778
|
+
group = { stroke: node.stroke, width: node.strokeWidth, alpha, nodes: [] };
|
|
779
|
+
strokeGroups.set(key, group);
|
|
780
|
+
}
|
|
781
|
+
group.nodes.push(node);
|
|
782
|
+
}
|
|
783
|
+
for (const { stroke, width, alpha, nodes: group } of strokeGroups.values()) {
|
|
784
|
+
ctx.strokeStyle = stroke;
|
|
785
|
+
ctx.lineWidth = width;
|
|
786
|
+
ctx.globalAlpha = alpha;
|
|
787
|
+
ctx.beginPath();
|
|
788
|
+
for (const node of group) {
|
|
789
|
+
const nr = r(node);
|
|
790
|
+
if (nr <= 0) continue;
|
|
791
|
+
const s = entranceShift(node);
|
|
792
|
+
ctx.moveTo(node.x + s.x + nr, node.y + s.y);
|
|
793
|
+
ctx.arc(node.x + s.x, node.y + s.y, nr, 0, TWO_PI);
|
|
794
|
+
}
|
|
795
|
+
ctx.stroke();
|
|
796
|
+
}
|
|
797
|
+
for (const node of specialNodes) {
|
|
798
|
+
const isHovered = node.id === hoveredNodeId;
|
|
799
|
+
const isSelected = selectedNodeIds.has(node.id);
|
|
800
|
+
const dimmed = searchMatches !== null && !searchMatches.has(node.id);
|
|
801
|
+
const baseRadius = Math.max(node.radius, minRadius);
|
|
802
|
+
const hoverScale = hoverRadiusScale?.get(node.id) ?? (isHovered ? 1.15 : 1);
|
|
803
|
+
const radius = baseRadius * hoverScale;
|
|
804
|
+
const brightened = isHovered && hoverScale >= 1.075;
|
|
805
|
+
const s = entranceShift(node);
|
|
806
|
+
const nx = node.x + s.x;
|
|
807
|
+
const ny = node.y + s.y;
|
|
808
|
+
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
809
|
+
if (showGlow && !dimmed) {
|
|
810
|
+
ctx.beginPath();
|
|
811
|
+
ctx.arc(nx, ny, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
|
|
812
|
+
ctx.fillStyle = node.fill;
|
|
813
|
+
ctx.globalAlpha = GLOW_ALPHA;
|
|
814
|
+
ctx.fill();
|
|
815
|
+
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
|
|
816
|
+
}
|
|
817
|
+
ctx.beginPath();
|
|
818
|
+
ctx.arc(nx, ny, radius, 0, TWO_PI);
|
|
819
|
+
ctx.fillStyle = brightened ? brighten(node.fill) : node.fill;
|
|
820
|
+
ctx.fill();
|
|
821
|
+
ctx.strokeStyle = node.stroke;
|
|
822
|
+
ctx.lineWidth = node.strokeWidth;
|
|
823
|
+
ctx.stroke();
|
|
824
|
+
if (isSelected) {
|
|
825
|
+
ctx.beginPath();
|
|
826
|
+
ctx.arc(nx, ny, radius + 3, 0, TWO_PI);
|
|
827
|
+
ctx.strokeStyle = theme.colors.categorical[0] ?? "#3b82f6";
|
|
828
|
+
ctx.lineWidth = 2;
|
|
829
|
+
ctx.stroke();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
ctx.globalAlpha = 1;
|
|
833
|
+
}
|
|
834
|
+
/** Batch glow circles by fill color. */
|
|
835
|
+
drawGlowBatched(ctx, nodes, searchMatches, minRadius) {
|
|
836
|
+
const glowGroups = /* @__PURE__ */ new Map();
|
|
837
|
+
for (const node of nodes) {
|
|
838
|
+
if (searchMatches && !searchMatches.has(node.id)) continue;
|
|
839
|
+
let group = glowGroups.get(node.fill);
|
|
840
|
+
if (!group) {
|
|
841
|
+
group = [];
|
|
842
|
+
glowGroups.set(node.fill, group);
|
|
843
|
+
}
|
|
844
|
+
group.push(node);
|
|
845
|
+
}
|
|
846
|
+
ctx.globalAlpha = GLOW_ALPHA;
|
|
847
|
+
for (const [fill, group] of glowGroups) {
|
|
848
|
+
ctx.fillStyle = fill;
|
|
849
|
+
ctx.beginPath();
|
|
850
|
+
for (const node of group) {
|
|
851
|
+
const gr = Math.max(node.radius, minRadius) * GLOW_RADIUS_MULTIPLIER;
|
|
852
|
+
ctx.moveTo(node.x + gr, node.y);
|
|
853
|
+
ctx.arc(node.x, node.y, gr, 0, TWO_PI);
|
|
854
|
+
}
|
|
855
|
+
ctx.fill();
|
|
856
|
+
}
|
|
857
|
+
ctx.globalAlpha = 1;
|
|
858
|
+
}
|
|
859
|
+
// -------------------------------------------------------------------------
|
|
860
|
+
// Labels (drawn individually, skipped during gestures)
|
|
861
|
+
// -------------------------------------------------------------------------
|
|
862
|
+
drawLabels(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, zoom, theme, entrance, enterAlphaFor) {
|
|
863
|
+
const la = entrance ? entrance.labelAlpha : 1;
|
|
864
|
+
const rawSize = 10 / zoom;
|
|
865
|
+
const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
|
|
866
|
+
ctx.font = `${fontSize}px ${theme.fonts.family}`;
|
|
867
|
+
ctx.textAlign = "center";
|
|
868
|
+
ctx.textBaseline = "top";
|
|
869
|
+
const haloColor = theme.colors.background !== "transparent" ? theme.colors.background : theme.isDark ? "rgba(0, 0, 0, 0.7)" : "rgba(255, 255, 255, 0.85)";
|
|
870
|
+
const forced = [];
|
|
871
|
+
const rest = [];
|
|
872
|
+
for (const node of nodes) {
|
|
873
|
+
if (!node.label) continue;
|
|
874
|
+
const isForced = node.id === hoveredNodeId || selectedNodeIds.has(node.id) || node.labelPriority === Infinity || (searchMatches?.has(node.id) ?? false);
|
|
875
|
+
if (isForced) forced.push(node);
|
|
876
|
+
else rest.push(node);
|
|
877
|
+
}
|
|
878
|
+
rest.sort((a, b) => b.labelPriority - a.labelPriority);
|
|
879
|
+
const budget = labelBudget(zoom);
|
|
880
|
+
const candidates = rest.slice(0, budget * 4);
|
|
881
|
+
const placed = [];
|
|
882
|
+
const pad = fontSize * 0.15;
|
|
883
|
+
const lineHeight = fontSize * 1.2;
|
|
884
|
+
const textWidth = (text) => ctx.measureText?.(text)?.width ?? text.length * fontSize * 0.55;
|
|
885
|
+
const boxFor = (node) => {
|
|
886
|
+
const w = textWidth(node.label);
|
|
887
|
+
const y0 = node.y + node.radius + 3;
|
|
888
|
+
return {
|
|
889
|
+
x0: node.x - w / 2 - pad,
|
|
890
|
+
x1: node.x + w / 2 + pad,
|
|
891
|
+
y0: y0 - pad,
|
|
892
|
+
y1: y0 + lineHeight + pad
|
|
893
|
+
};
|
|
894
|
+
};
|
|
895
|
+
const drawOne = (node, isForced) => {
|
|
896
|
+
const dimmed = searchMatches !== null && !searchMatches.has(node.id);
|
|
897
|
+
ctx.globalAlpha = (dimmed ? SEARCH_NON_MATCH_ALPHA : 1) * la * enterAlphaFor(node.id);
|
|
898
|
+
const labelY = node.y + node.radius + 3;
|
|
899
|
+
if (isForced) {
|
|
900
|
+
ctx.strokeStyle = haloColor;
|
|
901
|
+
ctx.lineWidth = 3;
|
|
902
|
+
ctx.lineJoin = "round";
|
|
903
|
+
ctx.miterLimit = 2;
|
|
904
|
+
ctx.strokeText(node.label, node.x, labelY);
|
|
905
|
+
}
|
|
906
|
+
ctx.fillStyle = isForced ? theme.colors.text : theme.colors.axis;
|
|
907
|
+
ctx.fillText(node.label, node.x, labelY);
|
|
908
|
+
};
|
|
909
|
+
for (const node of forced) placed.push(boxFor(node));
|
|
910
|
+
let drawn = 0;
|
|
911
|
+
for (const node of candidates) {
|
|
912
|
+
if (drawn >= budget) break;
|
|
913
|
+
const box = boxFor(node);
|
|
914
|
+
let collides = false;
|
|
915
|
+
for (const other of placed) {
|
|
916
|
+
if (boxesOverlap(box, other)) {
|
|
917
|
+
collides = true;
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (collides) continue;
|
|
922
|
+
placed.push(box);
|
|
923
|
+
drawOne(node, false);
|
|
924
|
+
drawn++;
|
|
925
|
+
}
|
|
926
|
+
for (const node of forced) drawOne(node, true);
|
|
927
|
+
ctx.globalAlpha = 1;
|
|
928
|
+
}
|
|
929
|
+
// -------------------------------------------------------------------------
|
|
930
|
+
// Exit ghosts (Phase 7)
|
|
931
|
+
// -------------------------------------------------------------------------
|
|
932
|
+
/**
|
|
933
|
+
* Draw exit ghosts (removed nodes/edges) UNDER the live marks at a global fade
|
|
934
|
+
* alpha. Neutral rendering: no focus dim, no search dim, no selection ring —
|
|
935
|
+
* they're on their way out. Batched by (stroke/style) for edges and (fill) for
|
|
936
|
+
* nodes, culled to the visible rect.
|
|
937
|
+
*/
|
|
938
|
+
drawGhosts(ctx, exiting, rect, edgeAlphaDefault) {
|
|
939
|
+
const alpha = exiting.alpha;
|
|
940
|
+
const edgeGroups = /* @__PURE__ */ new Map();
|
|
941
|
+
for (const edge of exiting.edges) {
|
|
942
|
+
if (!edgeInView(edge, rect)) continue;
|
|
943
|
+
const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
|
|
944
|
+
const group = edgeGroups.get(key);
|
|
945
|
+
if (group) group.push(edge);
|
|
946
|
+
else edgeGroups.set(key, [edge]);
|
|
947
|
+
}
|
|
948
|
+
for (const [, group] of edgeGroups) {
|
|
949
|
+
const sample = group[0];
|
|
950
|
+
const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
|
|
951
|
+
ctx.setLineDash(dash);
|
|
952
|
+
ctx.strokeStyle = sample.stroke;
|
|
953
|
+
ctx.lineWidth = sample.strokeWidth;
|
|
954
|
+
ctx.globalAlpha = edgeAlphaDefault * alpha;
|
|
955
|
+
ctx.beginPath();
|
|
956
|
+
for (const edge of group) {
|
|
957
|
+
ctx.moveTo(edge.sourceX, edge.sourceY);
|
|
958
|
+
ctx.lineTo(edge.targetX, edge.targetY);
|
|
959
|
+
}
|
|
960
|
+
ctx.stroke();
|
|
961
|
+
}
|
|
962
|
+
ctx.setLineDash([]);
|
|
963
|
+
const nodeGroups = /* @__PURE__ */ new Map();
|
|
964
|
+
for (const node of exiting.nodes) {
|
|
965
|
+
if (!nodeInView(node, rect)) continue;
|
|
966
|
+
const group = nodeGroups.get(node.fill);
|
|
967
|
+
if (group) group.push(node);
|
|
968
|
+
else nodeGroups.set(node.fill, [node]);
|
|
969
|
+
}
|
|
970
|
+
for (const [fill, group] of nodeGroups) {
|
|
971
|
+
ctx.fillStyle = fill;
|
|
972
|
+
ctx.globalAlpha = alpha;
|
|
973
|
+
ctx.beginPath();
|
|
974
|
+
for (const node of group) {
|
|
975
|
+
ctx.moveTo(node.x + node.radius, node.y);
|
|
976
|
+
ctx.arc(node.x, node.y, node.radius, 0, TWO_PI);
|
|
977
|
+
}
|
|
978
|
+
ctx.fill();
|
|
979
|
+
}
|
|
980
|
+
ctx.globalAlpha = 1;
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
function brighten(color) {
|
|
984
|
+
const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
985
|
+
if (rgbMatch) {
|
|
986
|
+
const r = Math.min(255, parseInt(rgbMatch[1], 10) + 40);
|
|
987
|
+
const g = Math.min(255, parseInt(rgbMatch[2], 10) + 40);
|
|
988
|
+
const b = Math.min(255, parseInt(rgbMatch[3], 10) + 40);
|
|
989
|
+
return `rgb(${r},${g},${b})`;
|
|
990
|
+
}
|
|
991
|
+
const hex = color.replace("#", "");
|
|
992
|
+
const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
|
|
993
|
+
if (full.length === 6) {
|
|
994
|
+
const r = Math.min(255, parseInt(full.slice(0, 2), 16) + 40);
|
|
995
|
+
const g = Math.min(255, parseInt(full.slice(2, 4), 16) + 40);
|
|
996
|
+
const b = Math.min(255, parseInt(full.slice(4, 6), 16) + 40);
|
|
997
|
+
return `rgb(${r},${g},${b})`;
|
|
998
|
+
}
|
|
999
|
+
return color;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/graph/focus-transition.ts
|
|
1003
|
+
function focusSnapshotsEqual(a, b) {
|
|
1004
|
+
return a.hasActive === b.hasActive && setsEqual(a.connected, b.connected) && nullableSetsEqual(a.searchMatches, b.searchMatches) && setsEqual(a.selected, b.selected);
|
|
1005
|
+
}
|
|
1006
|
+
function setsEqual(a, b) {
|
|
1007
|
+
if (a.size !== b.size) return false;
|
|
1008
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
function nullableSetsEqual(a, b) {
|
|
1012
|
+
if (a === null || b === null) return a === b;
|
|
1013
|
+
return setsEqual(a, b);
|
|
1014
|
+
}
|
|
1015
|
+
var FocusTransition = class {
|
|
1016
|
+
prev;
|
|
1017
|
+
next;
|
|
1018
|
+
startTime;
|
|
1019
|
+
duration;
|
|
1020
|
+
ease;
|
|
1021
|
+
constructor(initial, duration, ease, now) {
|
|
1022
|
+
this.prev = initial;
|
|
1023
|
+
this.next = initial;
|
|
1024
|
+
this.startTime = now;
|
|
1025
|
+
this.duration = Math.max(0, duration);
|
|
1026
|
+
this.ease = ease;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Point a fresh transition at `target`, capturing the endpoint closest to the
|
|
1030
|
+
* current display as the new `prev` (the `p < 0.5` rule). A no-op when
|
|
1031
|
+
* `target` already equals `next`.
|
|
1032
|
+
*/
|
|
1033
|
+
retarget(target, now) {
|
|
1034
|
+
if (focusSnapshotsEqual(target, this.next)) return;
|
|
1035
|
+
const p = this.rawProgress(now);
|
|
1036
|
+
this.prev = p < 0.5 ? this.prev : this.next;
|
|
1037
|
+
this.next = target;
|
|
1038
|
+
this.startTime = now;
|
|
1039
|
+
}
|
|
1040
|
+
/** Raw (un-eased) 0..1 progress. */
|
|
1041
|
+
rawProgress(now) {
|
|
1042
|
+
if (this.duration <= 0) return 1;
|
|
1043
|
+
return Math.min(1, Math.max(0, (now - this.startTime) / this.duration));
|
|
1044
|
+
}
|
|
1045
|
+
/** Eased 0..1 progress toward `next`. */
|
|
1046
|
+
progress(now) {
|
|
1047
|
+
return this.ease(this.rawProgress(now));
|
|
1048
|
+
}
|
|
1049
|
+
/** True once the transition has fully settled onto `next`. */
|
|
1050
|
+
isSettled(now) {
|
|
1051
|
+
return this.rawProgress(now) >= 1;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
function composeStandingFocus(highlight, searchMatches, selected, adjacency) {
|
|
1055
|
+
const hasHighlight = highlight !== null && highlight.size > 0;
|
|
1056
|
+
const hasSearch = searchMatches !== null && searchMatches.size > 0;
|
|
1057
|
+
let core = null;
|
|
1058
|
+
if (highlight !== null && hasHighlight && searchMatches !== null && hasSearch) {
|
|
1059
|
+
const inter = intersect(highlight, searchMatches);
|
|
1060
|
+
core = inter.size > 0 ? inter : searchMatches;
|
|
1061
|
+
} else if (hasHighlight) {
|
|
1062
|
+
core = highlight;
|
|
1063
|
+
} else if (hasSearch) {
|
|
1064
|
+
core = null;
|
|
1065
|
+
}
|
|
1066
|
+
const connected = /* @__PURE__ */ new Set();
|
|
1067
|
+
if (core) {
|
|
1068
|
+
for (const id of core) {
|
|
1069
|
+
connected.add(id);
|
|
1070
|
+
const neighbors = adjacency.get(id);
|
|
1071
|
+
if (neighbors) for (const nid of neighbors) connected.add(nid);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const hasActive = core !== null && core.size > 0 || hasSearch || selected.size > 0;
|
|
1075
|
+
return {
|
|
1076
|
+
hasActive,
|
|
1077
|
+
connected,
|
|
1078
|
+
searchMatches: hasSearch ? searchMatches : null,
|
|
1079
|
+
selected
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function layerHoverFocus(standing, hoveredId, hoverConnected) {
|
|
1083
|
+
if (hoveredId === null || hoverConnected === null) return standing;
|
|
1084
|
+
return {
|
|
1085
|
+
hasActive: true,
|
|
1086
|
+
connected: hoverConnected,
|
|
1087
|
+
searchMatches: standing.searchMatches,
|
|
1088
|
+
selected: standing.selected
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function intersect(a, b) {
|
|
1092
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
1093
|
+
const out = /* @__PURE__ */ new Set();
|
|
1094
|
+
for (const v of small) if (large.has(v)) out.add(v);
|
|
1095
|
+
return out;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// src/graph/highlight.ts
|
|
1099
|
+
function resolveHighlightTarget(target, nodes, adjacency) {
|
|
1100
|
+
if ("nodeIds" in target) return new Set(target.nodeIds);
|
|
1101
|
+
if ("neighborsOf" in target) {
|
|
1102
|
+
const set2 = /* @__PURE__ */ new Set();
|
|
1103
|
+
if (target.includeSelf !== false) set2.add(target.neighborsOf);
|
|
1104
|
+
const neighbors = adjacency.get(target.neighborsOf);
|
|
1105
|
+
if (neighbors) for (const nid of neighbors) set2.add(nid);
|
|
1106
|
+
return set2;
|
|
1107
|
+
}
|
|
1108
|
+
const values = new Set(
|
|
1109
|
+
Array.isArray(target.category.value) ? target.category.value : [target.category.value]
|
|
1110
|
+
);
|
|
1111
|
+
const field = target.category.field;
|
|
1112
|
+
const set = /* @__PURE__ */ new Set();
|
|
1113
|
+
for (const n of nodes) {
|
|
1114
|
+
const v = n.data?.[field];
|
|
1115
|
+
if (v != null && values.has(String(v))) set.add(n.id);
|
|
1116
|
+
}
|
|
1117
|
+
return set;
|
|
1118
|
+
}
|
|
1119
|
+
function categoryHighlightSet(activeCategories, nodeCategory) {
|
|
1120
|
+
if (activeCategories.size === 0) return null;
|
|
1121
|
+
const set = /* @__PURE__ */ new Set();
|
|
1122
|
+
for (const [id, cat] of nodeCategory) if (activeCategories.has(cat)) set.add(id);
|
|
1123
|
+
return set;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/graph/interaction.ts
|
|
1127
|
+
var ZOOM_MIN = 0.05;
|
|
1128
|
+
var ZOOM_MAX = 15;
|
|
1129
|
+
var ZOOM_STEP = -1e-3;
|
|
1130
|
+
var HIT_DISTANCE = 5;
|
|
1131
|
+
var GraphInteractionManager = class {
|
|
1132
|
+
canvas;
|
|
1133
|
+
spatialIndex;
|
|
1134
|
+
callbacks;
|
|
1135
|
+
transform = ZoomTransform.identity();
|
|
1136
|
+
dragState = null;
|
|
1137
|
+
panState = null;
|
|
1138
|
+
mousedownNodeId = null;
|
|
1139
|
+
selectedIds = /* @__PURE__ */ new Set();
|
|
1140
|
+
// Touch state
|
|
1141
|
+
lastTouchDist = null;
|
|
1142
|
+
lastTouchCenter = null;
|
|
1143
|
+
// Bound handlers for cleanup
|
|
1144
|
+
boundWheel;
|
|
1145
|
+
boundMouseDown;
|
|
1146
|
+
boundMouseMove;
|
|
1147
|
+
boundMouseUp;
|
|
1148
|
+
boundDblClick;
|
|
1149
|
+
boundTouchStart;
|
|
1150
|
+
boundTouchMove;
|
|
1151
|
+
boundTouchEnd;
|
|
1152
|
+
boundMouseLeave;
|
|
1153
|
+
constructor(canvas, spatialIndex, callbacks) {
|
|
1154
|
+
this.canvas = canvas;
|
|
1155
|
+
this.spatialIndex = spatialIndex;
|
|
1156
|
+
this.callbacks = callbacks;
|
|
1157
|
+
this.boundWheel = this.onWheel.bind(this);
|
|
1158
|
+
this.boundMouseDown = this.onMouseDown.bind(this);
|
|
1159
|
+
this.boundMouseMove = this.onMouseMove.bind(this);
|
|
1160
|
+
this.boundMouseUp = this.onMouseUp.bind(this);
|
|
1161
|
+
this.boundMouseLeave = this.onMouseLeave.bind(this);
|
|
1162
|
+
this.boundDblClick = this.onDblClick.bind(this);
|
|
1163
|
+
this.boundTouchStart = this.onTouchStart.bind(this);
|
|
1164
|
+
this.boundTouchMove = this.onTouchMove.bind(this);
|
|
1165
|
+
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
|
1166
|
+
canvas.addEventListener("wheel", this.boundWheel, { passive: false });
|
|
1167
|
+
canvas.addEventListener("mousedown", this.boundMouseDown);
|
|
1168
|
+
canvas.addEventListener("mousemove", this.boundMouseMove);
|
|
1169
|
+
canvas.addEventListener("mouseup", this.boundMouseUp);
|
|
1170
|
+
canvas.addEventListener("mouseleave", this.boundMouseLeave);
|
|
1171
|
+
canvas.addEventListener("dblclick", this.boundDblClick);
|
|
1172
|
+
canvas.addEventListener("touchstart", this.boundTouchStart, {
|
|
1173
|
+
passive: false
|
|
1174
|
+
});
|
|
1175
|
+
canvas.addEventListener("touchmove", this.boundTouchMove, {
|
|
1176
|
+
passive: false
|
|
1177
|
+
});
|
|
1178
|
+
canvas.addEventListener("touchend", this.boundTouchEnd);
|
|
1179
|
+
}
|
|
1180
|
+
setTransform(transform) {
|
|
1181
|
+
this.transform = transform;
|
|
1182
|
+
}
|
|
1183
|
+
getTransform() {
|
|
1184
|
+
return this.transform;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Replace the internal selection set. Used by the mount to prune deleted ids
|
|
1188
|
+
* after a data update so a later shift-click can't resurrect them through
|
|
1189
|
+
* `onSelectionChange`. Does NOT fire `onSelectionChange` — the caller owns any
|
|
1190
|
+
* downstream sync (the mount already holds the pruned set).
|
|
1191
|
+
*/
|
|
1192
|
+
setSelection(ids) {
|
|
1193
|
+
this.selectedIds = new Set(ids);
|
|
1194
|
+
}
|
|
1195
|
+
destroy() {
|
|
1196
|
+
this.canvas.removeEventListener("wheel", this.boundWheel);
|
|
1197
|
+
this.canvas.removeEventListener("mousedown", this.boundMouseDown);
|
|
1198
|
+
this.canvas.removeEventListener("mousemove", this.boundMouseMove);
|
|
1199
|
+
this.canvas.removeEventListener("mouseup", this.boundMouseUp);
|
|
1200
|
+
this.canvas.removeEventListener("mouseleave", this.boundMouseLeave);
|
|
1201
|
+
this.canvas.removeEventListener("dblclick", this.boundDblClick);
|
|
1202
|
+
this.canvas.removeEventListener("touchstart", this.boundTouchStart);
|
|
1203
|
+
this.canvas.removeEventListener("touchmove", this.boundTouchMove);
|
|
1204
|
+
this.canvas.removeEventListener("touchend", this.boundTouchEnd);
|
|
1205
|
+
}
|
|
1206
|
+
// -------------------------------------------------------------------------
|
|
1207
|
+
// Mouse handlers
|
|
1208
|
+
// -------------------------------------------------------------------------
|
|
1209
|
+
canvasXY(e) {
|
|
1210
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1211
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
1212
|
+
}
|
|
1213
|
+
hitTest(screenX, screenY) {
|
|
1214
|
+
const graph = this.transform.screenToGraph(screenX, screenY);
|
|
1215
|
+
const node = this.spatialIndex.findNearest(graph.x, graph.y, HIT_DISTANCE / this.transform.k);
|
|
1216
|
+
return node?.id ?? null;
|
|
1217
|
+
}
|
|
1218
|
+
onWheel(e) {
|
|
1219
|
+
e.preventDefault();
|
|
1220
|
+
const { x, y } = this.canvasXY(e);
|
|
1221
|
+
const factor = e.deltaY * ZOOM_STEP;
|
|
1222
|
+
const newK = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.transform.k * (1 + factor)));
|
|
1223
|
+
this.transform = this.transform.zoomAt(newK, x, y);
|
|
1224
|
+
this.callbacks.onTransformChange(this.transform);
|
|
1225
|
+
}
|
|
1226
|
+
onMouseDown(e) {
|
|
1227
|
+
const { x, y } = this.canvasXY(e);
|
|
1228
|
+
const hitId = this.hitTest(x, y);
|
|
1229
|
+
if (hitId) {
|
|
1230
|
+
this.dragState = { nodeId: hitId, started: false };
|
|
1231
|
+
this.mousedownNodeId = hitId;
|
|
1232
|
+
} else {
|
|
1233
|
+
this.panState = { startX: x, startY: y };
|
|
1234
|
+
this.mousedownNodeId = null;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
onMouseMove(e) {
|
|
1238
|
+
const { x, y } = this.canvasXY(e);
|
|
1239
|
+
if (this.callbacks.onPointerMove) {
|
|
1240
|
+
const gp = this.transform.screenToGraph(x, y);
|
|
1241
|
+
this.callbacks.onPointerMove(gp.x, gp.y);
|
|
1242
|
+
}
|
|
1243
|
+
if (this.dragState) {
|
|
1244
|
+
const graph = this.transform.screenToGraph(x, y);
|
|
1245
|
+
if (!this.dragState.started) {
|
|
1246
|
+
this.dragState.started = true;
|
|
1247
|
+
this.callbacks.onNodeDragStart(this.dragState.nodeId);
|
|
1248
|
+
}
|
|
1249
|
+
this.callbacks.onNodeDrag(this.dragState.nodeId, graph.x, graph.y);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (this.panState) {
|
|
1253
|
+
const dx = x - this.panState.startX;
|
|
1254
|
+
const dy = y - this.panState.startY;
|
|
1255
|
+
this.transform = this.transform.pan(dx, dy);
|
|
1256
|
+
this.panState = { startX: x, startY: y };
|
|
1257
|
+
this.callbacks.onTransformChange(this.transform);
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const hitId = this.hitTest(x, y);
|
|
1261
|
+
this.callbacks.onHoverChange(hitId);
|
|
1262
|
+
if (!hitId) {
|
|
1263
|
+
const graph = this.transform.screenToGraph(x, y);
|
|
1264
|
+
this.callbacks.onBackgroundHover?.(graph.x, graph.y, x, y);
|
|
1265
|
+
}
|
|
1266
|
+
this.canvas.style.cursor = hitId ? "pointer" : "default";
|
|
1267
|
+
}
|
|
1268
|
+
onMouseUp(e) {
|
|
1269
|
+
const { x, y } = this.canvasXY(e);
|
|
1270
|
+
if (this.dragState) {
|
|
1271
|
+
if (this.dragState.started) {
|
|
1272
|
+
this.callbacks.onNodeDragEnd(this.dragState.nodeId);
|
|
1273
|
+
} else {
|
|
1274
|
+
this.handleNodeClick(this.dragState.nodeId, e.shiftKey);
|
|
1275
|
+
}
|
|
1276
|
+
this.dragState = null;
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (this.panState) {
|
|
1280
|
+
this.panState = null;
|
|
1281
|
+
if (!this.mousedownNodeId) {
|
|
1282
|
+
const hitId = this.hitTest(x, y);
|
|
1283
|
+
if (!hitId) {
|
|
1284
|
+
this.selectedIds.clear();
|
|
1285
|
+
this.callbacks.onSelectionChange([]);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
onDblClick(e) {
|
|
1292
|
+
const { x, y } = this.canvasXY(e);
|
|
1293
|
+
const hitId = this.hitTest(x, y);
|
|
1294
|
+
if (hitId) {
|
|
1295
|
+
this.callbacks.onDoubleClick(hitId);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
onMouseLeave(_e) {
|
|
1299
|
+
this.callbacks.onHoverChange(null);
|
|
1300
|
+
this.canvas.style.cursor = "default";
|
|
1301
|
+
this.callbacks.onPointerLeave?.();
|
|
1302
|
+
if (this.panState) {
|
|
1303
|
+
this.panState = null;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
handleNodeClick(nodeId, shiftKey) {
|
|
1307
|
+
if (shiftKey) {
|
|
1308
|
+
if (this.selectedIds.has(nodeId)) {
|
|
1309
|
+
this.selectedIds.delete(nodeId);
|
|
1310
|
+
} else {
|
|
1311
|
+
this.selectedIds.add(nodeId);
|
|
1312
|
+
}
|
|
1313
|
+
} else {
|
|
1314
|
+
this.selectedIds.clear();
|
|
1315
|
+
this.selectedIds.add(nodeId);
|
|
1316
|
+
}
|
|
1317
|
+
this.callbacks.onSelectionChange([...this.selectedIds]);
|
|
1318
|
+
}
|
|
1319
|
+
// -------------------------------------------------------------------------
|
|
1320
|
+
// Touch handlers
|
|
1321
|
+
// -------------------------------------------------------------------------
|
|
1322
|
+
onTouchStart(e) {
|
|
1323
|
+
e.preventDefault();
|
|
1324
|
+
if (e.touches.length === 2) {
|
|
1325
|
+
const [t0, t1] = [e.touches[0], e.touches[1]];
|
|
1326
|
+
this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
|
1327
|
+
this.lastTouchCenter = {
|
|
1328
|
+
x: (t0.clientX + t1.clientX) / 2,
|
|
1329
|
+
y: (t0.clientY + t1.clientY) / 2
|
|
1330
|
+
};
|
|
1331
|
+
} else if (e.touches.length === 1) {
|
|
1332
|
+
const touch = e.touches[0];
|
|
1333
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1334
|
+
const x = touch.clientX - rect.left;
|
|
1335
|
+
const y = touch.clientY - rect.top;
|
|
1336
|
+
const hitId = this.hitTest(x, y);
|
|
1337
|
+
if (hitId) {
|
|
1338
|
+
this.mousedownNodeId = hitId;
|
|
1339
|
+
} else {
|
|
1340
|
+
this.panState = { startX: x, startY: y };
|
|
1341
|
+
this.mousedownNodeId = null;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
onTouchMove(e) {
|
|
1346
|
+
e.preventDefault();
|
|
1347
|
+
if (e.touches.length === 2 && this.lastTouchDist !== null) {
|
|
1348
|
+
const [t0, t1] = [e.touches[0], e.touches[1]];
|
|
1349
|
+
const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
|
1350
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1351
|
+
const centerX = (t0.clientX + t1.clientX) / 2 - rect.left;
|
|
1352
|
+
const centerY = (t0.clientY + t1.clientY) / 2 - rect.top;
|
|
1353
|
+
const scale = newDist / this.lastTouchDist;
|
|
1354
|
+
const newK = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.transform.k * scale));
|
|
1355
|
+
this.transform = this.transform.zoomAt(newK, centerX, centerY);
|
|
1356
|
+
if (this.lastTouchCenter) {
|
|
1357
|
+
const dx = centerX - (this.lastTouchCenter.x - rect.left);
|
|
1358
|
+
const dy = centerY - (this.lastTouchCenter.y - rect.top);
|
|
1359
|
+
this.transform = this.transform.pan(dx, dy);
|
|
1360
|
+
}
|
|
1361
|
+
this.lastTouchDist = newDist;
|
|
1362
|
+
this.lastTouchCenter = {
|
|
1363
|
+
x: (t0.clientX + t1.clientX) / 2,
|
|
1364
|
+
y: (t0.clientY + t1.clientY) / 2
|
|
1365
|
+
};
|
|
1366
|
+
this.callbacks.onTransformChange(this.transform);
|
|
1367
|
+
} else if (e.touches.length === 1 && this.panState) {
|
|
1368
|
+
const touch = e.touches[0];
|
|
1369
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1370
|
+
const x = touch.clientX - rect.left;
|
|
1371
|
+
const y = touch.clientY - rect.top;
|
|
1372
|
+
const dx = x - this.panState.startX;
|
|
1373
|
+
const dy = y - this.panState.startY;
|
|
1374
|
+
this.transform = this.transform.pan(dx, dy);
|
|
1375
|
+
this.panState = { startX: x, startY: y };
|
|
1376
|
+
this.callbacks.onTransformChange(this.transform);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
onTouchEnd(e) {
|
|
1380
|
+
if (e.touches.length === 0) {
|
|
1381
|
+
if (this.mousedownNodeId && !this.panState) {
|
|
1382
|
+
this.handleNodeClick(this.mousedownNodeId, false);
|
|
1383
|
+
} else if (!this.mousedownNodeId && this.panState) {
|
|
1384
|
+
this.selectedIds.clear();
|
|
1385
|
+
this.callbacks.onSelectionChange([]);
|
|
1386
|
+
}
|
|
1387
|
+
this.panState = null;
|
|
1388
|
+
this.mousedownNodeId = null;
|
|
1389
|
+
this.lastTouchDist = null;
|
|
1390
|
+
this.lastTouchCenter = null;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
// src/graph/keyboard.ts
|
|
1396
|
+
function attachGraphKeyboardNav(options) {
|
|
1397
|
+
const {
|
|
1398
|
+
canvas,
|
|
1399
|
+
getNodes,
|
|
1400
|
+
getSelectedIds,
|
|
1401
|
+
getAdjacency,
|
|
1402
|
+
onSelect,
|
|
1403
|
+
onDeselect,
|
|
1404
|
+
onZoom,
|
|
1405
|
+
onFitAll,
|
|
1406
|
+
onFocusSearch
|
|
1407
|
+
} = options;
|
|
1408
|
+
let focusedNodeId = null;
|
|
1409
|
+
if (!canvas.hasAttribute("tabindex")) {
|
|
1410
|
+
canvas.setAttribute("tabindex", "0");
|
|
1411
|
+
}
|
|
1412
|
+
function findNodeById(id) {
|
|
1413
|
+
return getNodes().find((n) => n.id === id);
|
|
1414
|
+
}
|
|
1415
|
+
function pickDirectionalNeighbor(fromNode, neighborIds, direction) {
|
|
1416
|
+
const nodes = getNodes();
|
|
1417
|
+
const candidates = nodes.filter((n) => neighborIds.has(n.id));
|
|
1418
|
+
if (candidates.length === 0) return null;
|
|
1419
|
+
let best = null;
|
|
1420
|
+
let bestScore = -Infinity;
|
|
1421
|
+
for (const c of candidates) {
|
|
1422
|
+
const dx = c.x - fromNode.x;
|
|
1423
|
+
const dy = c.y - fromNode.y;
|
|
1424
|
+
let score;
|
|
1425
|
+
switch (direction) {
|
|
1426
|
+
case "right":
|
|
1427
|
+
score = dx - Math.abs(dy) * 0.5;
|
|
1428
|
+
break;
|
|
1429
|
+
case "left":
|
|
1430
|
+
score = -dx - Math.abs(dy) * 0.5;
|
|
1431
|
+
break;
|
|
1432
|
+
case "down":
|
|
1433
|
+
score = dy - Math.abs(dx) * 0.5;
|
|
1434
|
+
break;
|
|
1435
|
+
case "up":
|
|
1436
|
+
score = -dy - Math.abs(dx) * 0.5;
|
|
1437
|
+
break;
|
|
1438
|
+
}
|
|
1439
|
+
if (score > bestScore) {
|
|
1440
|
+
bestScore = score;
|
|
1441
|
+
best = c;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return best?.id ?? null;
|
|
1445
|
+
}
|
|
1446
|
+
function onKeyDown(e) {
|
|
1447
|
+
switch (e.key) {
|
|
1448
|
+
case "Tab": {
|
|
1449
|
+
const selected = getSelectedIds();
|
|
1450
|
+
const nodes = getNodes();
|
|
1451
|
+
if (nodes.length === 0) return;
|
|
1452
|
+
if (selected.length > 0) {
|
|
1453
|
+
focusedNodeId = selected[0];
|
|
1454
|
+
} else if (!focusedNodeId || !findNodeById(focusedNodeId)) {
|
|
1455
|
+
focusedNodeId = nodes[0].id;
|
|
1456
|
+
}
|
|
1457
|
+
e.preventDefault();
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
case "ArrowUp":
|
|
1461
|
+
case "ArrowDown":
|
|
1462
|
+
case "ArrowLeft":
|
|
1463
|
+
case "ArrowRight": {
|
|
1464
|
+
if (!focusedNodeId) return;
|
|
1465
|
+
e.preventDefault();
|
|
1466
|
+
const focusedNode = findNodeById(focusedNodeId);
|
|
1467
|
+
if (!focusedNode) return;
|
|
1468
|
+
const adjacency = getAdjacency();
|
|
1469
|
+
const neighbors = adjacency.get(focusedNodeId);
|
|
1470
|
+
if (!neighbors || neighbors.size === 0) return;
|
|
1471
|
+
const dirMap = {
|
|
1472
|
+
ArrowUp: "up",
|
|
1473
|
+
ArrowDown: "down",
|
|
1474
|
+
ArrowLeft: "left",
|
|
1475
|
+
ArrowRight: "right"
|
|
1476
|
+
};
|
|
1477
|
+
const nextId = pickDirectionalNeighbor(focusedNode, neighbors, dirMap[e.key]);
|
|
1478
|
+
if (nextId) {
|
|
1479
|
+
focusedNodeId = nextId;
|
|
1480
|
+
onSelect(nextId);
|
|
1481
|
+
}
|
|
1482
|
+
break;
|
|
1483
|
+
}
|
|
1484
|
+
case "Enter": {
|
|
1485
|
+
if (focusedNodeId) {
|
|
1486
|
+
e.preventDefault();
|
|
1487
|
+
const selected = getSelectedIds();
|
|
1488
|
+
if (selected.includes(focusedNodeId)) {
|
|
1489
|
+
onDeselect();
|
|
1490
|
+
} else {
|
|
1491
|
+
onSelect(focusedNodeId);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
break;
|
|
1495
|
+
}
|
|
1496
|
+
case "Escape": {
|
|
1497
|
+
e.preventDefault();
|
|
1498
|
+
focusedNodeId = null;
|
|
1499
|
+
onDeselect();
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
case "+":
|
|
1503
|
+
case "=": {
|
|
1504
|
+
e.preventDefault();
|
|
1505
|
+
onZoom("in");
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
case "-":
|
|
1509
|
+
case "_": {
|
|
1510
|
+
e.preventDefault();
|
|
1511
|
+
onZoom("out");
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
case "Home": {
|
|
1515
|
+
e.preventDefault();
|
|
1516
|
+
onFitAll();
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
case "/": {
|
|
1520
|
+
if (onFocusSearch) {
|
|
1521
|
+
e.preventDefault();
|
|
1522
|
+
onFocusSearch();
|
|
1523
|
+
}
|
|
1524
|
+
break;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
canvas.addEventListener("keydown", onKeyDown);
|
|
1529
|
+
return () => {
|
|
1530
|
+
canvas.removeEventListener("keydown", onKeyDown);
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// src/graph/legend.ts
|
|
1535
|
+
function escapeHtml(str) {
|
|
1536
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1537
|
+
}
|
|
1538
|
+
function createGraphLegend(host, data, callbacks) {
|
|
1539
|
+
const listeners = [];
|
|
1540
|
+
function render(view) {
|
|
1541
|
+
teardownListeners();
|
|
1542
|
+
host.replaceChildren();
|
|
1543
|
+
if (view.nodes.length === 0 && view.edges.length === 0) {
|
|
1544
|
+
host.style.display = "none";
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
host.style.display = "";
|
|
1548
|
+
for (const entry of view.nodes) {
|
|
1549
|
+
host.appendChild(nodeRow(entry));
|
|
1550
|
+
}
|
|
1551
|
+
for (const entry of view.edges) {
|
|
1552
|
+
host.appendChild(edgeRow(entry));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
function nodeRow(entry) {
|
|
1556
|
+
const interactive = callbacks.interactive;
|
|
1557
|
+
const el = document.createElement(interactive ? "button" : "div");
|
|
1558
|
+
el.className = "oc-graph-legend-item";
|
|
1559
|
+
if (!entry.active) el.classList.add("oc-graph-legend-item--inactive");
|
|
1560
|
+
if (interactive) {
|
|
1561
|
+
const btn = el;
|
|
1562
|
+
btn.type = "button";
|
|
1563
|
+
btn.setAttribute("aria-pressed", String(entry.active));
|
|
1564
|
+
const onClick = () => callbacks.onToggle(entry.label);
|
|
1565
|
+
const onEnter = () => callbacks.onHover(entry.label);
|
|
1566
|
+
const onLeave = () => callbacks.onHover(null);
|
|
1567
|
+
btn.addEventListener("click", onClick);
|
|
1568
|
+
btn.addEventListener("mouseenter", onEnter);
|
|
1569
|
+
btn.addEventListener("mouseleave", onLeave);
|
|
1570
|
+
listeners.push(() => {
|
|
1571
|
+
btn.removeEventListener("click", onClick);
|
|
1572
|
+
btn.removeEventListener("mouseenter", onEnter);
|
|
1573
|
+
btn.removeEventListener("mouseleave", onLeave);
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
el.innerHTML = `<span class="oc-graph-legend-swatch" style="background:${escapeHtml(entry.color)}"></span><span class="oc-graph-legend-label">${escapeHtml(entry.label)}</span>` + (callbacks.counts && entry.count != null ? `<span class="oc-graph-legend-count">${entry.count.toLocaleString()}</span>` : "");
|
|
1577
|
+
return el;
|
|
1578
|
+
}
|
|
1579
|
+
function edgeRow(entry) {
|
|
1580
|
+
const el = document.createElement("div");
|
|
1581
|
+
el.className = "oc-graph-legend-item oc-graph-legend-item--edge";
|
|
1582
|
+
el.innerHTML = `<span class="oc-graph-legend-swatch oc-graph-legend-swatch--line" style="background:${escapeHtml(entry.color)}"></span><span class="oc-graph-legend-label">${escapeHtml(entry.label)}</span>` + (callbacks.counts && entry.count != null ? `<span class="oc-graph-legend-count">${entry.count.toLocaleString()}</span>` : "");
|
|
1583
|
+
return el;
|
|
1584
|
+
}
|
|
1585
|
+
function teardownListeners() {
|
|
1586
|
+
for (const off of listeners) off();
|
|
1587
|
+
listeners.length = 0;
|
|
1588
|
+
}
|
|
1589
|
+
render(data);
|
|
1590
|
+
return {
|
|
1591
|
+
update: render,
|
|
1592
|
+
destroy() {
|
|
1593
|
+
teardownListeners();
|
|
1594
|
+
host.replaceChildren();
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// src/graph/search.ts
|
|
1600
|
+
var GraphSearchManager = class {
|
|
1601
|
+
matchedIds = null;
|
|
1602
|
+
/** The last active (non-empty) query, so a data update can re-run it. */
|
|
1603
|
+
query = null;
|
|
1604
|
+
/**
|
|
1605
|
+
* Search for nodes matching the query string.
|
|
1606
|
+
* Returns a Set of matching node ids, or an empty set if nothing matches.
|
|
1607
|
+
*/
|
|
1608
|
+
search(query, nodes) {
|
|
1609
|
+
const q = query.toLowerCase().trim();
|
|
1610
|
+
if (q === "") {
|
|
1611
|
+
this.matchedIds = null;
|
|
1612
|
+
this.query = null;
|
|
1613
|
+
return /* @__PURE__ */ new Set();
|
|
1614
|
+
}
|
|
1615
|
+
this.query = query;
|
|
1616
|
+
const matches = /* @__PURE__ */ new Set();
|
|
1617
|
+
for (const node of nodes) {
|
|
1618
|
+
const label = (node.label ?? "").toLowerCase();
|
|
1619
|
+
const id = node.id.toLowerCase();
|
|
1620
|
+
if (label.includes(q) || id.includes(q)) {
|
|
1621
|
+
matches.add(node.id);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
this.matchedIds = matches;
|
|
1625
|
+
return matches;
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Clear the current search.
|
|
1629
|
+
* Returns null to indicate no active search.
|
|
1630
|
+
*/
|
|
1631
|
+
clearSearch() {
|
|
1632
|
+
this.matchedIds = null;
|
|
1633
|
+
this.query = null;
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
/** Get the current set of matched ids, or null if no search is active. */
|
|
1637
|
+
getMatches() {
|
|
1638
|
+
return this.matchedIds;
|
|
1639
|
+
}
|
|
1640
|
+
/** The last active query string, or null when no search is active. */
|
|
1641
|
+
getQuery() {
|
|
1642
|
+
return this.query;
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
// src/graph/seed.ts
|
|
1647
|
+
function hash32(str) {
|
|
1648
|
+
let h = 2166136261;
|
|
1649
|
+
for (let i = 0; i < str.length; i++) {
|
|
1650
|
+
h ^= str.charCodeAt(i);
|
|
1651
|
+
h = Math.imul(h, 16777619);
|
|
1652
|
+
}
|
|
1653
|
+
return h >>> 0;
|
|
1654
|
+
}
|
|
1655
|
+
function unit(h) {
|
|
1656
|
+
return (h >>> 0) / 4294967296;
|
|
1657
|
+
}
|
|
1658
|
+
var R0 = 300;
|
|
1659
|
+
function seedNodePositions(nodes, seed) {
|
|
1660
|
+
const n = nodes.length;
|
|
1661
|
+
if (n === 0) return;
|
|
1662
|
+
for (const node of nodes) {
|
|
1663
|
+
const u1 = unit(hash32(`${node.id}|${seed}|r`));
|
|
1664
|
+
const u2 = unit(hash32(`${node.id}|${seed}|t`));
|
|
1665
|
+
const r = R0 * Math.sqrt(u1);
|
|
1666
|
+
const theta = u2 * Math.PI * 2;
|
|
1667
|
+
let cx = 0;
|
|
1668
|
+
let cy = 0;
|
|
1669
|
+
if (node.community) {
|
|
1670
|
+
const ca = unit(hash32(`${node.community}|${seed}`)) * Math.PI * 2;
|
|
1671
|
+
const bias = 0.6 * R0;
|
|
1672
|
+
cx = Math.cos(ca) * bias;
|
|
1673
|
+
cy = Math.sin(ca) * bias;
|
|
1674
|
+
}
|
|
1675
|
+
node.x = cx + r * Math.cos(theta);
|
|
1676
|
+
node.y = cy + r * Math.sin(theta);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// src/graph/shell.ts
|
|
1681
|
+
var MIN_SURFACE_HEIGHT = 200;
|
|
1682
|
+
function getContainerDimensions(container) {
|
|
1683
|
+
const rect = container.getBoundingClientRect();
|
|
1684
|
+
return {
|
|
1685
|
+
width: Math.max(rect.width || 600, 100),
|
|
1686
|
+
height: Math.max(rect.height || 400, 100)
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
function createGraphShell(container, spec, compilation, options, warn) {
|
|
1690
|
+
const isDark = resolveDarkMode(options?.darkMode);
|
|
1691
|
+
const wrapper = document.createElement("div");
|
|
1692
|
+
wrapper.className = isDark ? "oc-graph-wrapper oc-dark" : "oc-graph-wrapper";
|
|
1693
|
+
if (isDark) {
|
|
1694
|
+
container.classList.add("oc-dark");
|
|
1695
|
+
} else {
|
|
1696
|
+
container.classList.remove("oc-dark");
|
|
1697
|
+
}
|
|
1698
|
+
const resolvedTheme = compilation.theme;
|
|
1699
|
+
if (resolvedTheme) {
|
|
1700
|
+
const s = wrapper.style;
|
|
1701
|
+
s.setProperty("--oc-bg", resolvedSurface(resolvedTheme));
|
|
1702
|
+
s.setProperty("--oc-text", resolvedTheme.colors.text);
|
|
1703
|
+
s.setProperty("--oc-text-secondary", resolvedTheme.colors.neutral.secondary);
|
|
1704
|
+
s.setProperty("--oc-text-muted", resolvedTheme.colors.axis);
|
|
1705
|
+
s.setProperty("--oc-border", resolvedTheme.colors.neutral.border);
|
|
1706
|
+
s.setProperty("--oc-font-family", resolvedTheme.fonts.family);
|
|
1707
|
+
s.fontFamily = resolvedTheme.fonts.family;
|
|
1708
|
+
}
|
|
1709
|
+
const chromeEl = document.createElement("div");
|
|
1710
|
+
chromeEl.className = "oc-graph-chrome";
|
|
1711
|
+
wrapper.appendChild(chromeEl);
|
|
1712
|
+
const legendSetting = options?.legend ?? spec.legend;
|
|
1713
|
+
let legendEl = null;
|
|
1714
|
+
if (legendSetting !== false) {
|
|
1715
|
+
legendEl = document.createElement("div");
|
|
1716
|
+
legendEl.className = "oc-graph-legend";
|
|
1717
|
+
wrapper.appendChild(legendEl);
|
|
1718
|
+
}
|
|
1719
|
+
container.appendChild(wrapper);
|
|
1720
|
+
const tooltipManager = options?.tooltip !== false ? createTooltipManager(wrapper) : null;
|
|
1721
|
+
const shell = {
|
|
1722
|
+
container,
|
|
1723
|
+
wrapper,
|
|
1724
|
+
chromeEl,
|
|
1725
|
+
legendEl,
|
|
1726
|
+
tooltipManager,
|
|
1727
|
+
isDark,
|
|
1728
|
+
mountSurface(el) {
|
|
1729
|
+
if (legendEl) wrapper.insertBefore(el, legendEl);
|
|
1730
|
+
else wrapper.appendChild(el);
|
|
1731
|
+
},
|
|
1732
|
+
renderChrome(next) {
|
|
1733
|
+
let html = "";
|
|
1734
|
+
if (next.chrome.title) {
|
|
1735
|
+
html += `<h2 class="oc-title">${escapeHtml2(next.chrome.title.text)}</h2>`;
|
|
1736
|
+
}
|
|
1737
|
+
if (next.chrome.subtitle) {
|
|
1738
|
+
html += `<p class="oc-subtitle">${escapeHtml2(next.chrome.subtitle.text)}</p>`;
|
|
1739
|
+
}
|
|
1740
|
+
chromeEl.innerHTML = html;
|
|
1741
|
+
chromeEl.style.display = html ? "" : "none";
|
|
1742
|
+
},
|
|
1743
|
+
/**
|
|
1744
|
+
* Keep the chrome block out of the legend's column: the title/subtitle wrap
|
|
1745
|
+
* before they reach the legend box instead of running underneath it. No-op
|
|
1746
|
+
* when there's no legend (or it has no measurable width, e.g. in happy-dom).
|
|
1747
|
+
*/
|
|
1748
|
+
syncChromeInset() {
|
|
1749
|
+
const legendW = legendEl?.offsetWidth ?? 0;
|
|
1750
|
+
chromeEl.style.right = legendW > 0 ? `${legendW + 24}px` : "";
|
|
1751
|
+
},
|
|
1752
|
+
getSize() {
|
|
1753
|
+
const { width, height } = getContainerDimensions(container);
|
|
1754
|
+
return { width, height: Math.max(height, MIN_SURFACE_HEIGHT) };
|
|
1755
|
+
},
|
|
1756
|
+
observeResize(callback) {
|
|
1757
|
+
if (options?.responsive === false) return () => {
|
|
1758
|
+
};
|
|
1759
|
+
return observeResize(container, () => {
|
|
1760
|
+
callback();
|
|
1761
|
+
});
|
|
1762
|
+
},
|
|
1763
|
+
warn,
|
|
1764
|
+
destroy() {
|
|
1765
|
+
tooltipManager?.destroy();
|
|
1766
|
+
if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
|
|
1767
|
+
container.classList.remove("oc-dark");
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
return shell;
|
|
1771
|
+
}
|
|
1772
|
+
function escapeHtml2(str) {
|
|
1773
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// src/graph/simulation.ts
|
|
1777
|
+
import {
|
|
1778
|
+
forceCenter,
|
|
1779
|
+
forceCollide,
|
|
1780
|
+
forceLink,
|
|
1781
|
+
forceManyBody,
|
|
1782
|
+
forceSimulation,
|
|
1783
|
+
forceX,
|
|
1784
|
+
forceY
|
|
1785
|
+
} from "d3-force";
|
|
1786
|
+
var SYNC_TICKS_PER_BATCH = 15;
|
|
1787
|
+
var SYNC_MAX_TICKS_CEIL = 800;
|
|
1788
|
+
var DEFAULT_ALPHA_MIN = 1e-3;
|
|
1789
|
+
var DEFAULT_WARMUP_BUDGET_MS = 250;
|
|
1790
|
+
function ticksToAlphaMin(alphaDecay, alphaMin = DEFAULT_ALPHA_MIN) {
|
|
1791
|
+
if (!(alphaDecay > 0) || alphaDecay >= 1) return SYNC_MAX_TICKS_CEIL;
|
|
1792
|
+
const n = Math.ceil(Math.log(alphaMin) / Math.log(1 - alphaDecay));
|
|
1793
|
+
return Math.min(SYNC_MAX_TICKS_CEIL, Math.max(1, n));
|
|
1794
|
+
}
|
|
1795
|
+
function forceCluster(nodes, strength) {
|
|
1796
|
+
return (alpha) => {
|
|
1797
|
+
const cx = /* @__PURE__ */ new Map();
|
|
1798
|
+
const cy = /* @__PURE__ */ new Map();
|
|
1799
|
+
const count = /* @__PURE__ */ new Map();
|
|
1800
|
+
for (const node of nodes) {
|
|
1801
|
+
if (!node.community) continue;
|
|
1802
|
+
const c = node.community;
|
|
1803
|
+
cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
|
|
1804
|
+
cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
|
|
1805
|
+
count.set(c, (count.get(c) ?? 0) + 1);
|
|
1806
|
+
}
|
|
1807
|
+
for (const [c, n] of count) {
|
|
1808
|
+
cx.set(c, cx.get(c) / n);
|
|
1809
|
+
cy.set(c, cy.get(c) / n);
|
|
1810
|
+
}
|
|
1811
|
+
const k = strength * alpha;
|
|
1812
|
+
for (const node of nodes) {
|
|
1813
|
+
if (!node.community) continue;
|
|
1814
|
+
const targetX = cx.get(node.community);
|
|
1815
|
+
const targetY = cy.get(node.community);
|
|
1816
|
+
node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
|
|
1817
|
+
node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
function forceCursor(nodes, pointer) {
|
|
1822
|
+
return (alpha) => {
|
|
1823
|
+
if (!pointer.active || pointer.radius <= 0) return;
|
|
1824
|
+
const r2 = pointer.radius * pointer.radius;
|
|
1825
|
+
const k = pointer.strength * alpha;
|
|
1826
|
+
for (const node of nodes) {
|
|
1827
|
+
const dx = (node.x ?? 0) - pointer.x;
|
|
1828
|
+
const dy = (node.y ?? 0) - pointer.y;
|
|
1829
|
+
const dist2 = dx * dx + dy * dy;
|
|
1830
|
+
if (dist2 >= r2) continue;
|
|
1831
|
+
const dist = Math.sqrt(dist2) || 1e-6;
|
|
1832
|
+
const falloff = (pointer.radius - dist) / pointer.radius;
|
|
1833
|
+
const push = k * falloff / dist;
|
|
1834
|
+
node.vx = (node.vx ?? 0) + dx * push;
|
|
1835
|
+
node.vy = (node.vy ?? 0) + dy * push;
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
var SimulationManager = class _SimulationManager {
|
|
1840
|
+
worker = null;
|
|
1841
|
+
syncSim = null;
|
|
1842
|
+
syncNodes = [];
|
|
1843
|
+
syncNodeMap = /* @__PURE__ */ new Map();
|
|
1844
|
+
tickCb = null;
|
|
1845
|
+
settledCb = null;
|
|
1846
|
+
destroyed = false;
|
|
1847
|
+
syncRafId = null;
|
|
1848
|
+
/** Derived per-graph cap on sync ticks, from alphaDecay via ticksToAlphaMin. */
|
|
1849
|
+
syncMaxTicks = SYNC_MAX_TICKS_CEIL;
|
|
1850
|
+
/** True until the sync warmup loop has completed (nothing renders before then). */
|
|
1851
|
+
syncWarmupPending = false;
|
|
1852
|
+
/** Remaining warmup ticks and the ms budget, consumed by the pre-reveal loop. */
|
|
1853
|
+
syncWarmupTicks = 0;
|
|
1854
|
+
syncWarmupBudgetMs = DEFAULT_WARMUP_BUDGET_MS;
|
|
1855
|
+
/** Injectable clock for the warmup ms budget (deterministic in tests). */
|
|
1856
|
+
now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
|
|
1857
|
+
// Stored for worker->sync fallback
|
|
1858
|
+
initNodes = [];
|
|
1859
|
+
initEdges = [];
|
|
1860
|
+
initConfig = null;
|
|
1861
|
+
// Cursor-repulsion pointer state (sync path). radius=0 keeps the force inert.
|
|
1862
|
+
pointer = { x: 0, y: 0, active: false, radius: 0, strength: 0 };
|
|
1863
|
+
// Separately tracked alpha-target intents so drag and cursor don't stomp each
|
|
1864
|
+
// other. The higher intent wins; releasing one falls back to the other.
|
|
1865
|
+
dragAlphaTarget = 0;
|
|
1866
|
+
cursorAlphaTarget = 0;
|
|
1867
|
+
constructor() {
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Create a SimulationManager. Uses Web Worker for large graphs,
|
|
1871
|
+
* synchronous fallback for small graphs or when Worker unavailable.
|
|
1872
|
+
*
|
|
1873
|
+
* `opts.now` injects a clock for the warmup ms budget (tests pass a fake one).
|
|
1874
|
+
*/
|
|
1875
|
+
static create(nodes, edges, config, opts) {
|
|
1876
|
+
const mgr = new _SimulationManager();
|
|
1877
|
+
if (opts?.now) mgr.now = opts.now;
|
|
1878
|
+
const useWorker = typeof Worker !== "undefined";
|
|
1879
|
+
if (useWorker) {
|
|
1880
|
+
mgr.initWorker(nodes, edges, config);
|
|
1881
|
+
} else {
|
|
1882
|
+
mgr.initSync(nodes, edges, config);
|
|
1883
|
+
}
|
|
1884
|
+
return mgr;
|
|
1885
|
+
}
|
|
1886
|
+
/** Register a callback for position updates. */
|
|
1887
|
+
onTick(cb) {
|
|
1888
|
+
this.tickCb = cb;
|
|
1889
|
+
}
|
|
1890
|
+
/** Register a callback for when the simulation has settled. */
|
|
1891
|
+
onSettled(cb) {
|
|
1892
|
+
this.settledCb = cb;
|
|
1893
|
+
}
|
|
1894
|
+
/** Reheat the simulation. */
|
|
1895
|
+
reheat(alpha) {
|
|
1896
|
+
if (this.destroyed) return;
|
|
1897
|
+
if (this.worker) {
|
|
1898
|
+
this.worker.postMessage({ type: "reheat", alpha });
|
|
1899
|
+
} else if (this.syncSim) {
|
|
1900
|
+
this.syncSim.alpha(alpha ?? 0.3).restart();
|
|
1901
|
+
this.runSyncTicks();
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Pin a node to fixed x/y coordinates.
|
|
1906
|
+
*
|
|
1907
|
+
* When `alphaTarget` is provided (springy drag), the sim is held warm so the
|
|
1908
|
+
* pinned node's neighbors follow springily. Omitting it posts a byte-identical
|
|
1909
|
+
* legacy message and leaves alpha untouched.
|
|
1910
|
+
*/
|
|
1911
|
+
pinNode(id, x, y, alphaTarget) {
|
|
1912
|
+
if (this.destroyed) return;
|
|
1913
|
+
if (this.worker) {
|
|
1914
|
+
this.worker.postMessage(
|
|
1915
|
+
alphaTarget != null ? { type: "pin", nodeId: id, x, y, alphaTarget } : { type: "pin", nodeId: id, x, y }
|
|
1916
|
+
);
|
|
1917
|
+
} else {
|
|
1918
|
+
const node = this.syncNodeMap.get(id);
|
|
1919
|
+
if (node) {
|
|
1920
|
+
node.fx = x;
|
|
1921
|
+
node.fy = y;
|
|
1922
|
+
}
|
|
1923
|
+
if (alphaTarget != null && this.syncSim) {
|
|
1924
|
+
this.dragAlphaTarget = alphaTarget;
|
|
1925
|
+
this.syncAlphaTarget();
|
|
1926
|
+
this.syncSim.restart();
|
|
1927
|
+
this.runSyncTicks();
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Unpin a node and reheat so forces settle it into equilibrium.
|
|
1933
|
+
*
|
|
1934
|
+
* When `alphaTarget` is provided (springy release), the sim cools back toward
|
|
1935
|
+
* that target instead of the legacy gentle reheat. Omitting it preserves the
|
|
1936
|
+
* exact legacy reheat behavior (and posts a byte-identical message).
|
|
1937
|
+
*/
|
|
1938
|
+
unpinNode(id, alphaTarget) {
|
|
1939
|
+
if (this.destroyed) return;
|
|
1940
|
+
if (this.worker) {
|
|
1941
|
+
this.worker.postMessage(
|
|
1942
|
+
alphaTarget != null ? { type: "unpin", nodeId: id, alphaTarget } : { type: "unpin", nodeId: id }
|
|
1943
|
+
);
|
|
1944
|
+
} else {
|
|
1945
|
+
const node = this.syncNodeMap.get(id);
|
|
1946
|
+
if (node) {
|
|
1947
|
+
node.fx = null;
|
|
1948
|
+
node.fy = null;
|
|
1949
|
+
}
|
|
1950
|
+
if (alphaTarget != null) {
|
|
1951
|
+
this.dragAlphaTarget = alphaTarget;
|
|
1952
|
+
this.syncAlphaTarget();
|
|
1953
|
+
this.runSyncTicks();
|
|
1954
|
+
} else if (this.syncSim && this.syncSim.alpha() < 0.1) {
|
|
1955
|
+
this.syncSim.alpha(0.1).restart();
|
|
1956
|
+
this.runSyncTicks();
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Feed the cursor-repulsion force a pointer position. `active: false` clears
|
|
1962
|
+
* the force. No-op when the graph has no cursor force configured (radius 0).
|
|
1963
|
+
*/
|
|
1964
|
+
setPointer(x, y, active) {
|
|
1965
|
+
if (this.destroyed) return;
|
|
1966
|
+
if (this.worker) {
|
|
1967
|
+
this.worker.postMessage({ type: "pointer", x, y, active });
|
|
1968
|
+
} else {
|
|
1969
|
+
if (this.pointer.radius <= 0) return;
|
|
1970
|
+
this.pointer.x = x;
|
|
1971
|
+
this.pointer.y = y;
|
|
1972
|
+
this.pointer.active = active;
|
|
1973
|
+
this.cursorAlphaTarget = active ? 0.03 : 0;
|
|
1974
|
+
if (this.syncSim) {
|
|
1975
|
+
this.syncAlphaTarget();
|
|
1976
|
+
if (active) {
|
|
1977
|
+
this.syncSim.restart();
|
|
1978
|
+
this.runSyncTicks();
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
/** Apply the max of the tracked alpha-target intents to the sync sim. */
|
|
1984
|
+
syncAlphaTarget() {
|
|
1985
|
+
if (!this.syncSim) return;
|
|
1986
|
+
this.syncSim.alphaTarget(Math.max(this.dragAlphaTarget, this.cursorAlphaTarget));
|
|
1987
|
+
}
|
|
1988
|
+
/** Drag a node (pins it and reheats slightly). */
|
|
1989
|
+
dragNode(id, x, y) {
|
|
1990
|
+
if (this.destroyed) return;
|
|
1991
|
+
if (this.worker) {
|
|
1992
|
+
this.worker.postMessage({ type: "drag", nodeId: id, x, y });
|
|
1993
|
+
} else {
|
|
1994
|
+
const node = this.syncNodeMap.get(id);
|
|
1995
|
+
if (node) {
|
|
1996
|
+
node.fx = x;
|
|
1997
|
+
node.fy = y;
|
|
1998
|
+
}
|
|
1999
|
+
if (this.syncSim && this.syncSim.alpha() < 0.1) {
|
|
2000
|
+
this.syncSim.alpha(0.1).restart();
|
|
2001
|
+
this.runSyncTicks();
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/** Tear down the simulation and release resources. */
|
|
2006
|
+
destroy() {
|
|
2007
|
+
this.destroyed = true;
|
|
2008
|
+
if (this.syncRafId !== null) {
|
|
2009
|
+
cancelAnimationFrame(this.syncRafId);
|
|
2010
|
+
this.syncRafId = null;
|
|
2011
|
+
}
|
|
2012
|
+
if (this.worker) {
|
|
2013
|
+
this.worker.postMessage({ type: "stop" });
|
|
2014
|
+
this.worker.terminate();
|
|
2015
|
+
this.worker = null;
|
|
2016
|
+
}
|
|
2017
|
+
if (this.syncSim) {
|
|
2018
|
+
this.syncSim.stop();
|
|
2019
|
+
this.syncSim = null;
|
|
2020
|
+
}
|
|
2021
|
+
this.tickCb = null;
|
|
2022
|
+
this.settledCb = null;
|
|
2023
|
+
}
|
|
2024
|
+
// -------------------------------------------------------------------------
|
|
2025
|
+
// Worker path
|
|
2026
|
+
// -------------------------------------------------------------------------
|
|
2027
|
+
initWorker(nodes, edges, config) {
|
|
2028
|
+
this.initNodes = nodes;
|
|
2029
|
+
this.initEdges = edges;
|
|
2030
|
+
this.initConfig = config;
|
|
2031
|
+
const initMsg = { type: "init", nodes, edges, config };
|
|
2032
|
+
const wireWorker = (worker) => {
|
|
2033
|
+
this.worker = worker;
|
|
2034
|
+
worker.onmessage = (event) => {
|
|
2035
|
+
if (this.destroyed) return;
|
|
2036
|
+
const msg = event.data;
|
|
2037
|
+
switch (msg.type) {
|
|
2038
|
+
case "positions":
|
|
2039
|
+
this.tickCb?.(msg.nodes, msg.alpha);
|
|
2040
|
+
break;
|
|
2041
|
+
case "settled":
|
|
2042
|
+
this.settledCb?.();
|
|
2043
|
+
break;
|
|
2044
|
+
case "error":
|
|
2045
|
+
console.error("[SimulationManager] Worker error:", msg.message);
|
|
2046
|
+
break;
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
2049
|
+
worker.postMessage(initMsg);
|
|
2050
|
+
};
|
|
2051
|
+
try {
|
|
2052
|
+
const w = new Worker(new URL("./simulation-worker.js", import.meta.url), {
|
|
2053
|
+
type: "module"
|
|
2054
|
+
});
|
|
2055
|
+
w.onerror = () => {
|
|
2056
|
+
if (this.destroyed) return;
|
|
2057
|
+
w.terminate();
|
|
2058
|
+
this.worker = null;
|
|
2059
|
+
try {
|
|
2060
|
+
const tsUrl = new URL(import.meta.url.replace(/\/[^/]+$/, "/simulation-worker.ts"));
|
|
2061
|
+
const w2 = new Worker(tsUrl, { type: "module" });
|
|
2062
|
+
w2.onerror = () => {
|
|
2063
|
+
if (this.destroyed) return;
|
|
2064
|
+
console.warn("[SimulationManager] Worker failed to load, falling back to sync");
|
|
2065
|
+
w2.terminate();
|
|
2066
|
+
this.worker = null;
|
|
2067
|
+
this.initSync(this.initNodes, this.initEdges, this.initConfig);
|
|
2068
|
+
};
|
|
2069
|
+
wireWorker(w2);
|
|
2070
|
+
} catch {
|
|
2071
|
+
console.warn("[SimulationManager] Worker creation failed, using sync fallback");
|
|
2072
|
+
this.initSync(this.initNodes, this.initEdges, this.initConfig);
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
wireWorker(w);
|
|
2076
|
+
} catch {
|
|
2077
|
+
console.warn("[SimulationManager] Worker creation failed, using sync fallback");
|
|
2078
|
+
this.initSync(nodes, edges, config);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
// -------------------------------------------------------------------------
|
|
2082
|
+
// Synchronous fallback
|
|
2083
|
+
// -------------------------------------------------------------------------
|
|
2084
|
+
initSync(nodes, edges, config) {
|
|
2085
|
+
this.syncNodes = nodes.map((n) => ({
|
|
2086
|
+
id: n.id,
|
|
2087
|
+
x: n.x,
|
|
2088
|
+
y: n.y,
|
|
2089
|
+
radius: n.radius,
|
|
2090
|
+
community: n.community
|
|
2091
|
+
}));
|
|
2092
|
+
this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
|
|
2093
|
+
const linkForce = forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance);
|
|
2094
|
+
if (config.linkStrength != null) {
|
|
2095
|
+
linkForce.strength(config.linkStrength);
|
|
2096
|
+
}
|
|
2097
|
+
const padding = config.collisionPadding ?? 2;
|
|
2098
|
+
this.syncSim = forceSimulation(this.syncNodes).force("link", linkForce).force("charge", forceManyBody().strength(config.chargeStrength)).force(
|
|
2099
|
+
"collide",
|
|
2100
|
+
forceCollide().radius((d) => d.radius + padding)
|
|
2101
|
+
).force("gravityX", forceX(0).strength(0.05)).force("gravityY", forceY(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
|
|
2102
|
+
if (config.centerForce !== false) {
|
|
2103
|
+
this.syncSim.force("center", forceCenter(0, 0));
|
|
2104
|
+
}
|
|
2105
|
+
if (config.clustering) {
|
|
2106
|
+
const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
|
|
2107
|
+
this.syncSim.force("cluster", clusterFn);
|
|
2108
|
+
}
|
|
2109
|
+
this.pointer.active = false;
|
|
2110
|
+
this.pointer.radius = config.cursorRepulsion?.radius ?? 0;
|
|
2111
|
+
this.pointer.strength = config.cursorRepulsion?.strength ?? 0;
|
|
2112
|
+
this.dragAlphaTarget = 0;
|
|
2113
|
+
this.cursorAlphaTarget = 0;
|
|
2114
|
+
const cursorFn = forceCursor(this.syncNodes, this.pointer);
|
|
2115
|
+
this.syncSim.force("cursor", cursorFn);
|
|
2116
|
+
if (config.initialAlpha != null) {
|
|
2117
|
+
this.syncSim.alpha(config.initialAlpha);
|
|
2118
|
+
}
|
|
2119
|
+
this.syncMaxTicks = ticksToAlphaMin(config.alphaDecay);
|
|
2120
|
+
this.syncWarmupTicks = config.warmupTicks ?? 0;
|
|
2121
|
+
this.syncWarmupBudgetMs = config.warmupBudgetMs ?? DEFAULT_WARMUP_BUDGET_MS;
|
|
2122
|
+
this.syncWarmupPending = this.syncWarmupTicks > 0;
|
|
2123
|
+
this.runSyncTicks(true);
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Run simulation ticks in batches, yielding to the main thread between
|
|
2127
|
+
* batches via requestAnimationFrame. This prevents a multi-second freeze
|
|
2128
|
+
* when the sync fallback handles large graphs (1k+ nodes).
|
|
2129
|
+
*
|
|
2130
|
+
* Each batch runs SYNC_TICKS_PER_BATCH ticks, emits positions for
|
|
2131
|
+
* progressive rendering, then schedules the next batch.
|
|
2132
|
+
*
|
|
2133
|
+
* @param deferred - When true, start via microtask (initial run where
|
|
2134
|
+
* callbacks aren't wired yet). Otherwise start immediately.
|
|
2135
|
+
*/
|
|
2136
|
+
runSyncTicks(deferred = false) {
|
|
2137
|
+
if (!this.syncSim || this.destroyed) return;
|
|
2138
|
+
if (this.syncRafId !== null) {
|
|
2139
|
+
cancelAnimationFrame(this.syncRafId);
|
|
2140
|
+
this.syncRafId = null;
|
|
2141
|
+
}
|
|
2142
|
+
const sim = this.syncSim;
|
|
2143
|
+
const maxTicks = this.syncMaxTicks;
|
|
2144
|
+
let tickCount = 0;
|
|
2145
|
+
const runWarmup = () => {
|
|
2146
|
+
if (this.destroyed || !this.syncSim) return;
|
|
2147
|
+
this.syncRafId = null;
|
|
2148
|
+
const start2 = this.now();
|
|
2149
|
+
while (this.syncWarmupTicks > 0) {
|
|
2150
|
+
for (let i = 0; i < SYNC_TICKS_PER_BATCH && this.syncWarmupTicks > 0; i++) {
|
|
2151
|
+
sim.tick();
|
|
2152
|
+
this.syncWarmupTicks--;
|
|
2153
|
+
if (sim.alpha() < DEFAULT_ALPHA_MIN) {
|
|
2154
|
+
this.syncWarmupTicks = 0;
|
|
2155
|
+
break;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
if (this.syncWarmupTicks > 0 && this.now() - start2 >= this.syncWarmupBudgetMs) {
|
|
2159
|
+
this.syncWarmupTicks = 0;
|
|
2160
|
+
break;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
this.syncWarmupPending = false;
|
|
2164
|
+
runBatch();
|
|
2165
|
+
};
|
|
2166
|
+
const runBatch = () => {
|
|
2167
|
+
if (this.destroyed || !this.syncSim) return;
|
|
2168
|
+
this.syncRafId = null;
|
|
2169
|
+
for (let i = 0; i < SYNC_TICKS_PER_BATCH && tickCount < maxTicks; i++, tickCount++) {
|
|
2170
|
+
sim.tick();
|
|
2171
|
+
if (sim.alpha() < DEFAULT_ALPHA_MIN) {
|
|
2172
|
+
tickCount = maxTicks;
|
|
2173
|
+
break;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const positions = this.syncNodes.map((n) => ({
|
|
2177
|
+
id: n.id,
|
|
2178
|
+
x: n.x ?? 0,
|
|
2179
|
+
y: n.y ?? 0
|
|
2180
|
+
}));
|
|
2181
|
+
const alpha = sim.alpha();
|
|
2182
|
+
const settled = alpha < DEFAULT_ALPHA_MIN || tickCount >= maxTicks;
|
|
2183
|
+
this.tickCb?.(positions, alpha);
|
|
2184
|
+
if (settled) {
|
|
2185
|
+
this.settledCb?.();
|
|
2186
|
+
} else {
|
|
2187
|
+
this.syncRafId = requestAnimationFrame(runBatch);
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
const start = this.syncWarmupPending ? runWarmup : runBatch;
|
|
2191
|
+
if (deferred) {
|
|
2192
|
+
queueMicrotask(start);
|
|
2193
|
+
} else {
|
|
2194
|
+
start();
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
// src/graph/spatial-index.ts
|
|
2200
|
+
var SpatialIndex2 = class extends SpatialIndex {
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
// src/graph/update-diff-config.ts
|
|
2204
|
+
function stableKey(c) {
|
|
2205
|
+
return JSON.stringify([
|
|
2206
|
+
c.chargeStrength,
|
|
2207
|
+
c.linkDistance,
|
|
2208
|
+
c.clustering ? [c.clustering.field, c.clustering.strength] : null,
|
|
2209
|
+
c.alphaDecay,
|
|
2210
|
+
c.velocityDecay,
|
|
2211
|
+
c.collisionRadius,
|
|
2212
|
+
c.collisionPadding ?? null,
|
|
2213
|
+
c.linkStrength ?? null,
|
|
2214
|
+
c.centerForce ?? null,
|
|
2215
|
+
c.seed ?? null,
|
|
2216
|
+
c.warmupTicks ?? null,
|
|
2217
|
+
c.warmupBudgetMs ?? null
|
|
2218
|
+
]);
|
|
2219
|
+
}
|
|
2220
|
+
function simulationConfigEqual(a, b) {
|
|
2221
|
+
return stableKey(a) === stableKey(b);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// src/graph/update-diff.ts
|
|
2225
|
+
var JITTER = 8;
|
|
2226
|
+
function jitter(id, seed) {
|
|
2227
|
+
const jx = hash32(`${id}|${seed}|jx`) / 4294967296 * 2 - 1;
|
|
2228
|
+
const jy = hash32(`${id}|${seed}|jy`) / 4294967296 * 2 - 1;
|
|
2229
|
+
return { x: jx * JITTER, y: jy * JITTER };
|
|
2230
|
+
}
|
|
2231
|
+
var FALLBACK_R0 = 300;
|
|
2232
|
+
function seededDisc(id, seed) {
|
|
2233
|
+
const u1 = hash32(`${id}|${seed}|r`) / 4294967296;
|
|
2234
|
+
const u2 = hash32(`${id}|${seed}|t`) / 4294967296;
|
|
2235
|
+
const r = FALLBACK_R0 * Math.sqrt(u1);
|
|
2236
|
+
const theta = u2 * Math.PI * 2;
|
|
2237
|
+
return { x: r * Math.cos(theta), y: r * Math.sin(theta) };
|
|
2238
|
+
}
|
|
2239
|
+
function diffGraphUpdate(prevNodes, prevEdges, next, prevConfig, seed) {
|
|
2240
|
+
const prevIds = new Set(prevNodes.map((n) => n.id));
|
|
2241
|
+
const nextIds = new Set(next.nodes.map((n) => n.id));
|
|
2242
|
+
const survivingPositions = /* @__PURE__ */ new Map();
|
|
2243
|
+
const enteringIds = [];
|
|
2244
|
+
for (const n of prevNodes) {
|
|
2245
|
+
if (nextIds.has(n.id)) survivingPositions.set(n.id, { x: n.x, y: n.y });
|
|
2246
|
+
}
|
|
2247
|
+
for (const n of next.nodes) {
|
|
2248
|
+
if (!prevIds.has(n.id)) enteringIds.push(n.id);
|
|
2249
|
+
}
|
|
2250
|
+
const exitingNodes = prevNodes.filter((n) => !nextIds.has(n.id));
|
|
2251
|
+
const nextEdgeKeys = new Set(next.edges.map((e) => `${e.source} ${e.target}`));
|
|
2252
|
+
const exitingEdges = prevEdges.filter((e) => !nextEdgeKeys.has(`${e.source} ${e.target}`));
|
|
2253
|
+
const prevEdgeCounts = /* @__PURE__ */ new Map();
|
|
2254
|
+
for (const e of prevEdges) {
|
|
2255
|
+
const k = `${e.source} ${e.target}`;
|
|
2256
|
+
prevEdgeCounts.set(k, (prevEdgeCounts.get(k) ?? 0) + 1);
|
|
2257
|
+
}
|
|
2258
|
+
let enteringEdgeCount = 0;
|
|
2259
|
+
const remainingPrev = new Map(prevEdgeCounts);
|
|
2260
|
+
for (const e of next.edges) {
|
|
2261
|
+
const k = `${e.source} ${e.target}`;
|
|
2262
|
+
const c = remainingPrev.get(k);
|
|
2263
|
+
if (c && c > 0) {
|
|
2264
|
+
remainingPrev.set(k, c - 1);
|
|
2265
|
+
} else {
|
|
2266
|
+
enteringEdgeCount++;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
const sameNodes = prevIds.size === nextIds.size && enteringIds.length === 0;
|
|
2270
|
+
const sameEdges = edgeSetsEqual(prevEdges, next.edges);
|
|
2271
|
+
const visualOnly = sameNodes && sameEdges && simulationConfigEqual(prevConfig, next.simulationConfig);
|
|
2272
|
+
const spawnPositions = /* @__PURE__ */ new Map();
|
|
2273
|
+
if (enteringIds.length > 0) {
|
|
2274
|
+
const enteringSet = new Set(enteringIds);
|
|
2275
|
+
const nextAdjacency = buildAdjacency(next.edges);
|
|
2276
|
+
for (const id of enteringIds) {
|
|
2277
|
+
const neighborPos = firstSurvivingNeighborPos(
|
|
2278
|
+
id,
|
|
2279
|
+
nextAdjacency,
|
|
2280
|
+
survivingPositions,
|
|
2281
|
+
enteringSet
|
|
2282
|
+
);
|
|
2283
|
+
const j = jitter(id, seed);
|
|
2284
|
+
if (neighborPos) {
|
|
2285
|
+
spawnPositions.set(id, { x: neighborPos.x + j.x, y: neighborPos.y + j.y });
|
|
2286
|
+
} else {
|
|
2287
|
+
const disc = seededDisc(id, seed);
|
|
2288
|
+
spawnPositions.set(id, { x: disc.x + j.x, y: disc.y + j.y });
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
return {
|
|
2293
|
+
visualOnly,
|
|
2294
|
+
enteringIds,
|
|
2295
|
+
survivingPositions,
|
|
2296
|
+
spawnPositions,
|
|
2297
|
+
exitingNodes,
|
|
2298
|
+
exitingEdges,
|
|
2299
|
+
enteringEdgeCount
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
function buildAdjacency(edges) {
|
|
2303
|
+
const map = /* @__PURE__ */ new Map();
|
|
2304
|
+
const push = (a, b) => {
|
|
2305
|
+
const list = map.get(a);
|
|
2306
|
+
if (list) list.push(b);
|
|
2307
|
+
else map.set(a, [b]);
|
|
2308
|
+
};
|
|
2309
|
+
for (const e of edges) {
|
|
2310
|
+
push(e.source, e.target);
|
|
2311
|
+
push(e.target, e.source);
|
|
2312
|
+
}
|
|
2313
|
+
return map;
|
|
2314
|
+
}
|
|
2315
|
+
function firstSurvivingNeighborPos(id, adjacency, survivingPositions, enteringSet) {
|
|
2316
|
+
const neighbors = adjacency.get(id);
|
|
2317
|
+
if (!neighbors) return null;
|
|
2318
|
+
for (const nid of neighbors) {
|
|
2319
|
+
if (enteringSet.has(nid)) continue;
|
|
2320
|
+
const pos = survivingPositions.get(nid);
|
|
2321
|
+
if (pos) return pos;
|
|
2322
|
+
}
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
function edgeSetsEqual(prev, next) {
|
|
2326
|
+
if (prev.length !== next.length) return false;
|
|
2327
|
+
const key = (e) => `${e.source}->${e.target}`;
|
|
2328
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2329
|
+
for (const e of prev) {
|
|
2330
|
+
const k = key(e);
|
|
2331
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
2332
|
+
}
|
|
2333
|
+
for (const e of next) {
|
|
2334
|
+
const k = key(e);
|
|
2335
|
+
const c = counts.get(k);
|
|
2336
|
+
if (!c) return false;
|
|
2337
|
+
counts.set(k, c - 1);
|
|
2338
|
+
}
|
|
2339
|
+
return true;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// src/graph-mount.ts
|
|
2343
|
+
var SPRINGY_DRAG_MAX_NODES = 5e3;
|
|
2344
|
+
var CURSOR_FORCE_MAX_NODES = 2e3;
|
|
2345
|
+
var CURSOR_POINTER_THROTTLE_MS = 33;
|
|
2346
|
+
var FOLLOW_SETTLE_ALPHA = 0.05;
|
|
2347
|
+
function createGraph(container, spec, options) {
|
|
2348
|
+
let currentSpec = spec;
|
|
2349
|
+
let compilation;
|
|
2350
|
+
let destroyed = false;
|
|
2351
|
+
let shell = null;
|
|
2352
|
+
let canvas = null;
|
|
2353
|
+
let chromeEl = null;
|
|
2354
|
+
let legendEl = null;
|
|
2355
|
+
let legendController = null;
|
|
2356
|
+
let renderer = null;
|
|
2357
|
+
let simulation = null;
|
|
2358
|
+
const spatialIndex = new SpatialIndex2();
|
|
2359
|
+
let interactionManager = null;
|
|
2360
|
+
const searchManager = new GraphSearchManager();
|
|
2361
|
+
let tooltipManager = null;
|
|
2362
|
+
let cleanupKeyboard = null;
|
|
2363
|
+
let disconnectResize = null;
|
|
2364
|
+
let positionedNodes = [];
|
|
2365
|
+
let positionedEdges = [];
|
|
2366
|
+
let adjacencyMap = /* @__PURE__ */ new Map();
|
|
2367
|
+
let nodeDataMap = /* @__PURE__ */ new Map();
|
|
2368
|
+
let edgeDataMap = /* @__PURE__ */ new Map();
|
|
2369
|
+
let hoveredNodeId = null;
|
|
2370
|
+
let hoveredEdgeId = null;
|
|
2371
|
+
let selectedNodeIds = /* @__PURE__ */ new Set();
|
|
2372
|
+
let animFrameId = null;
|
|
2373
|
+
let needsRender = false;
|
|
2374
|
+
let isGesturing = false;
|
|
2375
|
+
const scheduler = new AnimationScheduler(() => scheduleRender());
|
|
2376
|
+
let gestureTimeout = null;
|
|
2377
|
+
let lastEdgeHitTime = 0;
|
|
2378
|
+
let lastPointerFeedTime = 0;
|
|
2379
|
+
let activeFlight = null;
|
|
2380
|
+
let activeFollow = null;
|
|
2381
|
+
let lastAlpha = 1;
|
|
2382
|
+
let cameraChangePending = false;
|
|
2383
|
+
let highlightSet = null;
|
|
2384
|
+
let highlightDimOpacity = null;
|
|
2385
|
+
let seedIds = /* @__PURE__ */ new Set();
|
|
2386
|
+
let activeCategories = /* @__PURE__ */ new Set();
|
|
2387
|
+
let transientHighlight = null;
|
|
2388
|
+
let transientTarget = null;
|
|
2389
|
+
const seenWarnings = /* @__PURE__ */ new Set();
|
|
2390
|
+
let nodeCategory = /* @__PURE__ */ new Map();
|
|
2391
|
+
let focusTransition = null;
|
|
2392
|
+
let focusAnim = null;
|
|
2393
|
+
let hoverRadiusTween = null;
|
|
2394
|
+
let entranceProgress = 1;
|
|
2395
|
+
let entranceActive = false;
|
|
2396
|
+
let entranceStagger = false;
|
|
2397
|
+
let entranceOrderMap = null;
|
|
2398
|
+
let entranceOffsetMap = null;
|
|
2399
|
+
let entranceFitInFlight = false;
|
|
2400
|
+
let entranceReveal = null;
|
|
2401
|
+
let suppressEntranceOnce = options?.suppressEntrance ?? false;
|
|
2402
|
+
let enterAlphaMap = null;
|
|
2403
|
+
let exitingGhosts = null;
|
|
2404
|
+
function markGesture() {
|
|
2405
|
+
isGesturing = true;
|
|
2406
|
+
if (gestureTimeout !== null) clearTimeout(gestureTimeout);
|
|
2407
|
+
gestureTimeout = setTimeout(() => {
|
|
2408
|
+
isGesturing = false;
|
|
2409
|
+
gestureTimeout = null;
|
|
2410
|
+
needsRender = true;
|
|
2411
|
+
scheduleRender();
|
|
2412
|
+
}, 150);
|
|
2413
|
+
}
|
|
2414
|
+
function getContainerDimensions2() {
|
|
2415
|
+
return getContainerDimensions(container);
|
|
2416
|
+
}
|
|
2417
|
+
function warnOnce(message) {
|
|
2418
|
+
if (seenWarnings.has(message)) return;
|
|
2419
|
+
seenWarnings.add(message);
|
|
2420
|
+
if (options?.onWarn) options.onWarn(message);
|
|
2421
|
+
else console.warn(message);
|
|
2422
|
+
}
|
|
2423
|
+
function compile(specToCompile = currentSpec) {
|
|
2424
|
+
const { width, height } = getContainerDimensions2();
|
|
2425
|
+
const darkMode = resolveDarkMode(options?.darkMode);
|
|
2426
|
+
const compileOpts = {
|
|
2427
|
+
width,
|
|
2428
|
+
height,
|
|
2429
|
+
theme: options?.theme,
|
|
2430
|
+
darkMode,
|
|
2431
|
+
watermark: options?.watermark,
|
|
2432
|
+
onWarn: warnOnce
|
|
2433
|
+
};
|
|
2434
|
+
return compileGraph(specToCompile, compileOpts);
|
|
2435
|
+
}
|
|
2436
|
+
function buildDataMaps() {
|
|
2437
|
+
nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
|
|
2438
|
+
edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
|
|
2439
|
+
nodeCategory = /* @__PURE__ */ new Map();
|
|
2440
|
+
const field = compilation.legendField;
|
|
2441
|
+
if (field) {
|
|
2442
|
+
for (const n of compilation.nodes) {
|
|
2443
|
+
const v = n.data?.[field];
|
|
2444
|
+
if (v != null) nodeCategory.set(n.id, String(v));
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
function buildAdjacencyMap(edges) {
|
|
2449
|
+
const map = /* @__PURE__ */ new Map();
|
|
2450
|
+
for (const edge of edges) {
|
|
2451
|
+
if (!map.has(edge.source)) map.set(edge.source, /* @__PURE__ */ new Set());
|
|
2452
|
+
if (!map.has(edge.target)) map.set(edge.target, /* @__PURE__ */ new Set());
|
|
2453
|
+
map.get(edge.source).add(edge.target);
|
|
2454
|
+
map.get(edge.target).add(edge.source);
|
|
2455
|
+
}
|
|
2456
|
+
return map;
|
|
2457
|
+
}
|
|
2458
|
+
function toSimNodes(nodes) {
|
|
2459
|
+
return nodes.map((n) => ({
|
|
2460
|
+
id: n.id,
|
|
2461
|
+
radius: n.radius,
|
|
2462
|
+
community: n.community
|
|
2463
|
+
}));
|
|
2464
|
+
}
|
|
2465
|
+
function toSimEdges(edges) {
|
|
2466
|
+
return edges.map((e) => ({
|
|
2467
|
+
source: e.source,
|
|
2468
|
+
target: e.target
|
|
2469
|
+
}));
|
|
2470
|
+
}
|
|
2471
|
+
function nodeDataById(nodeId) {
|
|
2472
|
+
return nodeDataMap.get(nodeId) ?? {};
|
|
2473
|
+
}
|
|
2474
|
+
function pointToSegmentDist(px, py, ax, ay, bx, by) {
|
|
2475
|
+
const dx = bx - ax;
|
|
2476
|
+
const dy = by - ay;
|
|
2477
|
+
const lenSq = dx * dx + dy * dy;
|
|
2478
|
+
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
|
|
2479
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
2480
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
2481
|
+
}
|
|
2482
|
+
function hitTestEdge(graphX, graphY, threshold) {
|
|
2483
|
+
let bestDist = threshold;
|
|
2484
|
+
let bestEdgeId = null;
|
|
2485
|
+
for (const edge of positionedEdges) {
|
|
2486
|
+
const dist = pointToSegmentDist(
|
|
2487
|
+
graphX,
|
|
2488
|
+
graphY,
|
|
2489
|
+
edge.sourceX,
|
|
2490
|
+
edge.sourceY,
|
|
2491
|
+
edge.targetX,
|
|
2492
|
+
edge.targetY
|
|
2493
|
+
);
|
|
2494
|
+
if (dist < bestDist) {
|
|
2495
|
+
bestDist = dist;
|
|
2496
|
+
bestEdgeId = `${edge.source}->${edge.target}`;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return bestEdgeId;
|
|
2500
|
+
}
|
|
2501
|
+
function edgeDataById(edgeId) {
|
|
2502
|
+
return edgeDataMap.get(edgeId) ?? null;
|
|
2503
|
+
}
|
|
2504
|
+
function createSurface() {
|
|
2505
|
+
const activeShell = shell;
|
|
2506
|
+
if (!activeShell) return;
|
|
2507
|
+
canvas = document.createElement("canvas");
|
|
2508
|
+
canvas.className = "oc-graph-canvas";
|
|
2509
|
+
canvas.setAttribute("role", "img");
|
|
2510
|
+
if (compilation.a11y?.altText) {
|
|
2511
|
+
canvas.setAttribute("aria-label", compilation.a11y.altText);
|
|
2512
|
+
}
|
|
2513
|
+
activeShell.mountSurface(canvas);
|
|
2514
|
+
renderLegend();
|
|
2515
|
+
activeShell.syncChromeInset();
|
|
2516
|
+
const { width, height } = activeShell.getSize();
|
|
2517
|
+
renderer = new GraphCanvasRenderer(canvas);
|
|
2518
|
+
renderer.resize(width, height);
|
|
2519
|
+
}
|
|
2520
|
+
function renderChrome() {
|
|
2521
|
+
shell?.renderChrome(compilation);
|
|
2522
|
+
}
|
|
2523
|
+
function legendSetting() {
|
|
2524
|
+
return options?.legend ?? currentSpec.legend;
|
|
2525
|
+
}
|
|
2526
|
+
function legendConfig() {
|
|
2527
|
+
const l = legendSetting();
|
|
2528
|
+
if (l && typeof l === "object") {
|
|
2529
|
+
return { interactive: l.interactive ?? true, counts: l.counts ?? true };
|
|
2530
|
+
}
|
|
2531
|
+
return { interactive: true, counts: true };
|
|
2532
|
+
}
|
|
2533
|
+
function legendViewData() {
|
|
2534
|
+
return { nodes: getLegend().nodes, edges: getLegend().edges };
|
|
2535
|
+
}
|
|
2536
|
+
function renderLegend() {
|
|
2537
|
+
if (!legendEl) return;
|
|
2538
|
+
const cfg = legendConfig();
|
|
2539
|
+
if (!legendController) {
|
|
2540
|
+
legendController = createGraphLegend(legendEl, legendViewData(), {
|
|
2541
|
+
interactive: cfg.interactive,
|
|
2542
|
+
counts: cfg.counts,
|
|
2543
|
+
onToggle: (value) => toggleLegendCategory(value),
|
|
2544
|
+
onHover: (value) => {
|
|
2545
|
+
const field = compilation.legendField;
|
|
2546
|
+
options?.onLegendHover?.(value !== null && field ? { field, value } : null);
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
} else {
|
|
2550
|
+
legendController.update(legendViewData());
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
function syncLegendActiveState() {
|
|
2554
|
+
if (legendController) legendController.update(legendViewData());
|
|
2555
|
+
syncChromeInset();
|
|
2556
|
+
}
|
|
2557
|
+
function syncChromeInset() {
|
|
2558
|
+
shell?.syncChromeInset();
|
|
2559
|
+
}
|
|
2560
|
+
function chromeInsetTop() {
|
|
2561
|
+
if (!chromeEl || chromeEl.style.display === "none") return 0;
|
|
2562
|
+
return chromeEl.offsetHeight;
|
|
2563
|
+
}
|
|
2564
|
+
function springyDragEnabled() {
|
|
2565
|
+
return compilation.interaction.springyDrag && compilation.nodes.length <= SPRINGY_DRAG_MAX_NODES;
|
|
2566
|
+
}
|
|
2567
|
+
function cursorForceEnabled() {
|
|
2568
|
+
return compilation.interaction.cursorRepulsion !== null && compilation.nodes.length <= CURSOR_FORCE_MAX_NODES && !prefersReducedMotion();
|
|
2569
|
+
}
|
|
2570
|
+
function initSimulation(opts) {
|
|
2571
|
+
const simNodes = toSimNodes(compilation.nodes);
|
|
2572
|
+
const simEdges = toSimEdges(compilation.edges);
|
|
2573
|
+
const config = compilation.simulationConfig;
|
|
2574
|
+
if (opts?.positions) {
|
|
2575
|
+
seedNodePositions(simNodes, config.seed ?? 0);
|
|
2576
|
+
for (const n of simNodes) {
|
|
2577
|
+
const p = opts.positions.get(n.id);
|
|
2578
|
+
if (p) {
|
|
2579
|
+
n.x = p.x;
|
|
2580
|
+
n.y = p.y;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
} else {
|
|
2584
|
+
seedNodePositions(simNodes, config.seed ?? 0);
|
|
2585
|
+
}
|
|
2586
|
+
simulation = SimulationManager.create(simNodes, simEdges, {
|
|
2587
|
+
chargeStrength: config.chargeStrength,
|
|
2588
|
+
linkDistance: config.linkDistance,
|
|
2589
|
+
clustering: config.clustering,
|
|
2590
|
+
alphaDecay: config.alphaDecay,
|
|
2591
|
+
velocityDecay: config.velocityDecay,
|
|
2592
|
+
collisionRadius: config.collisionRadius,
|
|
2593
|
+
collisionPadding: config.collisionPadding,
|
|
2594
|
+
linkStrength: config.linkStrength,
|
|
2595
|
+
// Update sims suppress the (non-alpha-scaled) center force to avoid a
|
|
2596
|
+
// global jump on tick 1; the alpha-scaled forceX/forceY gravity still
|
|
2597
|
+
// holds the layout centered.
|
|
2598
|
+
centerForce: opts?.suppressCenter ? false : config.centerForce,
|
|
2599
|
+
warmupTicks: opts?.skipWarmup ? 0 : config.warmupTicks,
|
|
2600
|
+
warmupBudgetMs: config.warmupBudgetMs,
|
|
2601
|
+
initialAlpha: opts?.initialAlpha ?? config.initialAlpha,
|
|
2602
|
+
// Cursor force radius/strength (null when disabled or gated off by node
|
|
2603
|
+
// count). The mount only feeds pointer positions when the same gate holds.
|
|
2604
|
+
cursorRepulsion: cursorForceEnabled() ? compilation.interaction.cursorRepulsion : null
|
|
2605
|
+
});
|
|
2606
|
+
lastAlpha = opts?.initialAlpha ?? 1;
|
|
2607
|
+
let initialSettleDone = false;
|
|
2608
|
+
let initialFitDone = opts?.skipEntrance ?? false;
|
|
2609
|
+
simulation.onTick((positions, alpha) => {
|
|
2610
|
+
if (destroyed) return;
|
|
2611
|
+
lastAlpha = alpha;
|
|
2612
|
+
const posMap = /* @__PURE__ */ new Map();
|
|
2613
|
+
for (const p of positions) {
|
|
2614
|
+
posMap.set(p.id, { x: p.x, y: p.y });
|
|
2615
|
+
}
|
|
2616
|
+
positionedNodes = compilation.nodes.map((node, index) => {
|
|
2617
|
+
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
|
|
2618
|
+
return { ...node, x: pos.x, y: pos.y, index };
|
|
2619
|
+
});
|
|
2620
|
+
positionedEdges = compilation.edges.map((edge) => {
|
|
2621
|
+
const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
|
|
2622
|
+
const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
|
|
2623
|
+
return {
|
|
2624
|
+
...edge,
|
|
2625
|
+
sourceX: src.x,
|
|
2626
|
+
sourceY: src.y,
|
|
2627
|
+
targetX: tgt.x,
|
|
2628
|
+
targetY: tgt.y
|
|
2629
|
+
};
|
|
2630
|
+
});
|
|
2631
|
+
spatialIndex.rebuild(positionedNodes);
|
|
2632
|
+
if (!initialFitDone && positionedNodes.length > 0 && interactionManager && options?.fitOnLoad !== false) {
|
|
2633
|
+
initialFitDone = true;
|
|
2634
|
+
startEntrance();
|
|
2635
|
+
} else if (!initialFitDone && options?.fitOnLoad === false) {
|
|
2636
|
+
initialFitDone = true;
|
|
2637
|
+
}
|
|
2638
|
+
needsRender = true;
|
|
2639
|
+
scheduleRender();
|
|
2640
|
+
});
|
|
2641
|
+
simulation.onSettled(() => {
|
|
2642
|
+
if (initialSettleDone) return;
|
|
2643
|
+
initialSettleDone = true;
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
function computeInitialFit() {
|
|
2647
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
2648
|
+
const warmed = (compilation.simulationConfig.warmupTicks ?? 0) > 0;
|
|
2649
|
+
const { transform } = ZoomTransform.fitBounds(positionedNodes, cw, ch, void 0, {
|
|
2650
|
+
spread: !warmed,
|
|
2651
|
+
insetTop: chromeInsetTop()
|
|
2652
|
+
});
|
|
2653
|
+
return transform;
|
|
2654
|
+
}
|
|
2655
|
+
function startEntrance() {
|
|
2656
|
+
if (!interactionManager) return;
|
|
2657
|
+
const fit = computeInitialFit();
|
|
2658
|
+
const enter = compilation.animation?.enter ?? null;
|
|
2659
|
+
const suppressed = suppressEntranceOnce;
|
|
2660
|
+
suppressEntranceOnce = false;
|
|
2661
|
+
if (suppressed || !enter || prefersReducedMotion()) {
|
|
2662
|
+
interactionManager.setTransform(fit);
|
|
2663
|
+
cameraChangePending = true;
|
|
2664
|
+
entranceActive = false;
|
|
2665
|
+
entranceProgress = 1;
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
2669
|
+
const pulledBack = fit.zoomAt(fit.k * 0.7, cw / 2, ch / 2);
|
|
2670
|
+
interactionManager.setTransform(pulledBack);
|
|
2671
|
+
cameraChangePending = true;
|
|
2672
|
+
entranceActive = true;
|
|
2673
|
+
entranceProgress = 0;
|
|
2674
|
+
entranceStagger = enter.stagger && positionedNodes.length <= ENTRANCE_STAGGER_MAX_NODES;
|
|
2675
|
+
if (entranceStagger) {
|
|
2676
|
+
entranceOrderMap = entranceOrder(positionedNodes);
|
|
2677
|
+
entranceOffsetMap = entranceOffsets(positionedNodes);
|
|
2678
|
+
} else {
|
|
2679
|
+
entranceOrderMap = null;
|
|
2680
|
+
entranceOffsetMap = null;
|
|
2681
|
+
}
|
|
2682
|
+
if (enter.cameraFit) {
|
|
2683
|
+
entranceFitInFlight = true;
|
|
2684
|
+
flyCamera(fit, { duration: enter.duration + 100 }, () => {
|
|
2685
|
+
entranceFitInFlight = false;
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
const ease = resolveEase(enter.ease);
|
|
2689
|
+
entranceReveal = createTween({
|
|
2690
|
+
duration: enter.duration,
|
|
2691
|
+
ease,
|
|
2692
|
+
apply: (t) => {
|
|
2693
|
+
entranceProgress = t;
|
|
2694
|
+
needsRender = true;
|
|
2695
|
+
},
|
|
2696
|
+
onDone: () => {
|
|
2697
|
+
entranceProgress = 1;
|
|
2698
|
+
entranceActive = false;
|
|
2699
|
+
entranceReveal = null;
|
|
2700
|
+
needsRender = true;
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2703
|
+
scheduler.add(entranceReveal);
|
|
2704
|
+
}
|
|
2705
|
+
function getCanvasDimensions() {
|
|
2706
|
+
if (!canvas) return { width: 600, height: 400 };
|
|
2707
|
+
const rect = canvas.getBoundingClientRect();
|
|
2708
|
+
return {
|
|
2709
|
+
width: Math.max(rect.width || 600, 100),
|
|
2710
|
+
height: Math.max(rect.height || 400, 100)
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
function scheduleRender() {
|
|
2714
|
+
if (animFrameId !== null || destroyed) return;
|
|
2715
|
+
animFrameId = requestAnimationFrame(renderFrame);
|
|
2716
|
+
}
|
|
2717
|
+
function hoverConnectedSet(nodeId) {
|
|
2718
|
+
if (nodeId === null) return null;
|
|
2719
|
+
if (compilation.interaction.hoverMode === "category") {
|
|
2720
|
+
const cat = nodeCategory.get(nodeId);
|
|
2721
|
+
const set2 = /* @__PURE__ */ new Set([nodeId]);
|
|
2722
|
+
if (cat !== void 0) {
|
|
2723
|
+
for (const [id, c] of nodeCategory) if (c === cat) set2.add(id);
|
|
2724
|
+
}
|
|
2725
|
+
return set2;
|
|
2726
|
+
}
|
|
2727
|
+
const set = /* @__PURE__ */ new Set([nodeId]);
|
|
2728
|
+
const neighbors = adjacencyMap.get(nodeId);
|
|
2729
|
+
if (neighbors) for (const nid of neighbors) set.add(nid);
|
|
2730
|
+
return set;
|
|
2731
|
+
}
|
|
2732
|
+
function standingSnapshot() {
|
|
2733
|
+
return composeStandingFocus(
|
|
2734
|
+
highlightSet,
|
|
2735
|
+
searchManager.getMatches(),
|
|
2736
|
+
selectedNodeIds,
|
|
2737
|
+
adjacencyMap
|
|
2738
|
+
);
|
|
2739
|
+
}
|
|
2740
|
+
function targetSnapshot() {
|
|
2741
|
+
return layerHoverFocus(standingSnapshot(), hoveredNodeId, hoverConnectedSet(hoveredNodeId));
|
|
2742
|
+
}
|
|
2743
|
+
function armFocus(now) {
|
|
2744
|
+
const target = targetSnapshot();
|
|
2745
|
+
const hoverCfg = compilation.animation?.hover ?? null;
|
|
2746
|
+
const duration = hoverCfg && !prefersReducedMotion() ? hoverCfg.duration : 0;
|
|
2747
|
+
const ease = resolveEase(hoverCfg?.ease ?? "smooth");
|
|
2748
|
+
if (!focusTransition) {
|
|
2749
|
+
focusTransition = new FocusTransition(target, duration, ease, now);
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
focusTransition.retarget(target, now);
|
|
2753
|
+
if (focusAnim) scheduler.remove(focusAnim);
|
|
2754
|
+
focusAnim = {
|
|
2755
|
+
tick: (t) => {
|
|
2756
|
+
const running = focusTransition !== null && !focusTransition.isSettled(t);
|
|
2757
|
+
if (!running) focusAnim = null;
|
|
2758
|
+
return running;
|
|
2759
|
+
},
|
|
2760
|
+
finish: () => {
|
|
2761
|
+
focusAnim = null;
|
|
2762
|
+
},
|
|
2763
|
+
cancel: () => {
|
|
2764
|
+
focusAnim = null;
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
scheduler.add(focusAnim);
|
|
2768
|
+
}
|
|
2769
|
+
function refreshFocus() {
|
|
2770
|
+
armFocus(performance.now());
|
|
2771
|
+
needsRender = true;
|
|
2772
|
+
scheduleRender();
|
|
2773
|
+
}
|
|
2774
|
+
function resolveHighlightTarget2(target) {
|
|
2775
|
+
return resolveHighlightTarget(target, compilation.nodes, adjacencyMap);
|
|
2776
|
+
}
|
|
2777
|
+
function categoryHighlightSet2() {
|
|
2778
|
+
return categoryHighlightSet(activeCategories, nodeCategory);
|
|
2779
|
+
}
|
|
2780
|
+
function recomputeHighlight() {
|
|
2781
|
+
const filter = categoryHighlightSet2();
|
|
2782
|
+
const transient = transientHighlight !== null && transientHighlight.size > 0 ? transientHighlight : null;
|
|
2783
|
+
if (filter !== null && transient !== null) {
|
|
2784
|
+
const inter = /* @__PURE__ */ new Set();
|
|
2785
|
+
for (const id of transient) if (filter.has(id)) inter.add(id);
|
|
2786
|
+
highlightSet = inter.size > 0 ? inter : transient;
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
highlightSet = filter ?? transient;
|
|
2790
|
+
}
|
|
2791
|
+
function refreshTransientHighlight() {
|
|
2792
|
+
if (transientTarget === null) {
|
|
2793
|
+
transientHighlight = null;
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
const nextIds = new Set(compilation.nodes.map((n) => n.id));
|
|
2797
|
+
const resolved = new Set(
|
|
2798
|
+
[...resolveHighlightTarget2(transientTarget)].filter((id) => nextIds.has(id))
|
|
2799
|
+
);
|
|
2800
|
+
transientHighlight = resolved.size > 0 ? resolved : null;
|
|
2801
|
+
}
|
|
2802
|
+
function effectiveDimOpacity() {
|
|
2803
|
+
const custom = transientHighlight !== null ? highlightDimOpacity : null;
|
|
2804
|
+
return custom ?? compilation.interaction.dimOpacity;
|
|
2805
|
+
}
|
|
2806
|
+
function emitHighlightChange() {
|
|
2807
|
+
options?.onHighlightChange?.(highlightSet ? [...highlightSet] : null);
|
|
2808
|
+
}
|
|
2809
|
+
function applyInitialHighlight() {
|
|
2810
|
+
const init = compilation.initialHighlight;
|
|
2811
|
+
if (!init) return;
|
|
2812
|
+
activeCategories = new Set(init.values);
|
|
2813
|
+
recomputeHighlight();
|
|
2814
|
+
}
|
|
2815
|
+
function startHoverRadiusTween(nodeId) {
|
|
2816
|
+
const hoverCfg = compilation.animation?.hover ?? null;
|
|
2817
|
+
if (nodeId === null || !hoverCfg || prefersReducedMotion()) {
|
|
2818
|
+
hoverRadiusTween = nodeId ? { nodeId, scale: 1.15 } : null;
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
hoverRadiusTween = { nodeId, scale: 1 };
|
|
2822
|
+
const ease = resolveEase(hoverCfg.ease);
|
|
2823
|
+
const tween = createTween({
|
|
2824
|
+
duration: hoverCfg.duration,
|
|
2825
|
+
ease,
|
|
2826
|
+
apply: (t) => {
|
|
2827
|
+
if (hoverRadiusTween?.nodeId === nodeId) hoverRadiusTween.scale = 1 + 0.15 * t;
|
|
2828
|
+
needsRender = true;
|
|
2829
|
+
},
|
|
2830
|
+
onDone: () => {
|
|
2831
|
+
if (hoverRadiusTween?.nodeId === nodeId) hoverRadiusTween.scale = 1.15;
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
scheduler.add(tween);
|
|
2835
|
+
}
|
|
2836
|
+
function showNodeTooltip(nodeId) {
|
|
2837
|
+
if (!tooltipManager || !interactionManager) return;
|
|
2838
|
+
const defaults = compilation.tooltipDescriptors.get(nodeId);
|
|
2839
|
+
if (!defaults) return;
|
|
2840
|
+
const node = positionedNodes.find((n) => n.id === nodeId);
|
|
2841
|
+
if (!node) return;
|
|
2842
|
+
const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
|
|
2843
|
+
const formatter = tooltipFormatter();
|
|
2844
|
+
if (!formatter) {
|
|
2845
|
+
tooltipManager.show(defaults, screen.x, screen.y);
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const result = formatter({ kind: "node", data: nodeDataById(nodeId) }, defaults);
|
|
2849
|
+
applyFormatterResult(result, screen.x, screen.y);
|
|
2850
|
+
}
|
|
2851
|
+
function showEdgeTooltip(edgeId, data, screenX, screenY) {
|
|
2852
|
+
if (!tooltipManager) return;
|
|
2853
|
+
const edge = compilation.edges.find((e) => `${e.source}->${e.target}` === edgeId);
|
|
2854
|
+
const defaults = edge ? buildEdgeTooltip(edge) : { title: edgeId, fields: [] };
|
|
2855
|
+
const formatter = tooltipFormatter();
|
|
2856
|
+
if (!formatter) {
|
|
2857
|
+
tooltipManager.show(defaults, screenX, screenY);
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
const result = formatter({ kind: "edge", data }, defaults);
|
|
2861
|
+
applyFormatterResult(result, screenX, screenY);
|
|
2862
|
+
}
|
|
2863
|
+
function tooltipFormatter() {
|
|
2864
|
+
const t = options?.tooltip;
|
|
2865
|
+
return t && typeof t === "object" ? t.formatter ?? null : null;
|
|
2866
|
+
}
|
|
2867
|
+
function applyFormatterResult(result, x, y) {
|
|
2868
|
+
if (!tooltipManager) return;
|
|
2869
|
+
if (result === null) {
|
|
2870
|
+
tooltipManager.hide();
|
|
2871
|
+
} else if (typeof result === "string") {
|
|
2872
|
+
tooltipManager.show({ text: result }, x, y);
|
|
2873
|
+
} else if (result instanceof HTMLElement) {
|
|
2874
|
+
tooltipManager.show({ element: result }, x, y);
|
|
2875
|
+
} else {
|
|
2876
|
+
tooltipManager.show(result, x, y);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
function buildRenderState(now) {
|
|
2880
|
+
const transform = interactionManager.getTransform();
|
|
2881
|
+
if (!focusTransition) armFocus(now);
|
|
2882
|
+
const ft = focusTransition;
|
|
2883
|
+
const t = ft.progress(now);
|
|
2884
|
+
const focus = t < 1 ? { t, prev: ft.prev, next: ft.next } : void 0;
|
|
2885
|
+
const settledNext = ft.next;
|
|
2886
|
+
const hoverRadiusScale = hoverRadiusTween ? /* @__PURE__ */ new Map([[hoverRadiusTween.nodeId, hoverRadiusTween.scale]]) : void 0;
|
|
2887
|
+
return {
|
|
2888
|
+
nodes: positionedNodes,
|
|
2889
|
+
edges: positionedEdges,
|
|
2890
|
+
transform: { x: transform.x, y: transform.y, k: transform.k },
|
|
2891
|
+
hoveredNodeId,
|
|
2892
|
+
hoveredEdgeId,
|
|
2893
|
+
selectedNodeIds,
|
|
2894
|
+
adjacencyMap,
|
|
2895
|
+
theme: compilation.theme,
|
|
2896
|
+
searchMatches: searchManager.getMatches(),
|
|
2897
|
+
exemptIds: seedIds,
|
|
2898
|
+
isGesturing,
|
|
2899
|
+
watermark: compilation.watermark,
|
|
2900
|
+
focus: focus ?? { t: 1, prev: settledNext, next: settledNext },
|
|
2901
|
+
hoverRadiusScale,
|
|
2902
|
+
dimOpacity: effectiveDimOpacity(),
|
|
2903
|
+
entrance: entranceActive && entranceProgress < 1 ? {
|
|
2904
|
+
t: entranceProgress,
|
|
2905
|
+
stagger: entranceStagger,
|
|
2906
|
+
order: entranceOrderMap ?? void 0,
|
|
2907
|
+
offsets: entranceOffsetMap ?? void 0
|
|
2908
|
+
} : void 0,
|
|
2909
|
+
enterAlpha: enterAlphaMap ?? void 0,
|
|
2910
|
+
exiting: exitingGhosts ?? void 0
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
function renderFrame(now) {
|
|
2914
|
+
animFrameId = null;
|
|
2915
|
+
if (destroyed || !renderer || !interactionManager) return;
|
|
2916
|
+
if (scheduler.tick(now)) needsRender = true;
|
|
2917
|
+
if (needsRender) {
|
|
2918
|
+
needsRender = false;
|
|
2919
|
+
renderer.render(buildRenderState(now));
|
|
2920
|
+
}
|
|
2921
|
+
if (scheduler.active) scheduleRender();
|
|
2922
|
+
if (cameraChangePending) {
|
|
2923
|
+
cameraChangePending = false;
|
|
2924
|
+
const t = interactionManager.getTransform();
|
|
2925
|
+
options?.onCameraChange?.({ x: t.x, y: t.y, k: t.k });
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
function flyCamera(to, opts, onDone) {
|
|
2929
|
+
if (destroyed || !interactionManager) return;
|
|
2930
|
+
cancelFlight();
|
|
2931
|
+
const cameraCfg = compilation.animation?.camera ?? null;
|
|
2932
|
+
const resolveTarget = () => typeof to === "function" ? to() : to;
|
|
2933
|
+
const snap = cameraCfg === null || prefersReducedMotion() || opts?.duration === 0;
|
|
2934
|
+
if (snap) {
|
|
2935
|
+
interactionManager.setTransform(resolveTarget());
|
|
2936
|
+
cameraChangePending = true;
|
|
2937
|
+
needsRender = true;
|
|
2938
|
+
scheduleRender();
|
|
2939
|
+
onDone?.();
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
const { width, height } = getCanvasDimensions();
|
|
2943
|
+
const duration = opts?.duration ?? cameraCfg?.duration ?? "auto";
|
|
2944
|
+
const ease = opts?.ease ?? cameraCfg?.ease ?? "smooth";
|
|
2945
|
+
const heavy = positionedNodes.length > 1e3;
|
|
2946
|
+
const flight = createCameraFlight({
|
|
2947
|
+
from: interactionManager.getTransform(),
|
|
2948
|
+
to,
|
|
2949
|
+
viewport: { width, height },
|
|
2950
|
+
apply: (t) => {
|
|
2951
|
+
interactionManager.setTransform(t);
|
|
2952
|
+
isGesturing = heavy;
|
|
2953
|
+
cameraChangePending = true;
|
|
2954
|
+
needsRender = true;
|
|
2955
|
+
},
|
|
2956
|
+
onDone: () => {
|
|
2957
|
+
activeFlight = null;
|
|
2958
|
+
isGesturing = false;
|
|
2959
|
+
needsRender = true;
|
|
2960
|
+
if (typeof to === "function") startFollow(to);
|
|
2961
|
+
scheduleRender();
|
|
2962
|
+
onDone?.();
|
|
2963
|
+
},
|
|
2964
|
+
opts: { duration, ease }
|
|
2965
|
+
});
|
|
2966
|
+
activeFlight = flight;
|
|
2967
|
+
scheduler.add(flight);
|
|
2968
|
+
}
|
|
2969
|
+
function startFollow(target) {
|
|
2970
|
+
const follow = createCameraFollow({
|
|
2971
|
+
target,
|
|
2972
|
+
apply: (t) => {
|
|
2973
|
+
interactionManager.setTransform(t);
|
|
2974
|
+
cameraChangePending = true;
|
|
2975
|
+
needsRender = true;
|
|
2976
|
+
},
|
|
2977
|
+
isActive: () => !destroyed && lastAlpha >= FOLLOW_SETTLE_ALPHA
|
|
2978
|
+
});
|
|
2979
|
+
activeFollow = follow;
|
|
2980
|
+
scheduler.add(follow);
|
|
2981
|
+
}
|
|
2982
|
+
function cancelFlight() {
|
|
2983
|
+
if (activeFlight) {
|
|
2984
|
+
scheduler.remove(activeFlight);
|
|
2985
|
+
activeFlight = null;
|
|
2986
|
+
isGesturing = false;
|
|
2987
|
+
}
|
|
2988
|
+
if (activeFollow) {
|
|
2989
|
+
scheduler.remove(activeFollow);
|
|
2990
|
+
activeFollow = null;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
function initInteraction() {
|
|
2994
|
+
if (!canvas) return;
|
|
2995
|
+
interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
|
|
2996
|
+
onTransformChange(_transform) {
|
|
2997
|
+
cancelFlight();
|
|
2998
|
+
markGesture();
|
|
2999
|
+
cameraChangePending = true;
|
|
3000
|
+
needsRender = true;
|
|
3001
|
+
scheduleRender();
|
|
3002
|
+
},
|
|
3003
|
+
onHoverChange(nodeId) {
|
|
3004
|
+
if (nodeId === hoveredNodeId) return;
|
|
3005
|
+
hoveredNodeId = nodeId;
|
|
3006
|
+
armFocus(performance.now());
|
|
3007
|
+
startHoverRadiusTween(nodeId);
|
|
3008
|
+
needsRender = true;
|
|
3009
|
+
scheduleRender();
|
|
3010
|
+
if (nodeId && hoveredEdgeId) {
|
|
3011
|
+
hoveredEdgeId = null;
|
|
3012
|
+
options?.onEdgeHover?.(null);
|
|
3013
|
+
tooltipManager?.hide();
|
|
3014
|
+
}
|
|
3015
|
+
options?.onNodeHover?.(nodeId ? nodeDataById(nodeId) : null);
|
|
3016
|
+
if (nodeId && tooltipManager) {
|
|
3017
|
+
showNodeTooltip(nodeId);
|
|
3018
|
+
} else if (!nodeId) {
|
|
3019
|
+
tooltipManager?.hide();
|
|
3020
|
+
}
|
|
3021
|
+
},
|
|
3022
|
+
onBackgroundHover(graphX, graphY, screenX, screenY) {
|
|
3023
|
+
if (hoveredNodeId) return;
|
|
3024
|
+
const now = performance.now();
|
|
3025
|
+
if (now - lastEdgeHitTime < 32) {
|
|
3026
|
+
if (hoveredEdgeId) {
|
|
3027
|
+
hoveredEdgeId = null;
|
|
3028
|
+
needsRender = true;
|
|
3029
|
+
scheduleRender();
|
|
3030
|
+
options?.onEdgeHover?.(null);
|
|
3031
|
+
tooltipManager?.hide();
|
|
3032
|
+
}
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
lastEdgeHitTime = now;
|
|
3036
|
+
const transform = interactionManager?.getTransform();
|
|
3037
|
+
const threshold = 5 / (transform?.k ?? 1);
|
|
3038
|
+
const edgeId = hitTestEdge(graphX, graphY, threshold);
|
|
3039
|
+
if (edgeId !== hoveredEdgeId) {
|
|
3040
|
+
hoveredEdgeId = edgeId;
|
|
3041
|
+
needsRender = true;
|
|
3042
|
+
scheduleRender();
|
|
3043
|
+
if (edgeId) {
|
|
3044
|
+
const data = edgeDataById(edgeId);
|
|
3045
|
+
options?.onEdgeHover?.(data);
|
|
3046
|
+
if (tooltipManager && data) showEdgeTooltip(edgeId, data, screenX, screenY);
|
|
3047
|
+
} else {
|
|
3048
|
+
options?.onEdgeHover?.(null);
|
|
3049
|
+
tooltipManager?.hide();
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
},
|
|
3053
|
+
onSelectionChange(nodeIds) {
|
|
3054
|
+
selectedNodeIds = new Set(nodeIds);
|
|
3055
|
+
armFocus(performance.now());
|
|
3056
|
+
needsRender = true;
|
|
3057
|
+
scheduleRender();
|
|
3058
|
+
options?.onSelectionChange?.(nodeIds);
|
|
3059
|
+
if (nodeIds.length > 0) {
|
|
3060
|
+
const lastId = nodeIds[nodeIds.length - 1];
|
|
3061
|
+
options?.onNodeClick?.(nodeDataById(lastId));
|
|
3062
|
+
}
|
|
3063
|
+
},
|
|
3064
|
+
onNodeDragStart(nodeId) {
|
|
3065
|
+
const node = positionedNodes.find((n) => n.id === nodeId);
|
|
3066
|
+
const x = node?.x ?? 0;
|
|
3067
|
+
const y = node?.y ?? 0;
|
|
3068
|
+
simulation?.pinNode(nodeId, x, y, springyDragEnabled() ? 0.3 : void 0);
|
|
3069
|
+
canvas?.classList.add("oc-graph-canvas--dragging");
|
|
3070
|
+
},
|
|
3071
|
+
onNodeDrag(nodeId, x, y) {
|
|
3072
|
+
simulation?.dragNode(nodeId, x, y);
|
|
3073
|
+
},
|
|
3074
|
+
onNodeDragEnd(nodeId) {
|
|
3075
|
+
simulation?.unpinNode(nodeId, springyDragEnabled() ? 0 : void 0);
|
|
3076
|
+
canvas?.classList.remove("oc-graph-canvas--dragging");
|
|
3077
|
+
},
|
|
3078
|
+
onPointerMove(graphX, graphY) {
|
|
3079
|
+
if (!cursorForceEnabled()) return;
|
|
3080
|
+
const now = performance.now();
|
|
3081
|
+
if (now - lastPointerFeedTime < CURSOR_POINTER_THROTTLE_MS) return;
|
|
3082
|
+
lastPointerFeedTime = now;
|
|
3083
|
+
simulation?.setPointer(graphX, graphY, true);
|
|
3084
|
+
},
|
|
3085
|
+
onPointerLeave() {
|
|
3086
|
+
if (!cursorForceEnabled()) return;
|
|
3087
|
+
simulation?.setPointer(0, 0, false);
|
|
3088
|
+
},
|
|
3089
|
+
onDoubleClick(nodeId) {
|
|
3090
|
+
options?.onNodeDoubleClick?.(nodeDataById(nodeId));
|
|
3091
|
+
}
|
|
3092
|
+
});
|
|
3093
|
+
cleanupKeyboard = attachGraphKeyboardNav({
|
|
3094
|
+
canvas,
|
|
3095
|
+
getNodes: () => positionedNodes,
|
|
3096
|
+
getSelectedIds: () => [...selectedNodeIds],
|
|
3097
|
+
getAdjacency: () => adjacencyMap,
|
|
3098
|
+
onSelect(nodeId) {
|
|
3099
|
+
selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
|
|
3100
|
+
needsRender = true;
|
|
3101
|
+
scheduleRender();
|
|
3102
|
+
options?.onNodeClick?.(nodeDataById(nodeId));
|
|
3103
|
+
options?.onSelectionChange?.([nodeId]);
|
|
3104
|
+
},
|
|
3105
|
+
onDeselect() {
|
|
3106
|
+
selectedNodeIds.clear();
|
|
3107
|
+
needsRender = true;
|
|
3108
|
+
scheduleRender();
|
|
3109
|
+
options?.onSelectionChange?.([]);
|
|
3110
|
+
},
|
|
3111
|
+
onZoom(direction) {
|
|
3112
|
+
if (!interactionManager || !canvas) return;
|
|
3113
|
+
const t = interactionManager.getTransform();
|
|
3114
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
3115
|
+
const factor = direction === "in" ? 1.2 : 0.8;
|
|
3116
|
+
const newK = t.k * factor;
|
|
3117
|
+
const newTransform = t.zoomAt(newK, cw / 2, ch / 2);
|
|
3118
|
+
flyCamera(newTransform, { duration: 200 });
|
|
3119
|
+
},
|
|
3120
|
+
onFitAll() {
|
|
3121
|
+
zoomToFit();
|
|
3122
|
+
}
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
function search(query) {
|
|
3126
|
+
if (destroyed) return;
|
|
3127
|
+
searchManager.search(query, positionedNodes);
|
|
3128
|
+
needsRender = true;
|
|
3129
|
+
scheduleRender();
|
|
3130
|
+
}
|
|
3131
|
+
function clearSearch() {
|
|
3132
|
+
if (destroyed) return;
|
|
3133
|
+
searchManager.clearSearch();
|
|
3134
|
+
needsRender = true;
|
|
3135
|
+
scheduleRender();
|
|
3136
|
+
}
|
|
3137
|
+
function zoomToFit(opts) {
|
|
3138
|
+
if (destroyed || !interactionManager || positionedNodes.length === 0) return;
|
|
3139
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
3140
|
+
const { transform: fitTransform } = ZoomTransform.fitBounds(
|
|
3141
|
+
positionedNodes,
|
|
3142
|
+
cw,
|
|
3143
|
+
ch,
|
|
3144
|
+
opts?.padding,
|
|
3145
|
+
{
|
|
3146
|
+
insetTop: chromeInsetTop()
|
|
3147
|
+
}
|
|
3148
|
+
);
|
|
3149
|
+
flyCamera(fitTransform, opts);
|
|
3150
|
+
}
|
|
3151
|
+
function zoomToNode(nodeId, opts) {
|
|
3152
|
+
if (destroyed || !interactionManager || !canvas) return;
|
|
3153
|
+
const node = positionedNodes.find((n) => n.id === nodeId);
|
|
3154
|
+
if (!node) return;
|
|
3155
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
3156
|
+
const k = clampK(opts?.scale ?? 2);
|
|
3157
|
+
const provider = () => {
|
|
3158
|
+
const live = positionedNodes.find((n) => n.id === nodeId) ?? node;
|
|
3159
|
+
return new ZoomTransform(cw / 2 - live.x * k, ch / 2 - live.y * k, k);
|
|
3160
|
+
};
|
|
3161
|
+
flyCamera(provider, opts);
|
|
3162
|
+
}
|
|
3163
|
+
function flyTo(target, opts) {
|
|
3164
|
+
if (destroyed || !interactionManager) return;
|
|
3165
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
3166
|
+
const k = clampK(target.k ?? interactionManager.getTransform().k);
|
|
3167
|
+
flyCamera(new ZoomTransform(cw / 2 - target.x * k, ch / 2 - target.y * k, k), opts);
|
|
3168
|
+
}
|
|
3169
|
+
function centerAt(x, y, opts) {
|
|
3170
|
+
flyTo({ x, y }, opts);
|
|
3171
|
+
}
|
|
3172
|
+
function getCamera() {
|
|
3173
|
+
const t = interactionManager?.getTransform() ?? ZoomTransform.identity();
|
|
3174
|
+
return { x: t.x, y: t.y, k: t.k };
|
|
3175
|
+
}
|
|
3176
|
+
function selectNode(nodeId, opts) {
|
|
3177
|
+
if (destroyed) return;
|
|
3178
|
+
selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
|
|
3179
|
+
needsRender = true;
|
|
3180
|
+
scheduleRender();
|
|
3181
|
+
options?.onSelectionChange?.([nodeId]);
|
|
3182
|
+
const shouldFly = opts?.fly ?? compilation.interaction.selectFlyTo;
|
|
3183
|
+
if (shouldFly) zoomToNode(nodeId, opts);
|
|
3184
|
+
}
|
|
3185
|
+
function getSelectedNodes() {
|
|
3186
|
+
return [...selectedNodeIds];
|
|
3187
|
+
}
|
|
3188
|
+
function getSearchMatches() {
|
|
3189
|
+
return [...searchManager.getMatches() ?? []];
|
|
3190
|
+
}
|
|
3191
|
+
function highlight(target, opts) {
|
|
3192
|
+
if (destroyed) return;
|
|
3193
|
+
transientTarget = target;
|
|
3194
|
+
refreshTransientHighlight();
|
|
3195
|
+
highlightDimOpacity = opts?.dimOpacity ?? null;
|
|
3196
|
+
recomputeHighlight();
|
|
3197
|
+
refreshFocus();
|
|
3198
|
+
emitHighlightChange();
|
|
3199
|
+
}
|
|
3200
|
+
function clearHighlight() {
|
|
3201
|
+
if (destroyed) return;
|
|
3202
|
+
transientTarget = null;
|
|
3203
|
+
transientHighlight = null;
|
|
3204
|
+
highlightDimOpacity = null;
|
|
3205
|
+
recomputeHighlight();
|
|
3206
|
+
refreshFocus();
|
|
3207
|
+
emitHighlightChange();
|
|
3208
|
+
}
|
|
3209
|
+
function getHighlight() {
|
|
3210
|
+
return highlightSet ? [...highlightSet] : null;
|
|
3211
|
+
}
|
|
3212
|
+
function getLegend() {
|
|
3213
|
+
const nodeEntries = "entries" in compilation.legend ? compilation.legend.entries : [];
|
|
3214
|
+
return {
|
|
3215
|
+
field: compilation.legendField,
|
|
3216
|
+
nodes: nodeEntries.filter((e) => !e.overflow).map((e) => ({
|
|
3217
|
+
label: e.label,
|
|
3218
|
+
color: e.color,
|
|
3219
|
+
count: e.count,
|
|
3220
|
+
active: activeCategories.size === 0 || activeCategories.has(e.label)
|
|
3221
|
+
})),
|
|
3222
|
+
edges: (compilation.edgeLegend ?? []).map((e) => ({
|
|
3223
|
+
label: e.label,
|
|
3224
|
+
color: e.color,
|
|
3225
|
+
count: e.count
|
|
3226
|
+
}))
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
function setActiveCategories(values) {
|
|
3230
|
+
if (destroyed) return;
|
|
3231
|
+
activeCategories = new Set(values);
|
|
3232
|
+
recomputeHighlight();
|
|
3233
|
+
syncLegendActiveState();
|
|
3234
|
+
refreshFocus();
|
|
3235
|
+
emitHighlightChange();
|
|
3236
|
+
}
|
|
3237
|
+
function getActiveCategories() {
|
|
3238
|
+
return [...activeCategories];
|
|
3239
|
+
}
|
|
3240
|
+
function toggleLegendCategory(value) {
|
|
3241
|
+
if (activeCategories.has(value)) activeCategories.delete(value);
|
|
3242
|
+
else activeCategories.add(value);
|
|
3243
|
+
recomputeHighlight();
|
|
3244
|
+
syncLegendActiveState();
|
|
3245
|
+
refreshFocus();
|
|
3246
|
+
options?.onLegendToggle?.([...activeCategories]);
|
|
3247
|
+
emitHighlightChange();
|
|
3248
|
+
}
|
|
3249
|
+
function doResize() {
|
|
3250
|
+
if (destroyed || !canvas || !renderer || !shell) return;
|
|
3251
|
+
const { width, height } = shell.getSize();
|
|
3252
|
+
renderer.resize(width, height);
|
|
3253
|
+
syncChromeInset();
|
|
3254
|
+
if (entranceFitInFlight && interactionManager) {
|
|
3255
|
+
cancelFlight();
|
|
3256
|
+
entranceFitInFlight = false;
|
|
3257
|
+
interactionManager.setTransform(computeInitialFit());
|
|
3258
|
+
cameraChangePending = true;
|
|
3259
|
+
}
|
|
3260
|
+
needsRender = true;
|
|
3261
|
+
scheduleRender();
|
|
3262
|
+
}
|
|
3263
|
+
function update(newSpec) {
|
|
3264
|
+
if (destroyed) return;
|
|
3265
|
+
const nextCompilation = compile(newSpec);
|
|
3266
|
+
if (nextCompilation.numDimensions !== compilation.numDimensions) {
|
|
3267
|
+
warnOnce("createGraph: update() cannot change dimensions; remount the graph");
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
currentSpec = newSpec;
|
|
3271
|
+
scheduler.finishAll();
|
|
3272
|
+
entranceActive = false;
|
|
3273
|
+
entranceProgress = 1;
|
|
3274
|
+
entranceFitInFlight = false;
|
|
3275
|
+
entranceReveal = null;
|
|
3276
|
+
enterAlphaMap = null;
|
|
3277
|
+
exitingGhosts = null;
|
|
3278
|
+
const prevNodes = positionedNodes;
|
|
3279
|
+
const prevEdges = positionedEdges;
|
|
3280
|
+
const prevConfig = compilation.simulationConfig;
|
|
3281
|
+
compilation = nextCompilation;
|
|
3282
|
+
seedIds = new Set(compilation.seedNodeIds);
|
|
3283
|
+
const diff = diffGraphUpdate(
|
|
3284
|
+
prevNodes,
|
|
3285
|
+
prevEdges,
|
|
3286
|
+
compilation,
|
|
3287
|
+
prevConfig,
|
|
3288
|
+
prevConfig.seed ?? 0
|
|
3289
|
+
);
|
|
3290
|
+
if (diff.visualOnly) {
|
|
3291
|
+
runVisualOnlyUpdate();
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
runStructuralUpdate(diff);
|
|
3295
|
+
}
|
|
3296
|
+
function runVisualOnlyUpdate() {
|
|
3297
|
+
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
3298
|
+
buildDataMaps();
|
|
3299
|
+
const posMap = /* @__PURE__ */ new Map();
|
|
3300
|
+
for (const node of positionedNodes) {
|
|
3301
|
+
posMap.set(node.id, { x: node.x, y: node.y });
|
|
3302
|
+
}
|
|
3303
|
+
positionedNodes = compilation.nodes.map((node, index) => {
|
|
3304
|
+
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
|
|
3305
|
+
return { ...node, x: pos.x, y: pos.y, index };
|
|
3306
|
+
});
|
|
3307
|
+
positionedEdges = compilation.edges.map((edge) => {
|
|
3308
|
+
const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
|
|
3309
|
+
const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
|
|
3310
|
+
return { ...edge, sourceX: src.x, sourceY: src.y, targetX: tgt.x, targetY: tgt.y };
|
|
3311
|
+
});
|
|
3312
|
+
spatialIndex.rebuild(positionedNodes);
|
|
3313
|
+
refreshTransientHighlight();
|
|
3314
|
+
recomputeHighlight();
|
|
3315
|
+
reRunSearch();
|
|
3316
|
+
renderChrome();
|
|
3317
|
+
renderLegend();
|
|
3318
|
+
syncLegendActiveState();
|
|
3319
|
+
needsRender = true;
|
|
3320
|
+
scheduleRender();
|
|
3321
|
+
}
|
|
3322
|
+
function runStructuralUpdate(diff) {
|
|
3323
|
+
teardownSimOnly();
|
|
3324
|
+
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
3325
|
+
buildDataMaps();
|
|
3326
|
+
const positions = /* @__PURE__ */ new Map();
|
|
3327
|
+
for (const [id, p] of diff.survivingPositions) positions.set(id, p);
|
|
3328
|
+
for (const [id, p] of diff.spawnPositions) positions.set(id, p);
|
|
3329
|
+
const prevNodeCount = diff.survivingPositions.size + diff.exitingNodes.length;
|
|
3330
|
+
const nextNodeCount = diff.survivingPositions.size + diff.enteringIds.length;
|
|
3331
|
+
const nodeRatio = ratio(
|
|
3332
|
+
diff.enteringIds.length + diff.exitingNodes.length,
|
|
3333
|
+
Math.max(prevNodeCount, nextNodeCount)
|
|
3334
|
+
);
|
|
3335
|
+
const prevEdgeCount = compilation.edges.length - diff.enteringEdgeCount + diff.exitingEdges.length;
|
|
3336
|
+
const nextEdgeCount = compilation.edges.length;
|
|
3337
|
+
const edgeRatio = ratio(
|
|
3338
|
+
diff.enteringEdgeCount + diff.exitingEdges.length,
|
|
3339
|
+
Math.max(prevEdgeCount, nextEdgeCount)
|
|
3340
|
+
);
|
|
3341
|
+
const changeRatio = Math.max(nodeRatio, edgeRatio);
|
|
3342
|
+
const initialAlpha = Math.min(1, 0.3 + 0.7 * changeRatio);
|
|
3343
|
+
positionedNodes = compilation.nodes.map((node, index) => {
|
|
3344
|
+
const pos = positions.get(node.id) ?? { x: 0, y: 0 };
|
|
3345
|
+
return { ...node, x: pos.x, y: pos.y, index };
|
|
3346
|
+
});
|
|
3347
|
+
positionedEdges = compilation.edges.map((edge) => {
|
|
3348
|
+
const src = positions.get(edge.source) ?? { x: 0, y: 0 };
|
|
3349
|
+
const tgt = positions.get(edge.target) ?? { x: 0, y: 0 };
|
|
3350
|
+
return { ...edge, sourceX: src.x, sourceY: src.y, targetX: tgt.x, targetY: tgt.y };
|
|
3351
|
+
});
|
|
3352
|
+
spatialIndex.rebuild(positionedNodes);
|
|
3353
|
+
initSimulation({
|
|
3354
|
+
positions,
|
|
3355
|
+
suppressCenter: true,
|
|
3356
|
+
initialAlpha,
|
|
3357
|
+
skipWarmup: true,
|
|
3358
|
+
skipEntrance: true
|
|
3359
|
+
});
|
|
3360
|
+
renderChrome();
|
|
3361
|
+
renderLegend();
|
|
3362
|
+
reconcileStateAfterUpdate();
|
|
3363
|
+
startUpdateTransitions(diff);
|
|
3364
|
+
needsRender = true;
|
|
3365
|
+
scheduleRender();
|
|
3366
|
+
}
|
|
3367
|
+
function ratio(numerator, denominator) {
|
|
3368
|
+
return denominator > 0 ? numerator / denominator : 0;
|
|
3369
|
+
}
|
|
3370
|
+
function reconcileStateAfterUpdate() {
|
|
3371
|
+
const nextIds = new Set(compilation.nodes.map((n) => n.id));
|
|
3372
|
+
if (hoveredNodeId && !nextIds.has(hoveredNodeId)) hoveredNodeId = null;
|
|
3373
|
+
if (hoveredEdgeId) {
|
|
3374
|
+
const [src, tgt] = hoveredEdgeId.split("->");
|
|
3375
|
+
if (!nextIds.has(src) || !nextIds.has(tgt)) {
|
|
3376
|
+
hoveredEdgeId = null;
|
|
3377
|
+
options?.onEdgeHover?.(null);
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
const survivingSelection = [...selectedNodeIds].filter((id) => nextIds.has(id));
|
|
3381
|
+
selectedNodeIds = new Set(survivingSelection);
|
|
3382
|
+
interactionManager?.setSelection(survivingSelection);
|
|
3383
|
+
refreshTransientHighlight();
|
|
3384
|
+
recomputeHighlight();
|
|
3385
|
+
syncLegendActiveState();
|
|
3386
|
+
reRunSearch();
|
|
3387
|
+
focusTransition = null;
|
|
3388
|
+
armFocus(performance.now());
|
|
3389
|
+
}
|
|
3390
|
+
function reRunSearch() {
|
|
3391
|
+
const q = searchManager.getQuery();
|
|
3392
|
+
if (q !== null) searchManager.search(q, positionedNodes);
|
|
3393
|
+
}
|
|
3394
|
+
function startUpdateTransitions(diff) {
|
|
3395
|
+
const updateCfg = compilation.animation?.update ?? null;
|
|
3396
|
+
const exitCfg = compilation.animation?.exit ?? null;
|
|
3397
|
+
const reduced = prefersReducedMotion();
|
|
3398
|
+
if (diff.enteringIds.length > 0 && updateCfg && !reduced) {
|
|
3399
|
+
const entering = diff.enteringIds;
|
|
3400
|
+
enterAlphaMap = new Map(entering.map((id) => [id, 0]));
|
|
3401
|
+
const ease = resolveEase(updateCfg.ease);
|
|
3402
|
+
const tween = createTween({
|
|
3403
|
+
duration: updateCfg.duration,
|
|
3404
|
+
ease,
|
|
3405
|
+
apply: (t) => {
|
|
3406
|
+
const q = Math.round(t * 8) / 8;
|
|
3407
|
+
if (enterAlphaMap) for (const id of entering) enterAlphaMap.set(id, q);
|
|
3408
|
+
needsRender = true;
|
|
3409
|
+
},
|
|
3410
|
+
onDone: () => {
|
|
3411
|
+
enterAlphaMap = null;
|
|
3412
|
+
needsRender = true;
|
|
3413
|
+
}
|
|
3414
|
+
});
|
|
3415
|
+
scheduler.add(tween);
|
|
3416
|
+
}
|
|
3417
|
+
if ((diff.exitingNodes.length > 0 || diff.exitingEdges.length > 0) && exitCfg && !reduced) {
|
|
3418
|
+
exitingGhosts = { nodes: diff.exitingNodes, edges: diff.exitingEdges, alpha: 1 };
|
|
3419
|
+
const ease = resolveEase(exitCfg.ease);
|
|
3420
|
+
const tween = createTween({
|
|
3421
|
+
duration: exitCfg.duration,
|
|
3422
|
+
ease,
|
|
3423
|
+
apply: (t) => {
|
|
3424
|
+
if (exitingGhosts) exitingGhosts.alpha = 1 - t;
|
|
3425
|
+
needsRender = true;
|
|
3426
|
+
},
|
|
3427
|
+
onDone: () => {
|
|
3428
|
+
exitingGhosts = null;
|
|
3429
|
+
needsRender = true;
|
|
3430
|
+
}
|
|
3431
|
+
});
|
|
3432
|
+
scheduler.add(tween);
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
function updateVisuals(newSpec) {
|
|
3436
|
+
update(newSpec);
|
|
3437
|
+
}
|
|
3438
|
+
function teardownSimOnly() {
|
|
3439
|
+
simulation?.destroy();
|
|
3440
|
+
simulation = null;
|
|
3441
|
+
}
|
|
3442
|
+
function teardownSubsystems() {
|
|
3443
|
+
scheduler.cancelAll();
|
|
3444
|
+
activeFlight = null;
|
|
3445
|
+
activeFollow = null;
|
|
3446
|
+
entranceReveal = null;
|
|
3447
|
+
entranceActive = false;
|
|
3448
|
+
entranceProgress = 1;
|
|
3449
|
+
entranceFitInFlight = false;
|
|
3450
|
+
enterAlphaMap = null;
|
|
3451
|
+
exitingGhosts = null;
|
|
3452
|
+
if (animFrameId !== null) {
|
|
3453
|
+
cancelAnimationFrame(animFrameId);
|
|
3454
|
+
animFrameId = null;
|
|
3455
|
+
}
|
|
3456
|
+
if (cleanupKeyboard) {
|
|
3457
|
+
cleanupKeyboard();
|
|
3458
|
+
cleanupKeyboard = null;
|
|
3459
|
+
}
|
|
3460
|
+
interactionManager?.destroy();
|
|
3461
|
+
interactionManager = null;
|
|
3462
|
+
simulation?.destroy();
|
|
3463
|
+
simulation = null;
|
|
3464
|
+
}
|
|
3465
|
+
function destroy() {
|
|
3466
|
+
if (destroyed) return;
|
|
3467
|
+
destroyed = true;
|
|
3468
|
+
if (gestureTimeout !== null) {
|
|
3469
|
+
clearTimeout(gestureTimeout);
|
|
3470
|
+
gestureTimeout = null;
|
|
3471
|
+
}
|
|
3472
|
+
teardownSubsystems();
|
|
3473
|
+
if (disconnectResize) {
|
|
3474
|
+
disconnectResize();
|
|
3475
|
+
disconnectResize = null;
|
|
3476
|
+
}
|
|
3477
|
+
legendController?.destroy();
|
|
3478
|
+
legendController = null;
|
|
3479
|
+
shell?.destroy();
|
|
3480
|
+
shell = null;
|
|
3481
|
+
tooltipManager = null;
|
|
3482
|
+
canvas = null;
|
|
3483
|
+
chromeEl = null;
|
|
3484
|
+
legendEl = null;
|
|
3485
|
+
renderer = null;
|
|
3486
|
+
}
|
|
3487
|
+
try {
|
|
3488
|
+
compilation = compile();
|
|
3489
|
+
shell = createGraphShell(container, currentSpec, compilation, options, warnOnce);
|
|
3490
|
+
shell.renderChrome(compilation);
|
|
3491
|
+
} catch (err) {
|
|
3492
|
+
console.error("[viz] Graph mount failed:", err);
|
|
3493
|
+
throw err;
|
|
3494
|
+
}
|
|
3495
|
+
if (compilation.numDimensions === 3) {
|
|
3496
|
+
const factory = getGraphRenderer(3);
|
|
3497
|
+
if (!factory) {
|
|
3498
|
+
shell.destroy();
|
|
3499
|
+
shell = null;
|
|
3500
|
+
throw new Error(GRAPH_3D_NOT_REGISTERED_ERROR);
|
|
3501
|
+
}
|
|
3502
|
+
const ctx = {
|
|
3503
|
+
shell,
|
|
3504
|
+
spec: currentSpec,
|
|
3505
|
+
compilation,
|
|
3506
|
+
options,
|
|
3507
|
+
compile: (next) => compile(next)
|
|
3508
|
+
};
|
|
3509
|
+
try {
|
|
3510
|
+
return factory(ctx);
|
|
3511
|
+
} catch (err) {
|
|
3512
|
+
shell.destroy();
|
|
3513
|
+
shell = null;
|
|
3514
|
+
console.error("[viz] Graph mount failed:", err);
|
|
3515
|
+
throw err;
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
chromeEl = shell.chromeEl;
|
|
3519
|
+
legendEl = shell.legendEl;
|
|
3520
|
+
tooltipManager = shell.tooltipManager;
|
|
3521
|
+
try {
|
|
3522
|
+
seedIds = new Set(compilation.seedNodeIds);
|
|
3523
|
+
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
3524
|
+
buildDataMaps();
|
|
3525
|
+
applyInitialHighlight();
|
|
3526
|
+
createSurface();
|
|
3527
|
+
initSimulation();
|
|
3528
|
+
initInteraction();
|
|
3529
|
+
} catch (err) {
|
|
3530
|
+
console.error("[viz] Graph mount failed:", err);
|
|
3531
|
+
throw err;
|
|
3532
|
+
}
|
|
3533
|
+
disconnectResize = shell.observeResize(() => {
|
|
3534
|
+
doResize();
|
|
3535
|
+
});
|
|
3536
|
+
return {
|
|
3537
|
+
update,
|
|
3538
|
+
updateVisuals,
|
|
3539
|
+
search,
|
|
3540
|
+
clearSearch,
|
|
3541
|
+
zoomToFit,
|
|
3542
|
+
zoomToNode,
|
|
3543
|
+
flyTo,
|
|
3544
|
+
centerAt,
|
|
3545
|
+
getCamera,
|
|
3546
|
+
selectNode,
|
|
3547
|
+
getSelectedNodes,
|
|
3548
|
+
getSearchMatches,
|
|
3549
|
+
highlight,
|
|
3550
|
+
clearHighlight,
|
|
3551
|
+
getHighlight,
|
|
3552
|
+
getLegend,
|
|
3553
|
+
setActiveCategories,
|
|
3554
|
+
getActiveCategories,
|
|
3555
|
+
resize: doResize,
|
|
3556
|
+
destroy
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
export {
|
|
3561
|
+
registerGraphRenderer,
|
|
3562
|
+
GRAPH_3D_NOT_REGISTERED_ERROR,
|
|
3563
|
+
ENTRANCE_STAGGER_MAX_NODES,
|
|
3564
|
+
entranceOrder,
|
|
3565
|
+
popScale,
|
|
3566
|
+
popAlpha,
|
|
3567
|
+
composeStandingFocus,
|
|
3568
|
+
layerHoverFocus,
|
|
3569
|
+
resolveHighlightTarget,
|
|
3570
|
+
categoryHighlightSet,
|
|
3571
|
+
createGraphLegend,
|
|
3572
|
+
GraphSearchManager,
|
|
3573
|
+
seedNodePositions,
|
|
3574
|
+
diffGraphUpdate,
|
|
3575
|
+
createGraph
|
|
3576
|
+
};
|
|
3577
|
+
//# sourceMappingURL=chunk-WOLX5EZT.js.map
|