@yggy-tech/graphraum 0.0.0-stage → 0.34.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,4290 @@
1
+ import { AdditiveBlending, Box3, CanvasTexture, Color, InstancedBufferAttribute, InstancedMesh, MOUSE, Matrix4, MeshBasicMaterial, NormalBlending, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, ShaderMaterial, SphereGeometry, TOUCH, 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
+ /**
104
+ * Distance from the node center to its outline (stroke included) along a unit heading in the
105
+ * billboard plane, in world units. Every shape fits its `[-1, 1]` box, so the box diagonal bounds
106
+ * the search.
107
+ */
108
+ function outlineRadius(outline, headingX, headingY) {
109
+ const { height, width } = resolveNodeAxes({
110
+ height: outline.height,
111
+ size: outline.size,
112
+ width: outline.width
113
+ });
114
+ const stroke = Math.max(0, outline.strokeWidth ?? 0);
115
+ const halfWidth = width + stroke;
116
+ const halfHeight = height + stroke;
117
+ let low = 0;
118
+ let high = Math.hypot(halfWidth, halfHeight);
119
+ for (let step = 0; step < 24; step += 1) {
120
+ const mid = (low + high) / 2;
121
+ if (containsNodePoint(outline.shape, headingX * mid / halfWidth, headingY * mid / halfHeight)) low = mid;
122
+ else high = mid;
123
+ }
124
+ return low;
125
+ }
126
+ /**
127
+ * Walk from the node center toward the far point and stop on the outline,
128
+ * then pull back by `clearance` so the stroke is not stabbed.
129
+ *
130
+ * In `2d` the outline lies in the xy plane, so only the xy part of the chord counts. In `3d` the
131
+ * billboard faces the camera, so the edge stops one outline radius from the center along the 3D
132
+ * segment. The radius is measured along the chord's world xy heading (or +x when the chord is
133
+ * parallel to z), not along its on-screen heading. For circles the radius is the same in every
134
+ * direction, so the projected end lies on the outline or inside it (where the node covers the
135
+ * edge) from every view. Other shapes are approximate in 3D: an elongated or angular node can end
136
+ * inside its outline or short of it, depending on the camera.
137
+ */
138
+ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE, mode = "2d") {
139
+ const dx = far.x - center.x;
140
+ const dy = far.y - center.y;
141
+ const dz = (far.z ?? 0) - (center.z ?? 0);
142
+ const length = Math.hypot(dx, dy, dz);
143
+ if (length < 1e-6) return {
144
+ x: center.x,
145
+ y: center.y,
146
+ z: center.z ?? 0
147
+ };
148
+ const planar = Math.hypot(dx, dy);
149
+ const reach = mode === "3d" ? length : planar;
150
+ const radius = planar < 1e-6 ? outlineRadius(outline, 1, 0) : outlineRadius(outline, dx / planar, dy / planar);
151
+ const outlineT = reach < 1e-6 ? 1 : Math.min(1, radius / reach);
152
+ const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
153
+ const t = Math.max(0, outlineT - clearanceT);
154
+ return {
155
+ x: center.x + dx * t,
156
+ y: center.y + dy * t,
157
+ z: (center.z ?? 0) + dz * t
158
+ };
159
+ }
160
+ /** Trim both ends of a center-to-center chord (or curve endpoints) to node boundaries. */
161
+ function trimEdgeEndpoints(input) {
162
+ if ((input.attach ?? "boundary") === "center") return {
163
+ source: {
164
+ x: input.source.x,
165
+ y: input.source.y,
166
+ z: input.source.z ?? 0
167
+ },
168
+ target: {
169
+ x: input.target.x,
170
+ y: input.target.y,
171
+ z: input.target.z ?? 0
172
+ }
173
+ };
174
+ const clearance = input.clearance ?? .75;
175
+ const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance, input.mode);
176
+ const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance, input.mode);
177
+ const ox = input.target.x - input.source.x;
178
+ const oy = input.target.y - input.source.y;
179
+ const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
180
+ const dx = target.x - source.x;
181
+ const dy = target.y - source.y;
182
+ const dz = (target.z ?? 0) - (source.z ?? 0);
183
+ if (dx * ox + dy * oy + dz * oz <= 1e-8) return {
184
+ source: {
185
+ x: input.source.x,
186
+ y: input.source.y,
187
+ z: input.source.z ?? 0
188
+ },
189
+ target: {
190
+ x: input.target.x,
191
+ y: input.target.y,
192
+ z: input.target.z ?? 0
193
+ }
194
+ };
195
+ return {
196
+ source,
197
+ target
198
+ };
199
+ }
200
+ //#endregion
201
+ //#region src/edge-styles.ts
202
+ const graphraumEdgeStyles = [
203
+ "solid",
204
+ "dashed",
205
+ "dotted"
206
+ ];
207
+ const graphraumEdgeMarkers = ["none", "triangle"];
208
+ const graphraumEdgeMarkerEnds = [
209
+ "target",
210
+ "source",
211
+ "both"
212
+ ];
213
+ const graphraumEdgePaths = [
214
+ "straight",
215
+ "quadratic",
216
+ "cubic"
217
+ ];
218
+ const styleCodes = {
219
+ solid: 0,
220
+ dashed: 1,
221
+ dotted: 2
222
+ };
223
+ function assertEdgeStyle(edgeId, style) {
224
+ if (typeof style !== "string" || !graphraumEdgeStyles.includes(style)) throw new Error(`Edge "${edgeId}" style must be one of: ${graphraumEdgeStyles.map((s) => `"${s}"`).join(", ")}`);
225
+ }
226
+ function assertEdgeMarker(edgeId, marker) {
227
+ if (typeof marker !== "string" || !graphraumEdgeMarkers.includes(marker)) throw new Error(`Edge "${edgeId}" marker must be one of: ${graphraumEdgeMarkers.map((m) => `"${m}"`).join(", ")}`);
228
+ }
229
+ function assertEdgeMarkerEnd(edgeId, markerEnd) {
230
+ if (typeof markerEnd !== "string" || !graphraumEdgeMarkerEnds.includes(markerEnd)) throw new Error(`Edge "${edgeId}" markerEnd must be one of: ${graphraumEdgeMarkerEnds.map((m) => `"${m}"`).join(", ")}`);
231
+ }
232
+ function assertEdgePath(edgeId, path) {
233
+ if (typeof path !== "string" || !graphraumEdgePaths.includes(path)) throw new Error(`Edge "${edgeId}" path must be one of: ${graphraumEdgePaths.map((p) => `"${p}"`).join(", ")}`);
234
+ }
235
+ function assertControlPoint(edgeId, index, point) {
236
+ 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`);
237
+ }
238
+ function assertEdgeControlPoints(edgeId, path, controlPoints) {
239
+ if (controlPoints === void 0) return;
240
+ for (const [index, point] of controlPoints.entries()) assertControlPoint(edgeId, index, point);
241
+ const resolvedPath = path ?? "straight";
242
+ if (resolvedPath === "straight") return;
243
+ if (controlPoints.length === 0) return;
244
+ const expected = resolvedPath === "quadratic" ? 1 : 2;
245
+ 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})`);
246
+ }
247
+ function assertEdgeVisual(edgeId, visual) {
248
+ if (visual.width !== void 0 && (!Number.isFinite(visual.width) || visual.width <= 0)) throw new Error(`Edge "${edgeId}" visual must have a positive finite width`);
249
+ 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`);
250
+ if (visual.markerSize !== void 0 && (!Number.isFinite(visual.markerSize) || visual.markerSize <= 0)) throw new Error(`Edge "${edgeId}" visual must have a positive finite markerSize`);
251
+ if (visual.style !== void 0) assertEdgeStyle(edgeId, visual.style);
252
+ if (visual.marker !== void 0) assertEdgeMarker(edgeId, visual.marker);
253
+ if (visual.markerEnd !== void 0) assertEdgeMarkerEnd(edgeId, visual.markerEnd);
254
+ if (visual.path !== void 0) assertEdgePath(edgeId, visual.path);
255
+ assertEdgeControlPoints(edgeId, visual.path, visual.controlPoints);
256
+ }
257
+ function encodeEdgeStyle(style) {
258
+ return styleCodes[style ?? "solid"];
259
+ }
260
+ //#endregion
261
+ //#region src/force-layout.ts
262
+ const DEFAULT_FORCE_SETTINGS = {
263
+ centerAttraction: .002,
264
+ clusterStrength: .02,
265
+ damping: .82,
266
+ linkDistance: 24,
267
+ repulsion: 400,
268
+ springStrength: .005
269
+ };
270
+ function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
271
+ return {
272
+ centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
273
+ clusterStrength: Math.max(0, Math.min(.1, settings.clusterStrength ?? DEFAULT_FORCE_SETTINGS.clusterStrength)),
274
+ damping: Math.max(.1, Math.min(.99, settings.damping)),
275
+ linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
276
+ repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
277
+ springStrength: Math.max(1e-4, Math.min(.05, settings.springStrength))
278
+ };
279
+ }
280
+ function clampLinkDistance(value) {
281
+ return Math.max(1, Math.min(500, value));
282
+ }
283
+ function clampSpringStrength(value) {
284
+ return Math.max(1e-4, Math.min(.05, value));
285
+ }
286
+ function normalizePerEdgeArray(name, values, edgeCount, clamp) {
287
+ if (values === void 0) return null;
288
+ if (values.length !== edgeCount) throw new Error(`${name} length (${values.length}) must equal edge count (${edgeCount}).`);
289
+ const normalized = new Float32Array(edgeCount);
290
+ for (let index = 0; index < edgeCount; index += 1) normalized[index] = clamp(values[index] ?? 0);
291
+ return normalized;
292
+ }
293
+ function forceIterationCount(nodeCount) {
294
+ return Math.max(8, Math.min(32, Math.ceil(240 / Math.log2(nodeCount + 2))));
295
+ }
296
+ function resolveForceIterations(nodeCount, iterations) {
297
+ if (iterations === void 0) return forceIterationCount(nodeCount);
298
+ if (!Number.isSafeInteger(iterations) || iterations < 1) throw new Error("Force iterations must be a positive integer.");
299
+ return Math.min(64, iterations);
300
+ }
301
+ function initialPositions({ dimensions, nodeCount }) {
302
+ const positions = new Float32Array(nodeCount * 3);
303
+ const extent = Math.max(Math.sqrt(nodeCount) * 6, 24);
304
+ for (let index = 0; index < nodeCount; index += 1) {
305
+ const offset = index * 3;
306
+ const angle = random(index * 4 + 1) * Math.PI * 2;
307
+ if (dimensions === 2) {
308
+ const radius = Math.sqrt(random(index * 4 + 2)) * extent * .5;
309
+ positions.set([
310
+ Math.cos(angle) * radius,
311
+ Math.sin(angle) * radius,
312
+ 0
313
+ ], offset);
314
+ continue;
315
+ }
316
+ const zDirection = random(index * 4 + 2) * 2 - 1;
317
+ const planeRadius = Math.sqrt(1 - zDirection * zDirection);
318
+ const radius = Math.cbrt(random(index * 4 + 3)) * extent * .5;
319
+ positions.set([
320
+ Math.cos(angle) * planeRadius * radius,
321
+ Math.sin(angle) * planeRadius * radius,
322
+ zDirection * radius
323
+ ], offset);
324
+ }
325
+ return positions;
326
+ }
327
+ function random(seed) {
328
+ let value = Math.imul(seed ^ 2654435769, 2246822507);
329
+ value = Math.imul(value ^ value >>> 13, 3266489909);
330
+ return ((value ^ value >>> 16) >>> 0) / 4294967296;
331
+ }
332
+ function assertClusterCount(clusters, expected, subject) {
333
+ if (clusters.length !== expected) throw new Error(`Cluster index count (${clusters.length}) must match ${subject} (${expected}).`);
334
+ }
335
+ /**
336
+ * Live simulations index per-cluster scratch arrays by id, so ids must be dense: below the node count.
337
+ * Validate before allocating anything.
338
+ */
339
+ function assertDenseClusterIds(clusters, totalNodeCount) {
340
+ for (const cluster of clusters) if (cluster >= totalNodeCount) throw new Error(`Cluster index ${cluster} exceeds the node count ${totalNodeCount}; use dense ids starting at 0.`);
341
+ }
342
+ function clusterCountOf(clusters) {
343
+ let clusterCount = 0;
344
+ for (const cluster of clusters) clusterCount = Math.max(clusterCount, cluster + 1);
345
+ return clusterCount;
346
+ }
347
+ /**
348
+ * Seeds nodes around cluster centres: centres come from a force layout of the inter-cluster graph
349
+ * and each node is scattered around its centre. Shared by the one-shot and live clustered layouts.
350
+ */
351
+ function clusteredInitialPositions(request) {
352
+ const clusterCount = clusterCountOf(request.clusters);
353
+ const clusterEdges = new Uint32Array(request.edges.length);
354
+ let edgeCursor = 0;
355
+ for (let index = 0; index < request.edges.length; index += 2) {
356
+ const source = request.clusters[request.edges[index] ?? 0];
357
+ const target = request.clusters[request.edges[index + 1] ?? 0];
358
+ if (source === void 0 || target === void 0 || source === target) continue;
359
+ clusterEdges[edgeCursor++] = source;
360
+ clusterEdges[edgeCursor++] = target;
361
+ }
362
+ const centers = computeForcePositions({
363
+ dimensions: request.dimensions,
364
+ edges: clusterEdges.subarray(0, edgeCursor),
365
+ nodeCount: clusterCount,
366
+ settings: request.settings
367
+ });
368
+ const clusterSizes = new Uint32Array(clusterCount);
369
+ for (const cluster of request.clusters) clusterSizes[cluster] += 1;
370
+ const positions = new Float32Array(request.nodeCount * 3);
371
+ for (let index = 0; index < request.nodeCount; index += 1) {
372
+ const offset = index * 3;
373
+ const cluster = request.clusters[index] ?? 0;
374
+ const center = cluster * 3;
375
+ const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
376
+ const angle = random(index * 4 + 1) * Math.PI * 2;
377
+ positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
378
+ positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
379
+ if (request.dimensions === 3) {
380
+ const zDirection = random(index * 4 + 3) * 2 - 1;
381
+ positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
382
+ }
383
+ }
384
+ return positions;
385
+ }
386
+ function createForceSimulation(request) {
387
+ const settings = normalizeForceSettings(request.settings);
388
+ let nodeCount = request.nodeCount;
389
+ let edges = new Uint32Array(request.edges);
390
+ if (edges.length % 2 !== 0) throw new Error("Force edges must contain source/target pairs.");
391
+ const edgeCount = edges.length / 2;
392
+ let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
393
+ let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
394
+ let clusters = null;
395
+ if (request.clusters) {
396
+ assertClusterCount(request.clusters, request.nodeCount, "node count");
397
+ assertDenseClusterIds(request.clusters, request.nodeCount);
398
+ clusters = new Uint32Array(request.clusters);
399
+ }
400
+ let positions = clusters ? clusteredInitialPositions({
401
+ ...request,
402
+ clusters
403
+ }) : initialPositions(request);
404
+ let velocities = new Float32Array(positions.length);
405
+ let forces = new Float32Array(positions.length);
406
+ let iteration = 0;
407
+ let clusterCount = clusters ? clusterCountOf(clusters) : 0;
408
+ let clusterCentroids = new Float64Array(clusterCount * 3);
409
+ let clusterSizes = new Uint32Array(clusterCount);
410
+ /** Mean position per cluster in O(N): no per-node allocation. */
411
+ function updateClusterCentroids(activeClusters) {
412
+ clusterCentroids.fill(0);
413
+ clusterSizes.fill(0);
414
+ for (let index = 0; index < nodeCount; index += 1) {
415
+ const cluster = activeClusters[index] ?? 0;
416
+ const offset = index * 3;
417
+ const centroid = cluster * 3;
418
+ clusterSizes[cluster] += 1;
419
+ clusterCentroids[centroid] += positions[offset] ?? 0;
420
+ clusterCentroids[centroid + 1] += positions[offset + 1] ?? 0;
421
+ if (request.dimensions === 3) clusterCentroids[centroid + 2] += positions[offset + 2] ?? 0;
422
+ }
423
+ for (let cluster = 0; cluster < clusterCount; cluster += 1) {
424
+ const size = clusterSizes[cluster] ?? 0;
425
+ if (size === 0) continue;
426
+ for (let axis = 0; axis < request.dimensions; axis += 1) clusterCentroids[cluster * 3 + axis] /= size;
427
+ }
428
+ }
429
+ return {
430
+ get nodeCount() {
431
+ return nodeCount;
432
+ },
433
+ get edges() {
434
+ return edges;
435
+ },
436
+ get positions() {
437
+ return positions;
438
+ },
439
+ /**
440
+ * Add nodes while preserving every existing position and velocity. A simulation created with
441
+ * `clusters` requires one cluster index per added node (a new id starts a new cluster); a
442
+ * simulation created without clusters rejects them.
443
+ */
444
+ addNodes(count, initial, addedClusters) {
445
+ if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
446
+ if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
447
+ if (clusters && !addedClusters) throw new Error("This simulation was created with clusters: addNodes() requires a clusters array.");
448
+ if (!clusters && addedClusters) throw new Error("This simulation was created without clusters: addNodes() cannot take a clusters array.");
449
+ if (addedClusters) {
450
+ assertClusterCount(addedClusters, count, "added node count");
451
+ assertDenseClusterIds(addedClusters, nodeCount + count);
452
+ }
453
+ const start = nodeCount;
454
+ const nextPositions = new Float32Array((nodeCount + count) * 3);
455
+ nextPositions.set(positions);
456
+ const nextVelocities = new Float32Array(nextPositions.length);
457
+ nextVelocities.set(velocities);
458
+ const nextForces = new Float32Array(nextPositions.length);
459
+ nextForces.set(forces);
460
+ if (initial) nextPositions.set(initial, positions.length);
461
+ else {
462
+ const added = initialPositions({
463
+ dimensions: request.dimensions,
464
+ nodeCount: count
465
+ });
466
+ nextPositions.set(added, positions.length);
467
+ }
468
+ let nextClusters = clusters;
469
+ let nextClusterCount = clusterCount;
470
+ let nextCentroids = clusterCentroids;
471
+ let nextSizes = clusterSizes;
472
+ if (clusters && addedClusters) {
473
+ nextClusters = new Uint32Array(nodeCount + count);
474
+ nextClusters.set(clusters);
475
+ nextClusters.set(addedClusters, nodeCount);
476
+ nextClusterCount = Math.max(clusterCount, clusterCountOf(addedClusters));
477
+ if (nextClusterCount !== clusterCount) {
478
+ nextCentroids = new Float64Array(nextClusterCount * 3);
479
+ nextSizes = new Uint32Array(nextClusterCount);
480
+ }
481
+ }
482
+ clusters = nextClusters;
483
+ clusterCount = nextClusterCount;
484
+ clusterCentroids = nextCentroids;
485
+ clusterSizes = nextSizes;
486
+ positions = nextPositions;
487
+ velocities = nextVelocities;
488
+ forces = nextForces;
489
+ nodeCount += count;
490
+ return start;
491
+ },
492
+ /** Append endpoint indices for new edges; existing edge indices remain stable. */
493
+ addEdges(nextEdges) {
494
+ if (nextEdges.length % 2 !== 0) throw new Error("Force edges must contain source/target pairs.");
495
+ for (const index of nextEdges) if (index >= nodeCount) throw new Error(`Force edge references missing node index: ${index}`);
496
+ const addedEdgeCount = nextEdges.length / 2;
497
+ const merged = new Uint32Array(edges.length + nextEdges.length);
498
+ merged.set(edges);
499
+ merged.set(nextEdges, edges.length);
500
+ edges = merged;
501
+ if (linkDistances) {
502
+ const next = new Float32Array(linkDistances.length + addedEdgeCount);
503
+ next.set(linkDistances);
504
+ next.fill(settings.linkDistance, linkDistances.length);
505
+ linkDistances = next;
506
+ }
507
+ if (springStrengths) {
508
+ const next = new Float32Array(springStrengths.length + addedEdgeCount);
509
+ next.set(springStrengths);
510
+ next.fill(settings.springStrength, springStrengths.length);
511
+ springStrengths = next;
512
+ }
513
+ },
514
+ step(alpha) {
515
+ if (nodeCount === 0) return;
516
+ forces.fill(0);
517
+ const pullClusters = clusters && settings.clusterStrength > 0 ? clusters : null;
518
+ const clusterPull = settings.clusterStrength * alpha;
519
+ if (pullClusters) updateClusterCentroids(pullClusters);
520
+ for (let index = 0; index < nodeCount; index += 1) {
521
+ const offset = index * 3;
522
+ forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
523
+ forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
524
+ forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
525
+ if (pullClusters) {
526
+ const centroid = (pullClusters[index] ?? 0) * 3;
527
+ for (let axis = 0; axis < request.dimensions; axis += 1) forces[offset + axis] += ((clusterCentroids[centroid + axis] ?? 0) - (positions[offset + axis] ?? 0)) * clusterPull;
528
+ }
529
+ for (let sample = 0; sample < 8; sample += 1) {
530
+ const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
531
+ if (targetIndex === index) continue;
532
+ const target = targetIndex * 3;
533
+ const dx = (positions[offset] ?? 0) - (positions[target] ?? 0);
534
+ const dy = (positions[offset + 1] ?? 0) - (positions[target + 1] ?? 0);
535
+ const dz = request.dimensions === 3 ? (positions[offset + 2] ?? 0) - (positions[target + 2] ?? 0) : 0;
536
+ const distanceSquared = dx * dx + dy * dy + dz * dz + 16;
537
+ const scale = Math.min(2, settings.repulsion / distanceSquared) * alpha / Math.sqrt(distanceSquared);
538
+ forces[offset] += dx * scale;
539
+ forces[offset + 1] += dy * scale;
540
+ if (request.dimensions === 3) forces[offset + 2] += dz * scale;
541
+ }
542
+ }
543
+ for (let index = 0; index < edges.length; index += 2) {
544
+ const edgeIndex = index / 2;
545
+ const source = (edges[index] ?? 0) * 3;
546
+ const target = (edges[index + 1] ?? 0) * 3;
547
+ const dx = (positions[target] ?? 0) - (positions[source] ?? 0);
548
+ const dy = (positions[target + 1] ?? 0) - (positions[source + 1] ?? 0);
549
+ const dz = request.dimensions === 3 ? (positions[target + 2] ?? 0) - (positions[source + 2] ?? 0) : 0;
550
+ const distance = Math.sqrt(Math.max(dx * dx + dy * dy + dz * dz, 1e-4));
551
+ const linkDistance = linkDistances?.[edgeIndex] ?? settings.linkDistance;
552
+ const springStrength = springStrengths?.[edgeIndex] ?? settings.springStrength;
553
+ const force = Math.max(-1, Math.min(1, (distance - linkDistance) * springStrength * alpha)) / distance;
554
+ forces[source] += dx * force;
555
+ forces[source + 1] += dy * force;
556
+ forces[target] -= dx * force;
557
+ forces[target + 1] -= dy * force;
558
+ if (request.dimensions === 3) {
559
+ forces[source + 2] += dz * force;
560
+ forces[target + 2] -= dz * force;
561
+ }
562
+ }
563
+ for (let index = 0; index < nodeCount; index += 1) {
564
+ const offset = index * 3;
565
+ let vx = ((velocities[offset] ?? 0) + (forces[offset] ?? 0)) * settings.damping;
566
+ let vy = ((velocities[offset + 1] ?? 0) + (forces[offset + 1] ?? 0)) * settings.damping;
567
+ let vz = request.dimensions === 3 ? ((velocities[offset + 2] ?? 0) + (forces[offset + 2] ?? 0)) * settings.damping : 0;
568
+ const speed = Math.hypot(vx, vy, vz);
569
+ const maxSpeed = .5 + alpha * 8;
570
+ if (speed > maxSpeed) {
571
+ const scale = maxSpeed / speed;
572
+ vx *= scale;
573
+ vy *= scale;
574
+ vz *= scale;
575
+ }
576
+ velocities[offset] = vx;
577
+ velocities[offset + 1] = vy;
578
+ velocities[offset + 2] = vz;
579
+ positions[offset] = (positions[offset] ?? 0) + vx;
580
+ positions[offset + 1] = (positions[offset + 1] ?? 0) + vy;
581
+ positions[offset + 2] = request.dimensions === 3 ? (positions[offset + 2] ?? 0) + vz : 0;
582
+ }
583
+ const center = [
584
+ 0,
585
+ 0,
586
+ 0
587
+ ];
588
+ for (let index = 0; index < nodeCount; index += 1) {
589
+ const offset = index * 3;
590
+ center[0] += positions[offset] ?? 0;
591
+ center[1] += positions[offset + 1] ?? 0;
592
+ if (request.dimensions === 3) center[2] += positions[offset + 2] ?? 0;
593
+ }
594
+ for (let axis = 0; axis < request.dimensions; axis += 1) center[axis] /= nodeCount;
595
+ for (let index = 0; index < nodeCount; index += 1) {
596
+ const offset = index * 3;
597
+ for (let axis = 0; axis < request.dimensions; axis += 1) positions[offset + axis] -= center[axis];
598
+ }
599
+ iteration += 1;
600
+ }
601
+ };
602
+ }
603
+ function computeForcePositions(request) {
604
+ const simulation = createForceSimulation(request);
605
+ const iterations = resolveForceIterations(request.nodeCount, request.iterations);
606
+ for (let iteration = 0; iteration < iterations; iteration += 1) simulation.step(1 - iteration / iterations);
607
+ return simulation.positions;
608
+ }
609
+ function computeClusteredForcePositions(request) {
610
+ assertClusterCount(request.clusters, request.nodeCount, "node count");
611
+ return clusteredInitialPositions(request);
612
+ }
613
+ //#endregion
614
+ //#region src/graphology.ts
615
+ function projectGraphology(graph) {
616
+ const nodes = graph.mapNodes((id, attributes) => ({
617
+ attributes,
618
+ color: attributes.color,
619
+ id,
620
+ position: {
621
+ x: attributes.x ?? 0,
622
+ y: attributes.y ?? 0,
623
+ z: attributes.z
624
+ },
625
+ shape: attributes.shape,
626
+ size: attributes.size
627
+ }));
628
+ return {
629
+ edges: graph.mapEdges((id, attributes, source, target) => ({
630
+ attributes,
631
+ color: attributes.color,
632
+ id,
633
+ source,
634
+ target
635
+ })),
636
+ nodes
637
+ };
638
+ }
639
+ /** Projects a mutable Graphology graph into Graphraum and keeps the binding alive until disposed. */
640
+ function bindGraphology(renderer, graph) {
641
+ renderer.setData(projectGraphology(graph));
642
+ let flushPending = false;
643
+ let disposed = false;
644
+ const flush = () => {
645
+ flushPending = false;
646
+ if (disposed) return;
647
+ renderer.setData(projectGraphology(graph));
648
+ };
649
+ const schedule = () => {
650
+ if (flushPending) return;
651
+ flushPending = true;
652
+ queueMicrotask(flush);
653
+ };
654
+ graph.on("nodeAttributesUpdated", schedule);
655
+ graph.on("eachNodeAttributesUpdated", schedule);
656
+ graph.on("nodeAdded", schedule);
657
+ graph.on("nodeDropped", schedule);
658
+ graph.on("edgeAdded", schedule);
659
+ graph.on("edgeDropped", schedule);
660
+ graph.on("edgeAttributesUpdated", schedule);
661
+ graph.on("eachEdgeAttributesUpdated", schedule);
662
+ graph.on("cleared", schedule);
663
+ graph.on("edgesCleared", schedule);
664
+ return () => {
665
+ disposed = true;
666
+ graph.off("nodeAttributesUpdated", schedule);
667
+ graph.off("eachNodeAttributesUpdated", schedule);
668
+ graph.off("nodeAdded", schedule);
669
+ graph.off("nodeDropped", schedule);
670
+ graph.off("edgeAdded", schedule);
671
+ graph.off("edgeDropped", schedule);
672
+ graph.off("edgeAttributesUpdated", schedule);
673
+ graph.off("eachEdgeAttributesUpdated", schedule);
674
+ graph.off("cleared", schedule);
675
+ graph.off("edgesCleared", schedule);
676
+ };
677
+ }
678
+ /** Longest frame step the orbit applies, so a throttled or backgrounded tab does not jump on return. */
679
+ const AUTO_ORBIT_MAX_DELTA_SECONDS = .1;
680
+ const AUTO_ORBIT_OFF = {
681
+ enabled: false,
682
+ hidden: false,
683
+ interacting: false,
684
+ offscreen: false
685
+ };
686
+ function resolveAutoOrbitOptions(options) {
687
+ const speed = options.speed ?? .07;
688
+ const resumeAfterMs = options.resumeAfterMs ?? 3e3;
689
+ if (!Number.isFinite(speed) || speed <= 0 || speed > 2) throw new Error(`Auto-orbit speed must be a finite number above 0 and at most 2 radians per second; received ${speed}.`);
690
+ if (!Number.isFinite(resumeAfterMs) || resumeAfterMs < 0) throw new Error(`Auto-orbit resumeAfterMs must be a non-negative finite number of milliseconds; received ${resumeAfterMs}.`);
691
+ return {
692
+ speed,
693
+ resumeAfterMs
694
+ };
695
+ }
696
+ function nextAutoOrbitState(state, event) {
697
+ if (event.type === "enable") return state.enabled ? {
698
+ ...state,
699
+ hidden: event.hidden
700
+ } : {
701
+ ...AUTO_ORBIT_OFF,
702
+ enabled: true,
703
+ hidden: event.hidden
704
+ };
705
+ if (!state.enabled) return state;
706
+ switch (event.type) {
707
+ case "disable": return AUTO_ORBIT_OFF;
708
+ case "interaction": return {
709
+ ...state,
710
+ interacting: true
711
+ };
712
+ case "idle": return {
713
+ ...state,
714
+ interacting: false
715
+ };
716
+ case "visibility": return {
717
+ ...state,
718
+ hidden: event.hidden
719
+ };
720
+ case "intersection": return {
721
+ ...state,
722
+ offscreen: event.offscreen
723
+ };
724
+ }
725
+ }
726
+ function autoOrbitStatus(state) {
727
+ if (!state.enabled) return "off";
728
+ return state.hidden || state.interacting || state.offscreen ? "paused" : "active";
729
+ }
730
+ /** Seconds between two animation-frame timestamps, zero on the first frame, clamped to `[0, AUTO_ORBIT_MAX_DELTA_SECONDS]`. */
731
+ function autoOrbitDeltaSeconds(previousMs, nowMs) {
732
+ if (previousMs === null) return 0;
733
+ return Math.min(Math.max((nowMs - previousMs) / 1e3, 0), AUTO_ORBIT_MAX_DELTA_SECONDS);
734
+ }
735
+ /**
736
+ * OrbitControls (three r185) rotates `(2π / 60) * autoRotateSpeed * deltaTime` radians when
737
+ * `update(deltaTime)` receives seconds (and a fixed per-call step without it). Inverting that
738
+ * gives an `autoRotateSpeed` for which `update(dtSeconds)` turns exactly `radiansPerSecond`,
739
+ * independent of frame rate.
740
+ */
741
+ function autoRotateSpeedFor(radiansPerSecond) {
742
+ return radiansPerSecond * 60 / (2 * Math.PI);
743
+ }
744
+ //#endregion
745
+ //#region src/auto-orbit-loop.ts
746
+ /**
747
+ * Owns the auto-orbit frame loop and the browser signals that stop it. The loop exists only
748
+ * while the orbit is active: pointer, wheel and key input pause it until `resumeAfterMs` of
749
+ * idle, and a hidden document or an off-screen element stop it until they change back.
750
+ * Decisions come from the pure state in `auto-orbit.ts`; this class only wires DOM events,
751
+ * the resume timer and `requestAnimationFrame`. Interaction comes from the controls' `start`
752
+ * and `end` events, so multi-touch and lost pointer capture end the pause only once the last
753
+ * pointer lifts. DOM overlays above the canvas do not reach the controls and do not pause it.
754
+ */
755
+ var AutoOrbitLoop = class {
756
+ element;
757
+ interactions;
758
+ resumeAfterMs;
759
+ step;
760
+ state;
761
+ frame = null;
762
+ lastFrameMs = null;
763
+ resumeTimer = null;
764
+ observer;
765
+ constructor(element, interactions, resumeAfterMs, step) {
766
+ this.element = element;
767
+ this.interactions = interactions;
768
+ this.resumeAfterMs = resumeAfterMs;
769
+ this.step = step;
770
+ this.state = nextAutoOrbitState(AUTO_ORBIT_OFF, {
771
+ type: "enable",
772
+ hidden: element.ownerDocument.hidden
773
+ });
774
+ interactions.addEventListener("start", this.handleInteractionStart);
775
+ interactions.addEventListener("end", this.handleInteractionEnd);
776
+ element.ownerDocument.addEventListener("visibilitychange", this.handleVisibilityChange);
777
+ this.observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(this.handleIntersection);
778
+ this.observer?.observe(element);
779
+ this.sync();
780
+ }
781
+ get status() {
782
+ return autoOrbitStatus(this.state);
783
+ }
784
+ setResumeAfterMs(resumeAfterMs) {
785
+ this.resumeAfterMs = resumeAfterMs;
786
+ }
787
+ dispose() {
788
+ this.interactions.removeEventListener("start", this.handleInteractionStart);
789
+ this.interactions.removeEventListener("end", this.handleInteractionEnd);
790
+ this.element.ownerDocument.removeEventListener("visibilitychange", this.handleVisibilityChange);
791
+ this.observer?.disconnect();
792
+ this.clearResumeTimer();
793
+ this.dispatch({ type: "disable" });
794
+ }
795
+ dispatch(event) {
796
+ this.state = nextAutoOrbitState(this.state, event);
797
+ this.sync();
798
+ }
799
+ /** Starts the frame loop when the orbit becomes active and cancels it otherwise. */
800
+ sync() {
801
+ const active = this.status === "active";
802
+ if (active && this.frame === null) {
803
+ this.lastFrameMs = null;
804
+ this.frame = requestAnimationFrame(this.tick);
805
+ } else if (!active && this.frame !== null) {
806
+ cancelAnimationFrame(this.frame);
807
+ this.frame = null;
808
+ }
809
+ }
810
+ tick = (nowMs) => {
811
+ this.frame = null;
812
+ const deltaSeconds = autoOrbitDeltaSeconds(this.lastFrameMs, nowMs);
813
+ this.lastFrameMs = nowMs;
814
+ try {
815
+ this.step(deltaSeconds);
816
+ } catch (error) {
817
+ this.dispose();
818
+ throw error;
819
+ }
820
+ if (this.frame === null && this.status === "active") this.frame = requestAnimationFrame(this.tick);
821
+ };
822
+ armResumeTimer() {
823
+ this.clearResumeTimer();
824
+ this.resumeTimer = setTimeout(() => {
825
+ this.resumeTimer = null;
826
+ this.dispatch({ type: "idle" });
827
+ }, this.resumeAfterMs);
828
+ }
829
+ clearResumeTimer() {
830
+ if (this.resumeTimer === null) return;
831
+ clearTimeout(this.resumeTimer);
832
+ this.resumeTimer = null;
833
+ }
834
+ /**
835
+ * A drag, pinch or wheel step pauses the orbit; `end` restarts the idle countdown. `start` arms
836
+ * it too, so a swallowed pointerup cannot pause the orbit forever. Resuming mid-drag is harmless:
837
+ * OrbitControls (r185) applies autoRotate in `update()` only while its state is NONE.
838
+ */
839
+ handleInteractionStart = () => {
840
+ this.dispatch({ type: "interaction" });
841
+ this.armResumeTimer();
842
+ };
843
+ handleInteractionEnd = () => {
844
+ if (this.state.interacting) this.armResumeTimer();
845
+ };
846
+ handleVisibilityChange = () => {
847
+ this.dispatch({
848
+ type: "visibility",
849
+ hidden: this.element.ownerDocument.hidden
850
+ });
851
+ };
852
+ handleIntersection = (entries) => {
853
+ const entry = entries.at(-1);
854
+ if (entry) this.dispatch({
855
+ type: "intersection",
856
+ offscreen: !entry.isIntersecting
857
+ });
858
+ };
859
+ };
860
+ //#endregion
861
+ //#region src/camera-aspect.ts
862
+ /** Canvas aspect used by perspective cameras. Never 0. */
863
+ function containerAspect(width, height) {
864
+ return Math.max(width, 1) / Math.max(height, 1);
865
+ }
866
+ /** Keep a perspective camera from stretching on a non-square canvas. */
867
+ function applyPerspectiveContainerAspect(camera, width, height) {
868
+ camera.aspect = containerAspect(width, height);
869
+ }
870
+ function resolveFlyToOptions(options) {
871
+ const durationMs = options.durationMs ?? 1200;
872
+ const distanceScale = options.distanceScale ?? 1;
873
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error(`flyTo durationMs must be a non-negative finite number of milliseconds; received ${durationMs}.`);
874
+ if (!Number.isFinite(distanceScale) || distanceScale <= 0) throw new Error(`flyTo distanceScale must be a finite number above 0; received ${distanceScale}.`);
875
+ return {
876
+ distanceScale,
877
+ durationMs
878
+ };
879
+ }
880
+ /** Cubic ease-in-out on `[0, 1]`: slow start, slow arrival, symmetric around 0.5. */
881
+ function easeInOutCubic(t) {
882
+ return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
883
+ }
884
+ /** Linear progress of a flight, clamped to `[0, 1]`; a zero duration is already done. */
885
+ function flightProgress(elapsedMs, durationMs) {
886
+ if (durationMs <= 0) return 1;
887
+ return Math.min(Math.max(elapsedMs / durationMs, 0), 1);
888
+ }
889
+ /**
890
+ * Interpolates a positive scale (camera distance, orthographic zoom) geometrically, so each
891
+ * step zooms by the same factor and a long zoom does not rush its last part. Exact at both ends.
892
+ */
893
+ function interpolateScale(from, to, t) {
894
+ if (t <= 0) return from;
895
+ if (t >= 1) return to;
896
+ return from * (to / from) ** t;
897
+ }
898
+ //#endregion
899
+ //#region src/theme.ts
900
+ /** Dark canvas: Ink Black, Turf Green, Porcelain. */
901
+ const graphraumThemeDark = Object.freeze({
902
+ background: "#040f0f",
903
+ dimmedEdge: "#315a51",
904
+ dimmedEdgeOpacity: .25,
905
+ dimmedNode: "#315a51",
906
+ edge: "#226f54",
907
+ edgeOpacity: .55,
908
+ edgeWidth: 1.5,
909
+ endpointAttach: "boundary",
910
+ focusedNode: "#73c7a5",
911
+ hoveredNode: "#e4a853",
912
+ node: "#226f54",
913
+ nodeStroke: "#fcfffc",
914
+ selectedEdge: "#fcfffc",
915
+ selectedNode: "#fcfffc"
916
+ });
917
+ /** Light canvas: Porcelain field with Turf marks and Ink selection. */
918
+ const graphraumThemeLight = Object.freeze({
919
+ background: "#fcfffc",
920
+ dimmedEdge: "#9bb5ac",
921
+ dimmedEdgeOpacity: .35,
922
+ dimmedNode: "#9bb5ac",
923
+ edge: "#226f54",
924
+ edgeOpacity: .65,
925
+ edgeWidth: 1.5,
926
+ endpointAttach: "boundary",
927
+ focusedNode: "#1a5c45",
928
+ hoveredNode: "#c47a1a",
929
+ node: "#226f54",
930
+ nodeStroke: "#040f0f",
931
+ selectedEdge: "#040f0f",
932
+ selectedNode: "#040f0f"
933
+ });
934
+ const graphraumThemes = Object.freeze({
935
+ dark: graphraumThemeDark,
936
+ light: graphraumThemeLight
937
+ });
938
+ /** Alias of {@link graphraumThemeDark} for backward compatibility. */
939
+ const graphraumTheme = graphraumThemeDark;
940
+ function resolveGraphraumTheme(input, base = graphraumThemeDark) {
941
+ if (input === void 0) return { ...base };
942
+ if (typeof input === "string") {
943
+ const preset = graphraumThemes[input];
944
+ if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
945
+ return { ...preset };
946
+ }
947
+ return assertGraphraumTheme({
948
+ ...base,
949
+ ...input
950
+ });
951
+ }
952
+ function assertGraphraumTheme(theme) {
953
+ const opacity = theme.dimmedEdgeOpacity;
954
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`Theme dimmedEdgeOpacity must be a finite number between 0 and 1, received ${opacity}.`);
955
+ return theme;
956
+ }
957
+ /** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
958
+ function normalizeGraphraumBackground(background) {
959
+ return background === "transparent" ? null : background;
960
+ }
961
+ function isTransparentGraphraumBackground(background) {
962
+ return normalizeGraphraumBackground(background) === null;
963
+ }
964
+ /** Halo radius as a multiple of the node's half-extent at `glow: 1`. */
965
+ const GLOW_RADIUS_SCALE = 3.4;
966
+ /** Halo opacity multiplier under normal blending, so a light background shows a soft tint. */
967
+ const GLOW_NORMAL_OPACITY_SCALE = .6;
968
+ function assertNodeGlow(nodeId, glow) {
969
+ if (glow !== void 0 && (!Number.isFinite(glow) || glow < 0 || glow > 1)) throw new Error(`Node "${nodeId}" glow must be a finite number between 0 and 1`);
970
+ }
971
+ /**
972
+ * Halo radius in world units: `extent × (1 + (GLOW_RADIUS_SCALE − 1) × glow)`, where `extent` is
973
+ * the node's half-extent `max(width, height)`. Glow 0 collapses the halo under the node; glow 1
974
+ * reaches 3.4× the node extent.
975
+ */
976
+ function glowHaloRadius(extent, glow) {
977
+ return extent * (1 + (GLOW_RADIUS_SCALE - 1) * glow);
978
+ }
979
+ /** Peak halo opacity at the node center; the shader fades it radially to 0 at the halo radius. */
980
+ function glowHaloOpacity(glow) {
981
+ return glow;
982
+ }
983
+ /** Keeps `current` while it fits, otherwise grows to the next power of two capped at `maximum`. */
984
+ function glowBufferCapacity(required, current, maximum) {
985
+ if (required <= current) return current;
986
+ let capacity = Math.max(current, 1);
987
+ while (capacity < required) capacity *= 2;
988
+ return Math.min(capacity, maximum);
989
+ }
990
+ function createGlowSelection() {
991
+ return {
992
+ count: 0,
993
+ indices: /* @__PURE__ */ new Uint32Array(0)
994
+ };
995
+ }
996
+ /**
997
+ * Selects visible nodes with `glow > 0` into `selection`, highest glow first and ties by node
998
+ * index, capped at {@link GLOW_MAX_INSTANCES}. Selects nothing in the density LOD tier. Reuses
999
+ * the selection buffer and grows it geometrically; allocates no per-node objects.
1000
+ */
1001
+ function selectGlowNodes(visibleNodeIndices, glowAt, densityLod, selection) {
1002
+ selection.count = 0;
1003
+ if (densityLod) return selection;
1004
+ let candidates = 0;
1005
+ for (const nodeIndex of visibleNodeIndices) {
1006
+ if (!(glowAt(nodeIndex) > 0)) continue;
1007
+ if (candidates === selection.indices.length) {
1008
+ const grown = new Uint32Array(glowBufferCapacity(candidates + 1, candidates, Number.POSITIVE_INFINITY));
1009
+ grown.set(selection.indices);
1010
+ selection.indices = grown;
1011
+ }
1012
+ selection.indices[candidates] = nodeIndex;
1013
+ candidates += 1;
1014
+ }
1015
+ selection.indices.subarray(0, candidates).sort((a, b) => glowAt(b) - glowAt(a) || a - b);
1016
+ selection.count = Math.min(candidates, 256);
1017
+ return selection;
1018
+ }
1019
+ /**
1020
+ * Additive light reads on dark backgrounds but saturates to nothing on light ones. A solid
1021
+ * background whose relative luminance (linear sRGB, 0.2126 R + 0.7152 G + 0.0722 B) reaches
1022
+ * {@link GLOW_LIGHT_BACKGROUND_LUMINANCE} uses normal blending; transparent and pattern
1023
+ * backgrounds keep additive blending because their brightness is unknown.
1024
+ */
1025
+ function glowBlendModeFor(background) {
1026
+ const normalized = normalizeGraphraumBackground(background);
1027
+ if (normalized === null || typeof normalized === "object") return "additive";
1028
+ const { b, g, r } = new Color(normalized);
1029
+ return .2126 * r + .7152 * g + .0722 * b >= .5 ? "normal" : "additive";
1030
+ }
1031
+ function glowOpacityScale(mode) {
1032
+ return mode === "normal" ? GLOW_NORMAL_OPACITY_SCALE : 1;
1033
+ }
1034
+ //#endregion
1035
+ //#region src/compile-graph.ts
1036
+ function assertFinitePosition(nodeId, axis, value) {
1037
+ if (!Number.isFinite(value)) throw new Error(`Node "${nodeId}" has a non-finite ${axis} position`);
1038
+ }
1039
+ function assertNotEmpty(value, message) {
1040
+ if (value.trim().length === 0) throw new Error(message);
1041
+ }
1042
+ function compilePresentation(kind, id, presentation) {
1043
+ const prefix = `${kind} "${id}" presentation`;
1044
+ assertNotEmpty(presentation.title, `${prefix} title must not be empty`);
1045
+ if (presentation.subtitle !== void 0) assertNotEmpty(presentation.subtitle, `${prefix} subtitle must not be empty`);
1046
+ const propertyIds = /* @__PURE__ */ new Set();
1047
+ const properties = (presentation.properties ?? []).map((property) => {
1048
+ assertNotEmpty(property.id, `${prefix} property id must not be empty`);
1049
+ assertNotEmpty(property.label, `${prefix} property "${property.id}" label must not be empty`);
1050
+ if (propertyIds.has(property.id)) throw new Error(`${prefix} has duplicate property id "${property.id}"`);
1051
+ propertyIds.add(property.id);
1052
+ if (typeof property.value === "number" && !Number.isFinite(property.value)) throw new Error(`${prefix} property "${property.id}" must have a finite numeric value`);
1053
+ if (property.value !== null && ![
1054
+ "boolean",
1055
+ "number",
1056
+ "string"
1057
+ ].includes(typeof property.value)) throw new Error(`${prefix} property "${property.id}" must have a serializable scalar value`);
1058
+ return Object.freeze({ ...property });
1059
+ });
1060
+ const actionIds = /* @__PURE__ */ new Set();
1061
+ const actions = (presentation.actions ?? []).map((action) => {
1062
+ assertNotEmpty(action.id, `${prefix} action id must not be empty`);
1063
+ assertNotEmpty(action.label, `${prefix} action "${action.id}" label must not be empty`);
1064
+ if (action.disabled !== void 0 && typeof action.disabled !== "boolean") throw new Error(`${prefix} action "${action.id}" disabled must be a boolean`);
1065
+ if (actionIds.has(action.id)) throw new Error(`${prefix} has duplicate action id "${action.id}"`);
1066
+ actionIds.add(action.id);
1067
+ return Object.freeze({ ...action });
1068
+ });
1069
+ return Object.freeze({
1070
+ ...presentation,
1071
+ actions: Object.freeze(actions),
1072
+ properties: Object.freeze(properties)
1073
+ });
1074
+ }
1075
+ function assertPositiveFiniteVisual(id, field, value) {
1076
+ if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) throw new Error(`Node "${id}" visual must have a positive finite ${field}`);
1077
+ }
1078
+ function assertNonNegativeFiniteVisual(id, field, value) {
1079
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`Node "${id}" visual must have a finite non-negative ${field}`);
1080
+ }
1081
+ function compileNodeVisual(id, visual) {
1082
+ assertPositiveFiniteVisual(id, "size", visual.size);
1083
+ assertPositiveFiniteVisual(id, "width", visual.width);
1084
+ assertPositiveFiniteVisual(id, "height", visual.height);
1085
+ assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
1086
+ assertNodeGlow(id, visual.glow);
1087
+ if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
1088
+ return Object.freeze({ ...visual });
1089
+ }
1090
+ function compileEdgeVisual(id, visual) {
1091
+ assertEdgeVisual(id, visual);
1092
+ return Object.freeze({ ...visual });
1093
+ }
1094
+ /** Validates graph identity and compiles lookup-heavy objects into GPU-oriented position buffers. */
1095
+ function compileGraph(data, visuals) {
1096
+ const nodeIndices = /* @__PURE__ */ new Map();
1097
+ const nodePositions = new Float32Array(data.nodes.length * 3);
1098
+ const nodePresentations = /* @__PURE__ */ new Map();
1099
+ const nodeVisuals = [];
1100
+ for (const [index, node] of data.nodes.entries()) {
1101
+ if (nodeIndices.has(node.id)) throw new Error(`Duplicate node id: "${node.id}"`);
1102
+ assertFinitePosition(node.id, "x", node.position.x);
1103
+ assertFinitePosition(node.id, "y", node.position.y);
1104
+ assertFinitePosition(node.id, "z", node.position.z ?? 0);
1105
+ const encoding = visuals?.node?.(node);
1106
+ const visual = compileNodeVisual(node.id, encoding?.visual ?? {
1107
+ color: node.color,
1108
+ glow: node.glow,
1109
+ height: node.height,
1110
+ shape: node.shape,
1111
+ size: node.size,
1112
+ strokeColor: node.strokeColor,
1113
+ strokeWidth: node.strokeWidth,
1114
+ width: node.width
1115
+ });
1116
+ nodeVisuals.push(visual);
1117
+ if (encoding?.presentation) nodePresentations.set(node.id, compilePresentation("Node", node.id, encoding.presentation));
1118
+ nodeIndices.set(node.id, index);
1119
+ nodePositions.set([
1120
+ node.position.x,
1121
+ node.position.y,
1122
+ node.position.z ?? 0
1123
+ ], index * 3);
1124
+ }
1125
+ const edgeIds = /* @__PURE__ */ new Set();
1126
+ const edgeNodeIndices = new Uint32Array(data.edges.length * 2);
1127
+ const edgePositions = new Float32Array(data.edges.length * 6);
1128
+ const edgePresentations = /* @__PURE__ */ new Map();
1129
+ const edgeVisuals = [];
1130
+ const incidentEdgeIndices = Array.from({ length: data.nodes.length }, () => []);
1131
+ for (const [index, edge] of data.edges.entries()) {
1132
+ if (edgeIds.has(edge.id)) throw new Error(`Duplicate edge id: "${edge.id}"`);
1133
+ edgeIds.add(edge.id);
1134
+ const encoding = visuals?.edge?.(edge);
1135
+ const visual = compileEdgeVisual(edge.id, {
1136
+ color: edge.color,
1137
+ width: edge.width,
1138
+ opacity: edge.opacity,
1139
+ style: edge.style,
1140
+ marker: edge.marker,
1141
+ markerSize: edge.markerSize,
1142
+ markerEnd: edge.markerEnd,
1143
+ path: edge.path,
1144
+ controlPoints: edge.controlPoints,
1145
+ ...encoding?.visual
1146
+ });
1147
+ edgeVisuals.push(visual);
1148
+ if (encoding?.presentation) edgePresentations.set(edge.id, compilePresentation("Edge", edge.id, encoding.presentation));
1149
+ const sourceIndex = nodeIndices.get(edge.source);
1150
+ const targetIndex = nodeIndices.get(edge.target);
1151
+ if (sourceIndex === void 0) throw new Error(`Edge "${edge.id}" references missing source "${edge.source}"`);
1152
+ if (targetIndex === void 0) throw new Error(`Edge "${edge.id}" references missing target "${edge.target}"`);
1153
+ edgeNodeIndices.set([sourceIndex, targetIndex], index * 2);
1154
+ incidentEdgeIndices[sourceIndex]?.push(index);
1155
+ if (targetIndex !== sourceIndex) incidentEdgeIndices[targetIndex]?.push(index);
1156
+ edgePositions.set(nodePositions.subarray(sourceIndex * 3, sourceIndex * 3 + 3), index * 6);
1157
+ edgePositions.set(nodePositions.subarray(targetIndex * 3, targetIndex * 3 + 3), index * 6 + 3);
1158
+ }
1159
+ return {
1160
+ edgeNodeIndices,
1161
+ edgePositions,
1162
+ edgePresentations,
1163
+ edgeVisuals,
1164
+ incidentEdgeIndices,
1165
+ nodeIds: data.nodes.map((node) => node.id),
1166
+ nodeIndices,
1167
+ nodePositions,
1168
+ nodePresentations,
1169
+ nodeVisuals
1170
+ };
1171
+ }
1172
+ //#endregion
1173
+ //#region src/data-patch.ts
1174
+ /** Merge additions and removals into a dense graph snapshot. Cascades edges whose endpoints vanish. */
1175
+ function mergeDataPatch(currentNodes, currentEdges, patch) {
1176
+ const removedNodes = new Set(patch.removedNodeIds ?? []);
1177
+ const removedEdges = new Set(patch.removedEdgeIds ?? []);
1178
+ const nodes = new Map(currentNodes.map((node) => [node.id, node]));
1179
+ const edges = new Map(currentEdges.map((edge) => [edge.id, edge]));
1180
+ for (const id of removedNodes) nodes.delete(id);
1181
+ for (const id of removedEdges) edges.delete(id);
1182
+ for (const node of patch.addedNodes ?? []) nodes.set(node.id, node);
1183
+ for (const edge of patch.addedEdges ?? []) edges.set(edge.id, edge);
1184
+ return {
1185
+ nodes: [...nodes.values()],
1186
+ edges: [...edges.values()].filter((edge) => nodes.has(edge.source) && nodes.has(edge.target))
1187
+ };
1188
+ }
1189
+ /** True when the merged live graph fits the already-allocated mesh capacities. */
1190
+ function dataPatchFitsCapacity(merged, capacity) {
1191
+ return merged.nodes.length <= capacity.nodes && merged.edges.length <= capacity.edges;
1192
+ }
1193
+ /** True when the patch is add-only and can grow dense arrays without rewriting survivors. */
1194
+ function isAppendOnlyDataPatch(patch) {
1195
+ return (patch.removedNodeIds?.length ?? 0) === 0 && (patch.removedEdgeIds?.length ?? 0) === 0;
1196
+ }
1197
+ //#endregion
1198
+ //#region src/edge-paths.ts
1199
+ const AUTO_OFFSET_FRACTION = .18;
1200
+ function segmentCountForPath(path, tier) {
1201
+ if (tier === "overview") return 1;
1202
+ const resolved = path ?? "straight";
1203
+ if (resolved === "quadratic") return 16;
1204
+ if (resolved === "cubic") return 24;
1205
+ return 1;
1206
+ }
1207
+ function autoControlPoints(path, x1, y1, z1, x2, y2, z2) {
1208
+ const dx = x2 - x1;
1209
+ const dy = y2 - y1;
1210
+ const length = Math.hypot(dx, dy);
1211
+ const offset = length * AUTO_OFFSET_FRACTION;
1212
+ const nx = length > 0 ? -dy / length : 0;
1213
+ const ny = length > 0 ? dx / length : 1;
1214
+ if (path === "quadratic") return [{
1215
+ x: (x1 + x2) / 2 + nx * offset,
1216
+ y: (y1 + y2) / 2 + ny * offset,
1217
+ z: (z1 + z2) / 2
1218
+ }];
1219
+ if (path === "cubic") return [{
1220
+ x: x1 + dx / 3 + nx * offset,
1221
+ y: y1 + dy / 3 + ny * offset,
1222
+ z: z1 + (z2 - z1) / 3
1223
+ }, {
1224
+ x: x1 + dx * 2 / 3 - nx * offset,
1225
+ y: y1 + dy * 2 / 3 - ny * offset,
1226
+ z: z1 + (z2 - z1) * 2 / 3
1227
+ }];
1228
+ return [];
1229
+ }
1230
+ function lerp(a, b, t) {
1231
+ return a + (b - a) * t;
1232
+ }
1233
+ function sampleQuadratic(x1, y1, z1, cx, cy, cz, x2, y2, z2, t) {
1234
+ const u = 1 - t;
1235
+ return {
1236
+ x: u * u * x1 + 2 * u * t * cx + t * t * x2,
1237
+ y: u * u * y1 + 2 * u * t * cy + t * t * y2,
1238
+ z: u * u * z1 + 2 * u * t * cz + t * t * z2
1239
+ };
1240
+ }
1241
+ function sampleCubic(x1, y1, z1, c1x, c1y, c1z, c2x, c2y, c2z, x2, y2, z2, t) {
1242
+ const u = 1 - t;
1243
+ return {
1244
+ x: u * u * u * x1 + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x2,
1245
+ y: u * u * u * y1 + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y2,
1246
+ z: u * u * u * z1 + 3 * u * u * t * c1z + 3 * u * t * t * c2z + t * t * t * z2
1247
+ };
1248
+ }
1249
+ function resolveControlPoints(path, controlPoints, x1, y1, z1, x2, y2, z2) {
1250
+ if (path === "straight") return [];
1251
+ if (controlPoints && controlPoints.length > 0) return controlPoints;
1252
+ return autoControlPoints(path, x1, y1, z1, x2, y2, z2);
1253
+ }
1254
+ function sampleEdgePath(input) {
1255
+ const path = input.tier === "overview" ? "straight" : input.path ?? "straight";
1256
+ const segments = segmentCountForPath(path, input.tier);
1257
+ if (path === "straight" || segments <= 1) return [{
1258
+ x: input.x1,
1259
+ y: input.y1,
1260
+ z: input.z1
1261
+ }, {
1262
+ x: input.x2,
1263
+ y: input.y2,
1264
+ z: input.z2
1265
+ }];
1266
+ const controls = resolveControlPoints(path, input.controlPoints, input.x1, input.y1, input.z1, input.x2, input.y2, input.z2);
1267
+ const points = [];
1268
+ for (let index = 0; index <= segments; index += 1) {
1269
+ const t = index / segments;
1270
+ if (path === "quadratic") {
1271
+ const control = controls[0] ?? {
1272
+ x: lerp(input.x1, input.x2, .5),
1273
+ y: lerp(input.y1, input.y2, .5),
1274
+ z: lerp(input.z1, input.z2, .5)
1275
+ };
1276
+ points.push(sampleQuadratic(input.x1, input.y1, input.z1, control.x, control.y, control.z ?? 0, input.x2, input.y2, input.z2, t));
1277
+ } else {
1278
+ const c1 = controls[0] ?? {
1279
+ x: lerp(input.x1, input.x2, 1 / 3),
1280
+ y: lerp(input.y1, input.y2, 1 / 3),
1281
+ z: lerp(input.z1, input.z2, 1 / 3)
1282
+ };
1283
+ const c2 = controls[1] ?? {
1284
+ x: lerp(input.x1, input.x2, 2 / 3),
1285
+ y: lerp(input.y1, input.y2, 2 / 3),
1286
+ z: lerp(input.z1, input.z2, 2 / 3)
1287
+ };
1288
+ 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));
1289
+ }
1290
+ }
1291
+ return points;
1292
+ }
1293
+ //#endregion
1294
+ //#region src/edge-state.ts
1295
+ /**
1296
+ * Single source of truth for an edge instance's color and opacity. Full packing and the
1297
+ * incremental state repaint both call it, so both paths write identical instance colors.
1298
+ */
1299
+ function resolveEdgePaint(edgeIndex, visual, defaults, tier, edgeStates) {
1300
+ const opacity = tier === "overview" ? defaults.opacity : visual.opacity ?? defaults.opacity;
1301
+ const state = edgeStates?.stateOf(edgeIndex) ?? null;
1302
+ if (edgeStates && state === "selected") return {
1303
+ color: edgeStates.selectedColor,
1304
+ opacity
1305
+ };
1306
+ if (edgeStates && state === "dimmed") return {
1307
+ color: edgeStates.dimmedColor,
1308
+ opacity: Math.min(opacity, edgeStates.dimmedOpacity)
1309
+ };
1310
+ return {
1311
+ color: visual.color ?? defaults.color,
1312
+ opacity
1313
+ };
1314
+ }
1315
+ /** IDs present in exactly one of the two sets. */
1316
+ function changedIds(current, next) {
1317
+ return [...[...current].filter((id) => !next.has(id)), ...[...next].filter((id) => !current.has(id))];
1318
+ }
1319
+ //#endregion
1320
+ //#region src/edge-materialize.ts
1321
+ function packEdgeInstances(input) {
1322
+ const segments = [];
1323
+ const markers = [];
1324
+ let truncated = false;
1325
+ const attach = input.endpointAttach ?? "boundary";
1326
+ const clearance = input.endpointClearance ?? .75;
1327
+ for (const edgeIndex of input.edgeIndices) {
1328
+ const visual = input.edgeVisuals[edgeIndex] ?? {};
1329
+ const base = edgeIndex * 6;
1330
+ let x1 = input.endpointPositions[base] ?? 0;
1331
+ let y1 = input.endpointPositions[base + 1] ?? 0;
1332
+ let z1 = input.endpointPositions[base + 2] ?? 0;
1333
+ let x2 = input.endpointPositions[base + 3] ?? 0;
1334
+ let y2 = input.endpointPositions[base + 4] ?? 0;
1335
+ let z2 = input.endpointPositions[base + 5] ?? 0;
1336
+ if (attach === "boundary" && input.nodeOutlines && input.edgeNodeIndices) {
1337
+ const sourceIndex = input.edgeNodeIndices[edgeIndex * 2] ?? 0;
1338
+ const targetIndex = input.edgeNodeIndices[edgeIndex * 2 + 1] ?? 0;
1339
+ const sourceOutline = input.nodeOutlines[sourceIndex] ?? {};
1340
+ const targetOutline = input.nodeOutlines[targetIndex] ?? {};
1341
+ const trimmed = trimEdgeEndpoints({
1342
+ attach,
1343
+ clearance,
1344
+ mode: input.mode,
1345
+ source: {
1346
+ x: x1,
1347
+ y: y1,
1348
+ z: z1
1349
+ },
1350
+ target: {
1351
+ x: x2,
1352
+ y: y2,
1353
+ z: z2
1354
+ },
1355
+ sourceOutline,
1356
+ targetOutline
1357
+ });
1358
+ x1 = trimmed.source.x;
1359
+ y1 = trimmed.source.y;
1360
+ z1 = trimmed.source.z ?? 0;
1361
+ x2 = trimmed.target.x;
1362
+ y2 = trimmed.target.y;
1363
+ z2 = trimmed.target.z ?? 0;
1364
+ }
1365
+ const { color, opacity } = resolveEdgePaint(edgeIndex, visual, input.defaults, input.tier, input.edgeStates);
1366
+ const width = input.tier === "overview" ? input.defaults.width : visual.width ?? input.defaults.width;
1367
+ const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
1368
+ const points = sampleEdgePath({
1369
+ path: visual.path,
1370
+ controlPoints: visual.controlPoints,
1371
+ tier: input.tier,
1372
+ x1,
1373
+ y1,
1374
+ z1,
1375
+ x2,
1376
+ y2,
1377
+ z2
1378
+ });
1379
+ const segmentSpans = Math.max(0, points.length - 1);
1380
+ if (input.maxSegments !== void 0 && segments.length + segmentSpans > input.maxSegments) {
1381
+ truncated = true;
1382
+ break;
1383
+ }
1384
+ for (let index = 0; index < segmentSpans; index += 1) {
1385
+ const from = points[index];
1386
+ const to = points[index + 1];
1387
+ if (!from || !to) continue;
1388
+ segments.push({
1389
+ edgeIndex,
1390
+ x1: from.x,
1391
+ y1: from.y,
1392
+ z1: from.z ?? 0,
1393
+ x2: to.x,
1394
+ y2: to.y,
1395
+ z2: to.z ?? 0,
1396
+ color,
1397
+ width,
1398
+ opacity,
1399
+ style
1400
+ });
1401
+ }
1402
+ if (input.tier === "overview") continue;
1403
+ if ((visual.marker ?? "none") === "none") continue;
1404
+ const markerSize = (visual.markerSize ?? 1) * width;
1405
+ const ends = visual.markerEnd ?? "target";
1406
+ const first = points[0];
1407
+ const second = points[1] ?? points[0];
1408
+ const last = points.at(-1);
1409
+ const previous = points.at(-2) ?? last;
1410
+ if (!first || !second || !last || !previous) continue;
1411
+ const add = (end) => {
1412
+ const fromSource = end === "source";
1413
+ markers.push({
1414
+ edgeIndex,
1415
+ end,
1416
+ x: fromSource ? first.x : last.x,
1417
+ y: fromSource ? first.y : last.y,
1418
+ z: fromSource ? first.z ?? 0 : last.z ?? 0,
1419
+ dx: fromSource ? first.x - second.x : last.x - previous.x,
1420
+ dy: fromSource ? first.y - second.y : last.y - previous.y,
1421
+ dz: fromSource ? (first.z ?? 0) - (second.z ?? 0) : (last.z ?? 0) - (previous.z ?? 0),
1422
+ color,
1423
+ size: markerSize,
1424
+ opacity
1425
+ });
1426
+ };
1427
+ if (ends === "source" || ends === "both") add("source");
1428
+ if (ends === "target" || ends === "both") add("target");
1429
+ }
1430
+ return {
1431
+ segments,
1432
+ markers,
1433
+ truncated
1434
+ };
1435
+ }
1436
+ /** Map existing diagnostics lodLevel into the edge visual tier. */
1437
+ function edgeTierFromDiagnosticsLod(lodLevel) {
1438
+ return lodLevel === "detail" ? "detail" : "overview";
1439
+ }
1440
+ //#endregion
1441
+ //#region src/edge-picking.ts
1442
+ /** Squared distance from a point to the closest point on a finite segment. */
1443
+ function distanceSquaredToSegment2d(px, py, x1, y1, x2, y2) {
1444
+ const dx = x2 - x1;
1445
+ const dy = y2 - y1;
1446
+ const lengthSquared = dx * dx + dy * dy;
1447
+ if (lengthSquared === 0) {
1448
+ const ox = px - x1;
1449
+ const oy = py - y1;
1450
+ return ox * ox + oy * oy;
1451
+ }
1452
+ const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared));
1453
+ const closestX = x1 + t * dx;
1454
+ const closestY = y1 + t * dy;
1455
+ const ox = px - closestX;
1456
+ const oy = py - closestY;
1457
+ return ox * ox + oy * oy;
1458
+ }
1459
+ /**
1460
+ * Returns the edge index of the closest segment within its hit slop, or null.
1461
+ * When several edges qualify, the nearest wins; ties keep the first encountered edge index.
1462
+ */
1463
+ function pickClosestEdgeIndex(point, segments) {
1464
+ let nearestEdgeIndex = null;
1465
+ let nearestDistance = Number.POSITIVE_INFINITY;
1466
+ for (const segment of segments) {
1467
+ const distanceSquared = distanceSquaredToSegment2d(point.x, point.y, segment.x1, segment.y1, segment.x2, segment.y2);
1468
+ const slop = segment.hitSlop;
1469
+ if (distanceSquared > slop * slop) continue;
1470
+ if (distanceSquared < nearestDistance) {
1471
+ nearestDistance = distanceSquared;
1472
+ nearestEdgeIndex = segment.edgeIndex;
1473
+ }
1474
+ }
1475
+ return nearestEdgeIndex;
1476
+ }
1477
+ //#endregion
1478
+ //#region src/shader-output.ts
1479
+ /**
1480
+ * Encodes `gl_FragColor.rgb` from the linear working space into the renderer's output color space:
1481
+ * sRGB on the canvas (so a `#8a9096` theme color draws as `#8a9096`), unchanged on linear render
1482
+ * targets. Alpha is left untouched, so anti-aliased coverage and blending are unaffected.
1483
+ *
1484
+ * Append once to every display fragment shader, after its final `gl_FragColor` assignment.
1485
+ * Requires a `ShaderMaterial` (not `RawShaderMaterial`), whose prefix defines `linearToOutputTexel`.
1486
+ */
1487
+ const OUTPUT_COLOR_SPACE_FRAGMENT = "#include <colorspace_fragment>";
1488
+ //#endregion
1489
+ //#region src/edge-rendering.ts
1490
+ const vertexShader$2 = `
1491
+ attribute float instanceKind;
1492
+ attribute vec3 instanceEndA;
1493
+ attribute vec3 instanceEndB;
1494
+ attribute vec4 instanceColor;
1495
+ attribute float instanceWidth;
1496
+ attribute float instanceStyle;
1497
+
1498
+ varying vec4 edgeColor;
1499
+ varying float edgeKind;
1500
+ varying float edgeStyle;
1501
+ varying float edgeDashT;
1502
+ varying vec2 edgeUv;
1503
+
1504
+ void main() {
1505
+ edgeColor = instanceColor;
1506
+ edgeKind = instanceKind;
1507
+ edgeStyle = instanceStyle;
1508
+ edgeUv = uv;
1509
+ edgeDashT = 0.0;
1510
+
1511
+ vec4 viewA = modelViewMatrix * vec4(instanceEndA, 1.0);
1512
+ vec4 viewB = modelViewMatrix * vec4(instanceEndB, 1.0);
1513
+ vec4 viewPosition;
1514
+
1515
+ if (instanceKind < 0.5) {
1516
+ // Segment: stretch the unit quad from EndA to EndB in view space.
1517
+ vec2 delta = viewB.xy - viewA.xy;
1518
+ float len = length(delta);
1519
+ vec2 dir = len > 0.0001 ? delta / len : vec2(1.0, 0.0);
1520
+ vec2 perp = vec2(-dir.y, dir.x);
1521
+ vec2 mid = (viewA.xy + viewB.xy) * 0.5;
1522
+ vec2 offset = dir * position.x * len + perp * position.y * instanceWidth;
1523
+ float depth = mix(viewA.z, viewB.z, position.x + 0.5);
1524
+ viewPosition = vec4(mid + offset, depth, 1.0);
1525
+ edgeDashT = (position.x + 0.5) * len / max(instanceWidth * 4.0, 0.0001);
1526
+ } else {
1527
+ // Marker: oriented triangle with its apex pinned to EndA, pointing
1528
+ // along EndA -> EndB (the direction of travel), base trailing behind.
1529
+ vec2 dirVec = viewB.xy - viewA.xy;
1530
+ float dirLen = length(dirVec);
1531
+ vec2 dir = dirLen > 0.0001 ? dirVec / dirLen : vec2(1.0, 0.0);
1532
+ vec2 perp = vec2(-dir.y, dir.x);
1533
+ float t = position.x + 0.5;
1534
+ vec2 offset = dir * instanceWidth * (t - 1.0) + perp * position.y * instanceWidth;
1535
+ viewPosition = vec4(viewA.xy + offset, viewA.z, 1.0);
1536
+ }
1537
+
1538
+ gl_Position = projectionMatrix * viewPosition;
1539
+ }
1540
+ `;
1541
+ const fragmentShader$2 = `
1542
+ varying vec4 edgeColor;
1543
+ varying float edgeKind;
1544
+ varying float edgeStyle;
1545
+ varying float edgeDashT;
1546
+ varying vec2 edgeUv;
1547
+
1548
+ void main() {
1549
+ if (edgeKind < 0.5) {
1550
+ if (edgeStyle > 0.5) {
1551
+ float cell = fract(edgeDashT);
1552
+ if (edgeStyle < 1.5) {
1553
+ if (cell > 0.6) discard; // dashed
1554
+ } else {
1555
+ if (abs(cell - 0.5) > 0.15) discard; // dotted
1556
+ }
1557
+ }
1558
+ } else {
1559
+ // Triangle SDF: apex at edgeUv.x == 1, full width base at edgeUv.x == 0.
1560
+ float halfWidth = (1.0 - edgeUv.x) * 0.5;
1561
+ if (abs(edgeUv.y - 0.5) > halfWidth) discard;
1562
+ }
1563
+ if (edgeColor.a <= 0.0) discard;
1564
+ gl_FragColor = edgeColor;
1565
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1566
+ }
1567
+ `;
1568
+ function createEdgeGeometry(capacity) {
1569
+ const geometry = new PlaneGeometry(1, 1);
1570
+ geometry.setAttribute("instanceKind", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1571
+ geometry.setAttribute("instanceEndA", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
1572
+ geometry.setAttribute("instanceEndB", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
1573
+ geometry.setAttribute("instanceColor", new InstancedBufferAttribute(new Float32Array(capacity * 4), 4));
1574
+ geometry.setAttribute("instanceWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1575
+ geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1576
+ return geometry;
1577
+ }
1578
+ function createEdgeMaterial() {
1579
+ return new ShaderMaterial({
1580
+ depthTest: false,
1581
+ depthWrite: false,
1582
+ fragmentShader: fragmentShader$2,
1583
+ transparent: true,
1584
+ vertexShader: vertexShader$2
1585
+ });
1586
+ }
1587
+ function getInstancedAttribute(geometry, name) {
1588
+ const attribute = geometry.getAttribute(name);
1589
+ if (!(attribute instanceof InstancedBufferAttribute)) throw new Error(`edge geometry is missing the "${name}" instanced attribute`);
1590
+ return attribute;
1591
+ }
1592
+ function writeEdgeSegmentInstance(geometry, slot, segment) {
1593
+ const kind = getInstancedAttribute(geometry, "instanceKind");
1594
+ const endA = getInstancedAttribute(geometry, "instanceEndA");
1595
+ const endB = getInstancedAttribute(geometry, "instanceEndB");
1596
+ const color = getInstancedAttribute(geometry, "instanceColor");
1597
+ const width = getInstancedAttribute(geometry, "instanceWidth");
1598
+ const style = getInstancedAttribute(geometry, "instanceStyle");
1599
+ kind.setX(slot, 0);
1600
+ endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
1601
+ endB.setXYZ(slot, segment.x2, segment.y2, segment.z2);
1602
+ writeEdgeColorSlots(color, slot, 1, segment);
1603
+ width.setX(slot, segment.width);
1604
+ style.setX(slot, encodeEdgeStyle(segment.style));
1605
+ }
1606
+ function writeEdgeMarkerInstance(geometry, slot, marker) {
1607
+ const kind = getInstancedAttribute(geometry, "instanceKind");
1608
+ const endA = getInstancedAttribute(geometry, "instanceEndA");
1609
+ const endB = getInstancedAttribute(geometry, "instanceEndB");
1610
+ const color = getInstancedAttribute(geometry, "instanceColor");
1611
+ const width = getInstancedAttribute(geometry, "instanceWidth");
1612
+ const style = getInstancedAttribute(geometry, "instanceStyle");
1613
+ kind.setX(slot, 1);
1614
+ endA.setXYZ(slot, marker.x, marker.y, marker.z);
1615
+ endB.setXYZ(slot, marker.x + marker.dx, marker.y + marker.dy, marker.z + marker.dz);
1616
+ writeEdgeColorSlots(color, slot, 1, marker);
1617
+ width.setX(slot, marker.size);
1618
+ style.setX(slot, 0);
1619
+ }
1620
+ /** Writes one color and opacity into `count` consecutive instance slots starting at `start`. */
1621
+ function writeEdgeColorSlots(color, start, count, paint) {
1622
+ const rgb = new Color(paint.color);
1623
+ for (let slot = start; slot < start + count; slot += 1) color.setXYZW(slot, rgb.r, rgb.g, rgb.b, paint.opacity);
1624
+ }
1625
+ //#endregion
1626
+ //#region src/edge-viewport-patch.ts
1627
+ const EDGE_INSTANCE_ATTRIBUTES$1 = [
1628
+ "instanceKind",
1629
+ "instanceEndA",
1630
+ "instanceEndB",
1631
+ "instanceColor",
1632
+ "instanceWidth",
1633
+ "instanceStyle"
1634
+ ];
1635
+ /** Records GPU slot ranges for each visible edge after a full viewport materialize. */
1636
+ function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
1637
+ const layouts = /* @__PURE__ */ new Map();
1638
+ for (const [slot, segment] of segments.entries()) {
1639
+ const layout = layouts.get(segment.edgeIndex) ?? {
1640
+ markerCount: 0,
1641
+ markerStart: segmentInstanceCount,
1642
+ segmentCount: 0,
1643
+ segmentStart: slot
1644
+ };
1645
+ if (layout.segmentCount === 0) layout.segmentStart = slot;
1646
+ layout.segmentCount += 1;
1647
+ layouts.set(segment.edgeIndex, layout);
1648
+ }
1649
+ for (const [offset, marker] of markers.entries()) {
1650
+ const slot = segmentInstanceCount + offset;
1651
+ const layout = layouts.get(marker.edgeIndex) ?? {
1652
+ markerCount: 0,
1653
+ markerStart: slot,
1654
+ segmentCount: 0,
1655
+ segmentStart: -1
1656
+ };
1657
+ if (layout.markerCount === 0) layout.markerStart = slot;
1658
+ layout.markerCount += 1;
1659
+ layouts.set(marker.edgeIndex, layout);
1660
+ }
1661
+ return layouts;
1662
+ }
1663
+ /**
1664
+ * Rewrites GPU edge instances for moved endpoints without repacking the whole viewport.
1665
+ * Returns ok=false when any visible edge would change segment or marker counts.
1666
+ */
1667
+ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1668
+ const nextPickable = [...pickableSegments];
1669
+ let dirtySlotStart = Number.POSITIVE_INFINITY;
1670
+ let dirtySlotEnd = -1;
1671
+ const seen = /* @__PURE__ */ new Set();
1672
+ for (const edgeIndex of input.changedEdgeIndices) {
1673
+ if (seen.has(edgeIndex)) continue;
1674
+ seen.add(edgeIndex);
1675
+ const layout = input.layouts.get(edgeIndex);
1676
+ if (!layout || layout.segmentCount === 0 || layout.segmentStart < 0) continue;
1677
+ const packed = packEdgeInstances({
1678
+ edgeIndices: [edgeIndex],
1679
+ edgeVisuals: input.edgeVisuals,
1680
+ endpointPositions: input.endpointPositions,
1681
+ defaults: input.defaults,
1682
+ tier: input.tier,
1683
+ endpointAttach: input.endpointAttach,
1684
+ edgeNodeIndices: input.edgeNodeIndices,
1685
+ edgeStates: input.edgeStates,
1686
+ mode: input.mode,
1687
+ nodeOutlines: input.nodeOutlines
1688
+ });
1689
+ if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
1690
+ dirtySlotCount: 0,
1691
+ dirtySlotStart: 0,
1692
+ ok: false,
1693
+ pickableSegments: nextPickable
1694
+ };
1695
+ for (const [offset, segment] of packed.segments.entries()) {
1696
+ const slot = layout.segmentStart + offset;
1697
+ writeEdgeSegmentInstance(geometry, slot, segment);
1698
+ nextPickable[slot] = {
1699
+ edgeIndex,
1700
+ hitSlop: Math.max(input.minHitSlop, segment.width * input.worldPerPixel / 2 + 2 * input.worldPerPixel),
1701
+ x1: segment.x1,
1702
+ x2: segment.x2,
1703
+ y1: segment.y1,
1704
+ y2: segment.y2
1705
+ };
1706
+ dirtySlotStart = Math.min(dirtySlotStart, slot);
1707
+ dirtySlotEnd = Math.max(dirtySlotEnd, slot);
1708
+ }
1709
+ for (const [offset, marker] of packed.markers.entries()) {
1710
+ const slot = layout.markerStart + offset;
1711
+ writeEdgeMarkerInstance(geometry, slot, marker);
1712
+ dirtySlotStart = Math.min(dirtySlotStart, slot);
1713
+ dirtySlotEnd = Math.max(dirtySlotEnd, slot);
1714
+ }
1715
+ }
1716
+ if (dirtySlotEnd < dirtySlotStart) return {
1717
+ dirtySlotCount: 0,
1718
+ dirtySlotStart: 0,
1719
+ ok: true,
1720
+ pickableSegments: nextPickable
1721
+ };
1722
+ for (const name of EDGE_INSTANCE_ATTRIBUTES$1) {
1723
+ const attribute = geometry.getAttribute(name);
1724
+ attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
1725
+ attribute.needsUpdate = true;
1726
+ }
1727
+ return {
1728
+ dirtySlotCount: dirtySlotEnd - dirtySlotStart + 1,
1729
+ dirtySlotStart,
1730
+ ok: true,
1731
+ pickableSegments: nextPickable
1732
+ };
1733
+ }
1734
+ /**
1735
+ * Repaints the color and opacity of visible edges whose host state changed. Slot layout,
1736
+ * geometry, and picking stay untouched, so only the changed `instanceColor` ranges upload.
1737
+ * Edges without visible slots are skipped; the next materialize paints them from the same state.
1738
+ */
1739
+ function patchVisibleEdgePaint(geometry, input) {
1740
+ const visible = [...input.changedEdgeIndices].flatMap((edgeIndex) => {
1741
+ const layout = input.layouts.get(edgeIndex);
1742
+ return layout ? [{
1743
+ edgeIndex,
1744
+ layout
1745
+ }] : [];
1746
+ });
1747
+ if (!visible.every(({ layout }) => layout.segmentStart + layout.segmentCount <= input.instanceCount && layout.markerStart + layout.markerCount <= input.instanceCount)) return {
1748
+ ok: false,
1749
+ patchedSlots: 0
1750
+ };
1751
+ const color = getInstancedAttribute(geometry, "instanceColor");
1752
+ let patchedSlots = 0;
1753
+ for (const { edgeIndex, layout } of visible) {
1754
+ const paint = resolveEdgePaint(edgeIndex, input.edgeVisuals[edgeIndex] ?? {}, input.defaults, input.tier, input.edgeStates);
1755
+ for (const [start, count] of [[layout.segmentStart, layout.segmentCount], [layout.markerStart, layout.markerCount]]) {
1756
+ if (count === 0) continue;
1757
+ writeEdgeColorSlots(color, start, count, paint);
1758
+ color.addUpdateRange(start * color.itemSize, count * color.itemSize);
1759
+ patchedSlots += count;
1760
+ }
1761
+ }
1762
+ if (patchedSlots > 0) color.needsUpdate = true;
1763
+ return {
1764
+ ok: true,
1765
+ patchedSlots
1766
+ };
1767
+ }
1768
+ //#endregion
1769
+ //#region src/fit-3d.ts
1770
+ /** Newton steps on a piecewise-linear function before falling back to the plane-space centre. */
1771
+ const MAX_CENTERING_STEPS = 32;
1772
+ /**
1773
+ * The closest front-view camera that frames every node. Throws without nodes. `maxCenteringSteps`
1774
+ * exists so tests can exercise the fallback.
1775
+ */
1776
+ function fitPerspective(frame, nodes, maxCenteringSteps = MAX_CENTERING_STEPS) {
1777
+ if (nodes.count === 0) throw new Error("fitPerspective needs at least one node");
1778
+ const tanY = Math.tan(frame.fovDegrees * Math.PI / 360) * frame.fill;
1779
+ const tanX = tanY * frame.aspect;
1780
+ const node = {
1781
+ halfHeight: 0,
1782
+ halfWidth: 0,
1783
+ x: 0,
1784
+ y: 0,
1785
+ z: 0
1786
+ };
1787
+ let right = Number.NEGATIVE_INFINITY;
1788
+ let left = Number.NEGATIVE_INFINITY;
1789
+ let top = Number.NEGATIVE_INFINITY;
1790
+ let bottom = Number.NEGATIVE_INFINITY;
1791
+ let minZ = Number.POSITIVE_INFINITY;
1792
+ let maxZ = Number.NEGATIVE_INFINITY;
1793
+ for (let index = 0; index < nodes.count; index += 1) {
1794
+ nodes.read(index, node);
1795
+ right = Math.max(right, node.x + node.halfWidth + tanX * node.z);
1796
+ left = Math.max(left, -node.x + node.halfWidth + tanX * node.z);
1797
+ top = Math.max(top, node.y + node.halfHeight + tanY * node.z);
1798
+ bottom = Math.max(bottom, -node.y + node.halfHeight + tanY * node.z);
1799
+ minZ = Math.min(minZ, node.z);
1800
+ maxZ = Math.max(maxZ, node.z);
1801
+ }
1802
+ const cameraZX = (right + left) / (2 * tanX);
1803
+ const cameraZY = (top + bottom) / (2 * tanY);
1804
+ const cameraZ = Math.max(cameraZX, cameraZY);
1805
+ let x = (right - left) / 2;
1806
+ let y = (top - bottom) / 2;
1807
+ if (cameraZX < cameraZY) x = centerOnScreen(nodes, node, "x", cameraZ, x, maxCenteringSteps);
1808
+ else if (cameraZY < cameraZX) y = centerOnScreen(nodes, node, "y", cameraZ, y, maxCenteringSteps);
1809
+ const target = {
1810
+ x,
1811
+ y,
1812
+ z: (minZ + maxZ) / 2
1813
+ };
1814
+ const distance = cameraZ - target.z;
1815
+ const nearestDepth = cameraZ - maxZ;
1816
+ return {
1817
+ cameraZ,
1818
+ near: Math.min(Math.max(distance / 1e4, .1), nearestDepth / 2),
1819
+ far: distance * 10,
1820
+ target
1821
+ };
1822
+ }
1823
+ /**
1824
+ * Centre on the looser axis that makes the farthest screen reach equal on both sides.
1825
+ *
1826
+ * With depth `d = cameraZ - z`, a centre `c` keeps every node within screen reach `r` when
1827
+ * `max(p + h - r * d) <= c <= min(p - h + r * d)`. The gap between those bounds is convex and
1828
+ * decreasing in `r`, so Newton steps from `r = 0` rise monotonically to the smallest feasible
1829
+ * reach. `planeCenter` already fits, so it is the answer if the steps do not settle.
1830
+ */
1831
+ function centerOnScreen(nodes, node, axis, cameraZ, planeCenter, maxSteps) {
1832
+ let reach = 0;
1833
+ for (let step = 0; step < maxSteps; step += 1) {
1834
+ let low = Number.NEGATIVE_INFINITY;
1835
+ let high = Number.POSITIVE_INFINITY;
1836
+ let lowValue = 0;
1837
+ let lowDepth = 0;
1838
+ let highValue = 0;
1839
+ let highDepth = 0;
1840
+ for (let index = 0; index < nodes.count; index += 1) {
1841
+ nodes.read(index, node);
1842
+ const position = axis === "x" ? node.x : node.y;
1843
+ const half = axis === "x" ? node.halfWidth : node.halfHeight;
1844
+ const depth = cameraZ - node.z;
1845
+ const lowBound = position + half - reach * depth;
1846
+ const highBound = position - half + reach * depth;
1847
+ if (lowBound > low) {
1848
+ low = lowBound;
1849
+ lowValue = position + half;
1850
+ lowDepth = depth;
1851
+ }
1852
+ if (highBound < high) {
1853
+ high = highBound;
1854
+ highValue = position - half;
1855
+ highDepth = depth;
1856
+ }
1857
+ }
1858
+ if (low <= high) return (low + high) / 2;
1859
+ const next = (lowValue - highValue) / (lowDepth + highDepth);
1860
+ if (next <= reach) return (low + high) / 2;
1861
+ reach = next;
1862
+ }
1863
+ return planeCenter;
1864
+ }
1865
+ //#endregion
1866
+ //#region src/instance-color-ranges.ts
1867
+ /**
1868
+ * Marks RGB update ranges for visible instance slots.
1869
+ * Keeps earlier ranges that have not been uploaded yet (a viewport materialize in the same frame);
1870
+ * three.js merges and clears ranges on upload.
1871
+ * Returns how many slots were marked.
1872
+ */
1873
+ function markInstanceColorSlots(attribute, slots) {
1874
+ let count = 0;
1875
+ for (const slot of slots) {
1876
+ if (!Number.isSafeInteger(slot) || slot < 0) continue;
1877
+ attribute.addUpdateRange(slot * 3, 3);
1878
+ count += 1;
1879
+ }
1880
+ if (count > 0) attribute.needsUpdate = true;
1881
+ return count;
1882
+ }
1883
+ //#endregion
1884
+ //#region src/layout-positions.ts
1885
+ /** Validates worker-friendly XYZ layout buffers before positions reach the renderer. */
1886
+ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1887
+ if (layout.positions.length !== layout.nodeIds.length * 3) throw new Error("Layout positions must contain one XYZ triplet per node ID");
1888
+ const seen = /* @__PURE__ */ new Set();
1889
+ return layout.nodeIds.map((id, index) => {
1890
+ if (seen.has(id)) throw new Error(`Duplicate layout node ID: "${id}"`);
1891
+ seen.add(id);
1892
+ const nodeIndex = nodeIndices.get(id);
1893
+ if (nodeIndex === void 0) throw new Error(`Cannot position missing node: "${id}"`);
1894
+ const current = nodes[nodeIndex];
1895
+ if (!current) throw new Error(`Cannot position missing node: "${id}"`);
1896
+ const offset = index * 3;
1897
+ const position = {
1898
+ x: layout.positions[offset] ?? NaN,
1899
+ y: layout.positions[offset + 1] ?? NaN,
1900
+ z: layout.positions[offset + 2] ?? NaN
1901
+ };
1902
+ for (const [axis, value] of Object.entries(position)) if (!Number.isFinite(value)) throw new Error(`Layout node "${id}" has a non-finite ${axis} position`);
1903
+ return {
1904
+ colorChanged: false,
1905
+ glowChanged: false,
1906
+ index: nodeIndex,
1907
+ next: {
1908
+ ...current,
1909
+ position
1910
+ },
1911
+ positionChanged: true,
1912
+ shapeChanged: false,
1913
+ sizeChanged: false,
1914
+ strokeChanged: false
1915
+ };
1916
+ });
1917
+ }
1918
+ const vertexShader$1 = `
1919
+ attribute float instanceGlowOpacity;
1920
+ varying vec3 glowColor;
1921
+ varying vec2 glowPoint;
1922
+ varying float glowOpacity;
1923
+
1924
+ void main() {
1925
+ glowColor = instanceColor;
1926
+ glowPoint = position.xy;
1927
+ glowOpacity = instanceGlowOpacity;
1928
+ vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1929
+ center.xy += position.xy * length(instanceMatrix[0].xyz);
1930
+ gl_Position = projectionMatrix * center;
1931
+ }
1932
+ `;
1933
+ const fragmentShader$1 = `
1934
+ uniform float opacityScale;
1935
+ varying vec3 glowColor;
1936
+ varying vec2 glowPoint;
1937
+ varying float glowOpacity;
1938
+
1939
+ void main() {
1940
+ float distanceFromCenter = length(glowPoint);
1941
+ if (distanceFromCenter >= 1.0) discard;
1942
+ float falloff = 1.0 - distanceFromCenter;
1943
+ gl_FragColor = vec4(glowColor, falloff * falloff * glowOpacity * opacityScale);
1944
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1945
+ }
1946
+ `;
1947
+ function applyBlendMode(material, mode) {
1948
+ material.blending = mode === "normal" ? NormalBlending : AdditiveBlending;
1949
+ const opacityScale = material.uniforms.opacityScale;
1950
+ if (opacityScale) opacityScale.value = glowOpacityScale(mode);
1951
+ material.needsUpdate = true;
1952
+ }
1953
+ function createNodeGlowMaterial(mode) {
1954
+ const material = new ShaderMaterial({
1955
+ depthTest: false,
1956
+ depthWrite: false,
1957
+ fragmentShader: fragmentShader$1,
1958
+ transparent: true,
1959
+ uniforms: { opacityScale: { value: 1 } },
1960
+ vertexShader: vertexShader$1
1961
+ });
1962
+ applyBlendMode(material, mode);
1963
+ return material;
1964
+ }
1965
+ function createGlowMesh(capacity, mode) {
1966
+ const geometry = new PlaneGeometry(2, 2);
1967
+ geometry.setAttribute("instanceGlowOpacity", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1968
+ const mesh = new InstancedMesh(geometry, createNodeGlowMaterial(mode), capacity);
1969
+ mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
1970
+ mesh.name = "graphraum-node-glow";
1971
+ mesh.frustumCulled = false;
1972
+ mesh.renderOrder = -2;
1973
+ return mesh;
1974
+ }
1975
+ /**
1976
+ * Opt-in batched halo pass: one additive instanced mesh, created lazily on the first glowing
1977
+ * node and hidden while no halo is selected, so graphs without glow keep two draw calls.
1978
+ */
1979
+ var NodeGlowLayer = class {
1980
+ scene;
1981
+ selection = createGlowSelection();
1982
+ target = {
1983
+ color: new Color(),
1984
+ extent: 0,
1985
+ glow: 0,
1986
+ x: 0,
1987
+ y: 0,
1988
+ z: 0
1989
+ };
1990
+ matrix = new Matrix4();
1991
+ mesh = null;
1992
+ blendMode = "additive";
1993
+ constructor(scene) {
1994
+ this.scene = scene;
1995
+ }
1996
+ /** Additive on dark or unknown backgrounds, normal on light solid ones; see `glowBlendModeFor`. */
1997
+ setBlending(mode) {
1998
+ if (mode === this.blendMode) return;
1999
+ this.blendMode = mode;
2000
+ if (this.mesh) applyBlendMode(this.mesh.material, mode);
2001
+ }
2002
+ /** Halo instances in the current frame. */
2003
+ get count() {
2004
+ return this.mesh?.visible ? this.mesh.count : 0;
2005
+ }
2006
+ /** Reselects glowing visible nodes and rewrites every halo instance. */
2007
+ sync(visibleNodeIndices, glowAt, densityLod, describe) {
2008
+ selectGlowNodes(visibleNodeIndices, glowAt, densityLod, this.selection);
2009
+ if (this.selection.count > 0) this.ensureCapacity(this.selection.count);
2010
+ this.rewrite(describe);
2011
+ }
2012
+ /** Rewrites positions, radii, opacities, and colors of the current selection in place. */
2013
+ rewrite(describe) {
2014
+ const mesh = this.mesh;
2015
+ if (!mesh) return;
2016
+ const opacity = mesh.geometry.getAttribute("instanceGlowOpacity");
2017
+ let slot = 0;
2018
+ for (let index = 0; index < this.selection.count; index += 1) {
2019
+ const nodeIndex = this.selection.indices[index];
2020
+ if (nodeIndex === void 0 || !describe(nodeIndex, this.target)) continue;
2021
+ const { color, extent, glow, x, y, z } = this.target;
2022
+ const radius = glowHaloRadius(extent, glow);
2023
+ this.matrix.makeScale(radius, radius, radius).setPosition(x, y, z);
2024
+ mesh.setMatrixAt(slot, this.matrix);
2025
+ mesh.setColorAt(slot, color);
2026
+ opacity.setX(slot, glowHaloOpacity(glow));
2027
+ slot += 1;
2028
+ }
2029
+ mesh.count = slot;
2030
+ mesh.visible = slot > 0;
2031
+ mesh.instanceMatrix.needsUpdate = true;
2032
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
2033
+ opacity.needsUpdate = true;
2034
+ }
2035
+ /** Releases the GPU mesh; the layer stays reusable and recreates it on the next glowing node. */
2036
+ dispose() {
2037
+ this.selection.count = 0;
2038
+ this.releaseMesh();
2039
+ }
2040
+ releaseMesh() {
2041
+ if (!this.mesh) return;
2042
+ this.scene.remove(this.mesh);
2043
+ this.mesh.geometry.dispose();
2044
+ this.mesh.material.dispose();
2045
+ this.mesh.dispose();
2046
+ this.mesh = null;
2047
+ }
2048
+ ensureCapacity(required) {
2049
+ const current = this.mesh?.instanceMatrix.count ?? 0;
2050
+ const capacity = glowBufferCapacity(required, current, 256);
2051
+ if (this.mesh && capacity === current) return;
2052
+ this.releaseMesh();
2053
+ this.mesh = createGlowMesh(capacity, this.blendMode);
2054
+ this.scene.add(this.mesh);
2055
+ }
2056
+ };
2057
+ //#endregion
2058
+ //#region src/node-rendering.ts
2059
+ const vertexShader = `
2060
+ attribute float instanceShape;
2061
+ attribute float instanceStrokeWidth;
2062
+ attribute vec3 instanceStrokeColor;
2063
+ varying vec3 nodeColor;
2064
+ varying vec2 nodePoint;
2065
+ varying float nodeShape;
2066
+ varying float nodeStrokeWidth;
2067
+ varying vec3 nodeStrokeColor;
2068
+
2069
+ void main() {
2070
+ nodeColor = instanceColor;
2071
+ nodePoint = position.xy;
2072
+ nodeShape = instanceShape;
2073
+ nodeStrokeWidth = instanceStrokeWidth;
2074
+ nodeStrokeColor = instanceStrokeColor;
2075
+
2076
+ vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2077
+ float scaleX = length(instanceMatrix[0].xyz);
2078
+ float scaleY = length(instanceMatrix[1].xyz);
2079
+ center.xy += position.xy * vec2(scaleX, scaleY);
2080
+ gl_Position = projectionMatrix * center;
2081
+ }
2082
+ `;
2083
+ const fragmentShader = `
2084
+ varying vec3 nodeColor;
2085
+ varying vec2 nodePoint;
2086
+ varying float nodeShape;
2087
+ varying float nodeStrokeWidth;
2088
+ varying vec3 nodeStrokeColor;
2089
+
2090
+ // Apothem of a regular hexagon whose pointy left/right vertices reach x = ±1 (matches node-shapes.ts).
2091
+ const float HEXAGON_APOTHEM = 0.8660254037844386;
2092
+ // Y of the upward triangle's flat base, apex at (0, 1) (matches node-shapes.ts).
2093
+ const float TRIANGLE_BASE_Y = -0.7320508075688772;
2094
+ // Half-height of the horizontal pill's rounded caps, in unit space (matches node-shapes.ts).
2095
+ const float PILL_CAP_RADIUS = 0.5;
2096
+ // Half-length of the pill's straight segment, in unit space (matches node-shapes.ts).
2097
+ const float PILL_HALF_LENGTH = 0.5;
2098
+ // Fraction of the rounded rectangle's half-extent used as the corner radius (matches node-shapes.ts).
2099
+ const float ROUNDED_CORNER_RADIUS = 0.25;
2100
+
2101
+ float hexagonDistance(vec2 p) {
2102
+ vec2 a = abs(p);
2103
+ return HEXAGON_APOTHEM - max(a.y, a.x * HEXAGON_APOTHEM + a.y * 0.5);
2104
+ }
2105
+
2106
+ // Signed distance to an upward-pointing equilateral triangle, positive inside.
2107
+ float triangleDistance(vec2 p) {
2108
+ vec2 apex = vec2(0.0, 1.0);
2109
+ vec2 right = vec2(1.0, TRIANGLE_BASE_Y);
2110
+ vec2 left = vec2(-1.0, TRIANGLE_BASE_Y);
2111
+ vec2 e0 = right - apex;
2112
+ vec2 e1 = left - right;
2113
+ vec2 e2 = apex - left;
2114
+ vec2 v0 = p - apex;
2115
+ vec2 v1 = p - right;
2116
+ vec2 v2 = p - left;
2117
+ vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0);
2118
+ vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0);
2119
+ vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0);
2120
+ float s = sign(e0.x * e2.y - e0.y * e2.x);
2121
+ vec2 d0 = vec2(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x));
2122
+ vec2 d1 = vec2(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x));
2123
+ vec2 d2 = vec2(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x));
2124
+ vec2 d = min(min(d0, d1), d2);
2125
+ return sqrt(d.x) * sign(d.y);
2126
+ }
2127
+
2128
+ float pillDistance(vec2 p) {
2129
+ float clampedX = clamp(p.x, -PILL_HALF_LENGTH, PILL_HALF_LENGTH);
2130
+ return PILL_CAP_RADIUS - length(vec2(p.x - clampedX, p.y));
2131
+ }
2132
+
2133
+ float roundedDistance(vec2 p) {
2134
+ vec2 outsideOffset = abs(p) - vec2(1.0 - ROUNDED_CORNER_RADIUS);
2135
+ vec2 outside = max(outsideOffset, 0.0);
2136
+ float outsideDistance = length(outside) + min(max(outsideOffset.x, outsideOffset.y), 0.0) - ROUNDED_CORNER_RADIUS;
2137
+ return -outsideDistance;
2138
+ }
2139
+
2140
+ void main() {
2141
+ float distanceToEdge;
2142
+ if (nodeShape < 0.5) {
2143
+ distanceToEdge = 1.0 - length(nodePoint);
2144
+ } else if (nodeShape < 1.5) {
2145
+ distanceToEdge = 1.0 - max(abs(nodePoint.x), abs(nodePoint.y));
2146
+ } else if (nodeShape < 2.5) {
2147
+ distanceToEdge = 1.0 - abs(nodePoint.x) - abs(nodePoint.y);
2148
+ } else if (nodeShape < 3.5) {
2149
+ distanceToEdge = hexagonDistance(nodePoint);
2150
+ } else if (nodeShape < 4.5) {
2151
+ distanceToEdge = triangleDistance(nodePoint);
2152
+ } else if (nodeShape < 5.5) {
2153
+ distanceToEdge = pillDistance(nodePoint);
2154
+ } else {
2155
+ distanceToEdge = roundedDistance(nodePoint);
2156
+ }
2157
+
2158
+ float outerAlpha = smoothstep(0.0, fwidth(distanceToEdge), distanceToEdge);
2159
+ if (outerAlpha <= 0.0) discard;
2160
+
2161
+ vec3 fillColor = nodeColor;
2162
+ if (nodeStrokeWidth > 0.0) {
2163
+ // Stroke band occupies [outer edge, outer edge - nodeStrokeWidth]; below that, the fill takes over.
2164
+ float innerEdge = distanceToEdge - nodeStrokeWidth;
2165
+ float strokeToFill = smoothstep(0.0, fwidth(innerEdge), innerEdge);
2166
+ fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
2167
+ }
2168
+ gl_FragColor = vec4(fillColor, outerAlpha);
2169
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
2170
+ }
2171
+ `;
2172
+ function createNodeGeometry(capacity) {
2173
+ const geometry = new PlaneGeometry(2, 2);
2174
+ geometry.setAttribute("instanceShape", new InstancedBufferAttribute(new Float32Array(capacity), 1));
2175
+ geometry.setAttribute("instanceStrokeWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
2176
+ geometry.setAttribute("instanceStrokeColor", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
2177
+ return geometry;
2178
+ }
2179
+ /** Allocate InstancedMesh.instanceColor before first compile so Three injects the attribute. */
2180
+ function allocateNodeInstanceColors(mesh, capacity) {
2181
+ mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
2182
+ }
2183
+ function createNodeMaterial(depthTest) {
2184
+ return new ShaderMaterial({
2185
+ depthTest,
2186
+ depthWrite: depthTest,
2187
+ fragmentShader,
2188
+ transparent: true,
2189
+ vertexShader
2190
+ });
2191
+ }
2192
+ function setNodeShapeAt(attribute, index, shape) {
2193
+ attribute.setX(index, encodeNodeShape(shape));
2194
+ }
2195
+ function setNodeStrokeAt(strokeWidthAttribute, strokeColorAttribute, index, { strokeColor, strokeWidthUv }) {
2196
+ strokeWidthAttribute.setX(index, strokeWidthUv);
2197
+ const rgb = new Color(strokeColor);
2198
+ strokeColorAttribute.setXYZ(index, rgb.r, rgb.g, rgb.b);
2199
+ }
2200
+ //#endregion
2201
+ //#region src/node-stroke.ts
2202
+ /**
2203
+ * Ceiling for the stroke ring as a fraction of the shape's half-extent, so a large
2204
+ * `strokeWidth` in world units cannot swallow the fill entirely.
2205
+ */
2206
+ const MAX_STROKE_WIDTH_UV = .45;
2207
+ /**
2208
+ * Resolves a node's stroke into shader-ready values: `strokeColor` falls back to the theme
2209
+ * default, and `strokeWidthUv` converts the world-unit `strokeWidth` into a fraction of
2210
+ * `max(width, height)` — the same unit space `containsNodePoint` and the node fragment shader
2211
+ * use — so the ring reads consistently regardless of the node's rendered size.
2212
+ */
2213
+ function resolveNodeStroke(node, theme) {
2214
+ const strokeColor = node.strokeColor ?? theme.nodeStroke;
2215
+ const strokeWidth = node.strokeWidth ?? 0;
2216
+ if (strokeWidth <= 0) return {
2217
+ strokeColor,
2218
+ strokeWidthUv: 0
2219
+ };
2220
+ const { height, width } = resolveNodeAxes({
2221
+ height: node.height,
2222
+ nodeId: node.id,
2223
+ size: node.size,
2224
+ width: node.width
2225
+ });
2226
+ return {
2227
+ strokeColor,
2228
+ strokeWidthUv: Math.min(strokeWidth / Math.max(width, height), MAX_STROKE_WIDTH_UV)
2229
+ };
2230
+ }
2231
+ //#endregion
2232
+ //#region src/node-updates.ts
2233
+ function assertValidNode(node) {
2234
+ for (const [axis, value] of [
2235
+ ["x", node.position.x],
2236
+ ["y", node.position.y],
2237
+ ["z", node.position.z ?? 0]
2238
+ ]) if (!Number.isFinite(value)) throw new Error(`Node "${node.id}" has a non-finite ${axis} position`);
2239
+ resolveNodeAxes({
2240
+ height: node.height,
2241
+ nodeId: node.id,
2242
+ size: node.size,
2243
+ width: node.width
2244
+ });
2245
+ 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`);
2246
+ if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
2247
+ assertNodeGlow(node.id, node.glow);
2248
+ }
2249
+ /** Validates a complete update batch before the renderer mutates CPU or GPU state. */
2250
+ function prepareNodeUpdates(nodes, nodeIndices, updates) {
2251
+ const seen = /* @__PURE__ */ new Set();
2252
+ return updates.map((update) => {
2253
+ if (seen.has(update.id)) throw new Error(`Duplicate node update: "${update.id}"`);
2254
+ seen.add(update.id);
2255
+ const index = nodeIndices.get(update.id);
2256
+ if (index === void 0) throw new Error(`Cannot update missing node: "${update.id}"`);
2257
+ const current = nodes[index];
2258
+ if (!current) throw new Error(`Cannot update missing node: "${update.id}"`);
2259
+ const next = {
2260
+ ...current,
2261
+ ...Object.hasOwn(update, "color") ? { color: update.color } : {},
2262
+ ...Object.hasOwn(update, "glow") ? { glow: update.glow } : {},
2263
+ ...update.position ? { position: { ...update.position } } : {},
2264
+ ...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
2265
+ ...Object.hasOwn(update, "size") ? { size: update.size } : {},
2266
+ ...Object.hasOwn(update, "width") ? { width: update.width } : {},
2267
+ ...Object.hasOwn(update, "height") ? { height: update.height } : {},
2268
+ ...Object.hasOwn(update, "strokeWidth") ? { strokeWidth: update.strokeWidth } : {},
2269
+ ...Object.hasOwn(update, "strokeColor") ? { strokeColor: update.strokeColor } : {}
2270
+ };
2271
+ assertValidNode(next);
2272
+ return {
2273
+ colorChanged: Object.hasOwn(update, "color"),
2274
+ glowChanged: Object.hasOwn(update, "glow"),
2275
+ index,
2276
+ next,
2277
+ positionChanged: update.position !== void 0,
2278
+ shapeChanged: Object.hasOwn(update, "shape"),
2279
+ sizeChanged: Object.hasOwn(update, "size") || Object.hasOwn(update, "width") || Object.hasOwn(update, "height"),
2280
+ strokeChanged: Object.hasOwn(update, "strokeWidth") || Object.hasOwn(update, "strokeColor")
2281
+ };
2282
+ });
2283
+ }
2284
+ //#endregion
2285
+ //#region src/orbit-controls-mode.ts
2286
+ /**
2287
+ * Sets the controls for a 2D or 3D view. 2D turns rotation off, so one finger and the left
2288
+ * mouse button pan instead. One finger must not stay on ROTATE: when a pinch ends with one finger
2289
+ * still down, OrbitControls (r185) re-reads `touches.ONE`, returns early for a disabled rotate and
2290
+ * keeps the two-finger state, and the next move reads the lifted finger's position and throws.
2291
+ */
2292
+ function configureControlsForMode(controls, mode) {
2293
+ controls.enableRotate = mode === "3d";
2294
+ if (mode === "2d") {
2295
+ controls.minZoom = .01;
2296
+ controls.mouseButtons.LEFT = MOUSE.PAN;
2297
+ controls.touches.ONE = TOUCH.PAN;
2298
+ }
2299
+ }
2300
+ //#endregion
2301
+ //#region src/label-budget.ts
2302
+ /** Picks visible label ids by importance within a hard DOM budget. */
2303
+ function selectBudgetedLabelIds({ candidates, maxLabels }) {
2304
+ if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("maxLabels must be a non-negative integer.");
2305
+ return candidates.filter((candidate) => candidate.visible).sort((left, right) => {
2306
+ if (right.importance !== left.importance) return right.importance - left.importance;
2307
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
2308
+ }).slice(0, maxLabels).map((candidate) => candidate.id);
2309
+ }
2310
+ /**
2311
+ * Labels for a sparse focus set: keep focus ids that are visible, then optionally fill
2312
+ * remaining budget from other visible candidates by importance. Focus order is preserved.
2313
+ */
2314
+ function selectFocusLabelIds({ focusIds, candidates, maxLabels, fill = "importance" }) {
2315
+ if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("maxLabels must be a non-negative integer.");
2316
+ const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
2317
+ const chosen = [];
2318
+ const chosenSet = /* @__PURE__ */ new Set();
2319
+ for (const id of focusIds) {
2320
+ if (chosen.length >= maxLabels) break;
2321
+ if (chosenSet.has(id)) continue;
2322
+ if (!byId.get(id)?.visible) continue;
2323
+ chosen.push(id);
2324
+ chosenSet.add(id);
2325
+ }
2326
+ if (fill === "none" || chosen.length >= maxLabels) return chosen;
2327
+ const fillers = selectBudgetedLabelIds({
2328
+ candidates: candidates.filter((candidate) => !chosenSet.has(candidate.id)),
2329
+ maxLabels: maxLabels - chosen.length
2330
+ });
2331
+ return [...chosen, ...fillers];
2332
+ }
2333
+ /** Builds an ordered focus id list: selected, then hovered, then unique neighbors. */
2334
+ function orderFocusNodeIds(input) {
2335
+ const ordered = [];
2336
+ const seen = /* @__PURE__ */ new Set();
2337
+ for (const id of [
2338
+ ...input.selectedIds,
2339
+ ...input.hoveredIds,
2340
+ ...input.neighborIds
2341
+ ]) {
2342
+ if (seen.has(id)) continue;
2343
+ seen.add(id);
2344
+ ordered.push(id);
2345
+ }
2346
+ return ordered;
2347
+ }
2348
+ //#endregion
2349
+ //#region src/overlay-budget.ts
2350
+ function uniqueIds(ids) {
2351
+ const seen = /* @__PURE__ */ new Set();
2352
+ const ordered = [];
2353
+ for (const id of ids) {
2354
+ if (seen.has(id)) continue;
2355
+ seen.add(id);
2356
+ ordered.push(id);
2357
+ }
2358
+ return ordered;
2359
+ }
2360
+ function boundOverlayIds(ids, max, kind) {
2361
+ if (!Number.isSafeInteger(max) || max < 0) throw new Error(`Overlay ${kind} cap must be a non-negative integer.`);
2362
+ const list = uniqueIds(ids);
2363
+ if (list.length > max) throw new Error(`Overlay supports at most ${max} ${kind} (requested ${list.length}).`);
2364
+ return list;
2365
+ }
2366
+ function selectRichNodeIds(input) {
2367
+ if (!Number.isSafeInteger(input.maxRichNodes) || input.maxRichNodes < 0) throw new Error("maxRichNodes must be a non-negative integer.");
2368
+ return (input.policy === "selected" ? uniqueIds(input.selectedIds) : input.policy === "hovered" ? uniqueIds([...input.hoveredIds, ...input.selectedIds]) : uniqueIds([...input.selectedIds, ...input.hoveredIds])).slice(0, input.maxRichNodes);
2369
+ }
2370
+ //#endregion
2371
+ //#region src/overlay.ts
2372
+ function assertElement(element, kind) {
2373
+ if (element !== null && !(element instanceof HTMLElement)) throw new Error(`${kind} renderer must return an HTMLElement or null`);
2374
+ }
2375
+ function resolveLabelPolicy(options) {
2376
+ if (options.labelPolicy) return options.labelPolicy;
2377
+ return options.autoLabels === true ? "auto" : "manual";
2378
+ }
2379
+ /** A bounded DOM layer for labels, a focused-node toolbar, and optional rich HTML cards. */
2380
+ var GraphraumOverlay = class {
2381
+ graph;
2382
+ container;
2383
+ options;
2384
+ root = document.createElement("div");
2385
+ labels = /* @__PURE__ */ new Map();
2386
+ richNodes = /* @__PURE__ */ new Map();
2387
+ toolbar = null;
2388
+ toolbarNodeId = null;
2389
+ stopObservingView;
2390
+ stopObservingFocus;
2391
+ previousContainerPosition;
2392
+ labelPolicy;
2393
+ focusLabelFill;
2394
+ autoToolbar;
2395
+ autoRichNodes;
2396
+ maxLabels;
2397
+ maxRichNodes;
2398
+ constructor(graph, container, options) {
2399
+ this.graph = graph;
2400
+ this.container = container;
2401
+ this.options = options;
2402
+ const maxLabels = options.maxLabels ?? 50;
2403
+ if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("Overlay maxLabels must be a non-negative integer.");
2404
+ this.maxLabels = maxLabels;
2405
+ const maxRichNodes = options.maxRichNodes ?? 24;
2406
+ if (!Number.isSafeInteger(maxRichNodes) || maxRichNodes < 0) throw new Error("Overlay maxRichNodes must be a non-negative integer.");
2407
+ this.maxRichNodes = maxRichNodes;
2408
+ this.labelPolicy = resolveLabelPolicy(options);
2409
+ this.focusLabelFill = options.focusLabelFill ?? "importance";
2410
+ this.autoToolbar = options.autoToolbar ?? false;
2411
+ this.autoRichNodes = options.autoRichNodes ?? false;
2412
+ this.root.className = `graphraum-overlay ${options.overlayClassName ?? ""}`.trim();
2413
+ this.root.style.cssText = "inset:0;pointer-events:none;position:absolute;";
2414
+ this.previousContainerPosition = getComputedStyle(this.container).position === "static" ? this.container.style.position : null;
2415
+ if (this.previousContainerPosition !== null) this.container.style.position = "relative";
2416
+ this.container.append(this.root);
2417
+ this.stopObservingView = graph.onViewChange(() => this.update());
2418
+ this.stopObservingFocus = graph.onFocusChange(() => this.update());
2419
+ }
2420
+ /**
2421
+ * Replaces the labeled node set. When `labelPolicy` is `auto` or `focus`, the next view/focus
2422
+ * change overwrites this list from the policy.
2423
+ */
2424
+ setLabels(nodeIds) {
2425
+ this.syncLabels(boundOverlayIds(nodeIds, this.maxLabels, "labels"));
2426
+ this.updatePositions();
2427
+ }
2428
+ /**
2429
+ * Replaces the rich HTML node set. When `autoRichNodes` is set, the next focus change
2430
+ * overwrites this list from the policy.
2431
+ */
2432
+ setRichNodes(nodeIds) {
2433
+ if (this.autoRichNodes) return;
2434
+ this.syncRichNodes(boundOverlayIds(nodeIds, this.maxRichNodes, "rich nodes"));
2435
+ this.updatePositions();
2436
+ }
2437
+ setToolbar(nodeId) {
2438
+ if (this.autoToolbar) return;
2439
+ this.toolbar?.element.remove();
2440
+ this.toolbar = null;
2441
+ this.toolbarNodeId = nodeId;
2442
+ if (nodeId !== null) this.toolbar = this.createEntry(nodeId, this.options.renderToolbar, "Toolbar", "auto");
2443
+ this.updatePositions();
2444
+ }
2445
+ destroy() {
2446
+ this.stopObservingView();
2447
+ this.stopObservingFocus();
2448
+ this.root.remove();
2449
+ if (this.previousContainerPosition !== null && this.container.querySelector(".graphraum-overlay") === null) this.container.style.position = this.previousContainerPosition;
2450
+ this.labels.clear();
2451
+ this.richNodes.clear();
2452
+ this.toolbar = null;
2453
+ }
2454
+ update() {
2455
+ if (this.labelPolicy === "auto") this.syncLabels(selectBudgetedLabelIds({
2456
+ candidates: this.graph.getLabelCandidates(),
2457
+ maxLabels: this.maxLabels
2458
+ }));
2459
+ else if (this.labelPolicy === "focus") {
2460
+ const selectedIds = this.graph.getSelectedNodeIds();
2461
+ const hoveredIds = this.graph.getHoveredNodeIds();
2462
+ const neighborIds = [...selectedIds, ...hoveredIds].flatMap((id) => this.graph.getNeighborIds(id));
2463
+ this.syncLabels(selectFocusLabelIds({
2464
+ focusIds: orderFocusNodeIds({
2465
+ selectedIds,
2466
+ hoveredIds,
2467
+ neighborIds
2468
+ }),
2469
+ candidates: this.graph.getLabelCandidates(),
2470
+ maxLabels: this.maxLabels,
2471
+ fill: this.focusLabelFill
2472
+ }));
2473
+ }
2474
+ if (this.autoToolbar) this.syncToolbar();
2475
+ if (this.autoRichNodes) this.syncRichNodesFromFocus();
2476
+ this.updatePositions();
2477
+ }
2478
+ syncRichNodesFromFocus() {
2479
+ if (!this.autoRichNodes || !this.options.renderRichNode) return;
2480
+ this.syncRichNodes(selectRichNodeIds({
2481
+ hoveredIds: this.graph.getHoveredNodeIds(),
2482
+ maxRichNodes: this.maxRichNodes,
2483
+ policy: this.autoRichNodes,
2484
+ selectedIds: this.graph.getSelectedNodeIds()
2485
+ }));
2486
+ }
2487
+ syncToolbar() {
2488
+ if (!this.options.renderToolbar) return;
2489
+ const nextId = this.autoToolbar === "hovered" ? this.graph.getHoveredNodeIds()[0] ?? this.graph.getSelectedNodeIds()[0] ?? null : this.graph.getSelectedNodeIds()[0] ?? null;
2490
+ if (nextId === this.toolbarNodeId && this.toolbar) return;
2491
+ this.toolbar?.element.remove();
2492
+ this.toolbar = null;
2493
+ this.toolbarNodeId = nextId;
2494
+ if (nextId !== null) this.toolbar = this.createEntry(nextId, this.options.renderToolbar, "Toolbar", "auto");
2495
+ }
2496
+ syncLabels(nodeIds) {
2497
+ const nextIds = new Set(nodeIds);
2498
+ for (const [nodeId, entry] of this.labels) {
2499
+ if (nextIds.has(nodeId)) continue;
2500
+ entry.element.remove();
2501
+ this.labels.delete(nodeId);
2502
+ }
2503
+ for (const nodeId of nodeIds) {
2504
+ if (this.labels.has(nodeId)) continue;
2505
+ const entry = this.createEntry(nodeId, this.options.renderLabel, "Label", "none");
2506
+ if (entry) this.labels.set(nodeId, entry);
2507
+ }
2508
+ }
2509
+ syncRichNodes(nodeIds) {
2510
+ const nextIds = new Set(nodeIds);
2511
+ for (const [nodeId, entry] of this.richNodes) {
2512
+ if (nextIds.has(nodeId)) continue;
2513
+ entry.element.remove();
2514
+ this.richNodes.delete(nodeId);
2515
+ }
2516
+ for (const nodeId of nodeIds) {
2517
+ if (this.richNodes.has(nodeId)) continue;
2518
+ const entry = this.createEntry(nodeId, this.options.renderRichNode, "Rich", "auto");
2519
+ if (entry) this.richNodes.set(nodeId, entry);
2520
+ }
2521
+ }
2522
+ updatePositions() {
2523
+ for (const [nodeId, entry] of this.labels) this.updateEntry(nodeId, entry, this.options.renderLabel, "label", "none");
2524
+ for (const [nodeId, entry] of this.richNodes) this.updateEntry(nodeId, entry, this.options.renderRichNode, "rich", "auto");
2525
+ if (this.toolbar && this.toolbarNodeId) this.updateEntry(this.toolbarNodeId, this.toolbar, this.options.renderToolbar, "toolbar", "auto");
2526
+ }
2527
+ createEntry(nodeId, render, kind, pointerEvents) {
2528
+ if (!render || !this.graph.getNodeScreenPosition(nodeId)) return null;
2529
+ const presentation = this.graph.getNodePresentation(nodeId);
2530
+ const element = render({
2531
+ id: nodeId,
2532
+ presentation
2533
+ });
2534
+ assertElement(element, kind);
2535
+ if (!element) return null;
2536
+ const className = `graphraum-overlay-${kind.toLowerCase()}`;
2537
+ element.classList.add("graphraum-overlay-entry", className);
2538
+ if (kind === "Label" && this.options.labelClassName) this.applyClassName(element, this.options.labelClassName);
2539
+ if (kind === "Toolbar" && this.options.toolbarClassName) this.applyClassName(element, this.options.toolbarClassName);
2540
+ if (kind === "Rich" && this.options.richNodeClassName) this.applyClassName(element, this.options.richNodeClassName);
2541
+ element.dataset.graphraumNodeId = nodeId;
2542
+ element.style.position = "absolute";
2543
+ element.style.pointerEvents = pointerEvents;
2544
+ this.root.append(element);
2545
+ return {
2546
+ element,
2547
+ presentation
2548
+ };
2549
+ }
2550
+ updateEntry(nodeId, entry, render, kind, pointerEvents) {
2551
+ const position = this.graph.getNodeScreenPosition(nodeId);
2552
+ entry.element.hidden = !position?.visible;
2553
+ if (!position?.visible) return;
2554
+ const anchor = kind === "rich" ? "translate(-50%, 12px)" : "translate(-50%, -100%)";
2555
+ entry.element.style.transform = `translate(${position.x}px, ${position.y}px) ${anchor}`;
2556
+ const presentation = this.graph.getNodePresentation(nodeId);
2557
+ if (presentation === entry.presentation || !render) return;
2558
+ const replacement = render({
2559
+ id: nodeId,
2560
+ presentation
2561
+ });
2562
+ assertElement(replacement, `${kind[0].toUpperCase()}${kind.slice(1)}`);
2563
+ if (!replacement) {
2564
+ entry.element.remove();
2565
+ if (kind === "toolbar") this.toolbar = null;
2566
+ else if (kind === "rich") this.richNodes.delete(nodeId);
2567
+ else this.labels.delete(nodeId);
2568
+ return;
2569
+ }
2570
+ const className = `graphraum-overlay-${kind}`;
2571
+ replacement.classList.add("graphraum-overlay-entry", className);
2572
+ if (kind === "label" && this.options.labelClassName) this.applyClassName(replacement, this.options.labelClassName);
2573
+ if (kind === "toolbar" && this.options.toolbarClassName) this.applyClassName(replacement, this.options.toolbarClassName);
2574
+ if (kind === "rich" && this.options.richNodeClassName) this.applyClassName(replacement, this.options.richNodeClassName);
2575
+ replacement.dataset.graphraumNodeId = nodeId;
2576
+ replacement.style.cssText = `${entry.element.style.cssText};position:absolute;pointer-events:${pointerEvents};`;
2577
+ entry.element.replaceWith(replacement);
2578
+ entry.element = replacement;
2579
+ entry.presentation = presentation;
2580
+ }
2581
+ applyClassName(element, className) {
2582
+ for (const token of className.split(/\s+/g)) if (token) element.classList.add(token);
2583
+ }
2584
+ };
2585
+ //#endregion
2586
+ //#region src/spatial-grid-2d.ts
2587
+ function nodeAxes(node) {
2588
+ return resolveNodeAxes({
2589
+ height: node.height,
2590
+ nodeId: node.id,
2591
+ size: node.size,
2592
+ width: node.width
2593
+ });
2594
+ }
2595
+ /**
2596
+ * Coarse occupancy radius for cell bucketing and bounding-box checks: half of the node's
2597
+ * larger axis, padded by its world-unit `strokeWidth`. This is intentionally conservative
2598
+ * (a circle-equivalent bound around a possibly non-square shape) — exact hit testing in
2599
+ * `pick` uses the true `width`/`height` per axis via `containsNodePoint`.
2600
+ */
2601
+ function nodeOccupancyRadius(node) {
2602
+ const { height, width } = nodeAxes(node);
2603
+ return Math.max(width, height) + (node.strokeWidth ?? 0);
2604
+ }
2605
+ /** A mutable uniform grid shared by 2D picking and viewport visibility queries. */
2606
+ var SpatialGrid2D = class {
2607
+ cellSize;
2608
+ cells = /* @__PURE__ */ new Map();
2609
+ cellKeys = [];
2610
+ nodes = [];
2611
+ maxRadius = 0;
2612
+ constructor(cellSize = 32) {
2613
+ this.cellSize = cellSize;
2614
+ if (!Number.isFinite(cellSize) || cellSize <= 0) throw new Error("Spatial grid cell size must be positive.");
2615
+ }
2616
+ set(index, node) {
2617
+ const previousKey = this.cellKeys[index];
2618
+ if (previousKey) {
2619
+ const previousCell = this.cells.get(previousKey);
2620
+ if (previousCell) {
2621
+ const position = previousCell.indexOf(index);
2622
+ if (position >= 0) previousCell.splice(position, 1);
2623
+ if (previousCell.length === 0) this.cells.delete(previousKey);
2624
+ }
2625
+ }
2626
+ const key = this.key(this.coordinate(node.position.x), this.coordinate(node.position.y));
2627
+ const cell = this.cells.get(key) ?? [];
2628
+ cell.push(index);
2629
+ this.cells.set(key, cell);
2630
+ this.cellKeys[index] = key;
2631
+ this.nodes[index] = node;
2632
+ this.maxRadius = Math.max(this.maxRadius, nodeOccupancyRadius(node));
2633
+ }
2634
+ pick(x, y) {
2635
+ const cellRadius = Math.ceil(this.maxRadius / this.cellSize);
2636
+ const centerX = this.coordinate(x);
2637
+ const centerY = this.coordinate(y);
2638
+ let nearestIndex = null;
2639
+ let nearestDistance = Number.POSITIVE_INFINITY;
2640
+ 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)) ?? []) {
2641
+ const node = this.nodes[index];
2642
+ if (!node) continue;
2643
+ const { height, width } = nodeAxes(node);
2644
+ const offsetX = node.position.x - x;
2645
+ const offsetY = node.position.y - y;
2646
+ const distance = offsetX ** 2 + offsetY ** 2;
2647
+ if (containsNodePoint(node.shape, offsetX / width, offsetY / height) && distance < nearestDistance) {
2648
+ nearestDistance = distance;
2649
+ nearestIndex = index;
2650
+ }
2651
+ }
2652
+ return nearestIndex;
2653
+ }
2654
+ queryBounds(bounds, overscan = 0) {
2655
+ const left = bounds.left - overscan;
2656
+ const right = bounds.right + overscan;
2657
+ const bottom = bounds.bottom - overscan;
2658
+ const top = bounds.top + overscan;
2659
+ const indices = [];
2660
+ const minimumCellX = this.coordinate(left - this.maxRadius);
2661
+ const maximumCellX = this.coordinate(right + this.maxRadius);
2662
+ const minimumCellY = this.coordinate(bottom - this.maxRadius);
2663
+ const maximumCellY = this.coordinate(top + this.maxRadius);
2664
+ const queriedCellCount = (maximumCellX - minimumCellX + 1) * (maximumCellY - minimumCellY + 1);
2665
+ const candidateCells = Number.isSafeInteger(queriedCellCount) && queriedCellCount <= this.cells.size ? this.cellsInBounds(minimumCellX, maximumCellX, minimumCellY, maximumCellY) : this.cells.values();
2666
+ for (const cell of candidateCells) for (const index of cell) {
2667
+ const node = this.nodes[index];
2668
+ if (!node) continue;
2669
+ const radius = nodeOccupancyRadius(node);
2670
+ if (node.position.x + radius >= left && node.position.x - radius <= right && node.position.y + radius >= bottom && node.position.y - radius <= top) indices.push(index);
2671
+ }
2672
+ return indices;
2673
+ }
2674
+ *cellsInBounds(minimumX, maximumX, minimumY, maximumY) {
2675
+ for (let y = minimumY; y <= maximumY; y += 1) for (let x = minimumX; x <= maximumX; x += 1) yield this.cells.get(this.key(x, y)) ?? [];
2676
+ }
2677
+ coordinate(value) {
2678
+ return Math.floor(value / this.cellSize);
2679
+ }
2680
+ key(x, y) {
2681
+ return `${x}:${y}`;
2682
+ }
2683
+ };
2684
+ //#endregion
2685
+ //#region src/viewport-lod.ts
2686
+ /** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
2687
+ function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
2688
+ if (densityLodActive) return "density";
2689
+ return edgeCandidateCount > visibleEdgeSegmentCount ? "overview" : "detail";
2690
+ }
2691
+ /** Collects unique edges touching visible nodes without scanning the complete edge list. */
2692
+ function collectIncidentEdges(visibleNodeIndices, incidentEdgeIndices) {
2693
+ const edges = /* @__PURE__ */ new Set();
2694
+ for (const nodeIndex of visibleNodeIndices) for (const edgeIndex of incidentEdgeIndices[nodeIndex] ?? []) edges.add(edgeIndex);
2695
+ return [...edges];
2696
+ }
2697
+ /** Applies a deterministic, evenly distributed overview budget. */
2698
+ function applyEdgeBudget(edgeIndices, maximum) {
2699
+ if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error("Maximum visible edges must be a positive integer.");
2700
+ if (edgeIndices.length <= maximum) return edgeIndices;
2701
+ const step = edgeIndices.length / maximum;
2702
+ return Array.from({ length: maximum }, (_, index) => edgeIndices[Math.floor(index * step)]).filter((edgeIndex) => edgeIndex !== void 0);
2703
+ }
2704
+ /** Keeps density LOD stable while camera movement hovers around the visible-node budget. */
2705
+ function shouldUseDensityLod(candidateCount, maximum, active) {
2706
+ if (!Number.isSafeInteger(candidateCount) || candidateCount < 0) throw new Error("Visible node candidate count must be a non-negative integer.");
2707
+ if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error("Maximum visible nodes must be a positive integer.");
2708
+ return candidateCount > maximum * (active ? .85 : 1.1);
2709
+ }
2710
+ //#endregion
2711
+ //#region src/graphraum.ts
2712
+ /** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
2713
+ const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
2714
+ /** The 3D fit leaves 4% of the tighter canvas axis free on each side, e.g. for edge labels. */
2715
+ const PERSPECTIVE_FIT_FILL = .92;
2716
+ function prefersReducedMotion() {
2717
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2718
+ }
2719
+ function disposeMaterial(material) {
2720
+ for (const item of Array.isArray(material) ? material : [material]) item.dispose();
2721
+ }
2722
+ /** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
2723
+ const FLIGHT_CANCEL_EVENTS = [
2724
+ "keydown",
2725
+ "pointerdown",
2726
+ "wheel"
2727
+ ];
2728
+ const EDGE_INSTANCE_ATTRIBUTES = [
2729
+ "instanceKind",
2730
+ "instanceEndA",
2731
+ "instanceEndB",
2732
+ "instanceColor",
2733
+ "instanceWidth",
2734
+ "instanceStyle"
2735
+ ];
2736
+ /** A small, explicit WebGL graph renderer: one instanced node mesh and one batched edge geometry. */
2737
+ var Graphraum = class {
2738
+ container;
2739
+ renderer;
2740
+ scene = new Scene();
2741
+ raycaster = new Raycaster();
2742
+ pointer = new Vector2();
2743
+ pickCenter = new Vector3();
2744
+ pickRight = new Vector3();
2745
+ pickUp = new Vector3();
2746
+ theme;
2747
+ backgroundTexture = null;
2748
+ resizeObserver;
2749
+ maxVisibleEdges;
2750
+ maxVisibleNodes;
2751
+ viewportCulling;
2752
+ viewportOverscan;
2753
+ visuals;
2754
+ camera;
2755
+ controls;
2756
+ data = {
2757
+ nodes: [],
2758
+ edges: []
2759
+ };
2760
+ nodeIds = [];
2761
+ nodeIndices = /* @__PURE__ */ new Map();
2762
+ edgeNodeIndices = /* @__PURE__ */ new Uint32Array();
2763
+ canonicalEdgePositions = /* @__PURE__ */ new Float32Array();
2764
+ incidentEdgeIndices = [];
2765
+ nodePresentations = /* @__PURE__ */ new Map();
2766
+ edgePresentations = /* @__PURE__ */ new Map();
2767
+ spatialGrid2d = new SpatialGrid2D();
2768
+ nodeMesh = null;
2769
+ nodePickMesh = null;
2770
+ edgeMesh = null;
2771
+ glowLayer = new NodeGlowLayer(this.scene);
2772
+ hasGlowNodes = false;
2773
+ nodeCapacity = 0;
2774
+ edgeCapacity = 0;
2775
+ markerCapacity = 0;
2776
+ edgeInstanceCapacity = 0;
2777
+ selectedNodeIds = /* @__PURE__ */ new Set();
2778
+ selectedEdgeIds = /* @__PURE__ */ new Set();
2779
+ dimmedEdgeIds = /* @__PURE__ */ new Set();
2780
+ edgeIndexCache = {
2781
+ edges: [],
2782
+ indices: /* @__PURE__ */ new Map()
2783
+ };
2784
+ hoveredNodeIds = /* @__PURE__ */ new Set();
2785
+ focusedNodeIds = /* @__PURE__ */ new Set();
2786
+ dimmedNodeIds = /* @__PURE__ */ new Set();
2787
+ visibleNodeSlots = /* @__PURE__ */ new Map();
2788
+ visibleEdgeSlots = /* @__PURE__ */ new Map();
2789
+ visibleEdgeLayouts = /* @__PURE__ */ new Map();
2790
+ lastEdgeLodTier = "detail";
2791
+ visibleNodeIndices = [];
2792
+ pickableEdgeSegments = [];
2793
+ visibleEdgeSegmentEdgeIndices = [];
2794
+ visibleNodeCount = 0;
2795
+ visibleNodeCandidateCount = 0;
2796
+ densityLodActive = false;
2797
+ visibleEdgeSegmentCount = 0;
2798
+ visibleEdgeCount = 0;
2799
+ visibleEdgeMarkerCount = 0;
2800
+ visibleEdgeCandidateCount = 0;
2801
+ edgeSegmentCapacity = 0;
2802
+ mode;
2803
+ frameRequest = null;
2804
+ autoOrbit = null;
2805
+ flight = null;
2806
+ flightDirection = new Vector3();
2807
+ /** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
2808
+ orbitUpdating = false;
2809
+ destroyed = false;
2810
+ cpuFrameMilliseconds = 0;
2811
+ gpuFrameMilliseconds = null;
2812
+ gpuTimer = null;
2813
+ viewListeners = /* @__PURE__ */ new Set();
2814
+ focusListeners = /* @__PURE__ */ new Set();
2815
+ lastHoveredNodeId = null;
2816
+ constructor(container, options = {}) {
2817
+ this.container = container;
2818
+ this.mode = options.mode ?? "2d";
2819
+ this.maxVisibleEdges = options.maxVisibleEdges ?? 1e5;
2820
+ this.maxVisibleNodes = options.maxVisibleNodes ?? 1e5;
2821
+ this.viewportCulling = options.viewportCulling ?? true;
2822
+ this.viewportOverscan = options.viewportOverscan ?? 16;
2823
+ this.visuals = options.visuals;
2824
+ if (!Number.isSafeInteger(this.maxVisibleEdges) || this.maxVisibleEdges < 1) throw new Error("Maximum visible edges must be a positive integer.");
2825
+ if (!Number.isSafeInteger(this.maxVisibleNodes) || this.maxVisibleNodes < 1) throw new Error("Maximum visible nodes must be a positive integer.");
2826
+ if (!Number.isFinite(this.viewportOverscan) || this.viewportOverscan < 0) throw new Error("Viewport overscan must be a non-negative finite number.");
2827
+ this.theme = resolveGraphraumTheme(options.theme);
2828
+ this.renderer = new WebGLRenderer({
2829
+ antialias: options.antialias ?? false,
2830
+ alpha: true
2831
+ });
2832
+ this.applyBackground();
2833
+ const context = this.renderer.getContext();
2834
+ if (typeof WebGL2RenderingContext !== "undefined" && context instanceof WebGL2RenderingContext) {
2835
+ const extension = context.getExtension("EXT_disjoint_timer_query_webgl2");
2836
+ if (extension) this.gpuTimer = {
2837
+ context,
2838
+ extension,
2839
+ pending: []
2840
+ };
2841
+ }
2842
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2));
2843
+ this.renderer.domElement.style.display = "block";
2844
+ this.renderer.domElement.style.height = "100%";
2845
+ this.renderer.domElement.style.width = "100%";
2846
+ this.container.append(this.renderer.domElement);
2847
+ this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
2848
+ this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
2849
+ for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
2850
+ this.camera = this.createCamera();
2851
+ this.controls = this.createControls();
2852
+ this.resizeObserver = new ResizeObserver(() => this.resize());
2853
+ this.resizeObserver.observe(container);
2854
+ this.resize();
2855
+ }
2856
+ setData(data) {
2857
+ this.replaceData(data, true);
2858
+ }
2859
+ /** Merge a topology patch without fitting or resetting the current camera. */
2860
+ applyDataPatch(patch) {
2861
+ const addedNodeIds = new Set((patch.addedNodes ?? []).map((node) => node.id));
2862
+ const addedEdgeIds = new Set((patch.addedEdges ?? []).map((edge) => edge.id));
2863
+ if (addedNodeIds.size !== (patch.addedNodes ?? []).length) throw new Error("Duplicate added node ID.");
2864
+ if (addedEdgeIds.size !== (patch.addedEdges ?? []).length) throw new Error("Duplicate added edge ID.");
2865
+ if (isAppendOnlyDataPatch(patch) && this.canAppendDataPatch(patch)) {
2866
+ this.appendDataPatch(patch);
2867
+ return;
2868
+ }
2869
+ const merged = mergeDataPatch(this.data.nodes, this.data.edges, patch);
2870
+ if (this.canMutateDataInPlace(merged)) {
2871
+ this.mutateDataInPlace(merged);
2872
+ return;
2873
+ }
2874
+ this.replaceData(merged, false);
2875
+ }
2876
+ canAppendDataPatch(patch) {
2877
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return false;
2878
+ const nodeIds = new Set(this.data.nodes.map((node) => node.id));
2879
+ if ((patch.addedNodes ?? []).some((node) => nodeIds.has(node.id))) return false;
2880
+ const edgeIds = new Set(this.data.edges.map((edge) => edge.id));
2881
+ if ((patch.addedEdges ?? []).some((edge) => edgeIds.has(edge.id))) return false;
2882
+ const allNodeIds = /* @__PURE__ */ new Set([...nodeIds, ...(patch.addedNodes ?? []).map((node) => node.id)]);
2883
+ 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));
2884
+ }
2885
+ canMutateDataInPlace(merged) {
2886
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return false;
2887
+ return dataPatchFitsCapacity(merged, {
2888
+ edges: this.edgeCapacity,
2889
+ nodes: this.nodeCapacity
2890
+ });
2891
+ }
2892
+ mutateDataInPlace(merged) {
2893
+ const compiled = compileGraph(merged, this.visuals);
2894
+ this.data = {
2895
+ edges: merged.edges.map((edge, index) => ({
2896
+ ...edge,
2897
+ ...compiled.edgeVisuals[index]
2898
+ })),
2899
+ nodes: merged.nodes.map((node, index) => ({
2900
+ ...node,
2901
+ ...compiled.nodeVisuals[index],
2902
+ position: { ...node.position }
2903
+ }))
2904
+ };
2905
+ this.nodeIds = compiled.nodeIds;
2906
+ this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
2907
+ this.nodeIndices = new Map(compiled.nodeIndices);
2908
+ this.edgeNodeIndices = compiled.edgeNodeIndices;
2909
+ this.canonicalEdgePositions = compiled.edgePositions;
2910
+ this.incidentEdgeIndices = compiled.incidentEdgeIndices;
2911
+ this.nodePresentations = new Map(compiled.nodePresentations);
2912
+ this.edgePresentations = new Map(compiled.edgePresentations);
2913
+ this.spatialGrid2d = new SpatialGrid2D();
2914
+ for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
2915
+ const liveNodeIds = new Set(this.nodeIds);
2916
+ this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
2917
+ this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
2918
+ this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
2919
+ this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
2920
+ this.pruneEdgeStates();
2921
+ this.materializeViewport();
2922
+ this.requestRender();
2923
+ }
2924
+ appendDataPatch(patch) {
2925
+ const addedNodes = patch.addedNodes ?? [];
2926
+ const addedEdges = patch.addedEdges ?? [];
2927
+ if (addedNodes.length === 0 && addedEdges.length === 0) return;
2928
+ const nodeById = new Map(this.data.nodes.map((node) => [node.id, node]));
2929
+ for (const node of addedNodes) nodeById.set(node.id, node);
2930
+ const edgeEndpointNodes = addedEdges.flatMap((edge) => {
2931
+ const source = nodeById.get(edge.source);
2932
+ const target = nodeById.get(edge.target);
2933
+ return source && target ? [source, target] : [];
2934
+ });
2935
+ const patchNodes = [...addedNodes, ...edgeEndpointNodes].filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index);
2936
+ const compiled = compileGraph({
2937
+ nodes: patchNodes,
2938
+ edges: addedEdges
2939
+ }, this.visuals);
2940
+ const nodeVisuals = new Map(patchNodes.map((node, index) => [node.id, compiled.nodeVisuals[index]]));
2941
+ const nextNodes = [...this.data.nodes, ...addedNodes.map((node) => ({
2942
+ ...node,
2943
+ ...nodeVisuals.get(node.id)
2944
+ }))];
2945
+ const nextNodeIndices = new Map(this.nodeIndices);
2946
+ for (const [index, node] of nextNodes.entries()) nextNodeIndices.set(node.id, index);
2947
+ const nextEdges = [...this.data.edges, ...addedEdges.map((edge, index) => ({
2948
+ ...edge,
2949
+ ...compiled.edgeVisuals[index]
2950
+ }))];
2951
+ const nextEdgeIndices = new Uint32Array(nextEdges.length * 2);
2952
+ nextEdgeIndices.set(this.edgeNodeIndices);
2953
+ const nextEdgePositions = new Float32Array(nextEdges.length * 6);
2954
+ nextEdgePositions.set(this.canonicalEdgePositions);
2955
+ const nextIncidentEdges = this.incidentEdgeIndices.map((indices) => [...indices]);
2956
+ for (const [index, edge] of nextEdges.entries()) {
2957
+ const source = nextNodeIndices.get(edge.source);
2958
+ const target = nextNodeIndices.get(edge.target);
2959
+ if (source === void 0 || target === void 0) continue;
2960
+ nextEdgeIndices[index * 2] = source;
2961
+ nextEdgeIndices[index * 2 + 1] = target;
2962
+ if (!nextIncidentEdges[source]) nextIncidentEdges[source] = [];
2963
+ if (!nextIncidentEdges[target]) nextIncidentEdges[target] = [];
2964
+ if (index >= this.data.edges.length) {
2965
+ nextIncidentEdges[source]?.push(index);
2966
+ nextIncidentEdges[target]?.push(index);
2967
+ const sourceNode = nextNodes[source];
2968
+ const targetNode = nextNodes[target];
2969
+ if (sourceNode && targetNode) nextEdgePositions.set([
2970
+ sourceNode.position.x,
2971
+ sourceNode.position.y,
2972
+ sourceNode.position.z ?? 0,
2973
+ targetNode.position.x,
2974
+ targetNode.position.y,
2975
+ targetNode.position.z ?? 0
2976
+ ], index * 6);
2977
+ }
2978
+ }
2979
+ this.data = {
2980
+ nodes: nextNodes,
2981
+ edges: nextEdges
2982
+ };
2983
+ this.nodeIds = nextNodes.map((node) => node.id);
2984
+ this.hasGlowNodes = nextNodes.some(hasNodeGlow);
2985
+ this.nodeIndices = nextNodeIndices;
2986
+ this.edgeNodeIndices = nextEdgeIndices;
2987
+ this.canonicalEdgePositions = nextEdgePositions;
2988
+ this.incidentEdgeIndices = nextIncidentEdges;
2989
+ for (const node of addedNodes) {
2990
+ const index = nextNodeIndices.get(node.id);
2991
+ const nextNode = index === void 0 ? void 0 : this.data.nodes[index];
2992
+ if (index !== void 0 && nextNode) this.spatialGrid2d.set(index, nextNode);
2993
+ }
2994
+ for (const [id, presentation] of compiled.nodePresentations) this.nodePresentations.set(id, presentation);
2995
+ for (const [id, presentation] of compiled.edgePresentations) this.edgePresentations.set(id, presentation);
2996
+ this.materializeViewport();
2997
+ this.requestRender();
2998
+ }
2999
+ replaceData(data, fitView) {
3000
+ const compiled = compileGraph(data, this.visuals);
3001
+ this.disposeGraphObjects();
3002
+ this.data = {
3003
+ edges: data.edges.map((edge, index) => ({
3004
+ ...edge,
3005
+ ...compiled.edgeVisuals[index]
3006
+ })),
3007
+ nodes: data.nodes.map((node, index) => ({
3008
+ ...node,
3009
+ ...compiled.nodeVisuals[index],
3010
+ position: { ...node.position }
3011
+ }))
3012
+ };
3013
+ this.nodeIds = compiled.nodeIds;
3014
+ this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
3015
+ this.nodeIndices = new Map(compiled.nodeIndices);
3016
+ this.edgeNodeIndices = compiled.edgeNodeIndices;
3017
+ this.canonicalEdgePositions = compiled.edgePositions;
3018
+ this.incidentEdgeIndices = compiled.incidentEdgeIndices;
3019
+ this.nodePresentations = new Map(compiled.nodePresentations);
3020
+ this.edgePresentations = new Map(compiled.edgePresentations);
3021
+ this.spatialGrid2d = new SpatialGrid2D();
3022
+ for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
3023
+ this.pruneEdgeStates();
3024
+ this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
3025
+ const nodeCapacity = this.nodeCapacity;
3026
+ const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
3027
+ allocateNodeInstanceColors(nodeMesh, nodeCapacity);
3028
+ nodeMesh.renderOrder = 1;
3029
+ this.nodeMesh = nodeMesh;
3030
+ this.scene.add(nodeMesh);
3031
+ const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
3032
+ this.nodePickMesh = nodePickMesh;
3033
+ const capacities = edgeInstanceCapacities(data.edges.length, this.maxVisibleEdges);
3034
+ this.edgeCapacity = capacities.edgeCapacity;
3035
+ this.edgeSegmentCapacity = capacities.segmentCapacity;
3036
+ this.markerCapacity = capacities.markerCapacity;
3037
+ this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
3038
+ const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(), this.edgeInstanceCapacity);
3039
+ edgeMesh.frustumCulled = false;
3040
+ edgeMesh.renderOrder = -1;
3041
+ this.edgeMesh = edgeMesh;
3042
+ this.scene.add(edgeMesh);
3043
+ if (fitView) this.fitView();
3044
+ else {
3045
+ this.materializeViewport();
3046
+ this.requestRender();
3047
+ }
3048
+ }
3049
+ updateNodes(updates) {
3050
+ if (updates.length === 0) return;
3051
+ const objects = this.getGraphObjects("update nodes");
3052
+ const prepared = prepareNodeUpdates(this.data.nodes, this.nodeIndices, updates);
3053
+ this.applyPreparedNodeUpdates(prepared, objects);
3054
+ }
3055
+ /** Applies a transferable XYZ layout batch. Call repeatedly as a worker produces progressive positions. */
3056
+ applyLayout(layout) {
3057
+ const prepared = prepareLayoutPositions(this.data.nodes, this.nodeIndices, layout);
3058
+ if (prepared.length === 0) return;
3059
+ const objects = this.getGraphObjects("apply a layout");
3060
+ this.applyPreparedNodeUpdates(prepared, objects);
3061
+ }
3062
+ getGraphObjects(action) {
3063
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) throw new Error(`Cannot ${action} before graph data is set`);
3064
+ return {
3065
+ edgeMesh: this.edgeMesh,
3066
+ nodeMesh: this.nodeMesh,
3067
+ nodePickMesh: this.nodePickMesh
3068
+ };
3069
+ }
3070
+ applyPreparedNodeUpdates(prepared, { nodeMesh, nodePickMesh }) {
3071
+ const nodes = [...this.data.nodes];
3072
+ const matrix = new Matrix4();
3073
+ const nodeShape = nodeMesh.geometry.getAttribute("instanceShape");
3074
+ const nodeStrokeWidth = nodeMesh.geometry.getAttribute("instanceStrokeWidth");
3075
+ const nodeStrokeColor = nodeMesh.geometry.getAttribute("instanceStrokeColor");
3076
+ const viewportBounds = this.getViewportBounds2d();
3077
+ let visibilityChanged = false;
3078
+ let edgeEndpointsChanged = false;
3079
+ const changedEdgeIndices = /* @__PURE__ */ new Set();
3080
+ for (const update of prepared) {
3081
+ nodes[update.index] = update.next;
3082
+ if (update.positionChanged || update.sizeChanged) {
3083
+ const wasVisible = this.visibleNodeSlots.has(update.index);
3084
+ this.spatialGrid2d.set(update.index, update.next);
3085
+ visibilityChanged ||= wasVisible !== this.isNodeVisible2d(update.next, viewportBounds);
3086
+ }
3087
+ const nodeSlot = this.visibleNodeSlots.get(update.index);
3088
+ if (update.positionChanged || update.sizeChanged) {
3089
+ if (nodeSlot !== void 0) {
3090
+ const { height, width } = resolveNodeAxes({
3091
+ height: update.next.height,
3092
+ nodeId: update.next.id,
3093
+ size: update.next.size,
3094
+ width: update.next.width
3095
+ });
3096
+ const maxAxis = Math.max(width, height);
3097
+ matrix.makeScale(width, height, maxAxis);
3098
+ matrix.setPosition(update.next.position.x, update.next.position.y, update.next.position.z ?? 0);
3099
+ nodeMesh.setMatrixAt(nodeSlot, matrix);
3100
+ nodeMesh.instanceMatrix.addUpdateRange(nodeSlot * 16, 16);
3101
+ const pickScale = maxAxis * Math.SQRT2;
3102
+ matrix.makeScale(pickScale, pickScale, pickScale);
3103
+ matrix.setPosition(update.next.position.x, update.next.position.y, update.next.position.z ?? 0);
3104
+ nodePickMesh.setMatrixAt(nodeSlot, matrix);
3105
+ nodePickMesh.instanceMatrix.addUpdateRange(nodeSlot * 16, 16);
3106
+ }
3107
+ }
3108
+ if (update.colorChanged && nodeSlot !== void 0) {
3109
+ nodeMesh.setColorAt(nodeSlot, this.getNodeColor(update.next));
3110
+ nodeMesh.instanceColor?.addUpdateRange(nodeSlot * 3, 3);
3111
+ }
3112
+ if (update.shapeChanged && nodeSlot !== void 0) {
3113
+ setNodeShapeAt(nodeShape, nodeSlot, update.next.shape);
3114
+ nodeShape.addUpdateRange(nodeSlot, 1);
3115
+ }
3116
+ if (update.strokeChanged && nodeSlot !== void 0) {
3117
+ setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, nodeSlot, resolveNodeStroke(update.next, this.theme));
3118
+ nodeStrokeWidth.addUpdateRange(nodeSlot, 1);
3119
+ nodeStrokeColor.addUpdateRange(nodeSlot * 3, 3);
3120
+ }
3121
+ if (update.positionChanged) {
3122
+ const incidentEdges = this.incidentEdgeIndices[update.index] ?? [];
3123
+ if (incidentEdges.length > 0) edgeEndpointsChanged = true;
3124
+ for (const edgeIndex of incidentEdges) {
3125
+ changedEdgeIndices.add(edgeIndex);
3126
+ const sourceIndex = this.edgeNodeIndices[edgeIndex * 2];
3127
+ const canonicalOffset = edgeIndex * 6 + (sourceIndex === update.index ? 0 : 3);
3128
+ this.canonicalEdgePositions.set([
3129
+ update.next.position.x,
3130
+ update.next.position.y,
3131
+ update.next.position.z ?? 0
3132
+ ], canonicalOffset);
3133
+ }
3134
+ }
3135
+ }
3136
+ if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
3137
+ const glowChanged = prepared.some((update) => update.glowChanged);
3138
+ if (glowChanged) this.hasGlowNodes = nodes.some(hasNodeGlow);
3139
+ this.data = {
3140
+ ...this.data,
3141
+ nodes
3142
+ };
3143
+ if (visibilityChanged) {
3144
+ this.materializeViewport();
3145
+ this.requestRender();
3146
+ return;
3147
+ }
3148
+ if (edgeEndpointsChanged) {
3149
+ if (!this.patchVisibleEdgeEndpoints([...changedEdgeIndices])) this.materializeViewport();
3150
+ }
3151
+ if (prepared.some((update) => update.positionChanged || update.sizeChanged)) {
3152
+ nodeMesh.instanceMatrix.needsUpdate = true;
3153
+ nodePickMesh.instanceMatrix.needsUpdate = true;
3154
+ }
3155
+ if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
3156
+ if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
3157
+ if (glowChanged) this.syncGlow();
3158
+ else if (prepared.some((update) => update.positionChanged || update.sizeChanged || update.colorChanged)) this.glowLayer.rewrite(this.describeGlowNode);
3159
+ if (prepared.some((update) => update.strokeChanged)) {
3160
+ nodeStrokeWidth.needsUpdate = true;
3161
+ nodeStrokeColor.needsUpdate = true;
3162
+ }
3163
+ this.requestRender();
3164
+ }
3165
+ /** Patches GPU edge instances for moved endpoints when the visible edge set is unchanged. */
3166
+ patchVisibleEdgeEndpoints(changedEdgeIndices) {
3167
+ if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
3168
+ const worldPerPixel = this.worldUnitsPerPixel();
3169
+ const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
3170
+ const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
3171
+ changedEdgeIndices,
3172
+ defaults: {
3173
+ color: this.theme.edge,
3174
+ opacity: this.theme.edgeOpacity,
3175
+ width: this.theme.edgeWidth
3176
+ },
3177
+ edgeNodeIndices: this.edgeNodeIndices,
3178
+ edgeStates: this.edgeStateStyling(),
3179
+ edgeVisuals: this.data.edges,
3180
+ endpointAttach: this.theme.endpointAttach,
3181
+ endpointPositions: this.canonicalEdgePositions,
3182
+ layouts: this.visibleEdgeLayouts,
3183
+ minHitSlop,
3184
+ mode: this.mode,
3185
+ nodeOutlines: this.data.nodes.map((node) => ({
3186
+ height: node.height,
3187
+ shape: node.shape,
3188
+ size: node.size,
3189
+ strokeWidth: node.strokeWidth,
3190
+ width: node.width
3191
+ })),
3192
+ tier: this.lastEdgeLodTier,
3193
+ worldPerPixel
3194
+ }, this.pickableEdgeSegments);
3195
+ if (!result.ok) return false;
3196
+ this.pickableEdgeSegments = result.pickableSegments;
3197
+ return true;
3198
+ }
3199
+ setSelection(nodeIds) {
3200
+ this.setNodeState("selected", nodeIds);
3201
+ }
3202
+ /**
3203
+ * Highlights edges without changing node selection or source graph data. Unknown IDs are ignored.
3204
+ * Only the instance colors of edges whose selection changed are rewritten and uploaded.
3205
+ */
3206
+ setEdgeSelection(edgeIds) {
3207
+ const next = this.knownEdgeIds(edgeIds);
3208
+ const changed = changedIds(this.selectedEdgeIds, next);
3209
+ this.selectedEdgeIds = next;
3210
+ this.repaintEdgeStates(changed);
3211
+ }
3212
+ /**
3213
+ * Applies an application-owned edge state without changing source graph data. `dimmed` uses
3214
+ * `theme.dimmedEdge` / `theme.dimmedEdgeOpacity`; selected edges stay selected. Unknown IDs are ignored.
3215
+ */
3216
+ setEdgeState(state, edgeIds) {
3217
+ if (state !== "dimmed") throw new Error(`Unknown edge state "${String(state)}". Use "dimmed".`);
3218
+ const next = this.knownEdgeIds(edgeIds);
3219
+ const changed = changedIds(this.dimmedEdgeIds, next);
3220
+ this.dimmedEdgeIds = next;
3221
+ this.repaintEdgeStates(changed);
3222
+ }
3223
+ knownEdgeIds(edgeIds) {
3224
+ const indices = this.edgeIndicesById();
3225
+ return new Set([...edgeIds].filter((id) => indices.has(id)));
3226
+ }
3227
+ /**
3228
+ * Visible slots, LOD tier, and geometry only change through materialize or endpoint patches,
3229
+ * which keep `visibleEdgeLayouts` current, so a state change repaints in place. If a layout no
3230
+ * longer fits the drawn instances, that invariant broke: fall back to a full materialize.
3231
+ */
3232
+ repaintEdgeStates(changedEdgeIds) {
3233
+ if (!this.edgeMesh || changedEdgeIds.length === 0) return;
3234
+ const indices = this.edgeIndicesById();
3235
+ if (!patchVisibleEdgePaint(this.edgeMesh.geometry, {
3236
+ changedEdgeIndices: changedEdgeIds.flatMap((id) => indices.get(id) ?? []),
3237
+ defaults: {
3238
+ color: this.theme.edge,
3239
+ opacity: this.theme.edgeOpacity
3240
+ },
3241
+ edgeStates: this.edgeStateStyling(),
3242
+ edgeVisuals: this.data.edges,
3243
+ instanceCount: this.edgeMesh.count,
3244
+ layouts: this.visibleEdgeLayouts,
3245
+ tier: this.lastEdgeLodTier
3246
+ }).ok) this.materializeViewport();
3247
+ this.requestRender();
3248
+ }
3249
+ edgeStateStyling() {
3250
+ return {
3251
+ dimmedColor: this.theme.dimmedEdge,
3252
+ dimmedOpacity: this.theme.dimmedEdgeOpacity,
3253
+ selectedColor: this.theme.selectedEdge,
3254
+ stateOf: (edgeIndex) => {
3255
+ const id = this.data.edges[edgeIndex]?.id;
3256
+ if (id === void 0) return null;
3257
+ if (this.selectedEdgeIds.has(id)) return "selected";
3258
+ return this.dimmedEdgeIds.has(id) ? "dimmed" : null;
3259
+ }
3260
+ };
3261
+ }
3262
+ /** Edge ID to compiled index, rebuilt only when the edge list is replaced. */
3263
+ edgeIndicesById() {
3264
+ if (this.edgeIndexCache.edges !== this.data.edges) this.edgeIndexCache = {
3265
+ edges: this.data.edges,
3266
+ indices: new Map(this.data.edges.map((edge, index) => [edge.id, index]))
3267
+ };
3268
+ return this.edgeIndexCache.indices;
3269
+ }
3270
+ /** Drops edge states whose edges left the graph. */
3271
+ pruneEdgeStates() {
3272
+ const indices = this.edgeIndicesById();
3273
+ this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => indices.has(id)));
3274
+ this.dimmedEdgeIds = new Set([...this.dimmedEdgeIds].filter((id) => indices.has(id)));
3275
+ }
3276
+ /** Applies an application-owned visual state without changing source graph data. */
3277
+ setNodeState(state, nodeIds) {
3278
+ const nextSelection = new Set(nodeIds);
3279
+ const currentSelection = this.getNodeStateIds(state);
3280
+ const changedNodeIds = new Set([...currentSelection, ...nextSelection].filter((nodeId) => currentSelection.has(nodeId) !== nextSelection.has(nodeId)));
3281
+ if (changedNodeIds.size === 0) return;
3282
+ this.setNodeStateIds(state, nextSelection);
3283
+ this.applySelectionColors(changedNodeIds);
3284
+ this.requestRender();
3285
+ if (state === "selected" || state === "hovered") this.notifyFocusChange();
3286
+ }
3287
+ /** One-hop neighbor node ids via incident edges (undirected adjacency). */
3288
+ getNeighborIds(nodeId) {
3289
+ const index = this.nodeIndices.get(nodeId);
3290
+ if (index === void 0) return [];
3291
+ const neighbors = /* @__PURE__ */ new Set();
3292
+ for (const edgeIndex of this.incidentEdgeIndices[index] ?? []) {
3293
+ const edge = this.data.edges[edgeIndex];
3294
+ if (!edge) continue;
3295
+ if (edge.source !== nodeId) neighbors.add(edge.source);
3296
+ if (edge.target !== nodeId) neighbors.add(edge.target);
3297
+ }
3298
+ return [...neighbors];
3299
+ }
3300
+ getSelectedNodeIds() {
3301
+ return [...this.selectedNodeIds];
3302
+ }
3303
+ getHoveredNodeIds() {
3304
+ return [...this.hoveredNodeIds];
3305
+ }
3306
+ /** Schedules one render on the next animation frame. Repeated calls in one frame are coalesced. */
3307
+ render() {
3308
+ this.requestRender();
3309
+ }
3310
+ setMode(mode) {
3311
+ if (mode === this.mode) return;
3312
+ this.cancelFlight();
3313
+ this.stopAutoOrbit();
3314
+ this.mode = mode;
3315
+ this.controls.removeEventListener("change", this.handleViewChange);
3316
+ this.controls.dispose();
3317
+ this.camera = this.createCamera();
3318
+ this.controls = this.createControls();
3319
+ if (this.nodeMesh && !Array.isArray(this.nodeMesh.material)) {
3320
+ this.nodeMesh.material.depthTest = mode === "3d";
3321
+ this.nodeMesh.material.depthWrite = mode === "3d";
3322
+ this.nodeMesh.material.needsUpdate = true;
3323
+ }
3324
+ this.fitView();
3325
+ }
3326
+ getMode() {
3327
+ return this.mode;
3328
+ }
3329
+ /**
3330
+ * Slowly orbits the 3D camera around its target until called with `false`. The orbit pauses while
3331
+ * the camera controls handle a drag, pinch or wheel step and resumes after `resumeAfterMs` of idle;
3332
+ * it also stops while the document is hidden or the canvas is off screen. While it is active a
3333
+ * frame loop renders every frame (no viewport rematerialization), so check `prefers-reduced-motion`
3334
+ * before enabling it. Throws in 2D mode; `setMode("2d")` turns it off. Does nothing after `destroy()`.
3335
+ */
3336
+ setAutoOrbit(options) {
3337
+ if (options === false || this.destroyed) {
3338
+ this.stopAutoOrbit();
3339
+ return;
3340
+ }
3341
+ if (this.mode !== "3d") throw new Error("Auto-orbit requires 3D mode. Call setMode(\"3d\") before setAutoOrbit(options).");
3342
+ const { resumeAfterMs, speed } = resolveAutoOrbitOptions(options);
3343
+ this.controls.autoRotateSpeed = autoRotateSpeedFor(speed);
3344
+ if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
3345
+ else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
3346
+ }
3347
+ getNodePresentation(nodeId) {
3348
+ return this.nodePresentations.get(nodeId) ?? null;
3349
+ }
3350
+ /** Returns canvas-relative screen coordinates for a node, or null when the node does not exist. */
3351
+ getNodeScreenPosition(nodeId) {
3352
+ const index = this.nodeIndices.get(nodeId);
3353
+ const node = index === void 0 ? void 0 : this.data.nodes[index];
3354
+ if (!node) return null;
3355
+ const projected = new Vector3(node.position.x, node.position.y, node.position.z ?? 0).project(this.camera);
3356
+ const width = this.renderer.domElement.clientWidth;
3357
+ const height = this.renderer.domElement.clientHeight;
3358
+ return {
3359
+ visible: projected.x >= -1 && projected.x <= 1 && projected.y >= -1 && projected.y <= 1 && projected.z >= -1 && projected.z <= 1,
3360
+ x: (projected.x + 1) * width / 2,
3361
+ y: (1 - projected.y) * height / 2
3362
+ };
3363
+ }
3364
+ /**
3365
+ * Candidates for a budgeted DOM label layer. Importance is incident edge degree, with a boost
3366
+ * for selected nodes so selection stays labeled. Visibility comes from screen projection.
3367
+ */
3368
+ getLabelCandidates() {
3369
+ return this.data.nodes.map((node, index) => {
3370
+ const degree = this.incidentEdgeIndices[index]?.length ?? 0;
3371
+ return {
3372
+ id: node.id,
3373
+ importance: degree + (this.selectedNodeIds.has(node.id) ? SELECTED_LABEL_IMPORTANCE_BOOST : 0),
3374
+ visible: this.getNodeScreenPosition(node.id)?.visible ?? false
3375
+ };
3376
+ });
3377
+ }
3378
+ /** Converts client coordinates to graph world coordinates on the z=0 plane. */
3379
+ screenToWorld(clientX, clientY) {
3380
+ const bounds = this.renderer.domElement.getBoundingClientRect();
3381
+ if (bounds.width === 0 || bounds.height === 0) return null;
3382
+ const worldX = (clientX - bounds.left) / bounds.width * 2 - 1;
3383
+ const worldY = -((clientY - bounds.top) / bounds.height) * 2 + 1;
3384
+ this.pointer.set(worldX, worldY);
3385
+ if (this.camera instanceof OrthographicCamera) {
3386
+ const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
3387
+ return {
3388
+ x: world.x,
3389
+ y: world.y,
3390
+ z: world.z
3391
+ };
3392
+ }
3393
+ this.raycaster.setFromCamera(this.pointer, this.camera);
3394
+ const plane = new Plane(new Vector3(0, 0, 1), 0);
3395
+ const world = this.raycaster.ray.intersectPlane(plane, new Vector3());
3396
+ if (!world) return null;
3397
+ return {
3398
+ x: world.x,
3399
+ y: world.y,
3400
+ z: world.z
3401
+ };
3402
+ }
3403
+ /** Creates a bounded DOM layer for labels, a toolbar, and optional rich HTML focus cards. */
3404
+ createOverlay(options = {}) {
3405
+ return new GraphraumOverlay(this, this.container, options);
3406
+ }
3407
+ /** Subscribes to camera and viewport changes. Returns an unsubscribe function. */
3408
+ onViewChange(listener) {
3409
+ this.viewListeners.add(listener);
3410
+ listener();
3411
+ return () => this.viewListeners.delete(listener);
3412
+ }
3413
+ /** Subscribes to selection and hover changes that affect overlay focus chrome. */
3414
+ onFocusChange(listener) {
3415
+ this.focusListeners.add(listener);
3416
+ listener();
3417
+ return () => this.focusListeners.delete(listener);
3418
+ }
3419
+ notifyFocusChange() {
3420
+ for (const listener of this.focusListeners) listener();
3421
+ }
3422
+ handlePointerMove = (event) => {
3423
+ const nodeId = this.pickNode(event.clientX, event.clientY);
3424
+ if (nodeId === this.lastHoveredNodeId) return;
3425
+ this.lastHoveredNodeId = nodeId;
3426
+ this.setNodeState("hovered", nodeId ? [nodeId] : []);
3427
+ };
3428
+ handlePointerLeave = () => {
3429
+ if (this.lastHoveredNodeId === null && this.hoveredNodeIds.size === 0) return;
3430
+ this.lastHoveredNodeId = null;
3431
+ this.setNodeState("hovered", []);
3432
+ };
3433
+ getEdgePresentation(edgeId) {
3434
+ return this.edgePresentations.get(edgeId) ?? null;
3435
+ }
3436
+ getDiagnostics() {
3437
+ return {
3438
+ aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
3439
+ autoOrbit: this.autoOrbit?.status ?? "off",
3440
+ cpuFrameMilliseconds: this.cpuFrameMilliseconds,
3441
+ gpuFrameMilliseconds: this.gpuFrameMilliseconds,
3442
+ gpuDrawCalls: this.renderer.info.render.calls,
3443
+ gpuGeometries: this.renderer.info.memory.geometries,
3444
+ gpuTextures: this.renderer.info.memory.textures,
3445
+ lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
3446
+ pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
3447
+ dimmedEdges: this.dimmedEdgeIds.size,
3448
+ selectedEdges: this.selectedEdgeIds.size,
3449
+ selectedNodes: this.selectedNodeIds.size,
3450
+ totalEdges: this.data.edges.length,
3451
+ totalNodes: this.data.nodes.length,
3452
+ visibleEdgeCandidates: this.visibleEdgeCandidateCount,
3453
+ visibleEdgeMarkers: this.visibleEdgeMarkerCount,
3454
+ visibleEdgeSegments: this.visibleEdgeSegmentCount,
3455
+ visibleEdges: this.visibleEdgeCount,
3456
+ visibleGlowNodes: this.glowLayer.count,
3457
+ visibleNodes: this.visibleNodeCount,
3458
+ visibleNodeCandidates: this.visibleNodeCandidateCount
3459
+ };
3460
+ }
3461
+ /** Picks a node under the pointer. Prefer `pickHit` when edges must be selectable too. */
3462
+ pick(clientX, clientY) {
3463
+ const hit = this.pickHit(clientX, clientY);
3464
+ return hit?.kind === "node" ? hit.id : null;
3465
+ }
3466
+ /**
3467
+ * Picks the top-most graph element under the pointer. Nodes win over edges when both hit.
3468
+ * 2D uses the spatial grid for nodes and world-space segment distance for edges; 3D uses
3469
+ * the node pick mesh then the edge instance mesh.
3470
+ */
3471
+ pickHit(clientX, clientY) {
3472
+ const nodeId = this.pickNode(clientX, clientY);
3473
+ if (nodeId) return {
3474
+ kind: "node",
3475
+ id: nodeId
3476
+ };
3477
+ const edgeId = this.pickEdge(clientX, clientY);
3478
+ return edgeId ? {
3479
+ kind: "edge",
3480
+ id: edgeId
3481
+ } : null;
3482
+ }
3483
+ /** Frames every node, or only `nodeIds`; unknown ids are ignored and no match frames all nodes. Cancels a running `flyTo`. */
3484
+ fitView(options) {
3485
+ this.cancelFlight();
3486
+ if (!this.nodeMesh || this.data.nodes.length === 0) {
3487
+ this.requestRender();
3488
+ return;
3489
+ }
3490
+ const subset = this.nodesWithIds(options?.nodeIds);
3491
+ const nodes = subset.length > 0 ? subset : this.data.nodes;
3492
+ const width = Math.max(this.container.clientWidth, 1);
3493
+ const height = Math.max(this.container.clientHeight, 1);
3494
+ if (this.camera instanceof OrthographicCamera) {
3495
+ const aspect = width / height;
3496
+ const fit = orthographicFit(aspect, nodes);
3497
+ setOrthographicFrustum(this.camera, aspect, fit.visibleHeight);
3498
+ this.camera.position.set(fit.center.x, fit.center.y, fit.cameraZ);
3499
+ this.camera.lookAt(fit.center);
3500
+ this.controls.target.copy(fit.center);
3501
+ } else {
3502
+ const fit = this.perspectiveFit(this.camera, width, height, nodes);
3503
+ this.camera.near = fit.near;
3504
+ this.camera.far = fit.far;
3505
+ this.camera.position.set(fit.target.x, fit.target.y, fit.cameraZ);
3506
+ this.controls.target.set(fit.target.x, fit.target.y, fit.target.z);
3507
+ this.camera.lookAt(this.controls.target);
3508
+ }
3509
+ this.camera.updateProjectionMatrix();
3510
+ this.controls.update();
3511
+ this.materializeViewport();
3512
+ this.requestRender();
3513
+ }
3514
+ /**
3515
+ * Animates the camera to frame `nodeIds` as `fitView({ nodeIds })` would, with an ease-in-out
3516
+ * over `durationMs`. In 3D the camera and the orbit target move together and keep the current
3517
+ * view direction, so a running auto-orbit carries on around the new target, at the fitted
3518
+ * distance times `distanceScale`. In 2D it pans and zooms. Resolves `true` on arrival and
3519
+ * `false` when cancelled by canvas pointer, wheel or key input, another `flyTo`, `fitView`,
3520
+ * `setData`, `setMode`, a 2D resize or `destroy()`. Unknown IDs are ignored; when none match,
3521
+ * or after `destroy()`, it resolves `false` without moving. With `prefers-reduced-motion:
3522
+ * reduce` or a zero duration it jumps. Throws for an invalid duration or distance scale.
3523
+ */
3524
+ flyTo(options) {
3525
+ const { distanceScale, durationMs } = resolveFlyToOptions(options);
3526
+ this.cancelFlight();
3527
+ if (this.destroyed || !this.nodeMesh) return Promise.resolve(false);
3528
+ const nodes = this.nodesWithIds(options.nodeIds);
3529
+ if (nodes.length === 0) return Promise.resolve(false);
3530
+ const width = Math.max(this.container.clientWidth, 1);
3531
+ const height = Math.max(this.container.clientHeight, 1);
3532
+ const flight = this.camera instanceof OrthographicCamera ? this.planOrthographicFlight(this.camera, width / height, nodes, distanceScale) : this.planPerspectiveFlight(this.camera, width, height, nodes, distanceScale);
3533
+ if (durationMs === 0 || prefersReducedMotion()) {
3534
+ flight.step(1);
3535
+ flight.arrive();
3536
+ this.requestRender();
3537
+ return Promise.resolve(true);
3538
+ }
3539
+ return new Promise((resolve) => {
3540
+ this.flight = {
3541
+ ...flight,
3542
+ durationMs,
3543
+ resolve,
3544
+ startMs: null
3545
+ };
3546
+ this.requestRender();
3547
+ });
3548
+ }
3549
+ /** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
3550
+ nodesWithIds(nodeIds) {
3551
+ if (!nodeIds) return this.data.nodes;
3552
+ const wanted = new Set(nodeIds);
3553
+ return this.data.nodes.filter((node) => wanted.has(node.id));
3554
+ }
3555
+ /** The closest front-view camera that frames `nodes` (issue #124); applies the container aspect. */
3556
+ perspectiveFit(camera, width, height, nodes) {
3557
+ applyPerspectiveContainerAspect(camera, width, height);
3558
+ return fitPerspective({
3559
+ aspect: camera.aspect,
3560
+ fill: PERSPECTIVE_FIT_FILL,
3561
+ fovDegrees: camera.fov
3562
+ }, {
3563
+ count: nodes.length,
3564
+ read(index, out) {
3565
+ const node = nodes[index];
3566
+ const axes = resolveNodeAxes({
3567
+ height: node.height,
3568
+ nodeId: node.id,
3569
+ size: node.size,
3570
+ width: node.width
3571
+ });
3572
+ out.x = node.position.x;
3573
+ out.y = node.position.y;
3574
+ out.z = node.position.z ?? 0;
3575
+ const extent = Math.max(axes.width, axes.height);
3576
+ const glow = node.glow ?? 0;
3577
+ const halo = glow > 0 ? glowHaloRadius(extent, glow) : 0;
3578
+ out.halfWidth = Math.max(axes.width, halo);
3579
+ out.halfHeight = Math.max(axes.height, halo);
3580
+ }
3581
+ });
3582
+ }
3583
+ /**
3584
+ * A 3D flight keeps the view direction it reads from the camera every frame, so an orbit step
3585
+ * between frames turns the flight with it. The destination distance is the front-view fit
3586
+ * distance; near and far span both ends while flying, and the far plane at arrival still holds
3587
+ * the whole graph so the rest of it stays visible around the framed nodes.
3588
+ */
3589
+ planPerspectiveFlight(camera, width, height, nodes, distanceScale) {
3590
+ const fit = this.perspectiveFit(camera, width, height, nodes);
3591
+ const fromTarget = this.controls.target.clone();
3592
+ const toTarget = new Vector3(fit.target.x, fit.target.y, fit.target.z);
3593
+ const fromDistance = Math.max(camera.position.distanceTo(fromTarget), Number.EPSILON);
3594
+ const toDistance = (fit.cameraZ - fit.target.z) * distanceScale;
3595
+ const near = fit.near * Math.min(distanceScale, 1);
3596
+ const far = Math.max(fit.far * distanceScale, (toDistance + this.graphRadiusAround(toTarget)) * 1.1);
3597
+ camera.near = Math.min(camera.near, near);
3598
+ camera.far = Math.max(camera.far, far);
3599
+ camera.updateProjectionMatrix();
3600
+ return {
3601
+ arrive: () => {
3602
+ camera.near = near;
3603
+ camera.far = far;
3604
+ camera.updateProjectionMatrix();
3605
+ this.materializeViewport();
3606
+ },
3607
+ step: (eased) => {
3608
+ const direction = this.flightDirection.subVectors(camera.position, this.controls.target);
3609
+ if (direction.lengthSq() === 0) direction.set(0, 0, 1);
3610
+ direction.normalize();
3611
+ this.controls.target.lerpVectors(fromTarget, toTarget, eased);
3612
+ camera.position.copy(this.controls.target).addScaledVector(direction, interpolateScale(fromDistance, toDistance, eased));
3613
+ camera.lookAt(this.controls.target);
3614
+ }
3615
+ };
3616
+ }
3617
+ /**
3618
+ * A 2D flight switches to the destination frustum up front with a zoom that keeps the current
3619
+ * view, then pans and eases the zoom back to 1, so it lands exactly on the fitted frustum.
3620
+ */
3621
+ planOrthographicFlight(camera, aspect, nodes, distanceScale) {
3622
+ const fit = orthographicFit(aspect, nodes);
3623
+ const fromVisibleHeight = (camera.top - camera.bottom) / camera.zoom;
3624
+ const toVisibleHeight = fit.visibleHeight * distanceScale;
3625
+ setOrthographicFrustum(camera, aspect, toVisibleHeight);
3626
+ const fromZoom = toVisibleHeight / fromVisibleHeight;
3627
+ camera.zoom = fromZoom;
3628
+ camera.updateProjectionMatrix();
3629
+ const fromPosition = camera.position.clone();
3630
+ const toPosition = new Vector3(fit.center.x, fit.center.y, fit.cameraZ);
3631
+ const fromTarget = this.controls.target.clone();
3632
+ return {
3633
+ arrive: () => this.materializeViewport(),
3634
+ step: (eased) => {
3635
+ camera.zoom = interpolateScale(fromZoom, 1, eased);
3636
+ camera.position.lerpVectors(fromPosition, toPosition, eased);
3637
+ this.controls.target.lerpVectors(fromTarget, fit.center, eased);
3638
+ camera.updateProjectionMatrix();
3639
+ }
3640
+ };
3641
+ }
3642
+ /** Largest distance from `center` to any node's far edge. */
3643
+ graphRadiusAround(center) {
3644
+ let radius = 0;
3645
+ for (const node of this.data.nodes) {
3646
+ const axes = resolveNodeAxes({
3647
+ height: node.height,
3648
+ nodeId: node.id,
3649
+ size: node.size,
3650
+ width: node.width
3651
+ });
3652
+ const distance = Math.hypot(node.position.x - center.x, node.position.y - center.y, (node.position.z ?? 0) - center.z);
3653
+ radius = Math.max(radius, distance + Math.max(axes.width, axes.height));
3654
+ }
3655
+ return radius;
3656
+ }
3657
+ /** Steps a running flight to the frame time; settles and resolves it at the end. */
3658
+ advanceFlight(nowMs) {
3659
+ const flight = this.flight;
3660
+ if (!flight) return;
3661
+ flight.startMs ??= nowMs;
3662
+ const progress = flightProgress(nowMs - flight.startMs, flight.durationMs);
3663
+ try {
3664
+ flight.step(easeInOutCubic(progress));
3665
+ if (progress < 1) {
3666
+ if (this.mode === "2d") this.materializeViewport();
3667
+ this.requestRender();
3668
+ return;
3669
+ }
3670
+ this.flight = null;
3671
+ flight.arrive();
3672
+ } catch (error) {
3673
+ this.cancelFlight();
3674
+ throw error;
3675
+ }
3676
+ flight.resolve(true);
3677
+ }
3678
+ cancelFlight = () => {
3679
+ const flight = this.flight;
3680
+ if (!flight) return;
3681
+ this.flight = null;
3682
+ flight.resolve(false);
3683
+ };
3684
+ setTheme(theme) {
3685
+ this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
3686
+ this.applyBackground();
3687
+ if (this.nodeMesh) this.materializeViewport();
3688
+ this.requestRender();
3689
+ }
3690
+ setBackground(background) {
3691
+ this.setTheme({ background });
3692
+ }
3693
+ resize() {
3694
+ const width = Math.max(this.container.clientWidth, 1);
3695
+ const height = Math.max(this.container.clientHeight, 1);
3696
+ this.renderer.setSize(width, height, false);
3697
+ if (this.camera instanceof PerspectiveCamera) {
3698
+ applyPerspectiveContainerAspect(this.camera, width, height);
3699
+ this.camera.updateProjectionMatrix();
3700
+ } else if (this.camera instanceof OrthographicCamera) {
3701
+ this.fitView();
3702
+ return;
3703
+ }
3704
+ this.materializeViewport();
3705
+ this.requestRender();
3706
+ }
3707
+ destroy() {
3708
+ this.destroyed = true;
3709
+ this.cancelFlight();
3710
+ this.stopAutoOrbit();
3711
+ this.resizeObserver.disconnect();
3712
+ this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
3713
+ this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
3714
+ for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.removeEventListener(type, this.cancelFlight);
3715
+ this.controls.removeEventListener("change", this.handleViewChange);
3716
+ this.controls.dispose();
3717
+ if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
3718
+ if (this.gpuTimer) for (const query of this.gpuTimer.pending) this.gpuTimer.context.deleteQuery(query);
3719
+ this.disposeBackgroundTexture();
3720
+ this.disposeGraphObjects();
3721
+ this.renderer.dispose();
3722
+ this.renderer.domElement.remove();
3723
+ }
3724
+ requestRender = () => {
3725
+ if (this.frameRequest !== null) return;
3726
+ this.frameRequest = requestAnimationFrame((nowMs) => {
3727
+ this.frameRequest = null;
3728
+ this.advanceFlight(nowMs);
3729
+ this.renderFrame();
3730
+ });
3731
+ };
3732
+ /** Renders a pending frame now instead of on the next animation frame. */
3733
+ flushRender() {
3734
+ if (this.frameRequest === null) return;
3735
+ cancelAnimationFrame(this.frameRequest);
3736
+ this.frameRequest = null;
3737
+ this.renderFrame();
3738
+ }
3739
+ renderFrame() {
3740
+ this.readGpuTimers();
3741
+ const timer = this.gpuTimer;
3742
+ const query = timer?.context.createQuery() ?? null;
3743
+ if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
3744
+ const startedAt = performance.now();
3745
+ this.renderer.render(this.scene, this.camera);
3746
+ this.cpuFrameMilliseconds = performance.now() - startedAt;
3747
+ if (timer && query) {
3748
+ timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
3749
+ timer.pending.push(query);
3750
+ }
3751
+ for (const listener of this.viewListeners) listener();
3752
+ }
3753
+ stopAutoOrbit() {
3754
+ this.autoOrbit?.dispose();
3755
+ this.autoOrbit = null;
3756
+ }
3757
+ /**
3758
+ * One orbit frame. `autoRotate` is on only for this `update(dt)` call: OrbitControls also calls
3759
+ * `update()` without a delta from wheel and key handlers and `fitView`, which would otherwise add
3760
+ * a fixed per-call rotation. A moved camera emits `change` (a render request only, see
3761
+ * `handleViewChange`); flushing that request renders this frame instead of the next one. During
3762
+ * a `flyTo` the orbit still turns the camera but leaves the pending frame to the flight, which
3763
+ * steps from the turned direction before it renders. A
3764
+ * throwing update, render or view listener turns the orbit off rather than leaving it enabled
3765
+ * without a loop.
3766
+ */
3767
+ stepAutoOrbit = (deltaSeconds) => {
3768
+ try {
3769
+ this.controls.autoRotate = true;
3770
+ this.orbitUpdating = true;
3771
+ try {
3772
+ this.controls.update(deltaSeconds);
3773
+ } finally {
3774
+ this.controls.autoRotate = false;
3775
+ this.orbitUpdating = false;
3776
+ }
3777
+ if (this.flight === null) this.flushRender();
3778
+ } catch (error) {
3779
+ this.stopAutoOrbit();
3780
+ throw error;
3781
+ }
3782
+ };
3783
+ readGpuTimers() {
3784
+ const timer = this.gpuTimer;
3785
+ if (!timer) return;
3786
+ while (timer.pending.length > 0) {
3787
+ const query = timer.pending[0];
3788
+ if (!query || !timer.context.getQueryParameter(query, timer.context.QUERY_RESULT_AVAILABLE)) return;
3789
+ timer.pending.shift();
3790
+ if (!timer.context.getParameter(timer.extension.GPU_DISJOINT_EXT)) this.gpuFrameMilliseconds = timer.context.getQueryParameter(query, timer.context.QUERY_RESULT) / 1e6;
3791
+ timer.context.deleteQuery(query);
3792
+ }
3793
+ }
3794
+ /**
3795
+ * Orbit frames skip materialization. In 3D the packed instances do not depend on camera
3796
+ * orientation: there is no viewport culling, density LOD and edge tiers follow counts only,
3797
+ * and the one orientation-dependent value (`worldUnitsPerPixel`, for edge hit slop) is used
3798
+ * only by 2D picking. 3D picking raycasts the instances and screen positions are projected per
3799
+ * call, so an orbit frame needs only a render, whose view listeners still run every frame.
3800
+ */
3801
+ handleViewChange = () => {
3802
+ if (!this.orbitUpdating) this.materializeViewport();
3803
+ this.requestRender();
3804
+ };
3805
+ createCamera() {
3806
+ const aspect = containerAspect(this.container.clientWidth, this.container.clientHeight);
3807
+ return this.mode === "2d" ? new OrthographicCamera(-1, 1, 1, -1, -1e5, 1e5) : new PerspectiveCamera(45, aspect, .1, 1e5);
3808
+ }
3809
+ createControls() {
3810
+ const controls = new OrbitControls(this.camera, this.renderer.domElement);
3811
+ controls.enableDamping = false;
3812
+ controls.screenSpacePanning = true;
3813
+ configureControlsForMode(controls, this.mode);
3814
+ controls.addEventListener("change", this.handleViewChange);
3815
+ return controls;
3816
+ }
3817
+ pickNode(clientX, clientY) {
3818
+ if (!this.nodeMesh || !this.nodePickMesh) return null;
3819
+ const bounds = this.renderer.domElement.getBoundingClientRect();
3820
+ if (bounds.width === 0 || bounds.height === 0) return null;
3821
+ this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
3822
+ if (this.camera instanceof OrthographicCamera) {
3823
+ const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
3824
+ const index = this.spatialGrid2d.pick(world.x, world.y);
3825
+ return index === null ? null : this.nodeIds[index] ?? null;
3826
+ }
3827
+ this.raycaster.setFromCamera(this.pointer, this.camera);
3828
+ const checkedInstances = /* @__PURE__ */ new Set();
3829
+ for (const hit of this.raycaster.intersectObject(this.nodePickMesh, false)) {
3830
+ if (hit.instanceId === void 0 || checkedInstances.has(hit.instanceId)) continue;
3831
+ checkedInstances.add(hit.instanceId);
3832
+ const nodeIndex = this.visibleNodeIndices[hit.instanceId];
3833
+ if (nodeIndex === void 0) continue;
3834
+ const node = this.data.nodes[nodeIndex];
3835
+ if (!node) continue;
3836
+ const { height, width } = resolveNodeAxes({
3837
+ height: node.height,
3838
+ nodeId: node.id,
3839
+ size: node.size,
3840
+ width: node.width
3841
+ });
3842
+ this.pickCenter.set(node.position.x, node.position.y, node.position.z ?? 0).project(this.camera);
3843
+ this.pickRight.set(1, 0, 0).applyQuaternion(this.camera.quaternion).multiplyScalar(width);
3844
+ this.pickRight.set(this.pickRight.x + node.position.x, this.pickRight.y + node.position.y, this.pickRight.z + (node.position.z ?? 0));
3845
+ this.pickRight.project(this.camera);
3846
+ this.pickUp.set(0, 1, 0).applyQuaternion(this.camera.quaternion).multiplyScalar(height);
3847
+ this.pickUp.set(this.pickUp.x + node.position.x, this.pickUp.y + node.position.y, this.pickUp.z + (node.position.z ?? 0));
3848
+ this.pickUp.project(this.camera);
3849
+ const radiusNdcX = Math.abs(this.pickRight.x - this.pickCenter.x);
3850
+ const radiusNdcY = Math.abs(this.pickUp.y - this.pickCenter.y);
3851
+ if (radiusNdcX === 0 || radiusNdcY === 0) continue;
3852
+ const offsetX = (this.pointer.x - this.pickCenter.x) / radiusNdcX;
3853
+ const offsetY = (this.pointer.y - this.pickCenter.y) / radiusNdcY;
3854
+ if (containsNodePoint(node.shape, offsetX, offsetY)) return this.nodeIds[nodeIndex] ?? null;
3855
+ }
3856
+ return null;
3857
+ }
3858
+ pickEdge(clientX, clientY) {
3859
+ if (!this.edgeMesh || this.pickableEdgeSegments.length === 0) return null;
3860
+ const bounds = this.renderer.domElement.getBoundingClientRect();
3861
+ if (bounds.width === 0 || bounds.height === 0) return null;
3862
+ this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
3863
+ if (this.camera instanceof OrthographicCamera) {
3864
+ const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
3865
+ const edgeIndex = pickClosestEdgeIndex({
3866
+ x: world.x,
3867
+ y: world.y
3868
+ }, this.pickableEdgeSegments);
3869
+ return edgeIndex === null ? null : this.data.edges[edgeIndex]?.id ?? null;
3870
+ }
3871
+ this.raycaster.setFromCamera(this.pointer, this.camera);
3872
+ for (const hit of this.raycaster.intersectObject(this.edgeMesh, false)) {
3873
+ if (hit.instanceId === void 0) continue;
3874
+ if (hit.instanceId >= this.visibleEdgeSegmentCount) continue;
3875
+ const edgeIndex = this.visibleEdgeSegmentEdgeIndices[hit.instanceId];
3876
+ if (edgeIndex === void 0) continue;
3877
+ return this.data.edges[edgeIndex]?.id ?? null;
3878
+ }
3879
+ return null;
3880
+ }
3881
+ worldUnitsPerPixel() {
3882
+ const width = Math.max(this.renderer.domElement.clientWidth, 1);
3883
+ if (this.camera instanceof OrthographicCamera) return (this.camera.right - this.camera.left) / (width * this.camera.zoom);
3884
+ const bounds = this.renderer.domElement.getBoundingClientRect();
3885
+ const left = this.screenToWorld(bounds.left, bounds.top);
3886
+ const right = this.screenToWorld(bounds.left + 1, bounds.top);
3887
+ if (!left || !right) return 1;
3888
+ return Math.hypot(right.x - left.x, right.y - left.y) || 1;
3889
+ }
3890
+ applySelectionColors(nodeIds) {
3891
+ if (!this.nodeMesh?.instanceColor) return;
3892
+ const slots = [];
3893
+ for (const nodeId of nodeIds) {
3894
+ const index = this.nodeIndices.get(nodeId);
3895
+ if (index === void 0) continue;
3896
+ const slot = this.visibleNodeSlots.get(index);
3897
+ if (slot === void 0) continue;
3898
+ const node = this.data.nodes[index];
3899
+ if (!node) continue;
3900
+ this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
3901
+ slots.push(slot);
3902
+ }
3903
+ markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
3904
+ this.glowLayer.rewrite(this.describeGlowNode);
3905
+ }
3906
+ /** Rebuilds the opt-in halo pass; graphs without glow release it and skip the visible scan. */
3907
+ syncGlow() {
3908
+ if (!this.hasGlowNodes) {
3909
+ this.glowLayer.dispose();
3910
+ return;
3911
+ }
3912
+ this.glowLayer.setBlending(glowBlendModeFor(this.theme.background));
3913
+ this.glowLayer.sync(this.visibleNodeIndices, this.glowAt, this.densityLodActive, this.describeGlowNode);
3914
+ }
3915
+ glowAt = (nodeIndex) => this.data.nodes[nodeIndex]?.glow ?? 0;
3916
+ describeGlowNode = (nodeIndex, target) => {
3917
+ const node = this.data.nodes[nodeIndex];
3918
+ if (!node) return false;
3919
+ const { height, width } = resolveNodeAxes({
3920
+ height: node.height,
3921
+ nodeId: node.id,
3922
+ size: node.size,
3923
+ width: node.width
3924
+ });
3925
+ target.color.copy(this.getNodeColor(node));
3926
+ target.extent = Math.max(width, height);
3927
+ target.glow = node.glow ?? 0;
3928
+ target.x = node.position.x;
3929
+ target.y = node.position.y;
3930
+ target.z = node.position.z ?? 0;
3931
+ return true;
3932
+ };
3933
+ getNodeStateIds(state) {
3934
+ switch (state) {
3935
+ case "dimmed": return this.dimmedNodeIds;
3936
+ case "focused": return this.focusedNodeIds;
3937
+ case "hovered": return this.hoveredNodeIds;
3938
+ case "selected": return this.selectedNodeIds;
3939
+ }
3940
+ }
3941
+ setNodeStateIds(state, nodeIds) {
3942
+ switch (state) {
3943
+ case "dimmed":
3944
+ this.dimmedNodeIds = nodeIds;
3945
+ return;
3946
+ case "focused":
3947
+ this.focusedNodeIds = nodeIds;
3948
+ return;
3949
+ case "hovered":
3950
+ this.hoveredNodeIds = nodeIds;
3951
+ return;
3952
+ case "selected": this.selectedNodeIds = nodeIds;
3953
+ }
3954
+ }
3955
+ getNodeColor(node) {
3956
+ if (this.focusedNodeIds.has(node.id)) return new Color(this.theme.focusedNode);
3957
+ if (this.selectedNodeIds.has(node.id)) return new Color(this.theme.selectedNode);
3958
+ if (this.hoveredNodeIds.has(node.id)) return new Color(this.theme.hoveredNode);
3959
+ if (this.dimmedNodeIds.has(node.id)) return new Color(this.theme.dimmedNode);
3960
+ return new Color(node.color ?? this.theme.node);
3961
+ }
3962
+ getViewportBounds2d() {
3963
+ if (!(this.camera instanceof OrthographicCamera) || !this.viewportCulling) return null;
3964
+ const bottomLeft = new Vector3(-1, -1, 0).unproject(this.camera);
3965
+ const topRight = new Vector3(1, 1, 0).unproject(this.camera);
3966
+ return {
3967
+ bottom: bottomLeft.y,
3968
+ left: bottomLeft.x,
3969
+ right: topRight.x,
3970
+ top: topRight.y
3971
+ };
3972
+ }
3973
+ isNodeVisible2d(node, bounds) {
3974
+ if (!bounds) return true;
3975
+ const { height, width } = resolveNodeAxes({
3976
+ height: node.height,
3977
+ nodeId: node.id,
3978
+ size: node.size,
3979
+ width: node.width
3980
+ });
3981
+ 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;
3982
+ }
3983
+ disposeBackgroundTexture() {
3984
+ if (!this.backgroundTexture) return;
3985
+ this.backgroundTexture.dispose();
3986
+ this.backgroundTexture = null;
3987
+ }
3988
+ createBackgroundTexture(source, repeat) {
3989
+ const texture = source instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas ? new CanvasTexture(source) : new Texture(source);
3990
+ texture.colorSpace = SRGBColorSpace;
3991
+ texture.needsUpdate = true;
3992
+ const [repeatX, repeatY] = repeat ?? [1, 1];
3993
+ if (repeatX !== 1 || repeatY !== 1) {
3994
+ texture.wrapS = RepeatWrapping;
3995
+ texture.wrapT = RepeatWrapping;
3996
+ texture.repeat.set(repeatX, repeatY);
3997
+ }
3998
+ return texture;
3999
+ }
4000
+ applyBackground() {
4001
+ this.disposeBackgroundTexture();
4002
+ const background = normalizeGraphraumBackground(this.theme.background);
4003
+ if (background === null) {
4004
+ this.scene.background = null;
4005
+ this.renderer.setClearColor(0, 0);
4006
+ return;
4007
+ }
4008
+ if (typeof background === "object") {
4009
+ this.backgroundTexture = this.createBackgroundTexture(background.source, background.repeat);
4010
+ this.scene.background = this.backgroundTexture;
4011
+ this.renderer.setClearColor(0, 1);
4012
+ return;
4013
+ }
4014
+ const color = new Color(background);
4015
+ this.scene.background = color;
4016
+ this.renderer.setClearColor(color, 1);
4017
+ }
4018
+ materializeViewport() {
4019
+ if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return;
4020
+ const bounds = this.getViewportBounds2d();
4021
+ const visibleNodeCandidates = bounds ? this.spatialGrid2d.queryBounds(bounds, this.viewportOverscan) : this.data.nodes.map((_, index) => index);
4022
+ this.densityLodActive = shouldUseDensityLod(visibleNodeCandidates.length, this.maxVisibleNodes, this.densityLodActive);
4023
+ const materializedNodes = this.materializeNodes(visibleNodeCandidates, bounds);
4024
+ const visibleNodeIndices = materializedNodes.map(({ index }) => index);
4025
+ const edgeCandidates = bounds ? collectIncidentEdges(visibleNodeIndices, this.incidentEdgeIndices) : this.data.edges.map((_, index) => index);
4026
+ const visibleEdgeIndices = applyEdgeBudget(edgeCandidates, this.maxVisibleEdges);
4027
+ const matrix = new Matrix4();
4028
+ this.visibleNodeSlots = /* @__PURE__ */ new Map();
4029
+ this.visibleNodeIndices = visibleNodeIndices;
4030
+ this.nodeMesh.instanceMatrix.clearUpdateRanges();
4031
+ this.nodePickMesh.instanceMatrix.clearUpdateRanges();
4032
+ this.nodeMesh.instanceColor?.clearUpdateRanges();
4033
+ const nodeShape = this.nodeMesh.geometry.getAttribute("instanceShape");
4034
+ const nodeStrokeWidth = this.nodeMesh.geometry.getAttribute("instanceStrokeWidth");
4035
+ const nodeStrokeColor = this.nodeMesh.geometry.getAttribute("instanceStrokeColor");
4036
+ nodeShape.clearUpdateRanges();
4037
+ nodeStrokeWidth.clearUpdateRanges();
4038
+ nodeStrokeColor.clearUpdateRanges();
4039
+ for (const [slot, materialized] of materializedNodes.entries()) {
4040
+ const nodeIndex = materialized.index;
4041
+ const node = this.data.nodes[nodeIndex];
4042
+ if (!node) continue;
4043
+ this.visibleNodeSlots.set(nodeIndex, slot);
4044
+ const { height, width } = materialized;
4045
+ const maxAxis = Math.max(width, height);
4046
+ matrix.makeScale(width, height, maxAxis);
4047
+ matrix.setPosition(materialized.x, materialized.y, materialized.z);
4048
+ this.nodeMesh.setMatrixAt(slot, matrix);
4049
+ const pickScale = maxAxis * Math.SQRT2;
4050
+ matrix.makeScale(pickScale, pickScale, pickScale);
4051
+ matrix.setPosition(materialized.x, materialized.y, materialized.z);
4052
+ this.nodePickMesh.setMatrixAt(slot, matrix);
4053
+ this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
4054
+ setNodeShapeAt(nodeShape, slot, node.shape);
4055
+ setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, slot, resolveNodeStroke(node, this.theme));
4056
+ }
4057
+ this.nodeMesh.count = materializedNodes.length;
4058
+ this.nodePickMesh.count = materializedNodes.length;
4059
+ this.nodeMesh.instanceMatrix.addUpdateRange(0, materializedNodes.length * 16);
4060
+ this.nodeMesh.instanceMatrix.needsUpdate = true;
4061
+ this.nodePickMesh.instanceMatrix.addUpdateRange(0, materializedNodes.length * 16);
4062
+ this.nodePickMesh.instanceMatrix.needsUpdate = true;
4063
+ if (this.nodeMesh.instanceColor) {
4064
+ this.nodeMesh.instanceColor.addUpdateRange(0, materializedNodes.length * 3);
4065
+ this.nodeMesh.instanceColor.needsUpdate = true;
4066
+ }
4067
+ nodeShape.addUpdateRange(0, materializedNodes.length);
4068
+ nodeShape.needsUpdate = true;
4069
+ nodeStrokeWidth.addUpdateRange(0, materializedNodes.length);
4070
+ nodeStrokeWidth.needsUpdate = true;
4071
+ nodeStrokeColor.addUpdateRange(0, materializedNodes.length * 3);
4072
+ nodeStrokeColor.needsUpdate = true;
4073
+ const lodLevel = resolveLodLevel(this.densityLodActive, edgeCandidates.length, visibleEdgeIndices.length);
4074
+ this.lastEdgeLodTier = edgeTierFromDiagnosticsLod(lodLevel);
4075
+ const worldPerPixel = this.worldUnitsPerPixel();
4076
+ const packed = packEdgeInstances({
4077
+ edgeIndices: visibleEdgeIndices,
4078
+ edgeStates: this.edgeStateStyling(),
4079
+ edgeVisuals: this.data.edges,
4080
+ endpointPositions: this.canonicalEdgePositions,
4081
+ defaults: {
4082
+ color: this.theme.edge,
4083
+ opacity: this.theme.edgeOpacity,
4084
+ width: this.theme.edgeWidth
4085
+ },
4086
+ tier: this.lastEdgeLodTier,
4087
+ maxSegments: this.edgeSegmentCapacity,
4088
+ endpointAttach: this.theme.endpointAttach,
4089
+ edgeNodeIndices: this.edgeNodeIndices,
4090
+ mode: this.mode,
4091
+ nodeOutlines: this.data.nodes.map((node) => ({
4092
+ height: node.height,
4093
+ shape: node.shape,
4094
+ size: node.size,
4095
+ strokeWidth: node.strokeWidth,
4096
+ width: node.width
4097
+ }))
4098
+ });
4099
+ const edgeGeometry = this.edgeMesh.geometry;
4100
+ this.visibleEdgeSlots = /* @__PURE__ */ new Map();
4101
+ this.visibleEdgeLayouts = buildVisibleEdgeLayouts(packed.segments, packed.markers, packed.segments.length);
4102
+ this.pickableEdgeSegments = [];
4103
+ this.visibleEdgeSegmentEdgeIndices = [];
4104
+ let edgeSlot = 0;
4105
+ const distinctEdges = /* @__PURE__ */ new Set();
4106
+ const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
4107
+ for (const segment of packed.segments) {
4108
+ writeEdgeSegmentInstance(edgeGeometry, edgeSlot, segment);
4109
+ if (!this.visibleEdgeSlots.has(segment.edgeIndex)) this.visibleEdgeSlots.set(segment.edgeIndex, edgeSlot);
4110
+ distinctEdges.add(segment.edgeIndex);
4111
+ this.visibleEdgeSegmentEdgeIndices.push(segment.edgeIndex);
4112
+ this.pickableEdgeSegments.push({
4113
+ edgeIndex: segment.edgeIndex,
4114
+ hitSlop: Math.max(minHitSlop, segment.width * worldPerPixel / 2 + 2 * worldPerPixel),
4115
+ x1: segment.x1,
4116
+ y1: segment.y1,
4117
+ x2: segment.x2,
4118
+ y2: segment.y2
4119
+ });
4120
+ edgeSlot += 1;
4121
+ }
4122
+ const segmentCount = edgeSlot;
4123
+ for (const marker of packed.markers) {
4124
+ writeEdgeMarkerInstance(edgeGeometry, edgeSlot, marker);
4125
+ edgeSlot += 1;
4126
+ }
4127
+ this.edgeMesh.count = edgeSlot;
4128
+ for (const name of EDGE_INSTANCE_ATTRIBUTES) {
4129
+ const attribute = edgeGeometry.getAttribute(name);
4130
+ attribute.clearUpdateRanges();
4131
+ attribute.addUpdateRange(0, edgeSlot * attribute.itemSize);
4132
+ attribute.needsUpdate = true;
4133
+ }
4134
+ this.visibleNodeCandidateCount = visibleNodeCandidates.length;
4135
+ this.visibleNodeCount = materializedNodes.length;
4136
+ this.visibleEdgeCandidateCount = edgeCandidates.length;
4137
+ this.visibleEdgeSegmentCount = segmentCount;
4138
+ this.visibleEdgeCount = distinctEdges.size;
4139
+ this.visibleEdgeMarkerCount = packed.markers.length;
4140
+ this.syncGlow();
4141
+ }
4142
+ materializeNodes(indices, bounds) {
4143
+ if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
4144
+ const node = this.data.nodes[index];
4145
+ if (!node) return [];
4146
+ const { height, width } = resolveNodeAxes({
4147
+ height: node.height,
4148
+ nodeId: node.id,
4149
+ size: node.size,
4150
+ width: node.width
4151
+ });
4152
+ return [{
4153
+ height,
4154
+ index,
4155
+ width,
4156
+ x: node.position.x,
4157
+ y: node.position.y,
4158
+ z: node.position.z ?? 0
4159
+ }];
4160
+ });
4161
+ const aspect = Math.max(this.container.clientWidth, 1) / Math.max(this.container.clientHeight, 1);
4162
+ const columns = Math.max(1, Math.floor(Math.sqrt(this.maxVisibleNodes * aspect)));
4163
+ const rows = Math.max(1, Math.floor(this.maxVisibleNodes / columns));
4164
+ const cellWidth = Math.max((bounds.right - bounds.left) / columns, Number.EPSILON);
4165
+ const cellHeight = Math.max((bounds.top - bounds.bottom) / rows, Number.EPSILON);
4166
+ const clusters = /* @__PURE__ */ new Map();
4167
+ for (const index of indices) {
4168
+ const node = this.data.nodes[index];
4169
+ if (!node) continue;
4170
+ const axes = resolveNodeAxes({
4171
+ height: node.height,
4172
+ nodeId: node.id,
4173
+ size: node.size,
4174
+ width: node.width
4175
+ });
4176
+ const axisSize = Math.max(axes.width, axes.height);
4177
+ 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)))}`;
4178
+ const cluster = clusters.get(key);
4179
+ if (!cluster) {
4180
+ clusters.set(key, {
4181
+ count: 1,
4182
+ height: axisSize,
4183
+ index,
4184
+ width: axisSize,
4185
+ x: node.position.x,
4186
+ y: node.position.y,
4187
+ z: node.position.z ?? 0
4188
+ });
4189
+ continue;
4190
+ }
4191
+ cluster.count += 1;
4192
+ cluster.x += (node.position.x - cluster.x) / cluster.count;
4193
+ cluster.y += (node.position.y - cluster.y) / cluster.count;
4194
+ cluster.z += ((node.position.z ?? 0) - cluster.z) / cluster.count;
4195
+ const grownAxis = Math.min(Math.max(cluster.width, axisSize) + .25, 16);
4196
+ cluster.width = grownAxis;
4197
+ cluster.height = grownAxis;
4198
+ }
4199
+ return [...clusters.values()].slice(0, this.maxVisibleNodes);
4200
+ }
4201
+ disposeGraphObjects() {
4202
+ this.glowLayer.dispose();
4203
+ this.pickableEdgeSegments = [];
4204
+ this.visibleEdgeSegmentEdgeIndices = [];
4205
+ this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
4206
+ if (this.nodeMesh) {
4207
+ this.scene.remove(this.nodeMesh);
4208
+ this.nodeMesh.geometry.dispose();
4209
+ disposeMaterial(this.nodeMesh.material);
4210
+ this.nodeMesh = null;
4211
+ }
4212
+ if (this.nodePickMesh) {
4213
+ this.nodePickMesh.geometry.dispose();
4214
+ disposeMaterial(this.nodePickMesh.material);
4215
+ this.nodePickMesh = null;
4216
+ }
4217
+ if (this.edgeMesh) {
4218
+ this.scene.remove(this.edgeMesh);
4219
+ this.edgeMesh.geometry.dispose();
4220
+ disposeMaterial(this.edgeMesh.material);
4221
+ this.edgeMesh = null;
4222
+ }
4223
+ }
4224
+ };
4225
+ /** The 2D fit: the nodes' padded bounds, framed with 15% slack on the tighter axis. */
4226
+ function orthographicFit(aspect, nodes) {
4227
+ const bounds = new Box3();
4228
+ const minimum = new Vector3();
4229
+ const maximum = new Vector3();
4230
+ for (const node of nodes) {
4231
+ const { height, width } = resolveNodeAxes({
4232
+ height: node.height,
4233
+ nodeId: node.id,
4234
+ size: node.size,
4235
+ width: node.width
4236
+ });
4237
+ const radius = Math.max(width, height);
4238
+ const z = node.position.z ?? 0;
4239
+ bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
4240
+ bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
4241
+ }
4242
+ const center = bounds.getCenter(new Vector3());
4243
+ const size = bounds.getSize(new Vector3());
4244
+ return {
4245
+ cameraZ: center.z + Math.max(size.z, 1e3),
4246
+ center,
4247
+ visibleHeight: Math.max(size.y, size.x / aspect, 1) * 1.15
4248
+ };
4249
+ }
4250
+ /** Sets a frustum that shows `visibleHeight` world units at zoom 1 and resets the zoom. */
4251
+ function setOrthographicFrustum(camera, aspect, visibleHeight) {
4252
+ camera.left = -visibleHeight * aspect / 2;
4253
+ camera.right = visibleHeight * aspect / 2;
4254
+ camera.top = visibleHeight / 2;
4255
+ camera.bottom = -visibleHeight / 2;
4256
+ camera.zoom = 1;
4257
+ }
4258
+ function hasNodeGlow(node) {
4259
+ return (node.glow ?? 0) > 0;
4260
+ }
4261
+ function nextCapacity(length) {
4262
+ let capacity = 1;
4263
+ while (capacity < length) capacity *= 2;
4264
+ return capacity;
4265
+ }
4266
+ /**
4267
+ * Segment capacity is edgeCapacity × DETAIL_MAX_SEGMENTS (cubic worst case).
4268
+ * Marker capacity is always exactly 2× edge capacity (both ends) — never derived from
4269
+ * `nextCapacity(edgeCount * 2)` independently, which can undershoot for small edge counts
4270
+ * and let a later append with `markerEnd: "both"` overrun the instanced buffer.
4271
+ */
4272
+ function edgeInstanceCapacities(edgeCount, maxVisibleEdges) {
4273
+ const edgeCapacity = Math.min(maxVisibleEdges, nextCapacity(edgeCount));
4274
+ const segmentCapacity = edgeCapacity * 24;
4275
+ const markerCapacity = edgeCapacity * 2;
4276
+ return {
4277
+ edgeCapacity,
4278
+ segmentCapacity,
4279
+ markerCapacity,
4280
+ edgeInstanceCapacity: segmentCapacity + markerCapacity
4281
+ };
4282
+ }
4283
+ //#endregion
4284
+ //#region src/visuals.ts
4285
+ /** Defines typed, serializable graph visuals without coupling application data to the render loop. */
4286
+ function defineVisuals(visuals) {
4287
+ return visuals;
4288
+ }
4289
+ //#endregion
4290
+ 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 };