@yggy-tech/graphraum 0.0.0-stage → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3178 @@
1
+ import { Box3, CanvasTexture, Color, InstancedBufferAttribute, InstancedMesh, MOUSE, Matrix4, MeshBasicMaterial, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, ShaderMaterial, SphereGeometry, Texture, Vector2, Vector3, WebGLRenderer } from "three";
2
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
3
+ //#region src/node-axes.ts
4
+ function assertPositiveFinite(value, axis, nodeId) {
5
+ if (!Number.isFinite(value) || value <= 0) {
6
+ const preamble = nodeId === void 0 ? "" : `Node "${nodeId}" `;
7
+ throw new Error(`${preamble}${axis} must be a positive finite number`);
8
+ }
9
+ }
10
+ function resolveNodeAxes(input) {
11
+ const { defaultSize = 4, height, nodeId, size, width } = input;
12
+ const resolvedWidth = width ?? size ?? defaultSize;
13
+ const resolvedHeight = height ?? size ?? defaultSize;
14
+ assertPositiveFinite(resolvedWidth, "width", nodeId);
15
+ assertPositiveFinite(resolvedHeight, "height", nodeId);
16
+ return {
17
+ width: resolvedWidth,
18
+ height: resolvedHeight
19
+ };
20
+ }
21
+ //#endregion
22
+ //#region src/node-shapes.ts
23
+ const graphraumNodeShapes = [
24
+ "circle",
25
+ "square",
26
+ "diamond",
27
+ "hexagon",
28
+ "triangle",
29
+ "pill",
30
+ "rounded"
31
+ ];
32
+ const shapeCodes = {
33
+ circle: 0,
34
+ square: 1,
35
+ diamond: 2,
36
+ hexagon: 3,
37
+ triangle: 4,
38
+ pill: 5,
39
+ rounded: 6
40
+ };
41
+ function assertNodeShape(nodeId, shape) {
42
+ if (typeof shape !== "string" || !graphraumNodeShapes.includes(shape)) throw new Error(`Node "${nodeId}" shape must be one of: ${graphraumNodeShapes.map((candidate) => `"${candidate}"`).join(", ")}`);
43
+ }
44
+ function encodeNodeShape(shape) {
45
+ return shapeCodes[shape ?? "circle"];
46
+ }
47
+ /** Half-height of the horizontal pill's rounded caps, in unit space. */
48
+ const PILL_CAP_RADIUS = .5;
49
+ /** Half-length of the pill's straight segment, in unit space (leaves room for the caps within [-1, 1]). */
50
+ const PILL_HALF_LENGTH = 1 - PILL_CAP_RADIUS;
51
+ /** Apothem of a regular hexagon whose pointy left/right vertices reach x = ±1. */
52
+ const HEXAGON_APOTHEM = Math.sqrt(3) / 2;
53
+ /** Y of the upward triangle's flat base, given an apex at (0, 1) and an equilateral shape spanning x = [-1, 1]. */
54
+ const TRIANGLE_BASE_Y = 1 - Math.sqrt(3);
55
+ /** Fraction of the rounded rectangle's half-extent used as the corner radius. */
56
+ const ROUNDED_CORNER_RADIUS = .25;
57
+ function containsHexagon(x, y) {
58
+ const absX = Math.abs(x);
59
+ const absY = Math.abs(y);
60
+ return Math.max(absY, absX * HEXAGON_APOTHEM + absY * .5) <= HEXAGON_APOTHEM;
61
+ }
62
+ function edgeCross(ax, ay, bx, by, px, py) {
63
+ return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
64
+ }
65
+ function containsTriangle(x, y) {
66
+ const apexX = 0;
67
+ const apexY = 1;
68
+ const rightX = 1;
69
+ const leftX = -1;
70
+ const baseY = TRIANGLE_BASE_Y;
71
+ const d1 = edgeCross(apexX, apexY, rightX, baseY, x, y);
72
+ const d2 = edgeCross(rightX, baseY, leftX, baseY, x, y);
73
+ const d3 = edgeCross(leftX, baseY, apexX, apexY, x, y);
74
+ return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
75
+ }
76
+ function containsPill(x, y) {
77
+ const dx = x - Math.max(-.5, Math.min(PILL_HALF_LENGTH, x));
78
+ return dx * dx + y * y <= PILL_CAP_RADIUS * PILL_CAP_RADIUS;
79
+ }
80
+ function containsRounded(x, y) {
81
+ const innerHalfExtent = 1 - ROUNDED_CORNER_RADIUS;
82
+ const qx = Math.abs(x) - innerHalfExtent;
83
+ const qy = Math.abs(y) - innerHalfExtent;
84
+ const outsideX = Math.max(qx, 0);
85
+ const outsideY = Math.max(qy, 0);
86
+ return Math.sqrt(outsideX * outsideX + outsideY * outsideY) + Math.min(Math.max(qx, qy), 0) - ROUNDED_CORNER_RADIUS <= 0;
87
+ }
88
+ function containsNodePoint(shape, x, y) {
89
+ switch (shape ?? "circle") {
90
+ case "square": return Math.max(Math.abs(x), Math.abs(y)) <= 1;
91
+ case "diamond": return Math.abs(x) + Math.abs(y) <= 1;
92
+ case "hexagon": return containsHexagon(x, y);
93
+ case "triangle": return containsTriangle(x, y);
94
+ case "pill": return containsPill(x, y);
95
+ case "rounded": return containsRounded(x, y);
96
+ default: return x ** 2 + y ** 2 <= 1;
97
+ }
98
+ }
99
+ //#endregion
100
+ //#region src/edge-endpoint-attach.ts
101
+ const DEFAULT_ENDPOINT_ATTACH = "boundary";
102
+ const DEFAULT_ENDPOINT_CLEARANCE = .75;
103
+ function isInsideOutline(outline, worldX, worldY, center) {
104
+ const { height, width } = resolveNodeAxes({
105
+ height: outline.height,
106
+ size: outline.size,
107
+ width: outline.width
108
+ });
109
+ const stroke = Math.max(0, outline.strokeWidth ?? 0);
110
+ const halfWidth = width + stroke;
111
+ const halfHeight = height + stroke;
112
+ if (halfWidth <= 0 || halfHeight <= 0) return false;
113
+ return containsNodePoint(outline.shape, (worldX - center.x) / halfWidth, (worldY - center.y) / halfHeight);
114
+ }
115
+ /**
116
+ * Walk from the node center toward the far point and stop on the outline,
117
+ * then pull back by `clearance` so the stroke is not stabbed.
118
+ */
119
+ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
120
+ const dx = far.x - center.x;
121
+ const dy = far.y - center.y;
122
+ const dz = (far.z ?? 0) - (center.z ?? 0);
123
+ const length = Math.hypot(dx, dy, dz);
124
+ if (length < 1e-6) return {
125
+ x: center.x,
126
+ y: center.y,
127
+ z: center.z ?? 0
128
+ };
129
+ let low = 0;
130
+ let high = 1;
131
+ for (let step = 0; step < 24; step += 1) {
132
+ const mid = (low + high) / 2;
133
+ if (isInsideOutline(outline, center.x + dx * mid, center.y + dy * mid, center)) low = mid;
134
+ else high = mid;
135
+ }
136
+ const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
137
+ const t = Math.max(0, low - clearanceT);
138
+ return {
139
+ x: center.x + dx * t,
140
+ y: center.y + dy * t,
141
+ z: (center.z ?? 0) + dz * t
142
+ };
143
+ }
144
+ /** Trim both ends of a center-to-center chord (or curve endpoints) to node boundaries. */
145
+ function trimEdgeEndpoints(input) {
146
+ if ((input.attach ?? "boundary") === "center") return {
147
+ source: {
148
+ x: input.source.x,
149
+ y: input.source.y,
150
+ z: input.source.z ?? 0
151
+ },
152
+ target: {
153
+ x: input.target.x,
154
+ y: input.target.y,
155
+ z: input.target.z ?? 0
156
+ }
157
+ };
158
+ const clearance = input.clearance ?? .75;
159
+ const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance);
160
+ const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance);
161
+ const ox = input.target.x - input.source.x;
162
+ const oy = input.target.y - input.source.y;
163
+ const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
164
+ const dx = target.x - source.x;
165
+ const dy = target.y - source.y;
166
+ const dz = (target.z ?? 0) - (source.z ?? 0);
167
+ if (dx * ox + dy * oy + dz * oz <= 1e-8) return {
168
+ source: {
169
+ x: input.source.x,
170
+ y: input.source.y,
171
+ z: input.source.z ?? 0
172
+ },
173
+ target: {
174
+ x: input.target.x,
175
+ y: input.target.y,
176
+ z: input.target.z ?? 0
177
+ }
178
+ };
179
+ return {
180
+ source,
181
+ target
182
+ };
183
+ }
184
+ //#endregion
185
+ //#region src/edge-styles.ts
186
+ const graphraumEdgeStyles = [
187
+ "solid",
188
+ "dashed",
189
+ "dotted"
190
+ ];
191
+ const graphraumEdgeMarkers = ["none", "triangle"];
192
+ const graphraumEdgeMarkerEnds = [
193
+ "target",
194
+ "source",
195
+ "both"
196
+ ];
197
+ const graphraumEdgePaths = [
198
+ "straight",
199
+ "quadratic",
200
+ "cubic"
201
+ ];
202
+ const styleCodes = {
203
+ solid: 0,
204
+ dashed: 1,
205
+ dotted: 2
206
+ };
207
+ function assertEdgeStyle(edgeId, style) {
208
+ if (typeof style !== "string" || !graphraumEdgeStyles.includes(style)) throw new Error(`Edge "${edgeId}" style must be one of: ${graphraumEdgeStyles.map((s) => `"${s}"`).join(", ")}`);
209
+ }
210
+ function assertEdgeMarker(edgeId, marker) {
211
+ if (typeof marker !== "string" || !graphraumEdgeMarkers.includes(marker)) throw new Error(`Edge "${edgeId}" marker must be one of: ${graphraumEdgeMarkers.map((m) => `"${m}"`).join(", ")}`);
212
+ }
213
+ function assertEdgeMarkerEnd(edgeId, markerEnd) {
214
+ if (typeof markerEnd !== "string" || !graphraumEdgeMarkerEnds.includes(markerEnd)) throw new Error(`Edge "${edgeId}" markerEnd must be one of: ${graphraumEdgeMarkerEnds.map((m) => `"${m}"`).join(", ")}`);
215
+ }
216
+ function assertEdgePath(edgeId, path) {
217
+ if (typeof path !== "string" || !graphraumEdgePaths.includes(path)) throw new Error(`Edge "${edgeId}" path must be one of: ${graphraumEdgePaths.map((p) => `"${p}"`).join(", ")}`);
218
+ }
219
+ function assertControlPoint(edgeId, index, point) {
220
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || point.z !== void 0 && !Number.isFinite(point.z)) throw new Error(`Edge "${edgeId}" controlPoints[${index}] must have finite coordinates`);
221
+ }
222
+ function assertEdgeControlPoints(edgeId, path, controlPoints) {
223
+ if (controlPoints === void 0) return;
224
+ for (const [index, point] of controlPoints.entries()) assertControlPoint(edgeId, index, point);
225
+ const resolvedPath = path ?? "straight";
226
+ if (resolvedPath === "straight") return;
227
+ if (controlPoints.length === 0) return;
228
+ const expected = resolvedPath === "quadratic" ? 1 : 2;
229
+ if (controlPoints.length !== expected) throw new Error(`Edge "${edgeId}" path "${resolvedPath}" expects ${expected} control point${expected === 1 ? "" : "s"} or none for auto defaults (got ${controlPoints.length})`);
230
+ }
231
+ function assertEdgeVisual(edgeId, visual) {
232
+ if (visual.width !== void 0 && (!Number.isFinite(visual.width) || visual.width <= 0)) throw new Error(`Edge "${edgeId}" visual must have a positive finite width`);
233
+ if (visual.opacity !== void 0 && (!Number.isFinite(visual.opacity) || visual.opacity < 0 || visual.opacity > 1)) throw new Error(`Edge "${edgeId}" visual opacity must be a finite number between 0 and 1`);
234
+ if (visual.markerSize !== void 0 && (!Number.isFinite(visual.markerSize) || visual.markerSize <= 0)) throw new Error(`Edge "${edgeId}" visual must have a positive finite markerSize`);
235
+ if (visual.style !== void 0) assertEdgeStyle(edgeId, visual.style);
236
+ if (visual.marker !== void 0) assertEdgeMarker(edgeId, visual.marker);
237
+ if (visual.markerEnd !== void 0) assertEdgeMarkerEnd(edgeId, visual.markerEnd);
238
+ if (visual.path !== void 0) assertEdgePath(edgeId, visual.path);
239
+ assertEdgeControlPoints(edgeId, visual.path, visual.controlPoints);
240
+ }
241
+ function encodeEdgeStyle(style) {
242
+ return styleCodes[style ?? "solid"];
243
+ }
244
+ //#endregion
245
+ //#region src/force-layout.ts
246
+ const DEFAULT_FORCE_SETTINGS = {
247
+ centerAttraction: .002,
248
+ damping: .82,
249
+ linkDistance: 24,
250
+ repulsion: 400,
251
+ springStrength: .005
252
+ };
253
+ function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
254
+ return {
255
+ centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
256
+ damping: Math.max(.1, Math.min(.99, settings.damping)),
257
+ linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
258
+ repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
259
+ springStrength: Math.max(1e-4, Math.min(.05, settings.springStrength))
260
+ };
261
+ }
262
+ function clampLinkDistance(value) {
263
+ return Math.max(1, Math.min(500, value));
264
+ }
265
+ function clampSpringStrength(value) {
266
+ return Math.max(1e-4, Math.min(.05, value));
267
+ }
268
+ function normalizePerEdgeArray(name, values, edgeCount, clamp) {
269
+ if (values === void 0) return null;
270
+ if (values.length !== edgeCount) throw new Error(`${name} length (${values.length}) must equal edge count (${edgeCount}).`);
271
+ const normalized = new Float32Array(edgeCount);
272
+ for (let index = 0; index < edgeCount; index += 1) normalized[index] = clamp(values[index] ?? 0);
273
+ return normalized;
274
+ }
275
+ function forceIterationCount(nodeCount) {
276
+ return Math.max(8, Math.min(32, Math.ceil(240 / Math.log2(nodeCount + 2))));
277
+ }
278
+ function resolveForceIterations(nodeCount, iterations) {
279
+ if (iterations === void 0) return forceIterationCount(nodeCount);
280
+ if (!Number.isSafeInteger(iterations) || iterations < 1) throw new Error("Force iterations must be a positive integer.");
281
+ return Math.min(64, iterations);
282
+ }
283
+ function initialPositions({ dimensions, nodeCount }) {
284
+ const positions = new Float32Array(nodeCount * 3);
285
+ const extent = Math.max(Math.sqrt(nodeCount) * 6, 24);
286
+ for (let index = 0; index < nodeCount; index += 1) {
287
+ const offset = index * 3;
288
+ const angle = random(index * 4 + 1) * Math.PI * 2;
289
+ if (dimensions === 2) {
290
+ const radius = Math.sqrt(random(index * 4 + 2)) * extent * .5;
291
+ positions.set([
292
+ Math.cos(angle) * radius,
293
+ Math.sin(angle) * radius,
294
+ 0
295
+ ], offset);
296
+ continue;
297
+ }
298
+ const zDirection = random(index * 4 + 2) * 2 - 1;
299
+ const planeRadius = Math.sqrt(1 - zDirection * zDirection);
300
+ const radius = Math.cbrt(random(index * 4 + 3)) * extent * .5;
301
+ positions.set([
302
+ Math.cos(angle) * planeRadius * radius,
303
+ Math.sin(angle) * planeRadius * radius,
304
+ zDirection * radius
305
+ ], offset);
306
+ }
307
+ return positions;
308
+ }
309
+ function random(seed) {
310
+ let value = Math.imul(seed ^ 2654435769, 2246822507);
311
+ value = Math.imul(value ^ value >>> 13, 3266489909);
312
+ return ((value ^ value >>> 16) >>> 0) / 4294967296;
313
+ }
314
+ function createForceSimulation(request) {
315
+ const settings = normalizeForceSettings(request.settings);
316
+ let nodeCount = request.nodeCount;
317
+ let edges = new Uint32Array(request.edges);
318
+ if (edges.length % 2 !== 0) throw new Error("Force edges must contain source/target pairs.");
319
+ const edgeCount = edges.length / 2;
320
+ let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
321
+ let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
322
+ let positions = initialPositions(request);
323
+ let velocities = new Float32Array(positions.length);
324
+ let forces = new Float32Array(positions.length);
325
+ let iteration = 0;
326
+ return {
327
+ get nodeCount() {
328
+ return nodeCount;
329
+ },
330
+ get edges() {
331
+ return edges;
332
+ },
333
+ get positions() {
334
+ return positions;
335
+ },
336
+ /** Add nodes while preserving every existing position and velocity. */
337
+ addNodes(count, initial) {
338
+ if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
339
+ if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
340
+ const start = nodeCount;
341
+ const nextPositions = new Float32Array((nodeCount + count) * 3);
342
+ nextPositions.set(positions);
343
+ const nextVelocities = new Float32Array(nextPositions.length);
344
+ nextVelocities.set(velocities);
345
+ const nextForces = new Float32Array(nextPositions.length);
346
+ nextForces.set(forces);
347
+ if (initial) nextPositions.set(initial, positions.length);
348
+ else {
349
+ const added = initialPositions({
350
+ dimensions: request.dimensions,
351
+ nodeCount: count
352
+ });
353
+ nextPositions.set(added, positions.length);
354
+ }
355
+ positions = nextPositions;
356
+ velocities = nextVelocities;
357
+ forces = nextForces;
358
+ nodeCount += count;
359
+ return start;
360
+ },
361
+ /** Append endpoint indices for new edges; existing edge indices remain stable. */
362
+ addEdges(nextEdges) {
363
+ if (nextEdges.length % 2 !== 0) throw new Error("Force edges must contain source/target pairs.");
364
+ for (const index of nextEdges) if (index >= nodeCount) throw new Error(`Force edge references missing node index: ${index}`);
365
+ const addedEdgeCount = nextEdges.length / 2;
366
+ const merged = new Uint32Array(edges.length + nextEdges.length);
367
+ merged.set(edges);
368
+ merged.set(nextEdges, edges.length);
369
+ edges = merged;
370
+ if (linkDistances) {
371
+ const next = new Float32Array(linkDistances.length + addedEdgeCount);
372
+ next.set(linkDistances);
373
+ next.fill(settings.linkDistance, linkDistances.length);
374
+ linkDistances = next;
375
+ }
376
+ if (springStrengths) {
377
+ const next = new Float32Array(springStrengths.length + addedEdgeCount);
378
+ next.set(springStrengths);
379
+ next.fill(settings.springStrength, springStrengths.length);
380
+ springStrengths = next;
381
+ }
382
+ },
383
+ step(alpha) {
384
+ if (nodeCount === 0) return;
385
+ forces.fill(0);
386
+ for (let index = 0; index < nodeCount; index += 1) {
387
+ const offset = index * 3;
388
+ forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
389
+ forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
390
+ forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
391
+ for (let sample = 0; sample < 8; sample += 1) {
392
+ const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
393
+ if (targetIndex === index) continue;
394
+ const target = targetIndex * 3;
395
+ const dx = (positions[offset] ?? 0) - (positions[target] ?? 0);
396
+ const dy = (positions[offset + 1] ?? 0) - (positions[target + 1] ?? 0);
397
+ const dz = request.dimensions === 3 ? (positions[offset + 2] ?? 0) - (positions[target + 2] ?? 0) : 0;
398
+ const distanceSquared = dx * dx + dy * dy + dz * dz + 16;
399
+ const scale = Math.min(2, settings.repulsion / distanceSquared) * alpha / Math.sqrt(distanceSquared);
400
+ forces[offset] += dx * scale;
401
+ forces[offset + 1] += dy * scale;
402
+ if (request.dimensions === 3) forces[offset + 2] += dz * scale;
403
+ }
404
+ }
405
+ for (let index = 0; index < edges.length; index += 2) {
406
+ const edgeIndex = index / 2;
407
+ const source = (edges[index] ?? 0) * 3;
408
+ const target = (edges[index + 1] ?? 0) * 3;
409
+ const dx = (positions[target] ?? 0) - (positions[source] ?? 0);
410
+ const dy = (positions[target + 1] ?? 0) - (positions[source + 1] ?? 0);
411
+ const dz = request.dimensions === 3 ? (positions[target + 2] ?? 0) - (positions[source + 2] ?? 0) : 0;
412
+ const distance = Math.sqrt(Math.max(dx * dx + dy * dy + dz * dz, 1e-4));
413
+ const linkDistance = linkDistances?.[edgeIndex] ?? settings.linkDistance;
414
+ const springStrength = springStrengths?.[edgeIndex] ?? settings.springStrength;
415
+ const force = Math.max(-1, Math.min(1, (distance - linkDistance) * springStrength * alpha)) / distance;
416
+ forces[source] += dx * force;
417
+ forces[source + 1] += dy * force;
418
+ forces[target] -= dx * force;
419
+ forces[target + 1] -= dy * force;
420
+ if (request.dimensions === 3) {
421
+ forces[source + 2] += dz * force;
422
+ forces[target + 2] -= dz * force;
423
+ }
424
+ }
425
+ for (let index = 0; index < nodeCount; index += 1) {
426
+ const offset = index * 3;
427
+ let vx = ((velocities[offset] ?? 0) + (forces[offset] ?? 0)) * settings.damping;
428
+ let vy = ((velocities[offset + 1] ?? 0) + (forces[offset + 1] ?? 0)) * settings.damping;
429
+ let vz = request.dimensions === 3 ? ((velocities[offset + 2] ?? 0) + (forces[offset + 2] ?? 0)) * settings.damping : 0;
430
+ const speed = Math.hypot(vx, vy, vz);
431
+ const maxSpeed = .5 + alpha * 8;
432
+ if (speed > maxSpeed) {
433
+ const scale = maxSpeed / speed;
434
+ vx *= scale;
435
+ vy *= scale;
436
+ vz *= scale;
437
+ }
438
+ velocities[offset] = vx;
439
+ velocities[offset + 1] = vy;
440
+ velocities[offset + 2] = vz;
441
+ positions[offset] = (positions[offset] ?? 0) + vx;
442
+ positions[offset + 1] = (positions[offset + 1] ?? 0) + vy;
443
+ positions[offset + 2] = request.dimensions === 3 ? (positions[offset + 2] ?? 0) + vz : 0;
444
+ }
445
+ const center = [
446
+ 0,
447
+ 0,
448
+ 0
449
+ ];
450
+ for (let index = 0; index < nodeCount; index += 1) {
451
+ const offset = index * 3;
452
+ center[0] += positions[offset] ?? 0;
453
+ center[1] += positions[offset + 1] ?? 0;
454
+ if (request.dimensions === 3) center[2] += positions[offset + 2] ?? 0;
455
+ }
456
+ for (let axis = 0; axis < request.dimensions; axis += 1) center[axis] /= nodeCount;
457
+ for (let index = 0; index < nodeCount; index += 1) {
458
+ const offset = index * 3;
459
+ for (let axis = 0; axis < request.dimensions; axis += 1) positions[offset + axis] -= center[axis];
460
+ }
461
+ iteration += 1;
462
+ }
463
+ };
464
+ }
465
+ function computeForcePositions(request) {
466
+ const simulation = createForceSimulation(request);
467
+ const iterations = resolveForceIterations(request.nodeCount, request.iterations);
468
+ for (let iteration = 0; iteration < iterations; iteration += 1) simulation.step(1 - iteration / iterations);
469
+ return simulation.positions;
470
+ }
471
+ function computeClusteredForcePositions(request) {
472
+ if (request.clusters.length !== request.nodeCount) throw new Error("Cluster index count must match node count.");
473
+ let clusterCount = 0;
474
+ for (const cluster of request.clusters) clusterCount = Math.max(clusterCount, cluster + 1);
475
+ const clusterEdges = new Uint32Array(request.edges.length);
476
+ let edgeCursor = 0;
477
+ for (let index = 0; index < request.edges.length; index += 2) {
478
+ const source = request.clusters[request.edges[index] ?? 0];
479
+ const target = request.clusters[request.edges[index + 1] ?? 0];
480
+ if (source === void 0 || target === void 0 || source === target) continue;
481
+ clusterEdges[edgeCursor++] = source;
482
+ clusterEdges[edgeCursor++] = target;
483
+ }
484
+ const centers = computeForcePositions({
485
+ dimensions: request.dimensions,
486
+ edges: clusterEdges.subarray(0, edgeCursor),
487
+ nodeCount: clusterCount,
488
+ settings: request.settings
489
+ });
490
+ const clusterSizes = new Uint32Array(clusterCount);
491
+ for (const cluster of request.clusters) clusterSizes[cluster] += 1;
492
+ const positions = new Float32Array(request.nodeCount * 3);
493
+ for (let index = 0; index < request.nodeCount; index += 1) {
494
+ const offset = index * 3;
495
+ const cluster = request.clusters[index] ?? 0;
496
+ const center = cluster * 3;
497
+ const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
498
+ const angle = random(index * 4 + 1) * Math.PI * 2;
499
+ positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
500
+ positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
501
+ if (request.dimensions === 3) {
502
+ const zDirection = random(index * 4 + 3) * 2 - 1;
503
+ positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
504
+ }
505
+ }
506
+ return positions;
507
+ }
508
+ //#endregion
509
+ //#region src/graphology.ts
510
+ function projectGraphology(graph) {
511
+ const nodes = graph.mapNodes((id, attributes) => ({
512
+ attributes,
513
+ color: attributes.color,
514
+ id,
515
+ position: {
516
+ x: attributes.x ?? 0,
517
+ y: attributes.y ?? 0,
518
+ z: attributes.z
519
+ },
520
+ shape: attributes.shape,
521
+ size: attributes.size
522
+ }));
523
+ return {
524
+ edges: graph.mapEdges((id, attributes, source, target) => ({
525
+ attributes,
526
+ color: attributes.color,
527
+ id,
528
+ source,
529
+ target
530
+ })),
531
+ nodes
532
+ };
533
+ }
534
+ /** Projects a mutable Graphology graph into Graphraum and keeps the binding alive until disposed. */
535
+ function bindGraphology(renderer, graph) {
536
+ renderer.setData(projectGraphology(graph));
537
+ let flushPending = false;
538
+ let disposed = false;
539
+ const flush = () => {
540
+ flushPending = false;
541
+ if (disposed) return;
542
+ renderer.setData(projectGraphology(graph));
543
+ };
544
+ const schedule = () => {
545
+ if (flushPending) return;
546
+ flushPending = true;
547
+ queueMicrotask(flush);
548
+ };
549
+ graph.on("nodeAttributesUpdated", schedule);
550
+ graph.on("eachNodeAttributesUpdated", schedule);
551
+ graph.on("nodeAdded", schedule);
552
+ graph.on("nodeDropped", schedule);
553
+ graph.on("edgeAdded", schedule);
554
+ graph.on("edgeDropped", schedule);
555
+ graph.on("edgeAttributesUpdated", schedule);
556
+ graph.on("eachEdgeAttributesUpdated", schedule);
557
+ graph.on("cleared", schedule);
558
+ graph.on("edgesCleared", schedule);
559
+ return () => {
560
+ disposed = true;
561
+ graph.off("nodeAttributesUpdated", schedule);
562
+ graph.off("eachNodeAttributesUpdated", schedule);
563
+ graph.off("nodeAdded", schedule);
564
+ graph.off("nodeDropped", schedule);
565
+ graph.off("edgeAdded", schedule);
566
+ graph.off("edgeDropped", schedule);
567
+ graph.off("edgeAttributesUpdated", schedule);
568
+ graph.off("eachEdgeAttributesUpdated", schedule);
569
+ graph.off("cleared", schedule);
570
+ graph.off("edgesCleared", schedule);
571
+ };
572
+ }
573
+ //#endregion
574
+ //#region src/camera-aspect.ts
575
+ /** Canvas aspect used by perspective cameras. Never 0. */
576
+ function containerAspect(width, height) {
577
+ return Math.max(width, 1) / Math.max(height, 1);
578
+ }
579
+ /** Keep a perspective camera from stretching on a non-square canvas. */
580
+ function applyPerspectiveContainerAspect(camera, width, height) {
581
+ camera.aspect = containerAspect(width, height);
582
+ }
583
+ //#endregion
584
+ //#region src/compile-graph.ts
585
+ function assertFinitePosition(nodeId, axis, value) {
586
+ if (!Number.isFinite(value)) throw new Error(`Node "${nodeId}" has a non-finite ${axis} position`);
587
+ }
588
+ function assertNotEmpty(value, message) {
589
+ if (value.trim().length === 0) throw new Error(message);
590
+ }
591
+ function compilePresentation(kind, id, presentation) {
592
+ const prefix = `${kind} "${id}" presentation`;
593
+ assertNotEmpty(presentation.title, `${prefix} title must not be empty`);
594
+ if (presentation.subtitle !== void 0) assertNotEmpty(presentation.subtitle, `${prefix} subtitle must not be empty`);
595
+ const propertyIds = /* @__PURE__ */ new Set();
596
+ const properties = (presentation.properties ?? []).map((property) => {
597
+ assertNotEmpty(property.id, `${prefix} property id must not be empty`);
598
+ assertNotEmpty(property.label, `${prefix} property "${property.id}" label must not be empty`);
599
+ if (propertyIds.has(property.id)) throw new Error(`${prefix} has duplicate property id "${property.id}"`);
600
+ propertyIds.add(property.id);
601
+ if (typeof property.value === "number" && !Number.isFinite(property.value)) throw new Error(`${prefix} property "${property.id}" must have a finite numeric value`);
602
+ if (property.value !== null && ![
603
+ "boolean",
604
+ "number",
605
+ "string"
606
+ ].includes(typeof property.value)) throw new Error(`${prefix} property "${property.id}" must have a serializable scalar value`);
607
+ return Object.freeze({ ...property });
608
+ });
609
+ const actionIds = /* @__PURE__ */ new Set();
610
+ const actions = (presentation.actions ?? []).map((action) => {
611
+ assertNotEmpty(action.id, `${prefix} action id must not be empty`);
612
+ assertNotEmpty(action.label, `${prefix} action "${action.id}" label must not be empty`);
613
+ if (action.disabled !== void 0 && typeof action.disabled !== "boolean") throw new Error(`${prefix} action "${action.id}" disabled must be a boolean`);
614
+ if (actionIds.has(action.id)) throw new Error(`${prefix} has duplicate action id "${action.id}"`);
615
+ actionIds.add(action.id);
616
+ return Object.freeze({ ...action });
617
+ });
618
+ return Object.freeze({
619
+ ...presentation,
620
+ actions: Object.freeze(actions),
621
+ properties: Object.freeze(properties)
622
+ });
623
+ }
624
+ function assertPositiveFiniteVisual(id, field, value) {
625
+ if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) throw new Error(`Node "${id}" visual must have a positive finite ${field}`);
626
+ }
627
+ function assertNonNegativeFiniteVisual(id, field, value) {
628
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`Node "${id}" visual must have a finite non-negative ${field}`);
629
+ }
630
+ function compileNodeVisual(id, visual) {
631
+ assertPositiveFiniteVisual(id, "size", visual.size);
632
+ assertPositiveFiniteVisual(id, "width", visual.width);
633
+ assertPositiveFiniteVisual(id, "height", visual.height);
634
+ assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
635
+ if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
636
+ return Object.freeze({ ...visual });
637
+ }
638
+ function compileEdgeVisual(id, visual) {
639
+ assertEdgeVisual(id, visual);
640
+ return Object.freeze({ ...visual });
641
+ }
642
+ /** Validates graph identity and compiles lookup-heavy objects into GPU-oriented position buffers. */
643
+ function compileGraph(data, visuals) {
644
+ const nodeIndices = /* @__PURE__ */ new Map();
645
+ const nodePositions = new Float32Array(data.nodes.length * 3);
646
+ const nodePresentations = /* @__PURE__ */ new Map();
647
+ const nodeVisuals = [];
648
+ for (const [index, node] of data.nodes.entries()) {
649
+ if (nodeIndices.has(node.id)) throw new Error(`Duplicate node id: "${node.id}"`);
650
+ assertFinitePosition(node.id, "x", node.position.x);
651
+ assertFinitePosition(node.id, "y", node.position.y);
652
+ assertFinitePosition(node.id, "z", node.position.z ?? 0);
653
+ const encoding = visuals?.node?.(node);
654
+ const visual = compileNodeVisual(node.id, encoding?.visual ?? {
655
+ color: node.color,
656
+ height: node.height,
657
+ shape: node.shape,
658
+ size: node.size,
659
+ strokeColor: node.strokeColor,
660
+ strokeWidth: node.strokeWidth,
661
+ width: node.width
662
+ });
663
+ nodeVisuals.push(visual);
664
+ if (encoding?.presentation) nodePresentations.set(node.id, compilePresentation("Node", node.id, encoding.presentation));
665
+ nodeIndices.set(node.id, index);
666
+ nodePositions.set([
667
+ node.position.x,
668
+ node.position.y,
669
+ node.position.z ?? 0
670
+ ], index * 3);
671
+ }
672
+ const edgeIds = /* @__PURE__ */ new Set();
673
+ const edgeNodeIndices = new Uint32Array(data.edges.length * 2);
674
+ const edgePositions = new Float32Array(data.edges.length * 6);
675
+ const edgePresentations = /* @__PURE__ */ new Map();
676
+ const edgeVisuals = [];
677
+ const incidentEdgeIndices = Array.from({ length: data.nodes.length }, () => []);
678
+ for (const [index, edge] of data.edges.entries()) {
679
+ if (edgeIds.has(edge.id)) throw new Error(`Duplicate edge id: "${edge.id}"`);
680
+ edgeIds.add(edge.id);
681
+ const encoding = visuals?.edge?.(edge);
682
+ const visual = compileEdgeVisual(edge.id, {
683
+ color: edge.color,
684
+ width: edge.width,
685
+ opacity: edge.opacity,
686
+ style: edge.style,
687
+ marker: edge.marker,
688
+ markerSize: edge.markerSize,
689
+ markerEnd: edge.markerEnd,
690
+ path: edge.path,
691
+ controlPoints: edge.controlPoints,
692
+ ...encoding?.visual
693
+ });
694
+ edgeVisuals.push(visual);
695
+ if (encoding?.presentation) edgePresentations.set(edge.id, compilePresentation("Edge", edge.id, encoding.presentation));
696
+ const sourceIndex = nodeIndices.get(edge.source);
697
+ const targetIndex = nodeIndices.get(edge.target);
698
+ if (sourceIndex === void 0) throw new Error(`Edge "${edge.id}" references missing source "${edge.source}"`);
699
+ if (targetIndex === void 0) throw new Error(`Edge "${edge.id}" references missing target "${edge.target}"`);
700
+ edgeNodeIndices.set([sourceIndex, targetIndex], index * 2);
701
+ incidentEdgeIndices[sourceIndex]?.push(index);
702
+ if (targetIndex !== sourceIndex) incidentEdgeIndices[targetIndex]?.push(index);
703
+ edgePositions.set(nodePositions.subarray(sourceIndex * 3, sourceIndex * 3 + 3), index * 6);
704
+ edgePositions.set(nodePositions.subarray(targetIndex * 3, targetIndex * 3 + 3), index * 6 + 3);
705
+ }
706
+ return {
707
+ edgeNodeIndices,
708
+ edgePositions,
709
+ edgePresentations,
710
+ edgeVisuals,
711
+ incidentEdgeIndices,
712
+ nodeIds: data.nodes.map((node) => node.id),
713
+ nodeIndices,
714
+ nodePositions,
715
+ nodePresentations,
716
+ nodeVisuals
717
+ };
718
+ }
719
+ //#endregion
720
+ //#region src/data-patch.ts
721
+ /** Merge additions and removals into a dense graph snapshot. Cascades edges whose endpoints vanish. */
722
+ function mergeDataPatch(currentNodes, currentEdges, patch) {
723
+ const removedNodes = new Set(patch.removedNodeIds ?? []);
724
+ const removedEdges = new Set(patch.removedEdgeIds ?? []);
725
+ const nodes = new Map(currentNodes.map((node) => [node.id, node]));
726
+ const edges = new Map(currentEdges.map((edge) => [edge.id, edge]));
727
+ for (const id of removedNodes) nodes.delete(id);
728
+ for (const id of removedEdges) edges.delete(id);
729
+ for (const node of patch.addedNodes ?? []) nodes.set(node.id, node);
730
+ for (const edge of patch.addedEdges ?? []) edges.set(edge.id, edge);
731
+ return {
732
+ nodes: [...nodes.values()],
733
+ edges: [...edges.values()].filter((edge) => nodes.has(edge.source) && nodes.has(edge.target))
734
+ };
735
+ }
736
+ /** True when the merged live graph fits the already-allocated mesh capacities. */
737
+ function dataPatchFitsCapacity(merged, capacity) {
738
+ return merged.nodes.length <= capacity.nodes && merged.edges.length <= capacity.edges;
739
+ }
740
+ /** True when the patch is add-only and can grow dense arrays without rewriting survivors. */
741
+ function isAppendOnlyDataPatch(patch) {
742
+ return (patch.removedNodeIds?.length ?? 0) === 0 && (patch.removedEdgeIds?.length ?? 0) === 0;
743
+ }
744
+ //#endregion
745
+ //#region src/edge-paths.ts
746
+ const AUTO_OFFSET_FRACTION = .18;
747
+ function segmentCountForPath(path, tier) {
748
+ if (tier === "overview") return 1;
749
+ const resolved = path ?? "straight";
750
+ if (resolved === "quadratic") return 16;
751
+ if (resolved === "cubic") return 24;
752
+ return 1;
753
+ }
754
+ function autoControlPoints(path, x1, y1, z1, x2, y2, z2) {
755
+ const dx = x2 - x1;
756
+ const dy = y2 - y1;
757
+ const length = Math.hypot(dx, dy);
758
+ const offset = length * AUTO_OFFSET_FRACTION;
759
+ const nx = length > 0 ? -dy / length : 0;
760
+ const ny = length > 0 ? dx / length : 1;
761
+ if (path === "quadratic") return [{
762
+ x: (x1 + x2) / 2 + nx * offset,
763
+ y: (y1 + y2) / 2 + ny * offset,
764
+ z: (z1 + z2) / 2
765
+ }];
766
+ if (path === "cubic") return [{
767
+ x: x1 + dx / 3 + nx * offset,
768
+ y: y1 + dy / 3 + ny * offset,
769
+ z: z1 + (z2 - z1) / 3
770
+ }, {
771
+ x: x1 + dx * 2 / 3 - nx * offset,
772
+ y: y1 + dy * 2 / 3 - ny * offset,
773
+ z: z1 + (z2 - z1) * 2 / 3
774
+ }];
775
+ return [];
776
+ }
777
+ function lerp(a, b, t) {
778
+ return a + (b - a) * t;
779
+ }
780
+ function sampleQuadratic(x1, y1, z1, cx, cy, cz, x2, y2, z2, t) {
781
+ const u = 1 - t;
782
+ return {
783
+ x: u * u * x1 + 2 * u * t * cx + t * t * x2,
784
+ y: u * u * y1 + 2 * u * t * cy + t * t * y2,
785
+ z: u * u * z1 + 2 * u * t * cz + t * t * z2
786
+ };
787
+ }
788
+ function sampleCubic(x1, y1, z1, c1x, c1y, c1z, c2x, c2y, c2z, x2, y2, z2, t) {
789
+ const u = 1 - t;
790
+ return {
791
+ x: u * u * u * x1 + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x2,
792
+ y: u * u * u * y1 + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y2,
793
+ z: u * u * u * z1 + 3 * u * u * t * c1z + 3 * u * t * t * c2z + t * t * t * z2
794
+ };
795
+ }
796
+ function resolveControlPoints(path, controlPoints, x1, y1, z1, x2, y2, z2) {
797
+ if (path === "straight") return [];
798
+ if (controlPoints && controlPoints.length > 0) return controlPoints;
799
+ return autoControlPoints(path, x1, y1, z1, x2, y2, z2);
800
+ }
801
+ function sampleEdgePath(input) {
802
+ const path = input.tier === "overview" ? "straight" : input.path ?? "straight";
803
+ const segments = segmentCountForPath(path, input.tier);
804
+ if (path === "straight" || segments <= 1) return [{
805
+ x: input.x1,
806
+ y: input.y1,
807
+ z: input.z1
808
+ }, {
809
+ x: input.x2,
810
+ y: input.y2,
811
+ z: input.z2
812
+ }];
813
+ const controls = resolveControlPoints(path, input.controlPoints, input.x1, input.y1, input.z1, input.x2, input.y2, input.z2);
814
+ const points = [];
815
+ for (let index = 0; index <= segments; index += 1) {
816
+ const t = index / segments;
817
+ if (path === "quadratic") {
818
+ const control = controls[0] ?? {
819
+ x: lerp(input.x1, input.x2, .5),
820
+ y: lerp(input.y1, input.y2, .5),
821
+ z: lerp(input.z1, input.z2, .5)
822
+ };
823
+ points.push(sampleQuadratic(input.x1, input.y1, input.z1, control.x, control.y, control.z ?? 0, input.x2, input.y2, input.z2, t));
824
+ } else {
825
+ const c1 = controls[0] ?? {
826
+ x: lerp(input.x1, input.x2, 1 / 3),
827
+ y: lerp(input.y1, input.y2, 1 / 3),
828
+ z: lerp(input.z1, input.z2, 1 / 3)
829
+ };
830
+ const c2 = controls[1] ?? {
831
+ x: lerp(input.x1, input.x2, 2 / 3),
832
+ y: lerp(input.y1, input.y2, 2 / 3),
833
+ z: lerp(input.z1, input.z2, 2 / 3)
834
+ };
835
+ points.push(sampleCubic(input.x1, input.y1, input.z1, c1.x, c1.y, c1.z ?? 0, c2.x, c2.y, c2.z ?? 0, input.x2, input.y2, input.z2, t));
836
+ }
837
+ }
838
+ return points;
839
+ }
840
+ //#endregion
841
+ //#region src/edge-materialize.ts
842
+ function packEdgeInstances(input) {
843
+ const segments = [];
844
+ const markers = [];
845
+ let truncated = false;
846
+ const attach = input.endpointAttach ?? "boundary";
847
+ const clearance = input.endpointClearance ?? .75;
848
+ for (const edgeIndex of input.edgeIndices) {
849
+ const visual = input.edgeVisuals[edgeIndex] ?? {};
850
+ const base = edgeIndex * 6;
851
+ let x1 = input.endpointPositions[base] ?? 0;
852
+ let y1 = input.endpointPositions[base + 1] ?? 0;
853
+ let z1 = input.endpointPositions[base + 2] ?? 0;
854
+ let x2 = input.endpointPositions[base + 3] ?? 0;
855
+ let y2 = input.endpointPositions[base + 4] ?? 0;
856
+ let z2 = input.endpointPositions[base + 5] ?? 0;
857
+ if (attach === "boundary" && input.nodeOutlines && input.edgeNodeIndices) {
858
+ const sourceIndex = input.edgeNodeIndices[edgeIndex * 2] ?? 0;
859
+ const targetIndex = input.edgeNodeIndices[edgeIndex * 2 + 1] ?? 0;
860
+ const sourceOutline = input.nodeOutlines[sourceIndex] ?? {};
861
+ const targetOutline = input.nodeOutlines[targetIndex] ?? {};
862
+ const trimmed = trimEdgeEndpoints({
863
+ attach,
864
+ clearance,
865
+ source: {
866
+ x: x1,
867
+ y: y1,
868
+ z: z1
869
+ },
870
+ target: {
871
+ x: x2,
872
+ y: y2,
873
+ z: z2
874
+ },
875
+ sourceOutline,
876
+ targetOutline
877
+ });
878
+ x1 = trimmed.source.x;
879
+ y1 = trimmed.source.y;
880
+ z1 = trimmed.source.z ?? 0;
881
+ x2 = trimmed.target.x;
882
+ y2 = trimmed.target.y;
883
+ z2 = trimmed.target.z ?? 0;
884
+ }
885
+ const color = visual.color ?? input.defaults.color;
886
+ const width = input.tier === "overview" ? input.defaults.width : visual.width ?? input.defaults.width;
887
+ const opacity = input.tier === "overview" ? input.defaults.opacity : visual.opacity ?? input.defaults.opacity;
888
+ const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
889
+ const points = sampleEdgePath({
890
+ path: visual.path,
891
+ controlPoints: visual.controlPoints,
892
+ tier: input.tier,
893
+ x1,
894
+ y1,
895
+ z1,
896
+ x2,
897
+ y2,
898
+ z2
899
+ });
900
+ const segmentSpans = Math.max(0, points.length - 1);
901
+ if (input.maxSegments !== void 0 && segments.length + segmentSpans > input.maxSegments) {
902
+ truncated = true;
903
+ break;
904
+ }
905
+ for (let index = 0; index < segmentSpans; index += 1) {
906
+ const from = points[index];
907
+ const to = points[index + 1];
908
+ if (!from || !to) continue;
909
+ segments.push({
910
+ edgeIndex,
911
+ x1: from.x,
912
+ y1: from.y,
913
+ z1: from.z ?? 0,
914
+ x2: to.x,
915
+ y2: to.y,
916
+ z2: to.z ?? 0,
917
+ color,
918
+ width,
919
+ opacity,
920
+ style
921
+ });
922
+ }
923
+ if (input.tier === "overview") continue;
924
+ if ((visual.marker ?? "none") === "none") continue;
925
+ const markerSize = (visual.markerSize ?? 1) * width;
926
+ const ends = visual.markerEnd ?? "target";
927
+ const first = points[0];
928
+ const second = points[1] ?? points[0];
929
+ const last = points.at(-1);
930
+ const previous = points.at(-2) ?? last;
931
+ if (!first || !second || !last || !previous) continue;
932
+ const add = (end) => {
933
+ const fromSource = end === "source";
934
+ markers.push({
935
+ edgeIndex,
936
+ end,
937
+ x: fromSource ? first.x : last.x,
938
+ y: fromSource ? first.y : last.y,
939
+ z: fromSource ? first.z ?? 0 : last.z ?? 0,
940
+ dx: fromSource ? first.x - second.x : last.x - previous.x,
941
+ dy: fromSource ? first.y - second.y : last.y - previous.y,
942
+ dz: fromSource ? (first.z ?? 0) - (second.z ?? 0) : (last.z ?? 0) - (previous.z ?? 0),
943
+ color,
944
+ size: markerSize,
945
+ opacity
946
+ });
947
+ };
948
+ if (ends === "source" || ends === "both") add("source");
949
+ if (ends === "target" || ends === "both") add("target");
950
+ }
951
+ return {
952
+ segments,
953
+ markers,
954
+ truncated
955
+ };
956
+ }
957
+ /** Map existing diagnostics lodLevel into the edge visual tier. */
958
+ function edgeTierFromDiagnosticsLod(lodLevel) {
959
+ return lodLevel === "detail" ? "detail" : "overview";
960
+ }
961
+ //#endregion
962
+ //#region src/edge-picking.ts
963
+ /** Squared distance from a point to the closest point on a finite segment. */
964
+ function distanceSquaredToSegment2d(px, py, x1, y1, x2, y2) {
965
+ const dx = x2 - x1;
966
+ const dy = y2 - y1;
967
+ const lengthSquared = dx * dx + dy * dy;
968
+ if (lengthSquared === 0) {
969
+ const ox = px - x1;
970
+ const oy = py - y1;
971
+ return ox * ox + oy * oy;
972
+ }
973
+ const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared));
974
+ const closestX = x1 + t * dx;
975
+ const closestY = y1 + t * dy;
976
+ const ox = px - closestX;
977
+ const oy = py - closestY;
978
+ return ox * ox + oy * oy;
979
+ }
980
+ /**
981
+ * Returns the edge index of the closest segment within its hit slop, or null.
982
+ * When several edges qualify, the nearest wins; ties keep the first encountered edge index.
983
+ */
984
+ function pickClosestEdgeIndex(point, segments) {
985
+ let nearestEdgeIndex = null;
986
+ let nearestDistance = Number.POSITIVE_INFINITY;
987
+ for (const segment of segments) {
988
+ const distanceSquared = distanceSquaredToSegment2d(point.x, point.y, segment.x1, segment.y1, segment.x2, segment.y2);
989
+ const slop = segment.hitSlop;
990
+ if (distanceSquared > slop * slop) continue;
991
+ if (distanceSquared < nearestDistance) {
992
+ nearestDistance = distanceSquared;
993
+ nearestEdgeIndex = segment.edgeIndex;
994
+ }
995
+ }
996
+ return nearestEdgeIndex;
997
+ }
998
+ //#endregion
999
+ //#region src/edge-rendering.ts
1000
+ const vertexShader$1 = `
1001
+ attribute float instanceKind;
1002
+ attribute vec3 instanceEndA;
1003
+ attribute vec3 instanceEndB;
1004
+ attribute vec4 instanceColor;
1005
+ attribute float instanceWidth;
1006
+ attribute float instanceStyle;
1007
+
1008
+ varying vec4 edgeColor;
1009
+ varying float edgeKind;
1010
+ varying float edgeStyle;
1011
+ varying float edgeDashT;
1012
+ varying vec2 edgeUv;
1013
+
1014
+ void main() {
1015
+ edgeColor = instanceColor;
1016
+ edgeKind = instanceKind;
1017
+ edgeStyle = instanceStyle;
1018
+ edgeUv = uv;
1019
+ edgeDashT = 0.0;
1020
+
1021
+ vec4 viewA = modelViewMatrix * vec4(instanceEndA, 1.0);
1022
+ vec4 viewB = modelViewMatrix * vec4(instanceEndB, 1.0);
1023
+ vec4 viewPosition;
1024
+
1025
+ if (instanceKind < 0.5) {
1026
+ // Segment: stretch the unit quad from EndA to EndB in view space.
1027
+ vec2 delta = viewB.xy - viewA.xy;
1028
+ float len = length(delta);
1029
+ vec2 dir = len > 0.0001 ? delta / len : vec2(1.0, 0.0);
1030
+ vec2 perp = vec2(-dir.y, dir.x);
1031
+ vec2 mid = (viewA.xy + viewB.xy) * 0.5;
1032
+ vec2 offset = dir * position.x * len + perp * position.y * instanceWidth;
1033
+ float depth = mix(viewA.z, viewB.z, position.x + 0.5);
1034
+ viewPosition = vec4(mid + offset, depth, 1.0);
1035
+ edgeDashT = (position.x + 0.5) * len / max(instanceWidth * 4.0, 0.0001);
1036
+ } else {
1037
+ // Marker: oriented triangle with its apex pinned to EndA, pointing
1038
+ // along EndA -> EndB (the direction of travel), base trailing behind.
1039
+ vec2 dirVec = viewB.xy - viewA.xy;
1040
+ float dirLen = length(dirVec);
1041
+ vec2 dir = dirLen > 0.0001 ? dirVec / dirLen : vec2(1.0, 0.0);
1042
+ vec2 perp = vec2(-dir.y, dir.x);
1043
+ float t = position.x + 0.5;
1044
+ vec2 offset = dir * instanceWidth * (t - 1.0) + perp * position.y * instanceWidth;
1045
+ viewPosition = vec4(viewA.xy + offset, viewA.z, 1.0);
1046
+ }
1047
+
1048
+ gl_Position = projectionMatrix * viewPosition;
1049
+ }
1050
+ `;
1051
+ const fragmentShader$1 = `
1052
+ varying vec4 edgeColor;
1053
+ varying float edgeKind;
1054
+ varying float edgeStyle;
1055
+ varying float edgeDashT;
1056
+ varying vec2 edgeUv;
1057
+
1058
+ void main() {
1059
+ if (edgeKind < 0.5) {
1060
+ if (edgeStyle > 0.5) {
1061
+ float cell = fract(edgeDashT);
1062
+ if (edgeStyle < 1.5) {
1063
+ if (cell > 0.6) discard; // dashed
1064
+ } else {
1065
+ if (abs(cell - 0.5) > 0.15) discard; // dotted
1066
+ }
1067
+ }
1068
+ } else {
1069
+ // Triangle SDF: apex at edgeUv.x == 1, full width base at edgeUv.x == 0.
1070
+ float halfWidth = (1.0 - edgeUv.x) * 0.5;
1071
+ if (abs(edgeUv.y - 0.5) > halfWidth) discard;
1072
+ }
1073
+ if (edgeColor.a <= 0.0) discard;
1074
+ gl_FragColor = edgeColor;
1075
+ }
1076
+ `;
1077
+ function createEdgeGeometry(capacity) {
1078
+ const geometry = new PlaneGeometry(1, 1);
1079
+ geometry.setAttribute("instanceKind", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1080
+ geometry.setAttribute("instanceEndA", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
1081
+ geometry.setAttribute("instanceEndB", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
1082
+ geometry.setAttribute("instanceColor", new InstancedBufferAttribute(new Float32Array(capacity * 4), 4));
1083
+ geometry.setAttribute("instanceWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1084
+ geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1085
+ return geometry;
1086
+ }
1087
+ function createEdgeMaterial(depthTest) {
1088
+ return new ShaderMaterial({
1089
+ depthTest,
1090
+ depthWrite: depthTest,
1091
+ fragmentShader: fragmentShader$1,
1092
+ transparent: true,
1093
+ vertexShader: vertexShader$1
1094
+ });
1095
+ }
1096
+ function getInstancedAttribute(geometry, name) {
1097
+ const attribute = geometry.getAttribute(name);
1098
+ if (!(attribute instanceof InstancedBufferAttribute)) throw new Error(`edge geometry is missing the "${name}" instanced attribute`);
1099
+ return attribute;
1100
+ }
1101
+ function writeEdgeSegmentInstance(geometry, slot, segment) {
1102
+ const kind = getInstancedAttribute(geometry, "instanceKind");
1103
+ const endA = getInstancedAttribute(geometry, "instanceEndA");
1104
+ const endB = getInstancedAttribute(geometry, "instanceEndB");
1105
+ const color = getInstancedAttribute(geometry, "instanceColor");
1106
+ const width = getInstancedAttribute(geometry, "instanceWidth");
1107
+ const style = getInstancedAttribute(geometry, "instanceStyle");
1108
+ kind.setX(slot, 0);
1109
+ endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
1110
+ endB.setXYZ(slot, segment.x2, segment.y2, segment.z2);
1111
+ const rgb = new Color(segment.color);
1112
+ color.setXYZW(slot, rgb.r, rgb.g, rgb.b, segment.opacity);
1113
+ width.setX(slot, segment.width);
1114
+ style.setX(slot, encodeEdgeStyle(segment.style));
1115
+ }
1116
+ function writeEdgeMarkerInstance(geometry, slot, marker) {
1117
+ const kind = getInstancedAttribute(geometry, "instanceKind");
1118
+ const endA = getInstancedAttribute(geometry, "instanceEndA");
1119
+ const endB = getInstancedAttribute(geometry, "instanceEndB");
1120
+ const color = getInstancedAttribute(geometry, "instanceColor");
1121
+ const width = getInstancedAttribute(geometry, "instanceWidth");
1122
+ const style = getInstancedAttribute(geometry, "instanceStyle");
1123
+ kind.setX(slot, 1);
1124
+ endA.setXYZ(slot, marker.x, marker.y, marker.z);
1125
+ endB.setXYZ(slot, marker.x + marker.dx, marker.y + marker.dy, marker.z + marker.dz);
1126
+ const rgb = new Color(marker.color);
1127
+ color.setXYZW(slot, rgb.r, rgb.g, rgb.b, marker.opacity);
1128
+ width.setX(slot, marker.size);
1129
+ style.setX(slot, 0);
1130
+ }
1131
+ //#endregion
1132
+ //#region src/edge-viewport-patch.ts
1133
+ const EDGE_INSTANCE_ATTRIBUTES$1 = [
1134
+ "instanceKind",
1135
+ "instanceEndA",
1136
+ "instanceEndB",
1137
+ "instanceColor",
1138
+ "instanceWidth",
1139
+ "instanceStyle"
1140
+ ];
1141
+ /** Records GPU slot ranges for each visible edge after a full viewport materialize. */
1142
+ function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
1143
+ const layouts = /* @__PURE__ */ new Map();
1144
+ for (const [slot, segment] of segments.entries()) {
1145
+ const layout = layouts.get(segment.edgeIndex) ?? {
1146
+ markerCount: 0,
1147
+ markerStart: segmentInstanceCount,
1148
+ segmentCount: 0,
1149
+ segmentStart: slot
1150
+ };
1151
+ if (layout.segmentCount === 0) layout.segmentStart = slot;
1152
+ layout.segmentCount += 1;
1153
+ layouts.set(segment.edgeIndex, layout);
1154
+ }
1155
+ for (const [offset, marker] of markers.entries()) {
1156
+ const slot = segmentInstanceCount + offset;
1157
+ const layout = layouts.get(marker.edgeIndex) ?? {
1158
+ markerCount: 0,
1159
+ markerStart: slot,
1160
+ segmentCount: 0,
1161
+ segmentStart: -1
1162
+ };
1163
+ if (layout.markerCount === 0) layout.markerStart = slot;
1164
+ layout.markerCount += 1;
1165
+ layouts.set(marker.edgeIndex, layout);
1166
+ }
1167
+ return layouts;
1168
+ }
1169
+ /**
1170
+ * Rewrites GPU edge instances for moved endpoints without repacking the whole viewport.
1171
+ * Returns ok=false when any visible edge would change segment or marker counts.
1172
+ */
1173
+ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1174
+ const nextPickable = [...pickableSegments];
1175
+ let dirtySlotStart = Number.POSITIVE_INFINITY;
1176
+ let dirtySlotEnd = -1;
1177
+ const seen = /* @__PURE__ */ new Set();
1178
+ for (const edgeIndex of input.changedEdgeIndices) {
1179
+ if (seen.has(edgeIndex)) continue;
1180
+ seen.add(edgeIndex);
1181
+ const layout = input.layouts.get(edgeIndex);
1182
+ if (!layout || layout.segmentCount === 0 || layout.segmentStart < 0) continue;
1183
+ const packed = packEdgeInstances({
1184
+ edgeIndices: [edgeIndex],
1185
+ edgeVisuals: input.edgeVisuals,
1186
+ endpointPositions: input.endpointPositions,
1187
+ defaults: input.defaults,
1188
+ tier: input.tier,
1189
+ endpointAttach: input.endpointAttach,
1190
+ edgeNodeIndices: input.edgeNodeIndices,
1191
+ nodeOutlines: input.nodeOutlines
1192
+ });
1193
+ if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
1194
+ dirtySlotCount: 0,
1195
+ dirtySlotStart: 0,
1196
+ ok: false,
1197
+ pickableSegments: nextPickable
1198
+ };
1199
+ for (const [offset, segment] of packed.segments.entries()) {
1200
+ const slot = layout.segmentStart + offset;
1201
+ writeEdgeSegmentInstance(geometry, slot, segment);
1202
+ nextPickable[slot] = {
1203
+ edgeIndex,
1204
+ hitSlop: Math.max(input.minHitSlop, segment.width * input.worldPerPixel / 2 + 2 * input.worldPerPixel),
1205
+ x1: segment.x1,
1206
+ x2: segment.x2,
1207
+ y1: segment.y1,
1208
+ y2: segment.y2
1209
+ };
1210
+ dirtySlotStart = Math.min(dirtySlotStart, slot);
1211
+ dirtySlotEnd = Math.max(dirtySlotEnd, slot);
1212
+ }
1213
+ for (const [offset, marker] of packed.markers.entries()) {
1214
+ const slot = layout.markerStart + offset;
1215
+ writeEdgeMarkerInstance(geometry, slot, marker);
1216
+ dirtySlotStart = Math.min(dirtySlotStart, slot);
1217
+ dirtySlotEnd = Math.max(dirtySlotEnd, slot);
1218
+ }
1219
+ }
1220
+ if (dirtySlotEnd < dirtySlotStart) return {
1221
+ dirtySlotCount: 0,
1222
+ dirtySlotStart: 0,
1223
+ ok: true,
1224
+ pickableSegments: nextPickable
1225
+ };
1226
+ for (const name of EDGE_INSTANCE_ATTRIBUTES$1) {
1227
+ const attribute = geometry.getAttribute(name);
1228
+ attribute.clearUpdateRanges();
1229
+ attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
1230
+ attribute.needsUpdate = true;
1231
+ }
1232
+ return {
1233
+ dirtySlotCount: dirtySlotEnd - dirtySlotStart + 1,
1234
+ dirtySlotStart,
1235
+ ok: true,
1236
+ pickableSegments: nextPickable
1237
+ };
1238
+ }
1239
+ //#endregion
1240
+ //#region src/instance-color-ranges.ts
1241
+ /**
1242
+ * Marks RGB update ranges for visible instance slots.
1243
+ * Returns how many slots were marked.
1244
+ */
1245
+ function markInstanceColorSlots(attribute, slots) {
1246
+ attribute.clearUpdateRanges();
1247
+ let count = 0;
1248
+ for (const slot of slots) {
1249
+ if (!Number.isSafeInteger(slot) || slot < 0) continue;
1250
+ attribute.addUpdateRange(slot * 3, 3);
1251
+ count += 1;
1252
+ }
1253
+ if (count > 0) attribute.needsUpdate = true;
1254
+ return count;
1255
+ }
1256
+ //#endregion
1257
+ //#region src/layout-positions.ts
1258
+ /** Validates worker-friendly XYZ layout buffers before positions reach the renderer. */
1259
+ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1260
+ if (layout.positions.length !== layout.nodeIds.length * 3) throw new Error("Layout positions must contain one XYZ triplet per node ID");
1261
+ const seen = /* @__PURE__ */ new Set();
1262
+ return layout.nodeIds.map((id, index) => {
1263
+ if (seen.has(id)) throw new Error(`Duplicate layout node ID: "${id}"`);
1264
+ seen.add(id);
1265
+ const nodeIndex = nodeIndices.get(id);
1266
+ if (nodeIndex === void 0) throw new Error(`Cannot position missing node: "${id}"`);
1267
+ const current = nodes[nodeIndex];
1268
+ if (!current) throw new Error(`Cannot position missing node: "${id}"`);
1269
+ const offset = index * 3;
1270
+ const position = {
1271
+ x: layout.positions[offset] ?? NaN,
1272
+ y: layout.positions[offset + 1] ?? NaN,
1273
+ z: layout.positions[offset + 2] ?? NaN
1274
+ };
1275
+ for (const [axis, value] of Object.entries(position)) if (!Number.isFinite(value)) throw new Error(`Layout node "${id}" has a non-finite ${axis} position`);
1276
+ return {
1277
+ colorChanged: false,
1278
+ index: nodeIndex,
1279
+ next: {
1280
+ ...current,
1281
+ position
1282
+ },
1283
+ positionChanged: true,
1284
+ shapeChanged: false,
1285
+ sizeChanged: false,
1286
+ strokeChanged: false
1287
+ };
1288
+ });
1289
+ }
1290
+ //#endregion
1291
+ //#region src/node-rendering.ts
1292
+ const vertexShader = `
1293
+ attribute float instanceShape;
1294
+ attribute float instanceStrokeWidth;
1295
+ attribute vec3 instanceStrokeColor;
1296
+ varying vec3 nodeColor;
1297
+ varying vec2 nodePoint;
1298
+ varying float nodeShape;
1299
+ varying float nodeStrokeWidth;
1300
+ varying vec3 nodeStrokeColor;
1301
+
1302
+ void main() {
1303
+ nodeColor = instanceColor;
1304
+ nodePoint = position.xy;
1305
+ nodeShape = instanceShape;
1306
+ nodeStrokeWidth = instanceStrokeWidth;
1307
+ nodeStrokeColor = instanceStrokeColor;
1308
+
1309
+ vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1310
+ float scaleX = length(instanceMatrix[0].xyz);
1311
+ float scaleY = length(instanceMatrix[1].xyz);
1312
+ center.xy += position.xy * vec2(scaleX, scaleY);
1313
+ gl_Position = projectionMatrix * center;
1314
+ }
1315
+ `;
1316
+ const fragmentShader = `
1317
+ varying vec3 nodeColor;
1318
+ varying vec2 nodePoint;
1319
+ varying float nodeShape;
1320
+ varying float nodeStrokeWidth;
1321
+ varying vec3 nodeStrokeColor;
1322
+
1323
+ // Apothem of a regular hexagon whose pointy left/right vertices reach x = ±1 (matches node-shapes.ts).
1324
+ const float HEXAGON_APOTHEM = 0.8660254037844386;
1325
+ // Y of the upward triangle's flat base, apex at (0, 1) (matches node-shapes.ts).
1326
+ const float TRIANGLE_BASE_Y = -0.7320508075688772;
1327
+ // Half-height of the horizontal pill's rounded caps, in unit space (matches node-shapes.ts).
1328
+ const float PILL_CAP_RADIUS = 0.5;
1329
+ // Half-length of the pill's straight segment, in unit space (matches node-shapes.ts).
1330
+ const float PILL_HALF_LENGTH = 0.5;
1331
+ // Fraction of the rounded rectangle's half-extent used as the corner radius (matches node-shapes.ts).
1332
+ const float ROUNDED_CORNER_RADIUS = 0.25;
1333
+
1334
+ float hexagonDistance(vec2 p) {
1335
+ vec2 a = abs(p);
1336
+ return HEXAGON_APOTHEM - max(a.y, a.x * HEXAGON_APOTHEM + a.y * 0.5);
1337
+ }
1338
+
1339
+ // Signed distance to an upward-pointing equilateral triangle, positive inside.
1340
+ float triangleDistance(vec2 p) {
1341
+ vec2 apex = vec2(0.0, 1.0);
1342
+ vec2 right = vec2(1.0, TRIANGLE_BASE_Y);
1343
+ vec2 left = vec2(-1.0, TRIANGLE_BASE_Y);
1344
+ vec2 e0 = right - apex;
1345
+ vec2 e1 = left - right;
1346
+ vec2 e2 = apex - left;
1347
+ vec2 v0 = p - apex;
1348
+ vec2 v1 = p - right;
1349
+ vec2 v2 = p - left;
1350
+ vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0);
1351
+ vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0);
1352
+ vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0);
1353
+ float s = sign(e0.x * e2.y - e0.y * e2.x);
1354
+ vec2 d0 = vec2(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x));
1355
+ vec2 d1 = vec2(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x));
1356
+ vec2 d2 = vec2(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x));
1357
+ vec2 d = min(min(d0, d1), d2);
1358
+ return sqrt(d.x) * sign(d.y);
1359
+ }
1360
+
1361
+ float pillDistance(vec2 p) {
1362
+ float clampedX = clamp(p.x, -PILL_HALF_LENGTH, PILL_HALF_LENGTH);
1363
+ return PILL_CAP_RADIUS - length(vec2(p.x - clampedX, p.y));
1364
+ }
1365
+
1366
+ float roundedDistance(vec2 p) {
1367
+ vec2 outsideOffset = abs(p) - vec2(1.0 - ROUNDED_CORNER_RADIUS);
1368
+ vec2 outside = max(outsideOffset, 0.0);
1369
+ float outsideDistance = length(outside) + min(max(outsideOffset.x, outsideOffset.y), 0.0) - ROUNDED_CORNER_RADIUS;
1370
+ return -outsideDistance;
1371
+ }
1372
+
1373
+ void main() {
1374
+ float distanceToEdge;
1375
+ if (nodeShape < 0.5) {
1376
+ distanceToEdge = 1.0 - length(nodePoint);
1377
+ } else if (nodeShape < 1.5) {
1378
+ distanceToEdge = 1.0 - max(abs(nodePoint.x), abs(nodePoint.y));
1379
+ } else if (nodeShape < 2.5) {
1380
+ distanceToEdge = 1.0 - abs(nodePoint.x) - abs(nodePoint.y);
1381
+ } else if (nodeShape < 3.5) {
1382
+ distanceToEdge = hexagonDistance(nodePoint);
1383
+ } else if (nodeShape < 4.5) {
1384
+ distanceToEdge = triangleDistance(nodePoint);
1385
+ } else if (nodeShape < 5.5) {
1386
+ distanceToEdge = pillDistance(nodePoint);
1387
+ } else {
1388
+ distanceToEdge = roundedDistance(nodePoint);
1389
+ }
1390
+
1391
+ float outerAlpha = smoothstep(0.0, fwidth(distanceToEdge), distanceToEdge);
1392
+ if (outerAlpha <= 0.0) discard;
1393
+
1394
+ vec3 fillColor = nodeColor;
1395
+ if (nodeStrokeWidth > 0.0) {
1396
+ // Stroke band occupies [outer edge, outer edge - nodeStrokeWidth]; below that, the fill takes over.
1397
+ float innerEdge = distanceToEdge - nodeStrokeWidth;
1398
+ float strokeToFill = smoothstep(0.0, fwidth(innerEdge), innerEdge);
1399
+ fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
1400
+ }
1401
+ gl_FragColor = vec4(fillColor, outerAlpha);
1402
+ }
1403
+ `;
1404
+ function createNodeGeometry(capacity) {
1405
+ const geometry = new PlaneGeometry(2, 2);
1406
+ geometry.setAttribute("instanceShape", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1407
+ geometry.setAttribute("instanceStrokeWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1408
+ geometry.setAttribute("instanceStrokeColor", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
1409
+ return geometry;
1410
+ }
1411
+ /** Allocate InstancedMesh.instanceColor before first compile so Three injects the attribute. */
1412
+ function allocateNodeInstanceColors(mesh, capacity) {
1413
+ mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
1414
+ }
1415
+ function createNodeMaterial(depthTest) {
1416
+ return new ShaderMaterial({
1417
+ depthTest,
1418
+ depthWrite: depthTest,
1419
+ fragmentShader,
1420
+ transparent: true,
1421
+ vertexShader
1422
+ });
1423
+ }
1424
+ function setNodeShapeAt(attribute, index, shape) {
1425
+ attribute.setX(index, encodeNodeShape(shape));
1426
+ }
1427
+ function setNodeStrokeAt(strokeWidthAttribute, strokeColorAttribute, index, { strokeColor, strokeWidthUv }) {
1428
+ strokeWidthAttribute.setX(index, strokeWidthUv);
1429
+ const rgb = new Color(strokeColor);
1430
+ strokeColorAttribute.setXYZ(index, rgb.r, rgb.g, rgb.b);
1431
+ }
1432
+ //#endregion
1433
+ //#region src/node-stroke.ts
1434
+ /**
1435
+ * Ceiling for the stroke ring as a fraction of the shape's half-extent, so a large
1436
+ * `strokeWidth` in world units cannot swallow the fill entirely.
1437
+ */
1438
+ const MAX_STROKE_WIDTH_UV = .45;
1439
+ /**
1440
+ * Resolves a node's stroke into shader-ready values: `strokeColor` falls back to the theme
1441
+ * default, and `strokeWidthUv` converts the world-unit `strokeWidth` into a fraction of
1442
+ * `max(width, height)` — the same unit space `containsNodePoint` and the node fragment shader
1443
+ * use — so the ring reads consistently regardless of the node's rendered size.
1444
+ */
1445
+ function resolveNodeStroke(node, theme) {
1446
+ const strokeColor = node.strokeColor ?? theme.nodeStroke;
1447
+ const strokeWidth = node.strokeWidth ?? 0;
1448
+ if (strokeWidth <= 0) return {
1449
+ strokeColor,
1450
+ strokeWidthUv: 0
1451
+ };
1452
+ const { height, width } = resolveNodeAxes({
1453
+ height: node.height,
1454
+ nodeId: node.id,
1455
+ size: node.size,
1456
+ width: node.width
1457
+ });
1458
+ return {
1459
+ strokeColor,
1460
+ strokeWidthUv: Math.min(strokeWidth / Math.max(width, height), MAX_STROKE_WIDTH_UV)
1461
+ };
1462
+ }
1463
+ //#endregion
1464
+ //#region src/node-updates.ts
1465
+ function assertValidNode(node) {
1466
+ for (const [axis, value] of [
1467
+ ["x", node.position.x],
1468
+ ["y", node.position.y],
1469
+ ["z", node.position.z ?? 0]
1470
+ ]) if (!Number.isFinite(value)) throw new Error(`Node "${node.id}" has a non-finite ${axis} position`);
1471
+ resolveNodeAxes({
1472
+ height: node.height,
1473
+ nodeId: node.id,
1474
+ size: node.size,
1475
+ width: node.width
1476
+ });
1477
+ if (node.strokeWidth !== void 0 && (!Number.isFinite(node.strokeWidth) || node.strokeWidth < 0)) throw new Error(`Node "${node.id}" must have a finite non-negative strokeWidth`);
1478
+ if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
1479
+ }
1480
+ /** Validates a complete update batch before the renderer mutates CPU or GPU state. */
1481
+ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1482
+ const seen = /* @__PURE__ */ new Set();
1483
+ return updates.map((update) => {
1484
+ if (seen.has(update.id)) throw new Error(`Duplicate node update: "${update.id}"`);
1485
+ seen.add(update.id);
1486
+ const index = nodeIndices.get(update.id);
1487
+ if (index === void 0) throw new Error(`Cannot update missing node: "${update.id}"`);
1488
+ const current = nodes[index];
1489
+ if (!current) throw new Error(`Cannot update missing node: "${update.id}"`);
1490
+ const next = {
1491
+ ...current,
1492
+ ...Object.hasOwn(update, "color") ? { color: update.color } : {},
1493
+ ...update.position ? { position: { ...update.position } } : {},
1494
+ ...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
1495
+ ...Object.hasOwn(update, "size") ? { size: update.size } : {},
1496
+ ...Object.hasOwn(update, "width") ? { width: update.width } : {},
1497
+ ...Object.hasOwn(update, "height") ? { height: update.height } : {},
1498
+ ...Object.hasOwn(update, "strokeWidth") ? { strokeWidth: update.strokeWidth } : {},
1499
+ ...Object.hasOwn(update, "strokeColor") ? { strokeColor: update.strokeColor } : {}
1500
+ };
1501
+ assertValidNode(next);
1502
+ return {
1503
+ colorChanged: Object.hasOwn(update, "color"),
1504
+ index,
1505
+ next,
1506
+ positionChanged: update.position !== void 0,
1507
+ shapeChanged: Object.hasOwn(update, "shape"),
1508
+ sizeChanged: Object.hasOwn(update, "size") || Object.hasOwn(update, "width") || Object.hasOwn(update, "height"),
1509
+ strokeChanged: Object.hasOwn(update, "strokeWidth") || Object.hasOwn(update, "strokeColor")
1510
+ };
1511
+ });
1512
+ }
1513
+ //#endregion
1514
+ //#region src/label-budget.ts
1515
+ /** Picks visible label ids by importance within a hard DOM budget. */
1516
+ function selectBudgetedLabelIds({ candidates, maxLabels }) {
1517
+ if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("maxLabels must be a non-negative integer.");
1518
+ return candidates.filter((candidate) => candidate.visible).sort((left, right) => {
1519
+ if (right.importance !== left.importance) return right.importance - left.importance;
1520
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
1521
+ }).slice(0, maxLabels).map((candidate) => candidate.id);
1522
+ }
1523
+ /**
1524
+ * Labels for a sparse focus set: keep focus ids that are visible, then optionally fill
1525
+ * remaining budget from other visible candidates by importance. Focus order is preserved.
1526
+ */
1527
+ function selectFocusLabelIds({ focusIds, candidates, maxLabels, fill = "importance" }) {
1528
+ if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("maxLabels must be a non-negative integer.");
1529
+ const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
1530
+ const chosen = [];
1531
+ const chosenSet = /* @__PURE__ */ new Set();
1532
+ for (const id of focusIds) {
1533
+ if (chosen.length >= maxLabels) break;
1534
+ if (chosenSet.has(id)) continue;
1535
+ if (!byId.get(id)?.visible) continue;
1536
+ chosen.push(id);
1537
+ chosenSet.add(id);
1538
+ }
1539
+ if (fill === "none" || chosen.length >= maxLabels) return chosen;
1540
+ const fillers = selectBudgetedLabelIds({
1541
+ candidates: candidates.filter((candidate) => !chosenSet.has(candidate.id)),
1542
+ maxLabels: maxLabels - chosen.length
1543
+ });
1544
+ return [...chosen, ...fillers];
1545
+ }
1546
+ /** Builds an ordered focus id list: selected, then hovered, then unique neighbors. */
1547
+ function orderFocusNodeIds(input) {
1548
+ const ordered = [];
1549
+ const seen = /* @__PURE__ */ new Set();
1550
+ for (const id of [
1551
+ ...input.selectedIds,
1552
+ ...input.hoveredIds,
1553
+ ...input.neighborIds
1554
+ ]) {
1555
+ if (seen.has(id)) continue;
1556
+ seen.add(id);
1557
+ ordered.push(id);
1558
+ }
1559
+ return ordered;
1560
+ }
1561
+ //#endregion
1562
+ //#region src/overlay-budget.ts
1563
+ function uniqueIds(ids) {
1564
+ const seen = /* @__PURE__ */ new Set();
1565
+ const ordered = [];
1566
+ for (const id of ids) {
1567
+ if (seen.has(id)) continue;
1568
+ seen.add(id);
1569
+ ordered.push(id);
1570
+ }
1571
+ return ordered;
1572
+ }
1573
+ function boundOverlayIds(ids, max, kind) {
1574
+ if (!Number.isSafeInteger(max) || max < 0) throw new Error(`Overlay ${kind} cap must be a non-negative integer.`);
1575
+ const list = uniqueIds(ids);
1576
+ if (list.length > max) throw new Error(`Overlay supports at most ${max} ${kind} (requested ${list.length}).`);
1577
+ return list;
1578
+ }
1579
+ function selectRichNodeIds(input) {
1580
+ if (!Number.isSafeInteger(input.maxRichNodes) || input.maxRichNodes < 0) throw new Error("maxRichNodes must be a non-negative integer.");
1581
+ return (input.policy === "selected" ? uniqueIds(input.selectedIds) : input.policy === "hovered" ? uniqueIds([...input.hoveredIds, ...input.selectedIds]) : uniqueIds([...input.selectedIds, ...input.hoveredIds])).slice(0, input.maxRichNodes);
1582
+ }
1583
+ //#endregion
1584
+ //#region src/overlay.ts
1585
+ function assertElement(element, kind) {
1586
+ if (element !== null && !(element instanceof HTMLElement)) throw new Error(`${kind} renderer must return an HTMLElement or null`);
1587
+ }
1588
+ function resolveLabelPolicy(options) {
1589
+ if (options.labelPolicy) return options.labelPolicy;
1590
+ return options.autoLabels === true ? "auto" : "manual";
1591
+ }
1592
+ /** A bounded DOM layer for labels, a focused-node toolbar, and optional rich HTML cards. */
1593
+ var GraphraumOverlay = class {
1594
+ graph;
1595
+ container;
1596
+ options;
1597
+ root = document.createElement("div");
1598
+ labels = /* @__PURE__ */ new Map();
1599
+ richNodes = /* @__PURE__ */ new Map();
1600
+ toolbar = null;
1601
+ toolbarNodeId = null;
1602
+ stopObservingView;
1603
+ stopObservingFocus;
1604
+ previousContainerPosition;
1605
+ labelPolicy;
1606
+ focusLabelFill;
1607
+ autoToolbar;
1608
+ autoRichNodes;
1609
+ maxLabels;
1610
+ maxRichNodes;
1611
+ constructor(graph, container, options) {
1612
+ this.graph = graph;
1613
+ this.container = container;
1614
+ this.options = options;
1615
+ const maxLabels = options.maxLabels ?? 50;
1616
+ if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("Overlay maxLabels must be a non-negative integer.");
1617
+ this.maxLabels = maxLabels;
1618
+ const maxRichNodes = options.maxRichNodes ?? 24;
1619
+ if (!Number.isSafeInteger(maxRichNodes) || maxRichNodes < 0) throw new Error("Overlay maxRichNodes must be a non-negative integer.");
1620
+ this.maxRichNodes = maxRichNodes;
1621
+ this.labelPolicy = resolveLabelPolicy(options);
1622
+ this.focusLabelFill = options.focusLabelFill ?? "importance";
1623
+ this.autoToolbar = options.autoToolbar ?? false;
1624
+ this.autoRichNodes = options.autoRichNodes ?? false;
1625
+ this.root.className = `graphraum-overlay ${options.overlayClassName ?? ""}`.trim();
1626
+ this.root.style.cssText = "inset:0;pointer-events:none;position:absolute;";
1627
+ this.previousContainerPosition = getComputedStyle(this.container).position === "static" ? this.container.style.position : null;
1628
+ if (this.previousContainerPosition !== null) this.container.style.position = "relative";
1629
+ this.container.append(this.root);
1630
+ this.stopObservingView = graph.onViewChange(() => this.update());
1631
+ this.stopObservingFocus = graph.onFocusChange(() => this.update());
1632
+ }
1633
+ /**
1634
+ * Replaces the labeled node set. When `labelPolicy` is `auto` or `focus`, the next view/focus
1635
+ * change overwrites this list from the policy.
1636
+ */
1637
+ setLabels(nodeIds) {
1638
+ this.syncLabels(boundOverlayIds(nodeIds, this.maxLabels, "labels"));
1639
+ this.updatePositions();
1640
+ }
1641
+ /**
1642
+ * Replaces the rich HTML node set. When `autoRichNodes` is set, the next focus change
1643
+ * overwrites this list from the policy.
1644
+ */
1645
+ setRichNodes(nodeIds) {
1646
+ if (this.autoRichNodes) return;
1647
+ this.syncRichNodes(boundOverlayIds(nodeIds, this.maxRichNodes, "rich nodes"));
1648
+ this.updatePositions();
1649
+ }
1650
+ setToolbar(nodeId) {
1651
+ if (this.autoToolbar) return;
1652
+ this.toolbar?.element.remove();
1653
+ this.toolbar = null;
1654
+ this.toolbarNodeId = nodeId;
1655
+ if (nodeId !== null) this.toolbar = this.createEntry(nodeId, this.options.renderToolbar, "Toolbar", "auto");
1656
+ this.updatePositions();
1657
+ }
1658
+ destroy() {
1659
+ this.stopObservingView();
1660
+ this.stopObservingFocus();
1661
+ this.root.remove();
1662
+ if (this.previousContainerPosition !== null && this.container.querySelector(".graphraum-overlay") === null) this.container.style.position = this.previousContainerPosition;
1663
+ this.labels.clear();
1664
+ this.richNodes.clear();
1665
+ this.toolbar = null;
1666
+ }
1667
+ update() {
1668
+ if (this.labelPolicy === "auto") this.syncLabels(selectBudgetedLabelIds({
1669
+ candidates: this.graph.getLabelCandidates(),
1670
+ maxLabels: this.maxLabels
1671
+ }));
1672
+ else if (this.labelPolicy === "focus") {
1673
+ const selectedIds = this.graph.getSelectedNodeIds();
1674
+ const hoveredIds = this.graph.getHoveredNodeIds();
1675
+ const neighborIds = [...selectedIds, ...hoveredIds].flatMap((id) => this.graph.getNeighborIds(id));
1676
+ this.syncLabels(selectFocusLabelIds({
1677
+ focusIds: orderFocusNodeIds({
1678
+ selectedIds,
1679
+ hoveredIds,
1680
+ neighborIds
1681
+ }),
1682
+ candidates: this.graph.getLabelCandidates(),
1683
+ maxLabels: this.maxLabels,
1684
+ fill: this.focusLabelFill
1685
+ }));
1686
+ }
1687
+ if (this.autoToolbar) this.syncToolbar();
1688
+ if (this.autoRichNodes) this.syncRichNodesFromFocus();
1689
+ this.updatePositions();
1690
+ }
1691
+ syncRichNodesFromFocus() {
1692
+ if (!this.autoRichNodes || !this.options.renderRichNode) return;
1693
+ this.syncRichNodes(selectRichNodeIds({
1694
+ hoveredIds: this.graph.getHoveredNodeIds(),
1695
+ maxRichNodes: this.maxRichNodes,
1696
+ policy: this.autoRichNodes,
1697
+ selectedIds: this.graph.getSelectedNodeIds()
1698
+ }));
1699
+ }
1700
+ syncToolbar() {
1701
+ if (!this.options.renderToolbar) return;
1702
+ const nextId = this.autoToolbar === "hovered" ? this.graph.getHoveredNodeIds()[0] ?? this.graph.getSelectedNodeIds()[0] ?? null : this.graph.getSelectedNodeIds()[0] ?? null;
1703
+ if (nextId === this.toolbarNodeId && this.toolbar) return;
1704
+ this.toolbar?.element.remove();
1705
+ this.toolbar = null;
1706
+ this.toolbarNodeId = nextId;
1707
+ if (nextId !== null) this.toolbar = this.createEntry(nextId, this.options.renderToolbar, "Toolbar", "auto");
1708
+ }
1709
+ syncLabels(nodeIds) {
1710
+ const nextIds = new Set(nodeIds);
1711
+ for (const [nodeId, entry] of this.labels) {
1712
+ if (nextIds.has(nodeId)) continue;
1713
+ entry.element.remove();
1714
+ this.labels.delete(nodeId);
1715
+ }
1716
+ for (const nodeId of nodeIds) {
1717
+ if (this.labels.has(nodeId)) continue;
1718
+ const entry = this.createEntry(nodeId, this.options.renderLabel, "Label", "none");
1719
+ if (entry) this.labels.set(nodeId, entry);
1720
+ }
1721
+ }
1722
+ syncRichNodes(nodeIds) {
1723
+ const nextIds = new Set(nodeIds);
1724
+ for (const [nodeId, entry] of this.richNodes) {
1725
+ if (nextIds.has(nodeId)) continue;
1726
+ entry.element.remove();
1727
+ this.richNodes.delete(nodeId);
1728
+ }
1729
+ for (const nodeId of nodeIds) {
1730
+ if (this.richNodes.has(nodeId)) continue;
1731
+ const entry = this.createEntry(nodeId, this.options.renderRichNode, "Rich", "auto");
1732
+ if (entry) this.richNodes.set(nodeId, entry);
1733
+ }
1734
+ }
1735
+ updatePositions() {
1736
+ for (const [nodeId, entry] of this.labels) this.updateEntry(nodeId, entry, this.options.renderLabel, "label", "none");
1737
+ for (const [nodeId, entry] of this.richNodes) this.updateEntry(nodeId, entry, this.options.renderRichNode, "rich", "auto");
1738
+ if (this.toolbar && this.toolbarNodeId) this.updateEntry(this.toolbarNodeId, this.toolbar, this.options.renderToolbar, "toolbar", "auto");
1739
+ }
1740
+ createEntry(nodeId, render, kind, pointerEvents) {
1741
+ if (!render || !this.graph.getNodeScreenPosition(nodeId)) return null;
1742
+ const presentation = this.graph.getNodePresentation(nodeId);
1743
+ const element = render({
1744
+ id: nodeId,
1745
+ presentation
1746
+ });
1747
+ assertElement(element, kind);
1748
+ if (!element) return null;
1749
+ const className = `graphraum-overlay-${kind.toLowerCase()}`;
1750
+ element.classList.add("graphraum-overlay-entry", className);
1751
+ if (kind === "Label" && this.options.labelClassName) this.applyClassName(element, this.options.labelClassName);
1752
+ if (kind === "Toolbar" && this.options.toolbarClassName) this.applyClassName(element, this.options.toolbarClassName);
1753
+ if (kind === "Rich" && this.options.richNodeClassName) this.applyClassName(element, this.options.richNodeClassName);
1754
+ element.dataset.graphraumNodeId = nodeId;
1755
+ element.style.position = "absolute";
1756
+ element.style.pointerEvents = pointerEvents;
1757
+ this.root.append(element);
1758
+ return {
1759
+ element,
1760
+ presentation
1761
+ };
1762
+ }
1763
+ updateEntry(nodeId, entry, render, kind, pointerEvents) {
1764
+ const position = this.graph.getNodeScreenPosition(nodeId);
1765
+ entry.element.hidden = !position?.visible;
1766
+ if (!position?.visible) return;
1767
+ const anchor = kind === "rich" ? "translate(-50%, 12px)" : "translate(-50%, -100%)";
1768
+ entry.element.style.transform = `translate(${position.x}px, ${position.y}px) ${anchor}`;
1769
+ const presentation = this.graph.getNodePresentation(nodeId);
1770
+ if (presentation === entry.presentation || !render) return;
1771
+ const replacement = render({
1772
+ id: nodeId,
1773
+ presentation
1774
+ });
1775
+ assertElement(replacement, `${kind[0].toUpperCase()}${kind.slice(1)}`);
1776
+ if (!replacement) {
1777
+ entry.element.remove();
1778
+ if (kind === "toolbar") this.toolbar = null;
1779
+ else if (kind === "rich") this.richNodes.delete(nodeId);
1780
+ else this.labels.delete(nodeId);
1781
+ return;
1782
+ }
1783
+ const className = `graphraum-overlay-${kind}`;
1784
+ replacement.classList.add("graphraum-overlay-entry", className);
1785
+ if (kind === "label" && this.options.labelClassName) this.applyClassName(replacement, this.options.labelClassName);
1786
+ if (kind === "toolbar" && this.options.toolbarClassName) this.applyClassName(replacement, this.options.toolbarClassName);
1787
+ if (kind === "rich" && this.options.richNodeClassName) this.applyClassName(replacement, this.options.richNodeClassName);
1788
+ replacement.dataset.graphraumNodeId = nodeId;
1789
+ replacement.style.cssText = `${entry.element.style.cssText};position:absolute;pointer-events:${pointerEvents};`;
1790
+ entry.element.replaceWith(replacement);
1791
+ entry.element = replacement;
1792
+ entry.presentation = presentation;
1793
+ }
1794
+ applyClassName(element, className) {
1795
+ for (const token of className.split(/\s+/g)) if (token) element.classList.add(token);
1796
+ }
1797
+ };
1798
+ //#endregion
1799
+ //#region src/spatial-grid-2d.ts
1800
+ function nodeAxes(node) {
1801
+ return resolveNodeAxes({
1802
+ height: node.height,
1803
+ nodeId: node.id,
1804
+ size: node.size,
1805
+ width: node.width
1806
+ });
1807
+ }
1808
+ /**
1809
+ * Coarse occupancy radius for cell bucketing and bounding-box checks: half of the node's
1810
+ * larger axis, padded by its world-unit `strokeWidth`. This is intentionally conservative
1811
+ * (a circle-equivalent bound around a possibly non-square shape) — exact hit testing in
1812
+ * `pick` uses the true `width`/`height` per axis via `containsNodePoint`.
1813
+ */
1814
+ function nodeOccupancyRadius(node) {
1815
+ const { height, width } = nodeAxes(node);
1816
+ return Math.max(width, height) + (node.strokeWidth ?? 0);
1817
+ }
1818
+ /** A mutable uniform grid shared by 2D picking and viewport visibility queries. */
1819
+ var SpatialGrid2D = class {
1820
+ cellSize;
1821
+ cells = /* @__PURE__ */ new Map();
1822
+ cellKeys = [];
1823
+ nodes = [];
1824
+ maxRadius = 0;
1825
+ constructor(cellSize = 32) {
1826
+ this.cellSize = cellSize;
1827
+ if (!Number.isFinite(cellSize) || cellSize <= 0) throw new Error("Spatial grid cell size must be positive.");
1828
+ }
1829
+ set(index, node) {
1830
+ const previousKey = this.cellKeys[index];
1831
+ if (previousKey) {
1832
+ const previousCell = this.cells.get(previousKey);
1833
+ if (previousCell) {
1834
+ const position = previousCell.indexOf(index);
1835
+ if (position >= 0) previousCell.splice(position, 1);
1836
+ if (previousCell.length === 0) this.cells.delete(previousKey);
1837
+ }
1838
+ }
1839
+ const key = this.key(this.coordinate(node.position.x), this.coordinate(node.position.y));
1840
+ const cell = this.cells.get(key) ?? [];
1841
+ cell.push(index);
1842
+ this.cells.set(key, cell);
1843
+ this.cellKeys[index] = key;
1844
+ this.nodes[index] = node;
1845
+ this.maxRadius = Math.max(this.maxRadius, nodeOccupancyRadius(node));
1846
+ }
1847
+ pick(x, y) {
1848
+ const cellRadius = Math.ceil(this.maxRadius / this.cellSize);
1849
+ const centerX = this.coordinate(x);
1850
+ const centerY = this.coordinate(y);
1851
+ let nearestIndex = null;
1852
+ let nearestDistance = Number.POSITIVE_INFINITY;
1853
+ for (let cellY = centerY - cellRadius; cellY <= centerY + cellRadius; cellY += 1) for (let cellX = centerX - cellRadius; cellX <= centerX + cellRadius; cellX += 1) for (const index of this.cells.get(this.key(cellX, cellY)) ?? []) {
1854
+ const node = this.nodes[index];
1855
+ if (!node) continue;
1856
+ const { height, width } = nodeAxes(node);
1857
+ const offsetX = node.position.x - x;
1858
+ const offsetY = node.position.y - y;
1859
+ const distance = offsetX ** 2 + offsetY ** 2;
1860
+ if (containsNodePoint(node.shape, offsetX / width, offsetY / height) && distance < nearestDistance) {
1861
+ nearestDistance = distance;
1862
+ nearestIndex = index;
1863
+ }
1864
+ }
1865
+ return nearestIndex;
1866
+ }
1867
+ queryBounds(bounds, overscan = 0) {
1868
+ const left = bounds.left - overscan;
1869
+ const right = bounds.right + overscan;
1870
+ const bottom = bounds.bottom - overscan;
1871
+ const top = bounds.top + overscan;
1872
+ const indices = [];
1873
+ const minimumCellX = this.coordinate(left - this.maxRadius);
1874
+ const maximumCellX = this.coordinate(right + this.maxRadius);
1875
+ const minimumCellY = this.coordinate(bottom - this.maxRadius);
1876
+ const maximumCellY = this.coordinate(top + this.maxRadius);
1877
+ const queriedCellCount = (maximumCellX - minimumCellX + 1) * (maximumCellY - minimumCellY + 1);
1878
+ const candidateCells = Number.isSafeInteger(queriedCellCount) && queriedCellCount <= this.cells.size ? this.cellsInBounds(minimumCellX, maximumCellX, minimumCellY, maximumCellY) : this.cells.values();
1879
+ for (const cell of candidateCells) for (const index of cell) {
1880
+ const node = this.nodes[index];
1881
+ if (!node) continue;
1882
+ const radius = nodeOccupancyRadius(node);
1883
+ if (node.position.x + radius >= left && node.position.x - radius <= right && node.position.y + radius >= bottom && node.position.y - radius <= top) indices.push(index);
1884
+ }
1885
+ return indices;
1886
+ }
1887
+ *cellsInBounds(minimumX, maximumX, minimumY, maximumY) {
1888
+ for (let y = minimumY; y <= maximumY; y += 1) for (let x = minimumX; x <= maximumX; x += 1) yield this.cells.get(this.key(x, y)) ?? [];
1889
+ }
1890
+ coordinate(value) {
1891
+ return Math.floor(value / this.cellSize);
1892
+ }
1893
+ key(x, y) {
1894
+ return `${x}:${y}`;
1895
+ }
1896
+ };
1897
+ //#endregion
1898
+ //#region src/theme.ts
1899
+ /** Dark canvas: Ink Black, Turf Green, Porcelain. */
1900
+ const graphraumThemeDark = Object.freeze({
1901
+ background: "#040f0f",
1902
+ dimmedNode: "#315a51",
1903
+ edge: "#226f54",
1904
+ edgeOpacity: .55,
1905
+ edgeWidth: 1.5,
1906
+ endpointAttach: "boundary",
1907
+ focusedNode: "#73c7a5",
1908
+ hoveredNode: "#e4a853",
1909
+ node: "#226f54",
1910
+ nodeStroke: "#fcfffc",
1911
+ selectedEdge: "#fcfffc",
1912
+ selectedNode: "#fcfffc"
1913
+ });
1914
+ /** Light canvas: Porcelain field with Turf marks and Ink selection. */
1915
+ const graphraumThemeLight = Object.freeze({
1916
+ background: "#fcfffc",
1917
+ dimmedNode: "#9bb5ac",
1918
+ edge: "#226f54",
1919
+ edgeOpacity: .65,
1920
+ edgeWidth: 1.5,
1921
+ endpointAttach: "boundary",
1922
+ focusedNode: "#1a5c45",
1923
+ hoveredNode: "#c47a1a",
1924
+ node: "#226f54",
1925
+ nodeStroke: "#040f0f",
1926
+ selectedEdge: "#040f0f",
1927
+ selectedNode: "#040f0f"
1928
+ });
1929
+ const graphraumThemes = Object.freeze({
1930
+ dark: graphraumThemeDark,
1931
+ light: graphraumThemeLight
1932
+ });
1933
+ /** Alias of {@link graphraumThemeDark} for backward compatibility. */
1934
+ const graphraumTheme = graphraumThemeDark;
1935
+ function resolveGraphraumTheme(input, base = graphraumThemeDark) {
1936
+ if (input === void 0) return { ...base };
1937
+ if (typeof input === "string") {
1938
+ const preset = graphraumThemes[input];
1939
+ if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
1940
+ return { ...preset };
1941
+ }
1942
+ return {
1943
+ ...base,
1944
+ ...input
1945
+ };
1946
+ }
1947
+ /** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
1948
+ function normalizeGraphraumBackground(background) {
1949
+ return background === "transparent" ? null : background;
1950
+ }
1951
+ function isTransparentGraphraumBackground(background) {
1952
+ return normalizeGraphraumBackground(background) === null;
1953
+ }
1954
+ //#endregion
1955
+ //#region src/viewport-lod.ts
1956
+ /** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
1957
+ function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
1958
+ if (densityLodActive) return "density";
1959
+ return edgeCandidateCount > visibleEdgeSegmentCount ? "overview" : "detail";
1960
+ }
1961
+ /** Collects unique edges touching visible nodes without scanning the complete edge list. */
1962
+ function collectIncidentEdges(visibleNodeIndices, incidentEdgeIndices) {
1963
+ const edges = /* @__PURE__ */ new Set();
1964
+ for (const nodeIndex of visibleNodeIndices) for (const edgeIndex of incidentEdgeIndices[nodeIndex] ?? []) edges.add(edgeIndex);
1965
+ return [...edges];
1966
+ }
1967
+ /** Applies a deterministic, evenly distributed overview budget. */
1968
+ function applyEdgeBudget(edgeIndices, maximum) {
1969
+ if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error("Maximum visible edges must be a positive integer.");
1970
+ if (edgeIndices.length <= maximum) return edgeIndices;
1971
+ const step = edgeIndices.length / maximum;
1972
+ return Array.from({ length: maximum }, (_, index) => edgeIndices[Math.floor(index * step)]).filter((edgeIndex) => edgeIndex !== void 0);
1973
+ }
1974
+ /** Keeps density LOD stable while camera movement hovers around the visible-node budget. */
1975
+ function shouldUseDensityLod(candidateCount, maximum, active) {
1976
+ if (!Number.isSafeInteger(candidateCount) || candidateCount < 0) throw new Error("Visible node candidate count must be a non-negative integer.");
1977
+ if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error("Maximum visible nodes must be a positive integer.");
1978
+ return candidateCount > maximum * (active ? .85 : 1.1);
1979
+ }
1980
+ //#endregion
1981
+ //#region src/graphraum.ts
1982
+ /** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
1983
+ const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
1984
+ function disposeMaterial(material) {
1985
+ for (const item of Array.isArray(material) ? material : [material]) item.dispose();
1986
+ }
1987
+ const EDGE_INSTANCE_ATTRIBUTES = [
1988
+ "instanceKind",
1989
+ "instanceEndA",
1990
+ "instanceEndB",
1991
+ "instanceColor",
1992
+ "instanceWidth",
1993
+ "instanceStyle"
1994
+ ];
1995
+ /** A small, explicit WebGL graph renderer: one instanced node mesh and one batched edge geometry. */
1996
+ var Graphraum = class {
1997
+ container;
1998
+ renderer;
1999
+ scene = new Scene();
2000
+ raycaster = new Raycaster();
2001
+ pointer = new Vector2();
2002
+ pickCenter = new Vector3();
2003
+ pickRight = new Vector3();
2004
+ pickUp = new Vector3();
2005
+ theme;
2006
+ backgroundTexture = null;
2007
+ resizeObserver;
2008
+ maxVisibleEdges;
2009
+ maxVisibleNodes;
2010
+ viewportCulling;
2011
+ viewportOverscan;
2012
+ visuals;
2013
+ camera;
2014
+ controls;
2015
+ data = {
2016
+ nodes: [],
2017
+ edges: []
2018
+ };
2019
+ nodeIds = [];
2020
+ nodeIndices = /* @__PURE__ */ new Map();
2021
+ edgeNodeIndices = /* @__PURE__ */ new Uint32Array();
2022
+ canonicalEdgePositions = /* @__PURE__ */ new Float32Array();
2023
+ incidentEdgeIndices = [];
2024
+ nodePresentations = /* @__PURE__ */ new Map();
2025
+ edgePresentations = /* @__PURE__ */ new Map();
2026
+ spatialGrid2d = new SpatialGrid2D();
2027
+ nodeMesh = null;
2028
+ nodePickMesh = null;
2029
+ edgeMesh = null;
2030
+ nodeCapacity = 0;
2031
+ edgeCapacity = 0;
2032
+ markerCapacity = 0;
2033
+ edgeInstanceCapacity = 0;
2034
+ selectedNodeIds = /* @__PURE__ */ new Set();
2035
+ selectedEdgeIds = /* @__PURE__ */ new Set();
2036
+ hoveredNodeIds = /* @__PURE__ */ new Set();
2037
+ focusedNodeIds = /* @__PURE__ */ new Set();
2038
+ dimmedNodeIds = /* @__PURE__ */ new Set();
2039
+ visibleNodeSlots = /* @__PURE__ */ new Map();
2040
+ visibleEdgeSlots = /* @__PURE__ */ new Map();
2041
+ visibleEdgeLayouts = /* @__PURE__ */ new Map();
2042
+ lastEdgeLodTier = "detail";
2043
+ visibleNodeIndices = [];
2044
+ pickableEdgeSegments = [];
2045
+ visibleEdgeSegmentEdgeIndices = [];
2046
+ visibleNodeCount = 0;
2047
+ visibleNodeCandidateCount = 0;
2048
+ densityLodActive = false;
2049
+ visibleEdgeSegmentCount = 0;
2050
+ visibleEdgeCount = 0;
2051
+ visibleEdgeMarkerCount = 0;
2052
+ visibleEdgeCandidateCount = 0;
2053
+ edgeSegmentCapacity = 0;
2054
+ mode;
2055
+ frameRequest = null;
2056
+ cpuFrameMilliseconds = 0;
2057
+ gpuFrameMilliseconds = null;
2058
+ gpuTimer = null;
2059
+ viewListeners = /* @__PURE__ */ new Set();
2060
+ focusListeners = /* @__PURE__ */ new Set();
2061
+ lastHoveredNodeId = null;
2062
+ constructor(container, options = {}) {
2063
+ this.container = container;
2064
+ this.mode = options.mode ?? "2d";
2065
+ this.maxVisibleEdges = options.maxVisibleEdges ?? 1e5;
2066
+ this.maxVisibleNodes = options.maxVisibleNodes ?? 1e5;
2067
+ this.viewportCulling = options.viewportCulling ?? true;
2068
+ this.viewportOverscan = options.viewportOverscan ?? 16;
2069
+ this.visuals = options.visuals;
2070
+ if (!Number.isSafeInteger(this.maxVisibleEdges) || this.maxVisibleEdges < 1) throw new Error("Maximum visible edges must be a positive integer.");
2071
+ if (!Number.isSafeInteger(this.maxVisibleNodes) || this.maxVisibleNodes < 1) throw new Error("Maximum visible nodes must be a positive integer.");
2072
+ if (!Number.isFinite(this.viewportOverscan) || this.viewportOverscan < 0) throw new Error("Viewport overscan must be a non-negative finite number.");
2073
+ this.theme = resolveGraphraumTheme(options.theme);
2074
+ this.renderer = new WebGLRenderer({
2075
+ antialias: options.antialias ?? false,
2076
+ alpha: true
2077
+ });
2078
+ this.applyBackground();
2079
+ const context = this.renderer.getContext();
2080
+ if (typeof WebGL2RenderingContext !== "undefined" && context instanceof WebGL2RenderingContext) {
2081
+ const extension = context.getExtension("EXT_disjoint_timer_query_webgl2");
2082
+ if (extension) this.gpuTimer = {
2083
+ context,
2084
+ extension,
2085
+ pending: []
2086
+ };
2087
+ }
2088
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2));
2089
+ this.renderer.domElement.style.display = "block";
2090
+ this.renderer.domElement.style.height = "100%";
2091
+ this.renderer.domElement.style.width = "100%";
2092
+ this.container.append(this.renderer.domElement);
2093
+ this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
2094
+ this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
2095
+ this.camera = this.createCamera();
2096
+ this.controls = this.createControls();
2097
+ this.resizeObserver = new ResizeObserver(() => this.resize());
2098
+ this.resizeObserver.observe(container);
2099
+ this.resize();
2100
+ }
2101
+ setData(data) {
2102
+ this.replaceData(data, true);
2103
+ }
2104
+ /** Merge a topology patch without fitting or resetting the current camera. */
2105
+ applyDataPatch(patch) {
2106
+ const addedNodeIds = new Set((patch.addedNodes ?? []).map((node) => node.id));
2107
+ const addedEdgeIds = new Set((patch.addedEdges ?? []).map((edge) => edge.id));
2108
+ if (addedNodeIds.size !== (patch.addedNodes ?? []).length) throw new Error("Duplicate added node ID.");
2109
+ if (addedEdgeIds.size !== (patch.addedEdges ?? []).length) throw new Error("Duplicate added edge ID.");
2110
+ if (isAppendOnlyDataPatch(patch) && this.canAppendDataPatch(patch)) {
2111
+ this.appendDataPatch(patch);
2112
+ return;
2113
+ }
2114
+ const merged = mergeDataPatch(this.data.nodes, this.data.edges, patch);
2115
+ if (this.canMutateDataInPlace(merged)) {
2116
+ this.mutateDataInPlace(merged);
2117
+ return;
2118
+ }
2119
+ this.replaceData(merged, false);
2120
+ }
2121
+ canAppendDataPatch(patch) {
2122
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return false;
2123
+ const nodeIds = new Set(this.data.nodes.map((node) => node.id));
2124
+ if ((patch.addedNodes ?? []).some((node) => nodeIds.has(node.id))) return false;
2125
+ const edgeIds = new Set(this.data.edges.map((edge) => edge.id));
2126
+ if ((patch.addedEdges ?? []).some((edge) => edgeIds.has(edge.id))) return false;
2127
+ const allNodeIds = /* @__PURE__ */ new Set([...nodeIds, ...(patch.addedNodes ?? []).map((node) => node.id)]);
2128
+ return (patch.addedNodes?.length ?? 0) + this.data.nodes.length <= this.nodeCapacity && (patch.addedEdges?.length ?? 0) + this.data.edges.length <= this.edgeCapacity && (patch.addedEdges ?? []).every((edge) => allNodeIds.has(edge.source) && allNodeIds.has(edge.target));
2129
+ }
2130
+ canMutateDataInPlace(merged) {
2131
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return false;
2132
+ return dataPatchFitsCapacity(merged, {
2133
+ edges: this.edgeCapacity,
2134
+ nodes: this.nodeCapacity
2135
+ });
2136
+ }
2137
+ mutateDataInPlace(merged) {
2138
+ const compiled = compileGraph(merged, this.visuals);
2139
+ this.data = {
2140
+ edges: merged.edges.map((edge, index) => ({
2141
+ ...edge,
2142
+ ...compiled.edgeVisuals[index]
2143
+ })),
2144
+ nodes: merged.nodes.map((node, index) => ({
2145
+ ...node,
2146
+ ...compiled.nodeVisuals[index],
2147
+ position: { ...node.position }
2148
+ }))
2149
+ };
2150
+ this.nodeIds = compiled.nodeIds;
2151
+ this.nodeIndices = new Map(compiled.nodeIndices);
2152
+ this.edgeNodeIndices = compiled.edgeNodeIndices;
2153
+ this.canonicalEdgePositions = compiled.edgePositions;
2154
+ this.incidentEdgeIndices = compiled.incidentEdgeIndices;
2155
+ this.nodePresentations = new Map(compiled.nodePresentations);
2156
+ this.edgePresentations = new Map(compiled.edgePresentations);
2157
+ this.spatialGrid2d = new SpatialGrid2D();
2158
+ for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
2159
+ const liveNodeIds = new Set(this.nodeIds);
2160
+ const liveEdgeIds = new Set(this.data.edges.map((edge) => edge.id));
2161
+ this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
2162
+ this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
2163
+ this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
2164
+ this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
2165
+ this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => liveEdgeIds.has(id)));
2166
+ this.materializeViewport();
2167
+ this.requestRender();
2168
+ }
2169
+ appendDataPatch(patch) {
2170
+ const addedNodes = patch.addedNodes ?? [];
2171
+ const addedEdges = patch.addedEdges ?? [];
2172
+ if (addedNodes.length === 0 && addedEdges.length === 0) return;
2173
+ const nodeById = new Map(this.data.nodes.map((node) => [node.id, node]));
2174
+ for (const node of addedNodes) nodeById.set(node.id, node);
2175
+ const edgeEndpointNodes = addedEdges.flatMap((edge) => {
2176
+ const source = nodeById.get(edge.source);
2177
+ const target = nodeById.get(edge.target);
2178
+ return source && target ? [source, target] : [];
2179
+ });
2180
+ const patchNodes = [...addedNodes, ...edgeEndpointNodes].filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index);
2181
+ const compiled = compileGraph({
2182
+ nodes: patchNodes,
2183
+ edges: addedEdges
2184
+ }, this.visuals);
2185
+ const nodeVisuals = new Map(patchNodes.map((node, index) => [node.id, compiled.nodeVisuals[index]]));
2186
+ const nextNodes = [...this.data.nodes, ...addedNodes.map((node) => ({
2187
+ ...node,
2188
+ ...nodeVisuals.get(node.id)
2189
+ }))];
2190
+ const nextNodeIndices = new Map(this.nodeIndices);
2191
+ for (const [index, node] of nextNodes.entries()) nextNodeIndices.set(node.id, index);
2192
+ const nextEdges = [...this.data.edges, ...addedEdges.map((edge, index) => ({
2193
+ ...edge,
2194
+ ...compiled.edgeVisuals[index]
2195
+ }))];
2196
+ const nextEdgeIndices = new Uint32Array(nextEdges.length * 2);
2197
+ nextEdgeIndices.set(this.edgeNodeIndices);
2198
+ const nextEdgePositions = new Float32Array(nextEdges.length * 6);
2199
+ nextEdgePositions.set(this.canonicalEdgePositions);
2200
+ const nextIncidentEdges = this.incidentEdgeIndices.map((indices) => [...indices]);
2201
+ for (const [index, edge] of nextEdges.entries()) {
2202
+ const source = nextNodeIndices.get(edge.source);
2203
+ const target = nextNodeIndices.get(edge.target);
2204
+ if (source === void 0 || target === void 0) continue;
2205
+ nextEdgeIndices[index * 2] = source;
2206
+ nextEdgeIndices[index * 2 + 1] = target;
2207
+ if (!nextIncidentEdges[source]) nextIncidentEdges[source] = [];
2208
+ if (!nextIncidentEdges[target]) nextIncidentEdges[target] = [];
2209
+ if (index >= this.data.edges.length) {
2210
+ nextIncidentEdges[source]?.push(index);
2211
+ nextIncidentEdges[target]?.push(index);
2212
+ const sourceNode = nextNodes[source];
2213
+ const targetNode = nextNodes[target];
2214
+ if (sourceNode && targetNode) nextEdgePositions.set([
2215
+ sourceNode.position.x,
2216
+ sourceNode.position.y,
2217
+ sourceNode.position.z ?? 0,
2218
+ targetNode.position.x,
2219
+ targetNode.position.y,
2220
+ targetNode.position.z ?? 0
2221
+ ], index * 6);
2222
+ }
2223
+ }
2224
+ this.data = {
2225
+ nodes: nextNodes,
2226
+ edges: nextEdges
2227
+ };
2228
+ this.nodeIds = nextNodes.map((node) => node.id);
2229
+ this.nodeIndices = nextNodeIndices;
2230
+ this.edgeNodeIndices = nextEdgeIndices;
2231
+ this.canonicalEdgePositions = nextEdgePositions;
2232
+ this.incidentEdgeIndices = nextIncidentEdges;
2233
+ for (const node of addedNodes) {
2234
+ const index = nextNodeIndices.get(node.id);
2235
+ const nextNode = index === void 0 ? void 0 : this.data.nodes[index];
2236
+ if (index !== void 0 && nextNode) this.spatialGrid2d.set(index, nextNode);
2237
+ }
2238
+ for (const [id, presentation] of compiled.nodePresentations) this.nodePresentations.set(id, presentation);
2239
+ for (const [id, presentation] of compiled.edgePresentations) this.edgePresentations.set(id, presentation);
2240
+ this.materializeViewport();
2241
+ this.requestRender();
2242
+ }
2243
+ replaceData(data, fitView) {
2244
+ const compiled = compileGraph(data, this.visuals);
2245
+ this.disposeGraphObjects();
2246
+ this.data = {
2247
+ edges: data.edges.map((edge, index) => ({
2248
+ ...edge,
2249
+ ...compiled.edgeVisuals[index]
2250
+ })),
2251
+ nodes: data.nodes.map((node, index) => ({
2252
+ ...node,
2253
+ ...compiled.nodeVisuals[index],
2254
+ position: { ...node.position }
2255
+ }))
2256
+ };
2257
+ this.nodeIds = compiled.nodeIds;
2258
+ this.nodeIndices = new Map(compiled.nodeIndices);
2259
+ this.edgeNodeIndices = compiled.edgeNodeIndices;
2260
+ this.canonicalEdgePositions = compiled.edgePositions;
2261
+ this.incidentEdgeIndices = compiled.incidentEdgeIndices;
2262
+ this.nodePresentations = new Map(compiled.nodePresentations);
2263
+ this.edgePresentations = new Map(compiled.edgePresentations);
2264
+ this.spatialGrid2d = new SpatialGrid2D();
2265
+ for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
2266
+ this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
2267
+ const nodeCapacity = this.nodeCapacity;
2268
+ const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
2269
+ allocateNodeInstanceColors(nodeMesh, nodeCapacity);
2270
+ nodeMesh.renderOrder = this.mode === "2d" ? 1 : 0;
2271
+ this.nodeMesh = nodeMesh;
2272
+ this.scene.add(nodeMesh);
2273
+ const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
2274
+ this.nodePickMesh = nodePickMesh;
2275
+ const capacities = edgeInstanceCapacities(data.edges.length, this.maxVisibleEdges);
2276
+ this.edgeCapacity = capacities.edgeCapacity;
2277
+ this.edgeSegmentCapacity = capacities.segmentCapacity;
2278
+ this.markerCapacity = capacities.markerCapacity;
2279
+ this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
2280
+ const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(this.mode === "3d"), this.edgeInstanceCapacity);
2281
+ edgeMesh.frustumCulled = false;
2282
+ edgeMesh.renderOrder = this.mode === "2d" ? -1 : 0;
2283
+ this.edgeMesh = edgeMesh;
2284
+ this.scene.add(edgeMesh);
2285
+ if (fitView) this.fitView();
2286
+ else {
2287
+ this.materializeViewport();
2288
+ this.requestRender();
2289
+ }
2290
+ }
2291
+ updateNodes(updates) {
2292
+ if (updates.length === 0) return;
2293
+ const objects = this.getGraphObjects("update nodes");
2294
+ const prepared = prepareNodeUpdates(this.data.nodes, this.nodeIndices, updates);
2295
+ this.applyPreparedNodeUpdates(prepared, objects);
2296
+ }
2297
+ /** Applies a transferable XYZ layout batch. Call repeatedly as a worker produces progressive positions. */
2298
+ applyLayout(layout) {
2299
+ const prepared = prepareLayoutPositions(this.data.nodes, this.nodeIndices, layout);
2300
+ if (prepared.length === 0) return;
2301
+ const objects = this.getGraphObjects("apply a layout");
2302
+ this.applyPreparedNodeUpdates(prepared, objects);
2303
+ }
2304
+ getGraphObjects(action) {
2305
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) throw new Error(`Cannot ${action} before graph data is set`);
2306
+ return {
2307
+ edgeMesh: this.edgeMesh,
2308
+ nodeMesh: this.nodeMesh,
2309
+ nodePickMesh: this.nodePickMesh
2310
+ };
2311
+ }
2312
+ applyPreparedNodeUpdates(prepared, { nodeMesh, nodePickMesh }) {
2313
+ const nodes = [...this.data.nodes];
2314
+ const matrix = new Matrix4();
2315
+ const nodeShape = nodeMesh.geometry.getAttribute("instanceShape");
2316
+ const nodeStrokeWidth = nodeMesh.geometry.getAttribute("instanceStrokeWidth");
2317
+ const nodeStrokeColor = nodeMesh.geometry.getAttribute("instanceStrokeColor");
2318
+ const viewportBounds = this.getViewportBounds2d();
2319
+ let visibilityChanged = false;
2320
+ let edgeEndpointsChanged = false;
2321
+ const changedEdgeIndices = /* @__PURE__ */ new Set();
2322
+ for (const update of prepared) {
2323
+ nodes[update.index] = update.next;
2324
+ if (update.positionChanged || update.sizeChanged) {
2325
+ const wasVisible = this.visibleNodeSlots.has(update.index);
2326
+ this.spatialGrid2d.set(update.index, update.next);
2327
+ visibilityChanged ||= wasVisible !== this.isNodeVisible2d(update.next, viewportBounds);
2328
+ }
2329
+ const nodeSlot = this.visibleNodeSlots.get(update.index);
2330
+ if (update.positionChanged || update.sizeChanged) {
2331
+ if (nodeSlot !== void 0) {
2332
+ const { height, width } = resolveNodeAxes({
2333
+ height: update.next.height,
2334
+ nodeId: update.next.id,
2335
+ size: update.next.size,
2336
+ width: update.next.width
2337
+ });
2338
+ const maxAxis = Math.max(width, height);
2339
+ matrix.makeScale(width, height, maxAxis);
2340
+ matrix.setPosition(update.next.position.x, update.next.position.y, update.next.position.z ?? 0);
2341
+ nodeMesh.setMatrixAt(nodeSlot, matrix);
2342
+ nodeMesh.instanceMatrix.addUpdateRange(nodeSlot * 16, 16);
2343
+ const pickScale = maxAxis * Math.SQRT2;
2344
+ matrix.makeScale(pickScale, pickScale, pickScale);
2345
+ matrix.setPosition(update.next.position.x, update.next.position.y, update.next.position.z ?? 0);
2346
+ nodePickMesh.setMatrixAt(nodeSlot, matrix);
2347
+ nodePickMesh.instanceMatrix.addUpdateRange(nodeSlot * 16, 16);
2348
+ }
2349
+ }
2350
+ if (update.colorChanged && nodeSlot !== void 0) {
2351
+ nodeMesh.setColorAt(nodeSlot, this.getNodeColor(update.next));
2352
+ nodeMesh.instanceColor?.addUpdateRange(nodeSlot * 3, 3);
2353
+ }
2354
+ if (update.shapeChanged && nodeSlot !== void 0) {
2355
+ setNodeShapeAt(nodeShape, nodeSlot, update.next.shape);
2356
+ nodeShape.addUpdateRange(nodeSlot, 1);
2357
+ }
2358
+ if (update.strokeChanged && nodeSlot !== void 0) {
2359
+ setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, nodeSlot, resolveNodeStroke(update.next, this.theme));
2360
+ nodeStrokeWidth.addUpdateRange(nodeSlot, 1);
2361
+ nodeStrokeColor.addUpdateRange(nodeSlot * 3, 3);
2362
+ }
2363
+ if (update.positionChanged) {
2364
+ const incidentEdges = this.incidentEdgeIndices[update.index] ?? [];
2365
+ if (incidentEdges.length > 0) edgeEndpointsChanged = true;
2366
+ for (const edgeIndex of incidentEdges) {
2367
+ changedEdgeIndices.add(edgeIndex);
2368
+ const sourceIndex = this.edgeNodeIndices[edgeIndex * 2];
2369
+ const canonicalOffset = edgeIndex * 6 + (sourceIndex === update.index ? 0 : 3);
2370
+ this.canonicalEdgePositions.set([
2371
+ update.next.position.x,
2372
+ update.next.position.y,
2373
+ update.next.position.z ?? 0
2374
+ ], canonicalOffset);
2375
+ }
2376
+ }
2377
+ }
2378
+ if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
2379
+ this.data = {
2380
+ ...this.data,
2381
+ nodes
2382
+ };
2383
+ if (visibilityChanged) {
2384
+ this.materializeViewport();
2385
+ this.requestRender();
2386
+ return;
2387
+ }
2388
+ if (edgeEndpointsChanged) {
2389
+ if (!this.patchVisibleEdgeEndpoints([...changedEdgeIndices])) this.materializeViewport();
2390
+ }
2391
+ if (prepared.some((update) => update.positionChanged || update.sizeChanged)) {
2392
+ nodeMesh.instanceMatrix.needsUpdate = true;
2393
+ nodePickMesh.instanceMatrix.needsUpdate = true;
2394
+ }
2395
+ if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
2396
+ if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
2397
+ if (prepared.some((update) => update.strokeChanged)) {
2398
+ nodeStrokeWidth.needsUpdate = true;
2399
+ nodeStrokeColor.needsUpdate = true;
2400
+ }
2401
+ this.requestRender();
2402
+ }
2403
+ /** Patches GPU edge instances for moved endpoints when the visible edge set is unchanged. */
2404
+ patchVisibleEdgeEndpoints(changedEdgeIndices) {
2405
+ if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
2406
+ const worldPerPixel = this.worldUnitsPerPixel();
2407
+ const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
2408
+ const edgeVisuals = this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
2409
+ ...edge,
2410
+ color: this.theme.selectedEdge
2411
+ } : edge);
2412
+ const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
2413
+ changedEdgeIndices,
2414
+ defaults: {
2415
+ color: this.theme.edge,
2416
+ opacity: this.theme.edgeOpacity,
2417
+ width: this.theme.edgeWidth
2418
+ },
2419
+ edgeNodeIndices: this.edgeNodeIndices,
2420
+ edgeVisuals,
2421
+ endpointAttach: this.theme.endpointAttach,
2422
+ endpointPositions: this.canonicalEdgePositions,
2423
+ layouts: this.visibleEdgeLayouts,
2424
+ minHitSlop,
2425
+ nodeOutlines: this.data.nodes.map((node) => ({
2426
+ height: node.height,
2427
+ shape: node.shape,
2428
+ size: node.size,
2429
+ strokeWidth: node.strokeWidth,
2430
+ width: node.width
2431
+ })),
2432
+ tier: this.lastEdgeLodTier,
2433
+ worldPerPixel
2434
+ }, this.pickableEdgeSegments);
2435
+ if (!result.ok) return false;
2436
+ this.pickableEdgeSegments = result.pickableSegments;
2437
+ return true;
2438
+ }
2439
+ setSelection(nodeIds) {
2440
+ this.setNodeState("selected", nodeIds);
2441
+ }
2442
+ /** Highlights edges without changing node selection or source graph data. */
2443
+ setEdgeSelection(edgeIds) {
2444
+ this.selectedEdgeIds = new Set(edgeIds);
2445
+ this.materializeViewport();
2446
+ this.requestRender();
2447
+ }
2448
+ /** Applies an application-owned visual state without changing source graph data. */
2449
+ setNodeState(state, nodeIds) {
2450
+ const nextSelection = new Set(nodeIds);
2451
+ const currentSelection = this.getNodeStateIds(state);
2452
+ const changedNodeIds = new Set([...currentSelection, ...nextSelection].filter((nodeId) => currentSelection.has(nodeId) !== nextSelection.has(nodeId)));
2453
+ if (changedNodeIds.size === 0) return;
2454
+ this.setNodeStateIds(state, nextSelection);
2455
+ this.applySelectionColors(changedNodeIds);
2456
+ this.requestRender();
2457
+ if (state === "selected" || state === "hovered") this.notifyFocusChange();
2458
+ }
2459
+ /** One-hop neighbor node ids via incident edges (undirected adjacency). */
2460
+ getNeighborIds(nodeId) {
2461
+ const index = this.nodeIndices.get(nodeId);
2462
+ if (index === void 0) return [];
2463
+ const neighbors = /* @__PURE__ */ new Set();
2464
+ for (const edgeIndex of this.incidentEdgeIndices[index] ?? []) {
2465
+ const edge = this.data.edges[edgeIndex];
2466
+ if (!edge) continue;
2467
+ if (edge.source !== nodeId) neighbors.add(edge.source);
2468
+ if (edge.target !== nodeId) neighbors.add(edge.target);
2469
+ }
2470
+ return [...neighbors];
2471
+ }
2472
+ getSelectedNodeIds() {
2473
+ return [...this.selectedNodeIds];
2474
+ }
2475
+ getHoveredNodeIds() {
2476
+ return [...this.hoveredNodeIds];
2477
+ }
2478
+ /** Schedules one render on the next animation frame. Repeated calls in one frame are coalesced. */
2479
+ render() {
2480
+ this.requestRender();
2481
+ }
2482
+ setMode(mode) {
2483
+ if (mode === this.mode) return;
2484
+ this.mode = mode;
2485
+ this.controls.removeEventListener("change", this.handleViewChange);
2486
+ this.controls.dispose();
2487
+ this.camera = this.createCamera();
2488
+ this.controls = this.createControls();
2489
+ if (this.nodeMesh && !Array.isArray(this.nodeMesh.material)) {
2490
+ this.nodeMesh.material.depthTest = mode === "3d";
2491
+ this.nodeMesh.material.depthWrite = mode === "3d";
2492
+ this.nodeMesh.material.needsUpdate = true;
2493
+ this.nodeMesh.renderOrder = mode === "2d" ? 1 : 0;
2494
+ }
2495
+ if (this.edgeMesh && !Array.isArray(this.edgeMesh.material)) {
2496
+ this.edgeMesh.material.depthTest = mode === "3d";
2497
+ this.edgeMesh.material.depthWrite = mode === "3d";
2498
+ this.edgeMesh.material.needsUpdate = true;
2499
+ this.edgeMesh.renderOrder = mode === "2d" ? -1 : 0;
2500
+ }
2501
+ this.fitView();
2502
+ }
2503
+ getMode() {
2504
+ return this.mode;
2505
+ }
2506
+ getNodePresentation(nodeId) {
2507
+ return this.nodePresentations.get(nodeId) ?? null;
2508
+ }
2509
+ /** Returns canvas-relative screen coordinates for a node, or null when the node does not exist. */
2510
+ getNodeScreenPosition(nodeId) {
2511
+ const index = this.nodeIndices.get(nodeId);
2512
+ const node = index === void 0 ? void 0 : this.data.nodes[index];
2513
+ if (!node) return null;
2514
+ const projected = new Vector3(node.position.x, node.position.y, node.position.z ?? 0).project(this.camera);
2515
+ const width = this.renderer.domElement.clientWidth;
2516
+ const height = this.renderer.domElement.clientHeight;
2517
+ return {
2518
+ visible: projected.x >= -1 && projected.x <= 1 && projected.y >= -1 && projected.y <= 1 && projected.z >= -1 && projected.z <= 1,
2519
+ x: (projected.x + 1) * width / 2,
2520
+ y: (1 - projected.y) * height / 2
2521
+ };
2522
+ }
2523
+ /**
2524
+ * Candidates for a budgeted DOM label layer. Importance is incident edge degree, with a boost
2525
+ * for selected nodes so selection stays labeled. Visibility comes from screen projection.
2526
+ */
2527
+ getLabelCandidates() {
2528
+ return this.data.nodes.map((node, index) => {
2529
+ const degree = this.incidentEdgeIndices[index]?.length ?? 0;
2530
+ return {
2531
+ id: node.id,
2532
+ importance: degree + (this.selectedNodeIds.has(node.id) ? SELECTED_LABEL_IMPORTANCE_BOOST : 0),
2533
+ visible: this.getNodeScreenPosition(node.id)?.visible ?? false
2534
+ };
2535
+ });
2536
+ }
2537
+ /** Converts client coordinates to graph world coordinates on the z=0 plane. */
2538
+ screenToWorld(clientX, clientY) {
2539
+ const bounds = this.renderer.domElement.getBoundingClientRect();
2540
+ if (bounds.width === 0 || bounds.height === 0) return null;
2541
+ const worldX = (clientX - bounds.left) / bounds.width * 2 - 1;
2542
+ const worldY = -((clientY - bounds.top) / bounds.height) * 2 + 1;
2543
+ this.pointer.set(worldX, worldY);
2544
+ if (this.camera instanceof OrthographicCamera) {
2545
+ const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
2546
+ return {
2547
+ x: world.x,
2548
+ y: world.y,
2549
+ z: world.z
2550
+ };
2551
+ }
2552
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2553
+ const plane = new Plane(new Vector3(0, 0, 1), 0);
2554
+ const world = this.raycaster.ray.intersectPlane(plane, new Vector3());
2555
+ if (!world) return null;
2556
+ return {
2557
+ x: world.x,
2558
+ y: world.y,
2559
+ z: world.z
2560
+ };
2561
+ }
2562
+ /** Creates a bounded DOM layer for labels, a toolbar, and optional rich HTML focus cards. */
2563
+ createOverlay(options = {}) {
2564
+ return new GraphraumOverlay(this, this.container, options);
2565
+ }
2566
+ /** Subscribes to camera and viewport changes. Returns an unsubscribe function. */
2567
+ onViewChange(listener) {
2568
+ this.viewListeners.add(listener);
2569
+ listener();
2570
+ return () => this.viewListeners.delete(listener);
2571
+ }
2572
+ /** Subscribes to selection and hover changes that affect overlay focus chrome. */
2573
+ onFocusChange(listener) {
2574
+ this.focusListeners.add(listener);
2575
+ listener();
2576
+ return () => this.focusListeners.delete(listener);
2577
+ }
2578
+ notifyFocusChange() {
2579
+ for (const listener of this.focusListeners) listener();
2580
+ }
2581
+ handlePointerMove = (event) => {
2582
+ const nodeId = this.pickNode(event.clientX, event.clientY);
2583
+ if (nodeId === this.lastHoveredNodeId) return;
2584
+ this.lastHoveredNodeId = nodeId;
2585
+ this.setNodeState("hovered", nodeId ? [nodeId] : []);
2586
+ };
2587
+ handlePointerLeave = () => {
2588
+ if (this.lastHoveredNodeId === null && this.hoveredNodeIds.size === 0) return;
2589
+ this.lastHoveredNodeId = null;
2590
+ this.setNodeState("hovered", []);
2591
+ };
2592
+ getEdgePresentation(edgeId) {
2593
+ return this.edgePresentations.get(edgeId) ?? null;
2594
+ }
2595
+ getDiagnostics() {
2596
+ return {
2597
+ aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
2598
+ cpuFrameMilliseconds: this.cpuFrameMilliseconds,
2599
+ gpuFrameMilliseconds: this.gpuFrameMilliseconds,
2600
+ gpuDrawCalls: this.renderer.info.render.calls,
2601
+ gpuGeometries: this.renderer.info.memory.geometries,
2602
+ gpuTextures: this.renderer.info.memory.textures,
2603
+ lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
2604
+ pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
2605
+ selectedEdges: this.selectedEdgeIds.size,
2606
+ selectedNodes: this.selectedNodeIds.size,
2607
+ totalEdges: this.data.edges.length,
2608
+ totalNodes: this.data.nodes.length,
2609
+ visibleEdgeCandidates: this.visibleEdgeCandidateCount,
2610
+ visibleEdgeMarkers: this.visibleEdgeMarkerCount,
2611
+ visibleEdgeSegments: this.visibleEdgeSegmentCount,
2612
+ visibleEdges: this.visibleEdgeCount,
2613
+ visibleNodes: this.visibleNodeCount,
2614
+ visibleNodeCandidates: this.visibleNodeCandidateCount
2615
+ };
2616
+ }
2617
+ /** Picks a node under the pointer. Prefer `pickHit` when edges must be selectable too. */
2618
+ pick(clientX, clientY) {
2619
+ const hit = this.pickHit(clientX, clientY);
2620
+ return hit?.kind === "node" ? hit.id : null;
2621
+ }
2622
+ /**
2623
+ * Picks the top-most graph element under the pointer. Nodes win over edges when both hit.
2624
+ * 2D uses the spatial grid for nodes and world-space segment distance for edges; 3D uses
2625
+ * the node pick mesh then the edge instance mesh.
2626
+ */
2627
+ pickHit(clientX, clientY) {
2628
+ const nodeId = this.pickNode(clientX, clientY);
2629
+ if (nodeId) return {
2630
+ kind: "node",
2631
+ id: nodeId
2632
+ };
2633
+ const edgeId = this.pickEdge(clientX, clientY);
2634
+ return edgeId ? {
2635
+ kind: "edge",
2636
+ id: edgeId
2637
+ } : null;
2638
+ }
2639
+ fitView() {
2640
+ if (!this.nodeMesh || this.data.nodes.length === 0) {
2641
+ this.requestRender();
2642
+ return;
2643
+ }
2644
+ const bounds = new Box3();
2645
+ const minimum = new Vector3();
2646
+ const maximum = new Vector3();
2647
+ for (const node of this.data.nodes) {
2648
+ const { height, width } = resolveNodeAxes({
2649
+ height: node.height,
2650
+ nodeId: node.id,
2651
+ size: node.size,
2652
+ width: node.width
2653
+ });
2654
+ const radius = Math.max(width, height);
2655
+ const z = node.position.z ?? 0;
2656
+ bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
2657
+ bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
2658
+ }
2659
+ const center = bounds.getCenter(new Vector3());
2660
+ const size = bounds.getSize(new Vector3());
2661
+ const width = Math.max(this.container.clientWidth, 1);
2662
+ const height = Math.max(this.container.clientHeight, 1);
2663
+ const aspect = width / height;
2664
+ if (this.camera instanceof OrthographicCamera) {
2665
+ const visibleHeight = Math.max(size.y, size.x / aspect, 1) * 1.15;
2666
+ this.camera.left = -visibleHeight * aspect / 2;
2667
+ this.camera.right = visibleHeight * aspect / 2;
2668
+ this.camera.top = visibleHeight / 2;
2669
+ this.camera.bottom = -visibleHeight / 2;
2670
+ this.camera.position.set(center.x, center.y, center.z + Math.max(size.z, 1e3));
2671
+ } else {
2672
+ applyPerspectiveContainerAspect(this.camera, width, height);
2673
+ const distance = Math.max(size.length() / 2, 1) / Math.sin(this.camera.fov * Math.PI / 360);
2674
+ this.camera.position.set(center.x, center.y, center.z + distance * 1.15);
2675
+ this.camera.near = Math.max(distance / 1e4, .1);
2676
+ this.camera.far = distance * 10;
2677
+ }
2678
+ this.camera.lookAt(center);
2679
+ this.camera.updateProjectionMatrix();
2680
+ this.controls.target.copy(center);
2681
+ this.controls.update();
2682
+ this.materializeViewport();
2683
+ this.requestRender();
2684
+ }
2685
+ setTheme(theme) {
2686
+ this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
2687
+ this.applyBackground();
2688
+ if (this.nodeMesh) this.materializeViewport();
2689
+ this.requestRender();
2690
+ }
2691
+ setBackground(background) {
2692
+ this.setTheme({ background });
2693
+ }
2694
+ resize() {
2695
+ const width = Math.max(this.container.clientWidth, 1);
2696
+ const height = Math.max(this.container.clientHeight, 1);
2697
+ this.renderer.setSize(width, height, false);
2698
+ if (this.camera instanceof PerspectiveCamera) {
2699
+ applyPerspectiveContainerAspect(this.camera, width, height);
2700
+ this.camera.updateProjectionMatrix();
2701
+ } else if (this.camera instanceof OrthographicCamera) {
2702
+ this.fitView();
2703
+ return;
2704
+ }
2705
+ this.materializeViewport();
2706
+ this.requestRender();
2707
+ }
2708
+ destroy() {
2709
+ this.resizeObserver.disconnect();
2710
+ this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
2711
+ this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
2712
+ this.controls.removeEventListener("change", this.handleViewChange);
2713
+ this.controls.dispose();
2714
+ if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
2715
+ if (this.gpuTimer) for (const query of this.gpuTimer.pending) this.gpuTimer.context.deleteQuery(query);
2716
+ this.disposeBackgroundTexture();
2717
+ this.disposeGraphObjects();
2718
+ this.renderer.dispose();
2719
+ this.renderer.domElement.remove();
2720
+ }
2721
+ requestRender = () => {
2722
+ if (this.frameRequest !== null) return;
2723
+ this.frameRequest = requestAnimationFrame(() => {
2724
+ this.frameRequest = null;
2725
+ this.readGpuTimers();
2726
+ const timer = this.gpuTimer;
2727
+ const query = timer?.context.createQuery() ?? null;
2728
+ if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
2729
+ const startedAt = performance.now();
2730
+ this.renderer.render(this.scene, this.camera);
2731
+ this.cpuFrameMilliseconds = performance.now() - startedAt;
2732
+ if (timer && query) {
2733
+ timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
2734
+ timer.pending.push(query);
2735
+ }
2736
+ for (const listener of this.viewListeners) listener();
2737
+ });
2738
+ };
2739
+ readGpuTimers() {
2740
+ const timer = this.gpuTimer;
2741
+ if (!timer) return;
2742
+ while (timer.pending.length > 0) {
2743
+ const query = timer.pending[0];
2744
+ if (!query || !timer.context.getQueryParameter(query, timer.context.QUERY_RESULT_AVAILABLE)) return;
2745
+ timer.pending.shift();
2746
+ if (!timer.context.getParameter(timer.extension.GPU_DISJOINT_EXT)) this.gpuFrameMilliseconds = timer.context.getQueryParameter(query, timer.context.QUERY_RESULT) / 1e6;
2747
+ timer.context.deleteQuery(query);
2748
+ }
2749
+ }
2750
+ handleViewChange = () => {
2751
+ this.materializeViewport();
2752
+ this.requestRender();
2753
+ };
2754
+ createCamera() {
2755
+ const aspect = containerAspect(this.container.clientWidth, this.container.clientHeight);
2756
+ return this.mode === "2d" ? new OrthographicCamera(-1, 1, 1, -1, -1e5, 1e5) : new PerspectiveCamera(45, aspect, .1, 1e5);
2757
+ }
2758
+ createControls() {
2759
+ const controls = new OrbitControls(this.camera, this.renderer.domElement);
2760
+ controls.enableDamping = false;
2761
+ controls.enableRotate = this.mode === "3d";
2762
+ controls.screenSpacePanning = true;
2763
+ if (this.mode === "2d") {
2764
+ controls.minZoom = .01;
2765
+ controls.mouseButtons.LEFT = MOUSE.PAN;
2766
+ }
2767
+ controls.addEventListener("change", this.handleViewChange);
2768
+ return controls;
2769
+ }
2770
+ pickNode(clientX, clientY) {
2771
+ if (!this.nodeMesh || !this.nodePickMesh) return null;
2772
+ const bounds = this.renderer.domElement.getBoundingClientRect();
2773
+ if (bounds.width === 0 || bounds.height === 0) return null;
2774
+ this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
2775
+ if (this.camera instanceof OrthographicCamera) {
2776
+ const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
2777
+ const index = this.spatialGrid2d.pick(world.x, world.y);
2778
+ return index === null ? null : this.nodeIds[index] ?? null;
2779
+ }
2780
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2781
+ const checkedInstances = /* @__PURE__ */ new Set();
2782
+ for (const hit of this.raycaster.intersectObject(this.nodePickMesh, false)) {
2783
+ if (hit.instanceId === void 0 || checkedInstances.has(hit.instanceId)) continue;
2784
+ checkedInstances.add(hit.instanceId);
2785
+ const nodeIndex = this.visibleNodeIndices[hit.instanceId];
2786
+ if (nodeIndex === void 0) continue;
2787
+ const node = this.data.nodes[nodeIndex];
2788
+ if (!node) continue;
2789
+ const { height, width } = resolveNodeAxes({
2790
+ height: node.height,
2791
+ nodeId: node.id,
2792
+ size: node.size,
2793
+ width: node.width
2794
+ });
2795
+ this.pickCenter.set(node.position.x, node.position.y, node.position.z ?? 0).project(this.camera);
2796
+ this.pickRight.set(1, 0, 0).applyQuaternion(this.camera.quaternion).multiplyScalar(width);
2797
+ this.pickRight.set(this.pickRight.x + node.position.x, this.pickRight.y + node.position.y, this.pickRight.z + (node.position.z ?? 0));
2798
+ this.pickRight.project(this.camera);
2799
+ this.pickUp.set(0, 1, 0).applyQuaternion(this.camera.quaternion).multiplyScalar(height);
2800
+ this.pickUp.set(this.pickUp.x + node.position.x, this.pickUp.y + node.position.y, this.pickUp.z + (node.position.z ?? 0));
2801
+ this.pickUp.project(this.camera);
2802
+ const radiusNdcX = Math.abs(this.pickRight.x - this.pickCenter.x);
2803
+ const radiusNdcY = Math.abs(this.pickUp.y - this.pickCenter.y);
2804
+ if (radiusNdcX === 0 || radiusNdcY === 0) continue;
2805
+ const offsetX = (this.pointer.x - this.pickCenter.x) / radiusNdcX;
2806
+ const offsetY = (this.pointer.y - this.pickCenter.y) / radiusNdcY;
2807
+ if (containsNodePoint(node.shape, offsetX, offsetY)) return this.nodeIds[nodeIndex] ?? null;
2808
+ }
2809
+ return null;
2810
+ }
2811
+ pickEdge(clientX, clientY) {
2812
+ if (!this.edgeMesh || this.pickableEdgeSegments.length === 0) return null;
2813
+ const bounds = this.renderer.domElement.getBoundingClientRect();
2814
+ if (bounds.width === 0 || bounds.height === 0) return null;
2815
+ this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
2816
+ if (this.camera instanceof OrthographicCamera) {
2817
+ const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
2818
+ const edgeIndex = pickClosestEdgeIndex({
2819
+ x: world.x,
2820
+ y: world.y
2821
+ }, this.pickableEdgeSegments);
2822
+ return edgeIndex === null ? null : this.data.edges[edgeIndex]?.id ?? null;
2823
+ }
2824
+ this.raycaster.setFromCamera(this.pointer, this.camera);
2825
+ for (const hit of this.raycaster.intersectObject(this.edgeMesh, false)) {
2826
+ if (hit.instanceId === void 0) continue;
2827
+ if (hit.instanceId >= this.visibleEdgeSegmentCount) continue;
2828
+ const edgeIndex = this.visibleEdgeSegmentEdgeIndices[hit.instanceId];
2829
+ if (edgeIndex === void 0) continue;
2830
+ return this.data.edges[edgeIndex]?.id ?? null;
2831
+ }
2832
+ return null;
2833
+ }
2834
+ worldUnitsPerPixel() {
2835
+ const width = Math.max(this.renderer.domElement.clientWidth, 1);
2836
+ if (this.camera instanceof OrthographicCamera) return (this.camera.right - this.camera.left) / (width * this.camera.zoom);
2837
+ const bounds = this.renderer.domElement.getBoundingClientRect();
2838
+ const left = this.screenToWorld(bounds.left, bounds.top);
2839
+ const right = this.screenToWorld(bounds.left + 1, bounds.top);
2840
+ if (!left || !right) return 1;
2841
+ return Math.hypot(right.x - left.x, right.y - left.y) || 1;
2842
+ }
2843
+ applySelectionColors(nodeIds) {
2844
+ if (!this.nodeMesh?.instanceColor) return;
2845
+ const slots = [];
2846
+ for (const nodeId of nodeIds) {
2847
+ const index = this.nodeIndices.get(nodeId);
2848
+ if (index === void 0) continue;
2849
+ const slot = this.visibleNodeSlots.get(index);
2850
+ if (slot === void 0) continue;
2851
+ const node = this.data.nodes[index];
2852
+ if (!node) continue;
2853
+ this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
2854
+ slots.push(slot);
2855
+ }
2856
+ markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
2857
+ }
2858
+ getNodeStateIds(state) {
2859
+ switch (state) {
2860
+ case "dimmed": return this.dimmedNodeIds;
2861
+ case "focused": return this.focusedNodeIds;
2862
+ case "hovered": return this.hoveredNodeIds;
2863
+ case "selected": return this.selectedNodeIds;
2864
+ }
2865
+ }
2866
+ setNodeStateIds(state, nodeIds) {
2867
+ switch (state) {
2868
+ case "dimmed":
2869
+ this.dimmedNodeIds = nodeIds;
2870
+ return;
2871
+ case "focused":
2872
+ this.focusedNodeIds = nodeIds;
2873
+ return;
2874
+ case "hovered":
2875
+ this.hoveredNodeIds = nodeIds;
2876
+ return;
2877
+ case "selected": this.selectedNodeIds = nodeIds;
2878
+ }
2879
+ }
2880
+ getNodeColor(node) {
2881
+ if (this.focusedNodeIds.has(node.id)) return new Color(this.theme.focusedNode);
2882
+ if (this.selectedNodeIds.has(node.id)) return new Color(this.theme.selectedNode);
2883
+ if (this.hoveredNodeIds.has(node.id)) return new Color(this.theme.hoveredNode);
2884
+ if (this.dimmedNodeIds.has(node.id)) return new Color(this.theme.dimmedNode);
2885
+ return new Color(node.color ?? this.theme.node);
2886
+ }
2887
+ getViewportBounds2d() {
2888
+ if (!(this.camera instanceof OrthographicCamera) || !this.viewportCulling) return null;
2889
+ const bottomLeft = new Vector3(-1, -1, 0).unproject(this.camera);
2890
+ const topRight = new Vector3(1, 1, 0).unproject(this.camera);
2891
+ return {
2892
+ bottom: bottomLeft.y,
2893
+ left: bottomLeft.x,
2894
+ right: topRight.x,
2895
+ top: topRight.y
2896
+ };
2897
+ }
2898
+ isNodeVisible2d(node, bounds) {
2899
+ if (!bounds) return true;
2900
+ const { height, width } = resolveNodeAxes({
2901
+ height: node.height,
2902
+ nodeId: node.id,
2903
+ size: node.size,
2904
+ width: node.width
2905
+ });
2906
+ return node.position.x + width >= bounds.left - this.viewportOverscan && node.position.x - width <= bounds.right + this.viewportOverscan && node.position.y + height >= bounds.bottom - this.viewportOverscan && node.position.y - height <= bounds.top + this.viewportOverscan;
2907
+ }
2908
+ disposeBackgroundTexture() {
2909
+ if (!this.backgroundTexture) return;
2910
+ this.backgroundTexture.dispose();
2911
+ this.backgroundTexture = null;
2912
+ }
2913
+ createBackgroundTexture(source, repeat) {
2914
+ const texture = source instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas ? new CanvasTexture(source) : new Texture(source);
2915
+ texture.colorSpace = SRGBColorSpace;
2916
+ texture.needsUpdate = true;
2917
+ const [repeatX, repeatY] = repeat ?? [1, 1];
2918
+ if (repeatX !== 1 || repeatY !== 1) {
2919
+ texture.wrapS = RepeatWrapping;
2920
+ texture.wrapT = RepeatWrapping;
2921
+ texture.repeat.set(repeatX, repeatY);
2922
+ }
2923
+ return texture;
2924
+ }
2925
+ applyBackground() {
2926
+ this.disposeBackgroundTexture();
2927
+ const background = normalizeGraphraumBackground(this.theme.background);
2928
+ if (background === null) {
2929
+ this.scene.background = null;
2930
+ this.renderer.setClearColor(0, 0);
2931
+ return;
2932
+ }
2933
+ if (typeof background === "object") {
2934
+ this.backgroundTexture = this.createBackgroundTexture(background.source, background.repeat);
2935
+ this.scene.background = this.backgroundTexture;
2936
+ this.renderer.setClearColor(0, 1);
2937
+ return;
2938
+ }
2939
+ const color = new Color(background);
2940
+ this.scene.background = color;
2941
+ this.renderer.setClearColor(color, 1);
2942
+ }
2943
+ materializeViewport() {
2944
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return;
2945
+ const bounds = this.getViewportBounds2d();
2946
+ const visibleNodeCandidates = bounds ? this.spatialGrid2d.queryBounds(bounds, this.viewportOverscan) : this.data.nodes.map((_, index) => index);
2947
+ this.densityLodActive = shouldUseDensityLod(visibleNodeCandidates.length, this.maxVisibleNodes, this.densityLodActive);
2948
+ const materializedNodes = this.materializeNodes(visibleNodeCandidates, bounds);
2949
+ const visibleNodeIndices = materializedNodes.map(({ index }) => index);
2950
+ const edgeCandidates = bounds ? collectIncidentEdges(visibleNodeIndices, this.incidentEdgeIndices) : this.data.edges.map((_, index) => index);
2951
+ const visibleEdgeIndices = applyEdgeBudget(edgeCandidates, this.maxVisibleEdges);
2952
+ const matrix = new Matrix4();
2953
+ this.visibleNodeSlots = /* @__PURE__ */ new Map();
2954
+ this.visibleNodeIndices = visibleNodeIndices;
2955
+ this.nodeMesh.instanceMatrix.clearUpdateRanges();
2956
+ this.nodePickMesh.instanceMatrix.clearUpdateRanges();
2957
+ this.nodeMesh.instanceColor?.clearUpdateRanges();
2958
+ const nodeShape = this.nodeMesh.geometry.getAttribute("instanceShape");
2959
+ const nodeStrokeWidth = this.nodeMesh.geometry.getAttribute("instanceStrokeWidth");
2960
+ const nodeStrokeColor = this.nodeMesh.geometry.getAttribute("instanceStrokeColor");
2961
+ nodeShape.clearUpdateRanges();
2962
+ nodeStrokeWidth.clearUpdateRanges();
2963
+ nodeStrokeColor.clearUpdateRanges();
2964
+ for (const [slot, materialized] of materializedNodes.entries()) {
2965
+ const nodeIndex = materialized.index;
2966
+ const node = this.data.nodes[nodeIndex];
2967
+ if (!node) continue;
2968
+ this.visibleNodeSlots.set(nodeIndex, slot);
2969
+ const { height, width } = materialized;
2970
+ const maxAxis = Math.max(width, height);
2971
+ matrix.makeScale(width, height, maxAxis);
2972
+ matrix.setPosition(materialized.x, materialized.y, materialized.z);
2973
+ this.nodeMesh.setMatrixAt(slot, matrix);
2974
+ const pickScale = maxAxis * Math.SQRT2;
2975
+ matrix.makeScale(pickScale, pickScale, pickScale);
2976
+ matrix.setPosition(materialized.x, materialized.y, materialized.z);
2977
+ this.nodePickMesh.setMatrixAt(slot, matrix);
2978
+ this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
2979
+ setNodeShapeAt(nodeShape, slot, node.shape);
2980
+ setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, slot, resolveNodeStroke(node, this.theme));
2981
+ }
2982
+ this.nodeMesh.count = materializedNodes.length;
2983
+ this.nodePickMesh.count = materializedNodes.length;
2984
+ this.nodeMesh.instanceMatrix.addUpdateRange(0, materializedNodes.length * 16);
2985
+ this.nodeMesh.instanceMatrix.needsUpdate = true;
2986
+ this.nodePickMesh.instanceMatrix.addUpdateRange(0, materializedNodes.length * 16);
2987
+ this.nodePickMesh.instanceMatrix.needsUpdate = true;
2988
+ if (this.nodeMesh.instanceColor) {
2989
+ this.nodeMesh.instanceColor.addUpdateRange(0, materializedNodes.length * 3);
2990
+ this.nodeMesh.instanceColor.needsUpdate = true;
2991
+ }
2992
+ nodeShape.addUpdateRange(0, materializedNodes.length);
2993
+ nodeShape.needsUpdate = true;
2994
+ nodeStrokeWidth.addUpdateRange(0, materializedNodes.length);
2995
+ nodeStrokeWidth.needsUpdate = true;
2996
+ nodeStrokeColor.addUpdateRange(0, materializedNodes.length * 3);
2997
+ nodeStrokeColor.needsUpdate = true;
2998
+ const lodLevel = resolveLodLevel(this.densityLodActive, edgeCandidates.length, visibleEdgeIndices.length);
2999
+ this.lastEdgeLodTier = edgeTierFromDiagnosticsLod(lodLevel);
3000
+ const worldPerPixel = this.worldUnitsPerPixel();
3001
+ const packed = packEdgeInstances({
3002
+ edgeIndices: visibleEdgeIndices,
3003
+ edgeVisuals: this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
3004
+ ...edge,
3005
+ color: this.theme.selectedEdge
3006
+ } : edge),
3007
+ endpointPositions: this.canonicalEdgePositions,
3008
+ defaults: {
3009
+ color: this.theme.edge,
3010
+ opacity: this.theme.edgeOpacity,
3011
+ width: this.theme.edgeWidth
3012
+ },
3013
+ tier: this.lastEdgeLodTier,
3014
+ maxSegments: this.edgeSegmentCapacity,
3015
+ endpointAttach: this.theme.endpointAttach,
3016
+ edgeNodeIndices: this.edgeNodeIndices,
3017
+ nodeOutlines: this.data.nodes.map((node) => ({
3018
+ height: node.height,
3019
+ shape: node.shape,
3020
+ size: node.size,
3021
+ strokeWidth: node.strokeWidth,
3022
+ width: node.width
3023
+ }))
3024
+ });
3025
+ const edgeGeometry = this.edgeMesh.geometry;
3026
+ this.visibleEdgeSlots = /* @__PURE__ */ new Map();
3027
+ this.visibleEdgeLayouts = buildVisibleEdgeLayouts(packed.segments, packed.markers, packed.segments.length);
3028
+ this.pickableEdgeSegments = [];
3029
+ this.visibleEdgeSegmentEdgeIndices = [];
3030
+ let edgeSlot = 0;
3031
+ const distinctEdges = /* @__PURE__ */ new Set();
3032
+ const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
3033
+ for (const segment of packed.segments) {
3034
+ writeEdgeSegmentInstance(edgeGeometry, edgeSlot, segment);
3035
+ if (!this.visibleEdgeSlots.has(segment.edgeIndex)) this.visibleEdgeSlots.set(segment.edgeIndex, edgeSlot);
3036
+ distinctEdges.add(segment.edgeIndex);
3037
+ this.visibleEdgeSegmentEdgeIndices.push(segment.edgeIndex);
3038
+ this.pickableEdgeSegments.push({
3039
+ edgeIndex: segment.edgeIndex,
3040
+ hitSlop: Math.max(minHitSlop, segment.width * worldPerPixel / 2 + 2 * worldPerPixel),
3041
+ x1: segment.x1,
3042
+ y1: segment.y1,
3043
+ x2: segment.x2,
3044
+ y2: segment.y2
3045
+ });
3046
+ edgeSlot += 1;
3047
+ }
3048
+ const segmentCount = edgeSlot;
3049
+ for (const marker of packed.markers) {
3050
+ writeEdgeMarkerInstance(edgeGeometry, edgeSlot, marker);
3051
+ edgeSlot += 1;
3052
+ }
3053
+ this.edgeMesh.count = edgeSlot;
3054
+ for (const name of EDGE_INSTANCE_ATTRIBUTES) {
3055
+ const attribute = edgeGeometry.getAttribute(name);
3056
+ attribute.clearUpdateRanges();
3057
+ attribute.addUpdateRange(0, edgeSlot * attribute.itemSize);
3058
+ attribute.needsUpdate = true;
3059
+ }
3060
+ this.visibleNodeCandidateCount = visibleNodeCandidates.length;
3061
+ this.visibleNodeCount = materializedNodes.length;
3062
+ this.visibleEdgeCandidateCount = edgeCandidates.length;
3063
+ this.visibleEdgeSegmentCount = segmentCount;
3064
+ this.visibleEdgeCount = distinctEdges.size;
3065
+ this.visibleEdgeMarkerCount = packed.markers.length;
3066
+ }
3067
+ materializeNodes(indices, bounds) {
3068
+ if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
3069
+ const node = this.data.nodes[index];
3070
+ if (!node) return [];
3071
+ const { height, width } = resolveNodeAxes({
3072
+ height: node.height,
3073
+ nodeId: node.id,
3074
+ size: node.size,
3075
+ width: node.width
3076
+ });
3077
+ return [{
3078
+ height,
3079
+ index,
3080
+ width,
3081
+ x: node.position.x,
3082
+ y: node.position.y,
3083
+ z: node.position.z ?? 0
3084
+ }];
3085
+ });
3086
+ const aspect = Math.max(this.container.clientWidth, 1) / Math.max(this.container.clientHeight, 1);
3087
+ const columns = Math.max(1, Math.floor(Math.sqrt(this.maxVisibleNodes * aspect)));
3088
+ const rows = Math.max(1, Math.floor(this.maxVisibleNodes / columns));
3089
+ const cellWidth = Math.max((bounds.right - bounds.left) / columns, Number.EPSILON);
3090
+ const cellHeight = Math.max((bounds.top - bounds.bottom) / rows, Number.EPSILON);
3091
+ const clusters = /* @__PURE__ */ new Map();
3092
+ for (const index of indices) {
3093
+ const node = this.data.nodes[index];
3094
+ if (!node) continue;
3095
+ const axes = resolveNodeAxes({
3096
+ height: node.height,
3097
+ nodeId: node.id,
3098
+ size: node.size,
3099
+ width: node.width
3100
+ });
3101
+ const axisSize = Math.max(axes.width, axes.height);
3102
+ const key = `${Math.min(columns - 1, Math.max(0, Math.floor((node.position.x - bounds.left) / cellWidth)))}:${Math.min(rows - 1, Math.max(0, Math.floor((node.position.y - bounds.bottom) / cellHeight)))}`;
3103
+ const cluster = clusters.get(key);
3104
+ if (!cluster) {
3105
+ clusters.set(key, {
3106
+ count: 1,
3107
+ height: axisSize,
3108
+ index,
3109
+ width: axisSize,
3110
+ x: node.position.x,
3111
+ y: node.position.y,
3112
+ z: node.position.z ?? 0
3113
+ });
3114
+ continue;
3115
+ }
3116
+ cluster.count += 1;
3117
+ cluster.x += (node.position.x - cluster.x) / cluster.count;
3118
+ cluster.y += (node.position.y - cluster.y) / cluster.count;
3119
+ cluster.z += ((node.position.z ?? 0) - cluster.z) / cluster.count;
3120
+ const grownAxis = Math.min(Math.max(cluster.width, axisSize) + .25, 16);
3121
+ cluster.width = grownAxis;
3122
+ cluster.height = grownAxis;
3123
+ }
3124
+ return [...clusters.values()].slice(0, this.maxVisibleNodes);
3125
+ }
3126
+ disposeGraphObjects() {
3127
+ this.pickableEdgeSegments = [];
3128
+ this.visibleEdgeSegmentEdgeIndices = [];
3129
+ this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
3130
+ if (this.nodeMesh) {
3131
+ this.scene.remove(this.nodeMesh);
3132
+ this.nodeMesh.geometry.dispose();
3133
+ disposeMaterial(this.nodeMesh.material);
3134
+ this.nodeMesh = null;
3135
+ }
3136
+ if (this.nodePickMesh) {
3137
+ this.nodePickMesh.geometry.dispose();
3138
+ disposeMaterial(this.nodePickMesh.material);
3139
+ this.nodePickMesh = null;
3140
+ }
3141
+ if (this.edgeMesh) {
3142
+ this.scene.remove(this.edgeMesh);
3143
+ this.edgeMesh.geometry.dispose();
3144
+ disposeMaterial(this.edgeMesh.material);
3145
+ this.edgeMesh = null;
3146
+ }
3147
+ }
3148
+ };
3149
+ function nextCapacity(length) {
3150
+ let capacity = 1;
3151
+ while (capacity < length) capacity *= 2;
3152
+ return capacity;
3153
+ }
3154
+ /**
3155
+ * Segment capacity is edgeCapacity × DETAIL_MAX_SEGMENTS (cubic worst case).
3156
+ * Marker capacity is always exactly 2× edge capacity (both ends) — never derived from
3157
+ * `nextCapacity(edgeCount * 2)` independently, which can undershoot for small edge counts
3158
+ * and let a later append with `markerEnd: "both"` overrun the instanced buffer.
3159
+ */
3160
+ function edgeInstanceCapacities(edgeCount, maxVisibleEdges) {
3161
+ const edgeCapacity = Math.min(maxVisibleEdges, nextCapacity(edgeCount));
3162
+ const segmentCapacity = edgeCapacity * 24;
3163
+ const markerCapacity = edgeCapacity * 2;
3164
+ return {
3165
+ edgeCapacity,
3166
+ segmentCapacity,
3167
+ markerCapacity,
3168
+ edgeInstanceCapacity: segmentCapacity + markerCapacity
3169
+ };
3170
+ }
3171
+ //#endregion
3172
+ //#region src/visuals.ts
3173
+ /** Defines typed, serializable graph visuals without coupling application data to the render loop. */
3174
+ function defineVisuals(visuals) {
3175
+ return visuals;
3176
+ }
3177
+ //#endregion
3178
+ export { DEFAULT_ENDPOINT_ATTACH, DEFAULT_ENDPOINT_CLEARANCE, DEFAULT_FORCE_SETTINGS, Graphraum, GraphraumOverlay, attachPointOnOutline, bindGraphology, boundOverlayIds, computeClusteredForcePositions, computeForcePositions, createForceSimulation, defineVisuals, forceIterationCount, graphraumEdgePaths, graphraumNodeShapes, graphraumTheme, graphraumThemeDark, graphraumThemeLight, graphraumThemes, isTransparentGraphraumBackground, normalizeGraphraumBackground, orderFocusNodeIds, resolveGraphraumTheme, selectBudgetedLabelIds, selectFocusLabelIds, selectRichNodeIds, trimEdgeEndpoints };