@yggy-tech/graphraum 0.28.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +11 -16
  2. package/dist/index.d.ts +184 -7
  3. package/dist/index.js +1383 -242
  4. package/package.json +54 -55
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Box3, CanvasTexture, Color, InstancedBufferAttribute, InstancedMesh, MOUSE, Matrix4, MeshBasicMaterial, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, ShaderMaterial, SphereGeometry, Texture, Vector2, Vector3, WebGLRenderer } from "three";
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
2
  import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
3
3
  //#region src/node-axes.ts
4
4
  function assertPositiveFinite(value, axis, nodeId) {
@@ -100,7 +100,12 @@ function containsNodePoint(shape, x, y) {
100
100
  //#region src/edge-endpoint-attach.ts
101
101
  const DEFAULT_ENDPOINT_ATTACH = "boundary";
102
102
  const DEFAULT_ENDPOINT_CLEARANCE = .75;
103
- function isInsideOutline(outline, worldX, worldY, center) {
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) {
104
109
  const { height, width } = resolveNodeAxes({
105
110
  height: outline.height,
106
111
  size: outline.size,
@@ -109,14 +114,36 @@ function isInsideOutline(outline, worldX, worldY, center) {
109
114
  const stroke = Math.max(0, outline.strokeWidth ?? 0);
110
115
  const halfWidth = width + stroke;
111
116
  const halfHeight = height + stroke;
112
- if (halfWidth <= 0 || halfHeight <= 0) return false;
113
- return containsNodePoint(outline.shape, (worldX - center.x) / halfWidth, (worldY - center.y) / halfHeight);
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
+ * How far an edge end stops short of a 3D node's center, in world units: the outline radius (stroke
128
+ * included) less `clearance`. The renderer applies it on screen, where the camera-facing outline
129
+ * is, so it is exact for circles from every view. Other shapes use their radius along +x.
130
+ */
131
+ function outlineReach(outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
132
+ return Math.max(0, outlineRadius(outline, 1, 0) - Math.max(clearance, 0));
114
133
  }
115
134
  /**
116
135
  * Walk from the node center toward the far point and stop on the outline,
117
136
  * then pull back by `clearance` so the stroke is not stabbed.
137
+ *
138
+ * In `2d` the outline lies in the xy plane, so only the xy part of the chord counts. In `3d` the
139
+ * billboard faces the camera, so the edge stops one outline radius from the center along the 3D
140
+ * segment. The radius is measured along the chord's world xy heading (or +x when the chord is
141
+ * parallel to z), not along its on-screen heading. For circles the radius is the same in every
142
+ * direction, so the projected end lies on the outline or inside it (where the node covers the
143
+ * edge) from every view. Other shapes are approximate in 3D: an elongated or angular node can end
144
+ * inside its outline or short of it, depending on the camera.
118
145
  */
119
- function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
146
+ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE, mode = "2d") {
120
147
  const dx = far.x - center.x;
121
148
  const dy = far.y - center.y;
122
149
  const dz = (far.z ?? 0) - (center.z ?? 0);
@@ -126,15 +153,12 @@ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT
126
153
  y: center.y,
127
154
  z: center.z ?? 0
128
155
  };
129
- let low = 0;
130
- let high = 1;
131
- for (let step = 0; step < 24; step += 1) {
132
- const mid = (low + high) / 2;
133
- if (isInsideOutline(outline, center.x + dx * mid, center.y + dy * mid, center)) low = mid;
134
- else high = mid;
135
- }
156
+ const planar = Math.hypot(dx, dy);
157
+ const reach = mode === "3d" ? length : planar;
158
+ const radius = planar < 1e-6 ? outlineRadius(outline, 1, 0) : outlineRadius(outline, dx / planar, dy / planar);
159
+ const outlineT = reach < 1e-6 ? 1 : Math.min(1, radius / reach);
136
160
  const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
137
- const t = Math.max(0, low - clearanceT);
161
+ const t = Math.max(0, outlineT - clearanceT);
138
162
  return {
139
163
  x: center.x + dx * t,
140
164
  y: center.y + dy * t,
@@ -156,8 +180,8 @@ function trimEdgeEndpoints(input) {
156
180
  }
157
181
  };
158
182
  const clearance = input.clearance ?? .75;
159
- const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance);
160
- const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance);
183
+ const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance, input.mode);
184
+ const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance, input.mode);
161
185
  const ox = input.target.x - input.source.x;
162
186
  const oy = input.target.y - input.source.y;
163
187
  const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
@@ -245,6 +269,7 @@ function encodeEdgeStyle(style) {
245
269
  //#region src/force-layout.ts
246
270
  const DEFAULT_FORCE_SETTINGS = {
247
271
  centerAttraction: .002,
272
+ clusterStrength: .02,
248
273
  damping: .82,
249
274
  linkDistance: 24,
250
275
  repulsion: 400,
@@ -253,6 +278,7 @@ const DEFAULT_FORCE_SETTINGS = {
253
278
  function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
254
279
  return {
255
280
  centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
281
+ clusterStrength: Math.max(0, Math.min(.1, settings.clusterStrength ?? DEFAULT_FORCE_SETTINGS.clusterStrength)),
256
282
  damping: Math.max(.1, Math.min(.99, settings.damping)),
257
283
  linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
258
284
  repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
@@ -311,6 +337,60 @@ function random(seed) {
311
337
  value = Math.imul(value ^ value >>> 13, 3266489909);
312
338
  return ((value ^ value >>> 16) >>> 0) / 4294967296;
313
339
  }
340
+ function assertClusterCount(clusters, expected, subject) {
341
+ if (clusters.length !== expected) throw new Error(`Cluster index count (${clusters.length}) must match ${subject} (${expected}).`);
342
+ }
343
+ /**
344
+ * Live simulations index per-cluster scratch arrays by id, so ids must be dense: below the node count.
345
+ * Validate before allocating anything.
346
+ */
347
+ function assertDenseClusterIds(clusters, totalNodeCount) {
348
+ 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.`);
349
+ }
350
+ function clusterCountOf(clusters) {
351
+ let clusterCount = 0;
352
+ for (const cluster of clusters) clusterCount = Math.max(clusterCount, cluster + 1);
353
+ return clusterCount;
354
+ }
355
+ /**
356
+ * Seeds nodes around cluster centres: centres come from a force layout of the inter-cluster graph
357
+ * and each node is scattered around its centre. Shared by the one-shot and live clustered layouts.
358
+ */
359
+ function clusteredInitialPositions(request) {
360
+ const clusterCount = clusterCountOf(request.clusters);
361
+ const clusterEdges = new Uint32Array(request.edges.length);
362
+ let edgeCursor = 0;
363
+ for (let index = 0; index < request.edges.length; index += 2) {
364
+ const source = request.clusters[request.edges[index] ?? 0];
365
+ const target = request.clusters[request.edges[index + 1] ?? 0];
366
+ if (source === void 0 || target === void 0 || source === target) continue;
367
+ clusterEdges[edgeCursor++] = source;
368
+ clusterEdges[edgeCursor++] = target;
369
+ }
370
+ const centers = computeForcePositions({
371
+ dimensions: request.dimensions,
372
+ edges: clusterEdges.subarray(0, edgeCursor),
373
+ nodeCount: clusterCount,
374
+ settings: request.settings
375
+ });
376
+ const clusterSizes = new Uint32Array(clusterCount);
377
+ for (const cluster of request.clusters) clusterSizes[cluster] += 1;
378
+ const positions = new Float32Array(request.nodeCount * 3);
379
+ for (let index = 0; index < request.nodeCount; index += 1) {
380
+ const offset = index * 3;
381
+ const cluster = request.clusters[index] ?? 0;
382
+ const center = cluster * 3;
383
+ const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
384
+ const angle = random(index * 4 + 1) * Math.PI * 2;
385
+ positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
386
+ positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
387
+ if (request.dimensions === 3) {
388
+ const zDirection = random(index * 4 + 3) * 2 - 1;
389
+ positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
390
+ }
391
+ }
392
+ return positions;
393
+ }
314
394
  function createForceSimulation(request) {
315
395
  const settings = normalizeForceSettings(request.settings);
316
396
  let nodeCount = request.nodeCount;
@@ -319,10 +399,41 @@ function createForceSimulation(request) {
319
399
  const edgeCount = edges.length / 2;
320
400
  let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
321
401
  let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
322
- let positions = initialPositions(request);
402
+ let clusters = null;
403
+ if (request.clusters) {
404
+ assertClusterCount(request.clusters, request.nodeCount, "node count");
405
+ assertDenseClusterIds(request.clusters, request.nodeCount);
406
+ clusters = new Uint32Array(request.clusters);
407
+ }
408
+ let positions = clusters ? clusteredInitialPositions({
409
+ ...request,
410
+ clusters
411
+ }) : initialPositions(request);
323
412
  let velocities = new Float32Array(positions.length);
324
413
  let forces = new Float32Array(positions.length);
325
414
  let iteration = 0;
415
+ let clusterCount = clusters ? clusterCountOf(clusters) : 0;
416
+ let clusterCentroids = new Float64Array(clusterCount * 3);
417
+ let clusterSizes = new Uint32Array(clusterCount);
418
+ /** Mean position per cluster in O(N): no per-node allocation. */
419
+ function updateClusterCentroids(activeClusters) {
420
+ clusterCentroids.fill(0);
421
+ clusterSizes.fill(0);
422
+ for (let index = 0; index < nodeCount; index += 1) {
423
+ const cluster = activeClusters[index] ?? 0;
424
+ const offset = index * 3;
425
+ const centroid = cluster * 3;
426
+ clusterSizes[cluster] += 1;
427
+ clusterCentroids[centroid] += positions[offset] ?? 0;
428
+ clusterCentroids[centroid + 1] += positions[offset + 1] ?? 0;
429
+ if (request.dimensions === 3) clusterCentroids[centroid + 2] += positions[offset + 2] ?? 0;
430
+ }
431
+ for (let cluster = 0; cluster < clusterCount; cluster += 1) {
432
+ const size = clusterSizes[cluster] ?? 0;
433
+ if (size === 0) continue;
434
+ for (let axis = 0; axis < request.dimensions; axis += 1) clusterCentroids[cluster * 3 + axis] /= size;
435
+ }
436
+ }
326
437
  return {
327
438
  get nodeCount() {
328
439
  return nodeCount;
@@ -333,10 +444,20 @@ function createForceSimulation(request) {
333
444
  get positions() {
334
445
  return positions;
335
446
  },
336
- /** Add nodes while preserving every existing position and velocity. */
337
- addNodes(count, initial) {
447
+ /**
448
+ * Add nodes while preserving every existing position and velocity. A simulation created with
449
+ * `clusters` requires one cluster index per added node (a new id starts a new cluster); a
450
+ * simulation created without clusters rejects them.
451
+ */
452
+ addNodes(count, initial, addedClusters) {
338
453
  if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
339
454
  if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
455
+ if (clusters && !addedClusters) throw new Error("This simulation was created with clusters: addNodes() requires a clusters array.");
456
+ if (!clusters && addedClusters) throw new Error("This simulation was created without clusters: addNodes() cannot take a clusters array.");
457
+ if (addedClusters) {
458
+ assertClusterCount(addedClusters, count, "added node count");
459
+ assertDenseClusterIds(addedClusters, nodeCount + count);
460
+ }
340
461
  const start = nodeCount;
341
462
  const nextPositions = new Float32Array((nodeCount + count) * 3);
342
463
  nextPositions.set(positions);
@@ -352,6 +473,24 @@ function createForceSimulation(request) {
352
473
  });
353
474
  nextPositions.set(added, positions.length);
354
475
  }
476
+ let nextClusters = clusters;
477
+ let nextClusterCount = clusterCount;
478
+ let nextCentroids = clusterCentroids;
479
+ let nextSizes = clusterSizes;
480
+ if (clusters && addedClusters) {
481
+ nextClusters = new Uint32Array(nodeCount + count);
482
+ nextClusters.set(clusters);
483
+ nextClusters.set(addedClusters, nodeCount);
484
+ nextClusterCount = Math.max(clusterCount, clusterCountOf(addedClusters));
485
+ if (nextClusterCount !== clusterCount) {
486
+ nextCentroids = new Float64Array(nextClusterCount * 3);
487
+ nextSizes = new Uint32Array(nextClusterCount);
488
+ }
489
+ }
490
+ clusters = nextClusters;
491
+ clusterCount = nextClusterCount;
492
+ clusterCentroids = nextCentroids;
493
+ clusterSizes = nextSizes;
355
494
  positions = nextPositions;
356
495
  velocities = nextVelocities;
357
496
  forces = nextForces;
@@ -383,11 +522,18 @@ function createForceSimulation(request) {
383
522
  step(alpha) {
384
523
  if (nodeCount === 0) return;
385
524
  forces.fill(0);
525
+ const pullClusters = clusters && settings.clusterStrength > 0 ? clusters : null;
526
+ const clusterPull = settings.clusterStrength * alpha;
527
+ if (pullClusters) updateClusterCentroids(pullClusters);
386
528
  for (let index = 0; index < nodeCount; index += 1) {
387
529
  const offset = index * 3;
388
530
  forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
389
531
  forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
390
532
  forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
533
+ if (pullClusters) {
534
+ const centroid = (pullClusters[index] ?? 0) * 3;
535
+ for (let axis = 0; axis < request.dimensions; axis += 1) forces[offset + axis] += ((clusterCentroids[centroid + axis] ?? 0) - (positions[offset + axis] ?? 0)) * clusterPull;
536
+ }
391
537
  for (let sample = 0; sample < 8; sample += 1) {
392
538
  const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
393
539
  if (targetIndex === index) continue;
@@ -469,41 +615,8 @@ function computeForcePositions(request) {
469
615
  return simulation.positions;
470
616
  }
471
617
  function computeClusteredForcePositions(request) {
472
- if (request.clusters.length !== request.nodeCount) throw new Error("Cluster index count must match node count.");
473
- let clusterCount = 0;
474
- for (const cluster of request.clusters) clusterCount = Math.max(clusterCount, cluster + 1);
475
- const clusterEdges = new Uint32Array(request.edges.length);
476
- let edgeCursor = 0;
477
- for (let index = 0; index < request.edges.length; index += 2) {
478
- const source = request.clusters[request.edges[index] ?? 0];
479
- const target = request.clusters[request.edges[index + 1] ?? 0];
480
- if (source === void 0 || target === void 0 || source === target) continue;
481
- clusterEdges[edgeCursor++] = source;
482
- clusterEdges[edgeCursor++] = target;
483
- }
484
- const centers = computeForcePositions({
485
- dimensions: request.dimensions,
486
- edges: clusterEdges.subarray(0, edgeCursor),
487
- nodeCount: clusterCount,
488
- settings: request.settings
489
- });
490
- const clusterSizes = new Uint32Array(clusterCount);
491
- for (const cluster of request.clusters) clusterSizes[cluster] += 1;
492
- const positions = new Float32Array(request.nodeCount * 3);
493
- for (let index = 0; index < request.nodeCount; index += 1) {
494
- const offset = index * 3;
495
- const cluster = request.clusters[index] ?? 0;
496
- const center = cluster * 3;
497
- const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
498
- const angle = random(index * 4 + 1) * Math.PI * 2;
499
- positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
500
- positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
501
- if (request.dimensions === 3) {
502
- const zDirection = random(index * 4 + 3) * 2 - 1;
503
- positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
504
- }
505
- }
506
- return positions;
618
+ assertClusterCount(request.clusters, request.nodeCount, "node count");
619
+ return clusteredInitialPositions(request);
507
620
  }
508
621
  //#endregion
509
622
  //#region src/graphology.ts
@@ -570,6 +683,188 @@ function bindGraphology(renderer, graph) {
570
683
  graph.off("edgesCleared", schedule);
571
684
  };
572
685
  }
686
+ /** Longest frame step the orbit applies, so a throttled or backgrounded tab does not jump on return. */
687
+ const AUTO_ORBIT_MAX_DELTA_SECONDS = .1;
688
+ const AUTO_ORBIT_OFF = {
689
+ enabled: false,
690
+ hidden: false,
691
+ interacting: false,
692
+ offscreen: false
693
+ };
694
+ function resolveAutoOrbitOptions(options) {
695
+ const speed = options.speed ?? .07;
696
+ const resumeAfterMs = options.resumeAfterMs ?? 3e3;
697
+ 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}.`);
698
+ if (!Number.isFinite(resumeAfterMs) || resumeAfterMs < 0) throw new Error(`Auto-orbit resumeAfterMs must be a non-negative finite number of milliseconds; received ${resumeAfterMs}.`);
699
+ return {
700
+ speed,
701
+ resumeAfterMs
702
+ };
703
+ }
704
+ function nextAutoOrbitState(state, event) {
705
+ if (event.type === "enable") return state.enabled ? {
706
+ ...state,
707
+ hidden: event.hidden
708
+ } : {
709
+ ...AUTO_ORBIT_OFF,
710
+ enabled: true,
711
+ hidden: event.hidden
712
+ };
713
+ if (!state.enabled) return state;
714
+ switch (event.type) {
715
+ case "disable": return AUTO_ORBIT_OFF;
716
+ case "interaction": return {
717
+ ...state,
718
+ interacting: true
719
+ };
720
+ case "idle": return {
721
+ ...state,
722
+ interacting: false
723
+ };
724
+ case "visibility": return {
725
+ ...state,
726
+ hidden: event.hidden
727
+ };
728
+ case "intersection": return {
729
+ ...state,
730
+ offscreen: event.offscreen
731
+ };
732
+ }
733
+ }
734
+ function autoOrbitStatus(state) {
735
+ if (!state.enabled) return "off";
736
+ return state.hidden || state.interacting || state.offscreen ? "paused" : "active";
737
+ }
738
+ /** Seconds between two animation-frame timestamps, zero on the first frame, clamped to `[0, AUTO_ORBIT_MAX_DELTA_SECONDS]`. */
739
+ function autoOrbitDeltaSeconds(previousMs, nowMs) {
740
+ if (previousMs === null) return 0;
741
+ return Math.min(Math.max((nowMs - previousMs) / 1e3, 0), AUTO_ORBIT_MAX_DELTA_SECONDS);
742
+ }
743
+ /**
744
+ * OrbitControls (three r185) rotates `(2π / 60) * autoRotateSpeed * deltaTime` radians when
745
+ * `update(deltaTime)` receives seconds (and a fixed per-call step without it). Inverting that
746
+ * gives an `autoRotateSpeed` for which `update(dtSeconds)` turns exactly `radiansPerSecond`,
747
+ * independent of frame rate.
748
+ */
749
+ function autoRotateSpeedFor(radiansPerSecond) {
750
+ return radiansPerSecond * 60 / (2 * Math.PI);
751
+ }
752
+ //#endregion
753
+ //#region src/auto-orbit-loop.ts
754
+ /**
755
+ * Owns the auto-orbit frame loop and the browser signals that stop it. The loop exists only
756
+ * while the orbit is active: pointer, wheel and key input pause it until `resumeAfterMs` of
757
+ * idle, and a hidden document or an off-screen element stop it until they change back.
758
+ * Decisions come from the pure state in `auto-orbit.ts`; this class only wires DOM events,
759
+ * the resume timer and `requestAnimationFrame`. Interaction comes from the controls' `start`
760
+ * and `end` events, so multi-touch and lost pointer capture end the pause only once the last
761
+ * pointer lifts. DOM overlays above the canvas do not reach the controls and do not pause it.
762
+ */
763
+ var AutoOrbitLoop = class {
764
+ element;
765
+ interactions;
766
+ resumeAfterMs;
767
+ step;
768
+ state;
769
+ frame = null;
770
+ lastFrameMs = null;
771
+ resumeTimer = null;
772
+ observer;
773
+ constructor(element, interactions, resumeAfterMs, step) {
774
+ this.element = element;
775
+ this.interactions = interactions;
776
+ this.resumeAfterMs = resumeAfterMs;
777
+ this.step = step;
778
+ this.state = nextAutoOrbitState(AUTO_ORBIT_OFF, {
779
+ type: "enable",
780
+ hidden: element.ownerDocument.hidden
781
+ });
782
+ interactions.addEventListener("start", this.handleInteractionStart);
783
+ interactions.addEventListener("end", this.handleInteractionEnd);
784
+ element.ownerDocument.addEventListener("visibilitychange", this.handleVisibilityChange);
785
+ this.observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(this.handleIntersection);
786
+ this.observer?.observe(element);
787
+ this.sync();
788
+ }
789
+ get status() {
790
+ return autoOrbitStatus(this.state);
791
+ }
792
+ setResumeAfterMs(resumeAfterMs) {
793
+ this.resumeAfterMs = resumeAfterMs;
794
+ }
795
+ dispose() {
796
+ this.interactions.removeEventListener("start", this.handleInteractionStart);
797
+ this.interactions.removeEventListener("end", this.handleInteractionEnd);
798
+ this.element.ownerDocument.removeEventListener("visibilitychange", this.handleVisibilityChange);
799
+ this.observer?.disconnect();
800
+ this.clearResumeTimer();
801
+ this.dispatch({ type: "disable" });
802
+ }
803
+ dispatch(event) {
804
+ this.state = nextAutoOrbitState(this.state, event);
805
+ this.sync();
806
+ }
807
+ /** Starts the frame loop when the orbit becomes active and cancels it otherwise. */
808
+ sync() {
809
+ const active = this.status === "active";
810
+ if (active && this.frame === null) {
811
+ this.lastFrameMs = null;
812
+ this.frame = requestAnimationFrame(this.tick);
813
+ } else if (!active && this.frame !== null) {
814
+ cancelAnimationFrame(this.frame);
815
+ this.frame = null;
816
+ }
817
+ }
818
+ tick = (nowMs) => {
819
+ this.frame = null;
820
+ const deltaSeconds = autoOrbitDeltaSeconds(this.lastFrameMs, nowMs);
821
+ this.lastFrameMs = nowMs;
822
+ try {
823
+ this.step(deltaSeconds);
824
+ } catch (error) {
825
+ this.dispose();
826
+ throw error;
827
+ }
828
+ if (this.frame === null && this.status === "active") this.frame = requestAnimationFrame(this.tick);
829
+ };
830
+ armResumeTimer() {
831
+ this.clearResumeTimer();
832
+ this.resumeTimer = setTimeout(() => {
833
+ this.resumeTimer = null;
834
+ this.dispatch({ type: "idle" });
835
+ }, this.resumeAfterMs);
836
+ }
837
+ clearResumeTimer() {
838
+ if (this.resumeTimer === null) return;
839
+ clearTimeout(this.resumeTimer);
840
+ this.resumeTimer = null;
841
+ }
842
+ /**
843
+ * A drag, pinch or wheel step pauses the orbit; `end` restarts the idle countdown. `start` arms
844
+ * it too, so a swallowed pointerup cannot pause the orbit forever. Resuming mid-drag is harmless:
845
+ * OrbitControls (r185) applies autoRotate in `update()` only while its state is NONE.
846
+ */
847
+ handleInteractionStart = () => {
848
+ this.dispatch({ type: "interaction" });
849
+ this.armResumeTimer();
850
+ };
851
+ handleInteractionEnd = () => {
852
+ if (this.state.interacting) this.armResumeTimer();
853
+ };
854
+ handleVisibilityChange = () => {
855
+ this.dispatch({
856
+ type: "visibility",
857
+ hidden: this.element.ownerDocument.hidden
858
+ });
859
+ };
860
+ handleIntersection = (entries) => {
861
+ const entry = entries.at(-1);
862
+ if (entry) this.dispatch({
863
+ type: "intersection",
864
+ offscreen: !entry.isIntersecting
865
+ });
866
+ };
867
+ };
573
868
  //#endregion
574
869
  //#region src/camera-aspect.ts
575
870
  /** Canvas aspect used by perspective cameras. Never 0. */
@@ -580,6 +875,170 @@ function containerAspect(width, height) {
580
875
  function applyPerspectiveContainerAspect(camera, width, height) {
581
876
  camera.aspect = containerAspect(width, height);
582
877
  }
878
+ function resolveFlyToOptions(options) {
879
+ const durationMs = options.durationMs ?? 1200;
880
+ const distanceScale = options.distanceScale ?? 1;
881
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error(`flyTo durationMs must be a non-negative finite number of milliseconds; received ${durationMs}.`);
882
+ if (!Number.isFinite(distanceScale) || distanceScale <= 0) throw new Error(`flyTo distanceScale must be a finite number above 0; received ${distanceScale}.`);
883
+ return {
884
+ distanceScale,
885
+ durationMs
886
+ };
887
+ }
888
+ /** Cubic ease-in-out on `[0, 1]`: slow start, slow arrival, symmetric around 0.5. */
889
+ function easeInOutCubic(t) {
890
+ return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
891
+ }
892
+ /** Linear progress of a flight, clamped to `[0, 1]`; a zero duration is already done. */
893
+ function flightProgress(elapsedMs, durationMs) {
894
+ if (durationMs <= 0) return 1;
895
+ return Math.min(Math.max(elapsedMs / durationMs, 0), 1);
896
+ }
897
+ /**
898
+ * Interpolates a positive scale (camera distance, orthographic zoom) geometrically, so each
899
+ * step zooms by the same factor and a long zoom does not rush its last part. Exact at both ends.
900
+ */
901
+ function interpolateScale(from, to, t) {
902
+ if (t <= 0) return from;
903
+ if (t >= 1) return to;
904
+ return from * (to / from) ** t;
905
+ }
906
+ //#endregion
907
+ //#region src/theme.ts
908
+ /** Dark canvas: Ink Black, Turf Green, Porcelain. */
909
+ const graphraumThemeDark = Object.freeze({
910
+ background: "#040f0f",
911
+ dimmedEdge: "#315a51",
912
+ dimmedEdgeOpacity: .25,
913
+ dimmedNode: "#315a51",
914
+ edge: "#226f54",
915
+ edgeOpacity: .55,
916
+ edgeWidth: 1.5,
917
+ endpointAttach: "boundary",
918
+ focusedNode: "#73c7a5",
919
+ hoveredNode: "#e4a853",
920
+ node: "#226f54",
921
+ nodeStroke: "#fcfffc",
922
+ selectedEdge: "#fcfffc",
923
+ selectedNode: "#fcfffc"
924
+ });
925
+ /** Light canvas: Porcelain field with Turf marks and Ink selection. */
926
+ const graphraumThemeLight = Object.freeze({
927
+ background: "#fcfffc",
928
+ dimmedEdge: "#9bb5ac",
929
+ dimmedEdgeOpacity: .35,
930
+ dimmedNode: "#9bb5ac",
931
+ edge: "#226f54",
932
+ edgeOpacity: .65,
933
+ edgeWidth: 1.5,
934
+ endpointAttach: "boundary",
935
+ focusedNode: "#1a5c45",
936
+ hoveredNode: "#c47a1a",
937
+ node: "#226f54",
938
+ nodeStroke: "#040f0f",
939
+ selectedEdge: "#040f0f",
940
+ selectedNode: "#040f0f"
941
+ });
942
+ const graphraumThemes = Object.freeze({
943
+ dark: graphraumThemeDark,
944
+ light: graphraumThemeLight
945
+ });
946
+ /** Alias of {@link graphraumThemeDark} for backward compatibility. */
947
+ const graphraumTheme = graphraumThemeDark;
948
+ function resolveGraphraumTheme(input, base = graphraumThemeDark) {
949
+ if (input === void 0) return { ...base };
950
+ if (typeof input === "string") {
951
+ const preset = graphraumThemes[input];
952
+ if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
953
+ return { ...preset };
954
+ }
955
+ return assertGraphraumTheme({
956
+ ...base,
957
+ ...input
958
+ });
959
+ }
960
+ function assertGraphraumTheme(theme) {
961
+ const opacity = theme.dimmedEdgeOpacity;
962
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`Theme dimmedEdgeOpacity must be a finite number between 0 and 1, received ${opacity}.`);
963
+ return theme;
964
+ }
965
+ /** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
966
+ function normalizeGraphraumBackground(background) {
967
+ return background === "transparent" ? null : background;
968
+ }
969
+ function isTransparentGraphraumBackground(background) {
970
+ return normalizeGraphraumBackground(background) === null;
971
+ }
972
+ /** Halo radius as a multiple of the node's half-extent at `glow: 1`. */
973
+ const GLOW_RADIUS_SCALE = 3.4;
974
+ /** Halo opacity multiplier under normal blending, so a light background shows a soft tint. */
975
+ const GLOW_NORMAL_OPACITY_SCALE = .6;
976
+ function assertNodeGlow(nodeId, glow) {
977
+ 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`);
978
+ }
979
+ /**
980
+ * Halo radius in world units: `extent × (1 + (GLOW_RADIUS_SCALE − 1) × glow)`, where `extent` is
981
+ * the node's half-extent `max(width, height)`. Glow 0 collapses the halo under the node; glow 1
982
+ * reaches 3.4× the node extent.
983
+ */
984
+ function glowHaloRadius(extent, glow) {
985
+ return extent * (1 + (GLOW_RADIUS_SCALE - 1) * glow);
986
+ }
987
+ /** Peak halo opacity at the node center; the shader fades it radially to 0 at the halo radius. */
988
+ function glowHaloOpacity(glow) {
989
+ return glow;
990
+ }
991
+ /** Keeps `current` while it fits, otherwise grows to the next power of two capped at `maximum`. */
992
+ function glowBufferCapacity(required, current, maximum) {
993
+ if (required <= current) return current;
994
+ let capacity = Math.max(current, 1);
995
+ while (capacity < required) capacity *= 2;
996
+ return Math.min(capacity, maximum);
997
+ }
998
+ function createGlowSelection() {
999
+ return {
1000
+ count: 0,
1001
+ indices: /* @__PURE__ */ new Uint32Array(0)
1002
+ };
1003
+ }
1004
+ /**
1005
+ * Selects visible nodes with `glow > 0` into `selection`, highest glow first and ties by node
1006
+ * index, capped at {@link GLOW_MAX_INSTANCES}. Selects nothing in the density LOD tier. Reuses
1007
+ * the selection buffer and grows it geometrically; allocates no per-node objects.
1008
+ */
1009
+ function selectGlowNodes(visibleNodeIndices, glowAt, densityLod, selection) {
1010
+ selection.count = 0;
1011
+ if (densityLod) return selection;
1012
+ let candidates = 0;
1013
+ for (const nodeIndex of visibleNodeIndices) {
1014
+ if (!(glowAt(nodeIndex) > 0)) continue;
1015
+ if (candidates === selection.indices.length) {
1016
+ const grown = new Uint32Array(glowBufferCapacity(candidates + 1, candidates, Number.POSITIVE_INFINITY));
1017
+ grown.set(selection.indices);
1018
+ selection.indices = grown;
1019
+ }
1020
+ selection.indices[candidates] = nodeIndex;
1021
+ candidates += 1;
1022
+ }
1023
+ selection.indices.subarray(0, candidates).sort((a, b) => glowAt(b) - glowAt(a) || a - b);
1024
+ selection.count = Math.min(candidates, 256);
1025
+ return selection;
1026
+ }
1027
+ /**
1028
+ * Additive light reads on dark backgrounds but saturates to nothing on light ones. A solid
1029
+ * background whose relative luminance (linear sRGB, 0.2126 R + 0.7152 G + 0.0722 B) reaches
1030
+ * {@link GLOW_LIGHT_BACKGROUND_LUMINANCE} uses normal blending; transparent and pattern
1031
+ * backgrounds keep additive blending because their brightness is unknown.
1032
+ */
1033
+ function glowBlendModeFor(background) {
1034
+ const normalized = normalizeGraphraumBackground(background);
1035
+ if (normalized === null || typeof normalized === "object") return "additive";
1036
+ const { b, g, r } = new Color(normalized);
1037
+ return .2126 * r + .7152 * g + .0722 * b >= .5 ? "normal" : "additive";
1038
+ }
1039
+ function glowOpacityScale(mode) {
1040
+ return mode === "normal" ? GLOW_NORMAL_OPACITY_SCALE : 1;
1041
+ }
583
1042
  //#endregion
584
1043
  //#region src/compile-graph.ts
585
1044
  function assertFinitePosition(nodeId, axis, value) {
@@ -632,6 +1091,7 @@ function compileNodeVisual(id, visual) {
632
1091
  assertPositiveFiniteVisual(id, "width", visual.width);
633
1092
  assertPositiveFiniteVisual(id, "height", visual.height);
634
1093
  assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
1094
+ assertNodeGlow(id, visual.glow);
635
1095
  if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
636
1096
  return Object.freeze({ ...visual });
637
1097
  }
@@ -653,6 +1113,7 @@ function compileGraph(data, visuals) {
653
1113
  const encoding = visuals?.node?.(node);
654
1114
  const visual = compileNodeVisual(node.id, encoding?.visual ?? {
655
1115
  color: node.color,
1116
+ glow: node.glow,
656
1117
  height: node.height,
657
1118
  shape: node.shape,
658
1119
  size: node.size,
@@ -838,6 +1299,32 @@ function sampleEdgePath(input) {
838
1299
  return points;
839
1300
  }
840
1301
  //#endregion
1302
+ //#region src/edge-state.ts
1303
+ /**
1304
+ * Single source of truth for an edge instance's color and opacity. Full packing and the
1305
+ * incremental state repaint both call it, so both paths write identical instance colors.
1306
+ */
1307
+ function resolveEdgePaint(edgeIndex, visual, defaults, tier, edgeStates) {
1308
+ const opacity = tier === "overview" ? defaults.opacity : visual.opacity ?? defaults.opacity;
1309
+ const state = edgeStates?.stateOf(edgeIndex) ?? null;
1310
+ if (edgeStates && state === "selected") return {
1311
+ color: edgeStates.selectedColor,
1312
+ opacity
1313
+ };
1314
+ if (edgeStates && state === "dimmed") return {
1315
+ color: edgeStates.dimmedColor,
1316
+ opacity: Math.min(opacity, edgeStates.dimmedOpacity)
1317
+ };
1318
+ return {
1319
+ color: visual.color ?? defaults.color,
1320
+ opacity
1321
+ };
1322
+ }
1323
+ /** IDs present in exactly one of the two sets. */
1324
+ function changedIds(current, next) {
1325
+ return [...[...current].filter((id) => !next.has(id)), ...[...next].filter((id) => !current.has(id))];
1326
+ }
1327
+ //#endregion
841
1328
  //#region src/edge-materialize.ts
842
1329
  function packEdgeInstances(input) {
843
1330
  const segments = [];
@@ -854,37 +1341,43 @@ function packEdgeInstances(input) {
854
1341
  let x2 = input.endpointPositions[base + 3] ?? 0;
855
1342
  let y2 = input.endpointPositions[base + 4] ?? 0;
856
1343
  let z2 = input.endpointPositions[base + 5] ?? 0;
1344
+ let trimSource = 0;
1345
+ let trimTarget = 0;
857
1346
  if (attach === "boundary" && input.nodeOutlines && input.edgeNodeIndices) {
858
1347
  const sourceIndex = input.edgeNodeIndices[edgeIndex * 2] ?? 0;
859
1348
  const targetIndex = input.edgeNodeIndices[edgeIndex * 2 + 1] ?? 0;
860
1349
  const sourceOutline = input.nodeOutlines[sourceIndex] ?? {};
861
1350
  const targetOutline = input.nodeOutlines[targetIndex] ?? {};
862
- const trimmed = trimEdgeEndpoints({
863
- attach,
864
- clearance,
865
- source: {
866
- x: x1,
867
- y: y1,
868
- z: z1
869
- },
870
- target: {
871
- x: x2,
872
- y: y2,
873
- z: z2
874
- },
875
- sourceOutline,
876
- targetOutline
877
- });
878
- x1 = trimmed.source.x;
879
- y1 = trimmed.source.y;
880
- z1 = trimmed.source.z ?? 0;
881
- x2 = trimmed.target.x;
882
- y2 = trimmed.target.y;
883
- z2 = trimmed.target.z ?? 0;
884
- }
885
- const color = visual.color ?? input.defaults.color;
1351
+ if (input.mode === "3d") {
1352
+ trimSource = outlineReach(sourceOutline, clearance);
1353
+ trimTarget = outlineReach(targetOutline, clearance);
1354
+ } else {
1355
+ const trimmed = trimEdgeEndpoints({
1356
+ attach,
1357
+ clearance,
1358
+ source: {
1359
+ x: x1,
1360
+ y: y1,
1361
+ z: z1
1362
+ },
1363
+ target: {
1364
+ x: x2,
1365
+ y: y2,
1366
+ z: z2
1367
+ },
1368
+ sourceOutline,
1369
+ targetOutline
1370
+ });
1371
+ x1 = trimmed.source.x;
1372
+ y1 = trimmed.source.y;
1373
+ z1 = trimmed.source.z ?? 0;
1374
+ x2 = trimmed.target.x;
1375
+ y2 = trimmed.target.y;
1376
+ z2 = trimmed.target.z ?? 0;
1377
+ }
1378
+ }
1379
+ const { color, opacity } = resolveEdgePaint(edgeIndex, visual, input.defaults, input.tier, input.edgeStates);
886
1380
  const width = input.tier === "overview" ? input.defaults.width : visual.width ?? input.defaults.width;
887
- const opacity = input.tier === "overview" ? input.defaults.opacity : visual.opacity ?? input.defaults.opacity;
888
1381
  const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
889
1382
  const points = sampleEdgePath({
890
1383
  path: visual.path,
@@ -917,7 +1410,9 @@ function packEdgeInstances(input) {
917
1410
  color,
918
1411
  width,
919
1412
  opacity,
920
- style
1413
+ style,
1414
+ trimStart: index === 0 ? trimSource : 0,
1415
+ trimEnd: index === segmentSpans - 1 ? trimTarget : 0
921
1416
  });
922
1417
  }
923
1418
  if (input.tier === "overview") continue;
@@ -942,7 +1437,8 @@ function packEdgeInstances(input) {
942
1437
  dz: fromSource ? (first.z ?? 0) - (second.z ?? 0) : (last.z ?? 0) - (previous.z ?? 0),
943
1438
  color,
944
1439
  size: markerSize,
945
- opacity
1440
+ opacity,
1441
+ trim: fromSource ? trimSource : trimTarget
946
1442
  });
947
1443
  };
948
1444
  if (ends === "source" || ends === "both") add("source");
@@ -996,14 +1492,26 @@ function pickClosestEdgeIndex(point, segments) {
996
1492
  return nearestEdgeIndex;
997
1493
  }
998
1494
  //#endregion
1495
+ //#region src/shader-output.ts
1496
+ /**
1497
+ * Encodes `gl_FragColor.rgb` from the linear working space into the renderer's output color space:
1498
+ * sRGB on the canvas (so a `#8a9096` theme color draws as `#8a9096`), unchanged on linear render
1499
+ * targets. Alpha is left untouched, so anti-aliased coverage and blending are unaffected.
1500
+ *
1501
+ * Append once to every display fragment shader, after its final `gl_FragColor` assignment.
1502
+ * Requires a `ShaderMaterial` (not `RawShaderMaterial`), whose prefix defines `linearToOutputTexel`.
1503
+ */
1504
+ const OUTPUT_COLOR_SPACE_FRAGMENT = "#include <colorspace_fragment>";
1505
+ //#endregion
999
1506
  //#region src/edge-rendering.ts
1000
- const vertexShader$1 = `
1507
+ const vertexShader$2 = `
1001
1508
  attribute float instanceKind;
1002
1509
  attribute vec3 instanceEndA;
1003
1510
  attribute vec3 instanceEndB;
1004
1511
  attribute vec4 instanceColor;
1005
1512
  attribute float instanceWidth;
1006
1513
  attribute float instanceStyle;
1514
+ attribute vec2 instanceTrim;
1007
1515
 
1008
1516
  varying vec4 edgeColor;
1009
1517
  varying float edgeKind;
@@ -1027,6 +1535,18 @@ void main() {
1027
1535
  vec2 delta = viewB.xy - viewA.xy;
1028
1536
  float len = length(delta);
1029
1537
  vec2 dir = len > 0.0001 ? delta / len : vec2(1.0, 0.0);
1538
+ // 3D boundary ends (see trimViewSegment): stop on the camera-facing outline. A trimmed end
1539
+ // keeps its node's depth, where the billboard is, so it meets the rim from every view.
1540
+ if (instanceTrim.x + instanceTrim.y > 0.0) {
1541
+ if (len - instanceTrim.x - instanceTrim.y <= 0.0) {
1542
+ gl_Position = vec4(0.0, 0.0, 2.0, 1.0); // hidden behind its nodes: clip it away
1543
+ return;
1544
+ }
1545
+ viewA.xy += dir * instanceTrim.x;
1546
+ viewB.xy -= dir * instanceTrim.y;
1547
+ delta = viewB.xy - viewA.xy;
1548
+ len = length(delta);
1549
+ }
1030
1550
  vec2 perp = vec2(-dir.y, dir.x);
1031
1551
  vec2 mid = (viewA.xy + viewB.xy) * 0.5;
1032
1552
  vec2 offset = dir * position.x * len + perp * position.y * instanceWidth;
@@ -1041,14 +1561,16 @@ void main() {
1041
1561
  vec2 dir = dirLen > 0.0001 ? dirVec / dirLen : vec2(1.0, 0.0);
1042
1562
  vec2 perp = vec2(-dir.y, dir.x);
1043
1563
  float t = position.x + 0.5;
1564
+ // 3D boundary ends pull the apex back onto the camera-facing outline (see markerApexInView).
1565
+ vec2 apex = viewA.xy - dir * instanceTrim.x;
1044
1566
  vec2 offset = dir * instanceWidth * (t - 1.0) + perp * position.y * instanceWidth;
1045
- viewPosition = vec4(viewA.xy + offset, viewA.z, 1.0);
1567
+ viewPosition = vec4(apex + offset, viewA.z, 1.0);
1046
1568
  }
1047
1569
 
1048
1570
  gl_Position = projectionMatrix * viewPosition;
1049
1571
  }
1050
1572
  `;
1051
- const fragmentShader$1 = `
1573
+ const fragmentShader$2 = `
1052
1574
  varying vec4 edgeColor;
1053
1575
  varying float edgeKind;
1054
1576
  varying float edgeStyle;
@@ -1072,8 +1594,19 @@ void main() {
1072
1594
  }
1073
1595
  if (edgeColor.a <= 0.0) discard;
1074
1596
  gl_FragColor = edgeColor;
1597
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1075
1598
  }
1076
1599
  `;
1600
+ /** Per-instance attributes of the edge geometry; updates mark each of them dirty. */
1601
+ const EDGE_INSTANCE_ATTRIBUTES = [
1602
+ "instanceKind",
1603
+ "instanceEndA",
1604
+ "instanceEndB",
1605
+ "instanceColor",
1606
+ "instanceWidth",
1607
+ "instanceStyle",
1608
+ "instanceTrim"
1609
+ ];
1077
1610
  function createEdgeGeometry(capacity) {
1078
1611
  const geometry = new PlaneGeometry(1, 1);
1079
1612
  geometry.setAttribute("instanceKind", new InstancedBufferAttribute(new Float32Array(capacity), 1));
@@ -1082,15 +1615,16 @@ function createEdgeGeometry(capacity) {
1082
1615
  geometry.setAttribute("instanceColor", new InstancedBufferAttribute(new Float32Array(capacity * 4), 4));
1083
1616
  geometry.setAttribute("instanceWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1084
1617
  geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1618
+ geometry.setAttribute("instanceTrim", new InstancedBufferAttribute(new Float32Array(capacity * 2), 2));
1085
1619
  return geometry;
1086
1620
  }
1087
- function createEdgeMaterial(depthTest) {
1621
+ function createEdgeMaterial() {
1088
1622
  return new ShaderMaterial({
1089
- depthTest,
1090
- depthWrite: depthTest,
1091
- fragmentShader: fragmentShader$1,
1623
+ depthTest: false,
1624
+ depthWrite: false,
1625
+ fragmentShader: fragmentShader$2,
1092
1626
  transparent: true,
1093
- vertexShader: vertexShader$1
1627
+ vertexShader: vertexShader$2
1094
1628
  });
1095
1629
  }
1096
1630
  function getInstancedAttribute(geometry, name) {
@@ -1108,10 +1642,10 @@ function writeEdgeSegmentInstance(geometry, slot, segment) {
1108
1642
  kind.setX(slot, 0);
1109
1643
  endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
1110
1644
  endB.setXYZ(slot, segment.x2, segment.y2, segment.z2);
1111
- const rgb = new Color(segment.color);
1112
- color.setXYZW(slot, rgb.r, rgb.g, rgb.b, segment.opacity);
1645
+ writeEdgeColorSlots(color, slot, 1, segment);
1113
1646
  width.setX(slot, segment.width);
1114
1647
  style.setX(slot, encodeEdgeStyle(segment.style));
1648
+ getInstancedAttribute(geometry, "instanceTrim").setXY(slot, segment.trimStart, segment.trimEnd);
1115
1649
  }
1116
1650
  function writeEdgeMarkerInstance(geometry, slot, marker) {
1117
1651
  const kind = getInstancedAttribute(geometry, "instanceKind");
@@ -1123,21 +1657,18 @@ function writeEdgeMarkerInstance(geometry, slot, marker) {
1123
1657
  kind.setX(slot, 1);
1124
1658
  endA.setXYZ(slot, marker.x, marker.y, marker.z);
1125
1659
  endB.setXYZ(slot, marker.x + marker.dx, marker.y + marker.dy, marker.z + marker.dz);
1126
- const rgb = new Color(marker.color);
1127
- color.setXYZW(slot, rgb.r, rgb.g, rgb.b, marker.opacity);
1660
+ writeEdgeColorSlots(color, slot, 1, marker);
1128
1661
  width.setX(slot, marker.size);
1129
1662
  style.setX(slot, 0);
1663
+ getInstancedAttribute(geometry, "instanceTrim").setXY(slot, marker.trim, 0);
1664
+ }
1665
+ /** Writes one color and opacity into `count` consecutive instance slots starting at `start`. */
1666
+ function writeEdgeColorSlots(color, start, count, paint) {
1667
+ const rgb = new Color(paint.color);
1668
+ for (let slot = start; slot < start + count; slot += 1) color.setXYZW(slot, rgb.r, rgb.g, rgb.b, paint.opacity);
1130
1669
  }
1131
1670
  //#endregion
1132
1671
  //#region src/edge-viewport-patch.ts
1133
- const EDGE_INSTANCE_ATTRIBUTES$1 = [
1134
- "instanceKind",
1135
- "instanceEndA",
1136
- "instanceEndB",
1137
- "instanceColor",
1138
- "instanceWidth",
1139
- "instanceStyle"
1140
- ];
1141
1672
  /** Records GPU slot ranges for each visible edge after a full viewport materialize. */
1142
1673
  function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
1143
1674
  const layouts = /* @__PURE__ */ new Map();
@@ -1188,6 +1719,8 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1188
1719
  tier: input.tier,
1189
1720
  endpointAttach: input.endpointAttach,
1190
1721
  edgeNodeIndices: input.edgeNodeIndices,
1722
+ edgeStates: input.edgeStates,
1723
+ mode: input.mode,
1191
1724
  nodeOutlines: input.nodeOutlines
1192
1725
  });
1193
1726
  if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
@@ -1223,9 +1756,8 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1223
1756
  ok: true,
1224
1757
  pickableSegments: nextPickable
1225
1758
  };
1226
- for (const name of EDGE_INSTANCE_ATTRIBUTES$1) {
1759
+ for (const name of EDGE_INSTANCE_ATTRIBUTES) {
1227
1760
  const attribute = geometry.getAttribute(name);
1228
- attribute.clearUpdateRanges();
1229
1761
  attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
1230
1762
  attribute.needsUpdate = true;
1231
1763
  }
@@ -1236,14 +1768,146 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1236
1768
  pickableSegments: nextPickable
1237
1769
  };
1238
1770
  }
1771
+ /**
1772
+ * Repaints the color and opacity of visible edges whose host state changed. Slot layout,
1773
+ * geometry, and picking stay untouched, so only the changed `instanceColor` ranges upload.
1774
+ * Edges without visible slots are skipped; the next materialize paints them from the same state.
1775
+ */
1776
+ function patchVisibleEdgePaint(geometry, input) {
1777
+ const visible = [...input.changedEdgeIndices].flatMap((edgeIndex) => {
1778
+ const layout = input.layouts.get(edgeIndex);
1779
+ return layout ? [{
1780
+ edgeIndex,
1781
+ layout
1782
+ }] : [];
1783
+ });
1784
+ if (!visible.every(({ layout }) => layout.segmentStart + layout.segmentCount <= input.instanceCount && layout.markerStart + layout.markerCount <= input.instanceCount)) return {
1785
+ ok: false,
1786
+ patchedSlots: 0
1787
+ };
1788
+ const color = getInstancedAttribute(geometry, "instanceColor");
1789
+ let patchedSlots = 0;
1790
+ for (const { edgeIndex, layout } of visible) {
1791
+ const paint = resolveEdgePaint(edgeIndex, input.edgeVisuals[edgeIndex] ?? {}, input.defaults, input.tier, input.edgeStates);
1792
+ for (const [start, count] of [[layout.segmentStart, layout.segmentCount], [layout.markerStart, layout.markerCount]]) {
1793
+ if (count === 0) continue;
1794
+ writeEdgeColorSlots(color, start, count, paint);
1795
+ color.addUpdateRange(start * color.itemSize, count * color.itemSize);
1796
+ patchedSlots += count;
1797
+ }
1798
+ }
1799
+ if (patchedSlots > 0) color.needsUpdate = true;
1800
+ return {
1801
+ ok: true,
1802
+ patchedSlots
1803
+ };
1804
+ }
1805
+ //#endregion
1806
+ //#region src/fit-3d.ts
1807
+ /** Newton steps on a piecewise-linear function before falling back to the plane-space centre. */
1808
+ const MAX_CENTERING_STEPS = 32;
1809
+ /**
1810
+ * The closest front-view camera that frames every node. Throws without nodes. `maxCenteringSteps`
1811
+ * exists so tests can exercise the fallback.
1812
+ */
1813
+ function fitPerspective(frame, nodes, maxCenteringSteps = MAX_CENTERING_STEPS) {
1814
+ if (nodes.count === 0) throw new Error("fitPerspective needs at least one node");
1815
+ const tanY = Math.tan(frame.fovDegrees * Math.PI / 360) * frame.fill;
1816
+ const tanX = tanY * frame.aspect;
1817
+ const node = {
1818
+ halfHeight: 0,
1819
+ halfWidth: 0,
1820
+ x: 0,
1821
+ y: 0,
1822
+ z: 0
1823
+ };
1824
+ let right = Number.NEGATIVE_INFINITY;
1825
+ let left = Number.NEGATIVE_INFINITY;
1826
+ let top = Number.NEGATIVE_INFINITY;
1827
+ let bottom = Number.NEGATIVE_INFINITY;
1828
+ let minZ = Number.POSITIVE_INFINITY;
1829
+ let maxZ = Number.NEGATIVE_INFINITY;
1830
+ for (let index = 0; index < nodes.count; index += 1) {
1831
+ nodes.read(index, node);
1832
+ right = Math.max(right, node.x + node.halfWidth + tanX * node.z);
1833
+ left = Math.max(left, -node.x + node.halfWidth + tanX * node.z);
1834
+ top = Math.max(top, node.y + node.halfHeight + tanY * node.z);
1835
+ bottom = Math.max(bottom, -node.y + node.halfHeight + tanY * node.z);
1836
+ minZ = Math.min(minZ, node.z);
1837
+ maxZ = Math.max(maxZ, node.z);
1838
+ }
1839
+ const cameraZX = (right + left) / (2 * tanX);
1840
+ const cameraZY = (top + bottom) / (2 * tanY);
1841
+ const cameraZ = Math.max(cameraZX, cameraZY);
1842
+ let x = (right - left) / 2;
1843
+ let y = (top - bottom) / 2;
1844
+ if (cameraZX < cameraZY) x = centerOnScreen(nodes, node, "x", cameraZ, x, maxCenteringSteps);
1845
+ else if (cameraZY < cameraZX) y = centerOnScreen(nodes, node, "y", cameraZ, y, maxCenteringSteps);
1846
+ const target = {
1847
+ x,
1848
+ y,
1849
+ z: (minZ + maxZ) / 2
1850
+ };
1851
+ const distance = cameraZ - target.z;
1852
+ const nearestDepth = cameraZ - maxZ;
1853
+ return {
1854
+ cameraZ,
1855
+ near: Math.min(Math.max(distance / 1e4, .1), nearestDepth / 2),
1856
+ far: distance * 10,
1857
+ target
1858
+ };
1859
+ }
1860
+ /**
1861
+ * Centre on the looser axis that makes the farthest screen reach equal on both sides.
1862
+ *
1863
+ * With depth `d = cameraZ - z`, a centre `c` keeps every node within screen reach `r` when
1864
+ * `max(p + h - r * d) <= c <= min(p - h + r * d)`. The gap between those bounds is convex and
1865
+ * decreasing in `r`, so Newton steps from `r = 0` rise monotonically to the smallest feasible
1866
+ * reach. `planeCenter` already fits, so it is the answer if the steps do not settle.
1867
+ */
1868
+ function centerOnScreen(nodes, node, axis, cameraZ, planeCenter, maxSteps) {
1869
+ let reach = 0;
1870
+ for (let step = 0; step < maxSteps; step += 1) {
1871
+ let low = Number.NEGATIVE_INFINITY;
1872
+ let high = Number.POSITIVE_INFINITY;
1873
+ let lowValue = 0;
1874
+ let lowDepth = 0;
1875
+ let highValue = 0;
1876
+ let highDepth = 0;
1877
+ for (let index = 0; index < nodes.count; index += 1) {
1878
+ nodes.read(index, node);
1879
+ const position = axis === "x" ? node.x : node.y;
1880
+ const half = axis === "x" ? node.halfWidth : node.halfHeight;
1881
+ const depth = cameraZ - node.z;
1882
+ const lowBound = position + half - reach * depth;
1883
+ const highBound = position - half + reach * depth;
1884
+ if (lowBound > low) {
1885
+ low = lowBound;
1886
+ lowValue = position + half;
1887
+ lowDepth = depth;
1888
+ }
1889
+ if (highBound < high) {
1890
+ high = highBound;
1891
+ highValue = position - half;
1892
+ highDepth = depth;
1893
+ }
1894
+ }
1895
+ if (low <= high) return (low + high) / 2;
1896
+ const next = (lowValue - highValue) / (lowDepth + highDepth);
1897
+ if (next <= reach) return (low + high) / 2;
1898
+ reach = next;
1899
+ }
1900
+ return planeCenter;
1901
+ }
1239
1902
  //#endregion
1240
1903
  //#region src/instance-color-ranges.ts
1241
1904
  /**
1242
1905
  * Marks RGB update ranges for visible instance slots.
1906
+ * Keeps earlier ranges that have not been uploaded yet (a viewport materialize in the same frame);
1907
+ * three.js merges and clears ranges on upload.
1243
1908
  * Returns how many slots were marked.
1244
1909
  */
1245
1910
  function markInstanceColorSlots(attribute, slots) {
1246
- attribute.clearUpdateRanges();
1247
1911
  let count = 0;
1248
1912
  for (const slot of slots) {
1249
1913
  if (!Number.isSafeInteger(slot) || slot < 0) continue;
@@ -1275,6 +1939,7 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1275
1939
  for (const [axis, value] of Object.entries(position)) if (!Number.isFinite(value)) throw new Error(`Layout node "${id}" has a non-finite ${axis} position`);
1276
1940
  return {
1277
1941
  colorChanged: false,
1942
+ glowChanged: false,
1278
1943
  index: nodeIndex,
1279
1944
  next: {
1280
1945
  ...current,
@@ -1287,6 +1952,145 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1287
1952
  };
1288
1953
  });
1289
1954
  }
1955
+ const vertexShader$1 = `
1956
+ attribute float instanceGlowOpacity;
1957
+ varying vec3 glowColor;
1958
+ varying vec2 glowPoint;
1959
+ varying float glowOpacity;
1960
+
1961
+ void main() {
1962
+ glowColor = instanceColor;
1963
+ glowPoint = position.xy;
1964
+ glowOpacity = instanceGlowOpacity;
1965
+ vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1966
+ center.xy += position.xy * length(instanceMatrix[0].xyz);
1967
+ gl_Position = projectionMatrix * center;
1968
+ }
1969
+ `;
1970
+ const fragmentShader$1 = `
1971
+ uniform float opacityScale;
1972
+ varying vec3 glowColor;
1973
+ varying vec2 glowPoint;
1974
+ varying float glowOpacity;
1975
+
1976
+ void main() {
1977
+ float distanceFromCenter = length(glowPoint);
1978
+ if (distanceFromCenter >= 1.0) discard;
1979
+ float falloff = 1.0 - distanceFromCenter;
1980
+ gl_FragColor = vec4(glowColor, falloff * falloff * glowOpacity * opacityScale);
1981
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1982
+ }
1983
+ `;
1984
+ function applyBlendMode(material, mode) {
1985
+ material.blending = mode === "normal" ? NormalBlending : AdditiveBlending;
1986
+ const opacityScale = material.uniforms.opacityScale;
1987
+ if (opacityScale) opacityScale.value = glowOpacityScale(mode);
1988
+ material.needsUpdate = true;
1989
+ }
1990
+ function createNodeGlowMaterial(mode) {
1991
+ const material = new ShaderMaterial({
1992
+ depthTest: false,
1993
+ depthWrite: false,
1994
+ fragmentShader: fragmentShader$1,
1995
+ transparent: true,
1996
+ uniforms: { opacityScale: { value: 1 } },
1997
+ vertexShader: vertexShader$1
1998
+ });
1999
+ applyBlendMode(material, mode);
2000
+ return material;
2001
+ }
2002
+ function createGlowMesh(capacity, mode) {
2003
+ const geometry = new PlaneGeometry(2, 2);
2004
+ geometry.setAttribute("instanceGlowOpacity", new InstancedBufferAttribute(new Float32Array(capacity), 1));
2005
+ const mesh = new InstancedMesh(geometry, createNodeGlowMaterial(mode), capacity);
2006
+ mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
2007
+ mesh.name = "graphraum-node-glow";
2008
+ mesh.frustumCulled = false;
2009
+ mesh.renderOrder = -2;
2010
+ return mesh;
2011
+ }
2012
+ /**
2013
+ * Opt-in batched halo pass: one additive instanced mesh, created lazily on the first glowing
2014
+ * node and hidden while no halo is selected, so graphs without glow keep two draw calls.
2015
+ */
2016
+ var NodeGlowLayer = class {
2017
+ scene;
2018
+ selection = createGlowSelection();
2019
+ target = {
2020
+ color: new Color(),
2021
+ extent: 0,
2022
+ glow: 0,
2023
+ x: 0,
2024
+ y: 0,
2025
+ z: 0
2026
+ };
2027
+ matrix = new Matrix4();
2028
+ mesh = null;
2029
+ blendMode = "additive";
2030
+ constructor(scene) {
2031
+ this.scene = scene;
2032
+ }
2033
+ /** Additive on dark or unknown backgrounds, normal on light solid ones; see `glowBlendModeFor`. */
2034
+ setBlending(mode) {
2035
+ if (mode === this.blendMode) return;
2036
+ this.blendMode = mode;
2037
+ if (this.mesh) applyBlendMode(this.mesh.material, mode);
2038
+ }
2039
+ /** Halo instances in the current frame. */
2040
+ get count() {
2041
+ return this.mesh?.visible ? this.mesh.count : 0;
2042
+ }
2043
+ /** Reselects glowing visible nodes and rewrites every halo instance. */
2044
+ sync(visibleNodeIndices, glowAt, densityLod, describe) {
2045
+ selectGlowNodes(visibleNodeIndices, glowAt, densityLod, this.selection);
2046
+ if (this.selection.count > 0) this.ensureCapacity(this.selection.count);
2047
+ this.rewrite(describe);
2048
+ }
2049
+ /** Rewrites positions, radii, opacities, and colors of the current selection in place. */
2050
+ rewrite(describe) {
2051
+ const mesh = this.mesh;
2052
+ if (!mesh) return;
2053
+ const opacity = mesh.geometry.getAttribute("instanceGlowOpacity");
2054
+ let slot = 0;
2055
+ for (let index = 0; index < this.selection.count; index += 1) {
2056
+ const nodeIndex = this.selection.indices[index];
2057
+ if (nodeIndex === void 0 || !describe(nodeIndex, this.target)) continue;
2058
+ const { color, extent, glow, x, y, z } = this.target;
2059
+ const radius = glowHaloRadius(extent, glow);
2060
+ this.matrix.makeScale(radius, radius, radius).setPosition(x, y, z);
2061
+ mesh.setMatrixAt(slot, this.matrix);
2062
+ mesh.setColorAt(slot, color);
2063
+ opacity.setX(slot, glowHaloOpacity(glow));
2064
+ slot += 1;
2065
+ }
2066
+ mesh.count = slot;
2067
+ mesh.visible = slot > 0;
2068
+ mesh.instanceMatrix.needsUpdate = true;
2069
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
2070
+ opacity.needsUpdate = true;
2071
+ }
2072
+ /** Releases the GPU mesh; the layer stays reusable and recreates it on the next glowing node. */
2073
+ dispose() {
2074
+ this.selection.count = 0;
2075
+ this.releaseMesh();
2076
+ }
2077
+ releaseMesh() {
2078
+ if (!this.mesh) return;
2079
+ this.scene.remove(this.mesh);
2080
+ this.mesh.geometry.dispose();
2081
+ this.mesh.material.dispose();
2082
+ this.mesh.dispose();
2083
+ this.mesh = null;
2084
+ }
2085
+ ensureCapacity(required) {
2086
+ const current = this.mesh?.instanceMatrix.count ?? 0;
2087
+ const capacity = glowBufferCapacity(required, current, 256);
2088
+ if (this.mesh && capacity === current) return;
2089
+ this.releaseMesh();
2090
+ this.mesh = createGlowMesh(capacity, this.blendMode);
2091
+ this.scene.add(this.mesh);
2092
+ }
2093
+ };
1290
2094
  //#endregion
1291
2095
  //#region src/node-rendering.ts
1292
2096
  const vertexShader = `
@@ -1399,6 +2203,7 @@ void main() {
1399
2203
  fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
1400
2204
  }
1401
2205
  gl_FragColor = vec4(fillColor, outerAlpha);
2206
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1402
2207
  }
1403
2208
  `;
1404
2209
  function createNodeGeometry(capacity) {
@@ -1476,6 +2281,7 @@ function assertValidNode(node) {
1476
2281
  });
1477
2282
  if (node.strokeWidth !== void 0 && (!Number.isFinite(node.strokeWidth) || node.strokeWidth < 0)) throw new Error(`Node "${node.id}" must have a finite non-negative strokeWidth`);
1478
2283
  if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
2284
+ assertNodeGlow(node.id, node.glow);
1479
2285
  }
1480
2286
  /** Validates a complete update batch before the renderer mutates CPU or GPU state. */
1481
2287
  function prepareNodeUpdates(nodes, nodeIndices, updates) {
@@ -1490,6 +2296,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1490
2296
  const next = {
1491
2297
  ...current,
1492
2298
  ...Object.hasOwn(update, "color") ? { color: update.color } : {},
2299
+ ...Object.hasOwn(update, "glow") ? { glow: update.glow } : {},
1493
2300
  ...update.position ? { position: { ...update.position } } : {},
1494
2301
  ...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
1495
2302
  ...Object.hasOwn(update, "size") ? { size: update.size } : {},
@@ -1501,6 +2308,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1501
2308
  assertValidNode(next);
1502
2309
  return {
1503
2310
  colorChanged: Object.hasOwn(update, "color"),
2311
+ glowChanged: Object.hasOwn(update, "glow"),
1504
2312
  index,
1505
2313
  next,
1506
2314
  positionChanged: update.position !== void 0,
@@ -1511,6 +2319,22 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1511
2319
  });
1512
2320
  }
1513
2321
  //#endregion
2322
+ //#region src/orbit-controls-mode.ts
2323
+ /**
2324
+ * Sets the controls for a 2D or 3D view. 2D turns rotation off, so one finger and the left
2325
+ * mouse button pan instead. One finger must not stay on ROTATE: when a pinch ends with one finger
2326
+ * still down, OrbitControls (r185) re-reads `touches.ONE`, returns early for a disabled rotate and
2327
+ * keeps the two-finger state, and the next move reads the lifted finger's position and throws.
2328
+ */
2329
+ function configureControlsForMode(controls, mode) {
2330
+ controls.enableRotate = mode === "3d";
2331
+ if (mode === "2d") {
2332
+ controls.minZoom = .01;
2333
+ controls.mouseButtons.LEFT = MOUSE.PAN;
2334
+ controls.touches.ONE = TOUCH.PAN;
2335
+ }
2336
+ }
2337
+ //#endregion
1514
2338
  //#region src/label-budget.ts
1515
2339
  /** Picks visible label ids by importance within a hard DOM budget. */
1516
2340
  function selectBudgetedLabelIds({ candidates, maxLabels }) {
@@ -1895,63 +2719,6 @@ var SpatialGrid2D = class {
1895
2719
  }
1896
2720
  };
1897
2721
  //#endregion
1898
- //#region src/theme.ts
1899
- /** Dark canvas: Ink Black, Turf Green, Porcelain. */
1900
- const graphraumThemeDark = Object.freeze({
1901
- background: "#040f0f",
1902
- dimmedNode: "#315a51",
1903
- edge: "#226f54",
1904
- edgeOpacity: .55,
1905
- edgeWidth: 1.5,
1906
- endpointAttach: "boundary",
1907
- focusedNode: "#73c7a5",
1908
- hoveredNode: "#e4a853",
1909
- node: "#226f54",
1910
- nodeStroke: "#fcfffc",
1911
- selectedEdge: "#fcfffc",
1912
- selectedNode: "#fcfffc"
1913
- });
1914
- /** Light canvas: Porcelain field with Turf marks and Ink selection. */
1915
- const graphraumThemeLight = Object.freeze({
1916
- background: "#fcfffc",
1917
- dimmedNode: "#9bb5ac",
1918
- edge: "#226f54",
1919
- edgeOpacity: .65,
1920
- edgeWidth: 1.5,
1921
- endpointAttach: "boundary",
1922
- focusedNode: "#1a5c45",
1923
- hoveredNode: "#c47a1a",
1924
- node: "#226f54",
1925
- nodeStroke: "#040f0f",
1926
- selectedEdge: "#040f0f",
1927
- selectedNode: "#040f0f"
1928
- });
1929
- const graphraumThemes = Object.freeze({
1930
- dark: graphraumThemeDark,
1931
- light: graphraumThemeLight
1932
- });
1933
- /** Alias of {@link graphraumThemeDark} for backward compatibility. */
1934
- const graphraumTheme = graphraumThemeDark;
1935
- function resolveGraphraumTheme(input, base = graphraumThemeDark) {
1936
- if (input === void 0) return { ...base };
1937
- if (typeof input === "string") {
1938
- const preset = graphraumThemes[input];
1939
- if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
1940
- return { ...preset };
1941
- }
1942
- return {
1943
- ...base,
1944
- ...input
1945
- };
1946
- }
1947
- /** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
1948
- function normalizeGraphraumBackground(background) {
1949
- return background === "transparent" ? null : background;
1950
- }
1951
- function isTransparentGraphraumBackground(background) {
1952
- return normalizeGraphraumBackground(background) === null;
1953
- }
1954
- //#endregion
1955
2722
  //#region src/viewport-lod.ts
1956
2723
  /** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
1957
2724
  function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
@@ -1981,16 +2748,19 @@ function shouldUseDensityLod(candidateCount, maximum, active) {
1981
2748
  //#region src/graphraum.ts
1982
2749
  /** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
1983
2750
  const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
2751
+ /** The 3D fit leaves 4% of the tighter canvas axis free on each side, e.g. for edge labels. */
2752
+ const PERSPECTIVE_FIT_FILL = .92;
2753
+ function prefersReducedMotion() {
2754
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2755
+ }
1984
2756
  function disposeMaterial(material) {
1985
2757
  for (const item of Array.isArray(material) ? material : [material]) item.dispose();
1986
2758
  }
1987
- const EDGE_INSTANCE_ATTRIBUTES = [
1988
- "instanceKind",
1989
- "instanceEndA",
1990
- "instanceEndB",
1991
- "instanceColor",
1992
- "instanceWidth",
1993
- "instanceStyle"
2759
+ /** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
2760
+ const FLIGHT_CANCEL_EVENTS = [
2761
+ "keydown",
2762
+ "pointerdown",
2763
+ "wheel"
1994
2764
  ];
1995
2765
  /** A small, explicit WebGL graph renderer: one instanced node mesh and one batched edge geometry. */
1996
2766
  var Graphraum = class {
@@ -2027,12 +2797,19 @@ var Graphraum = class {
2027
2797
  nodeMesh = null;
2028
2798
  nodePickMesh = null;
2029
2799
  edgeMesh = null;
2800
+ glowLayer = new NodeGlowLayer(this.scene);
2801
+ hasGlowNodes = false;
2030
2802
  nodeCapacity = 0;
2031
2803
  edgeCapacity = 0;
2032
2804
  markerCapacity = 0;
2033
2805
  edgeInstanceCapacity = 0;
2034
2806
  selectedNodeIds = /* @__PURE__ */ new Set();
2035
2807
  selectedEdgeIds = /* @__PURE__ */ new Set();
2808
+ dimmedEdgeIds = /* @__PURE__ */ new Set();
2809
+ edgeIndexCache = {
2810
+ edges: [],
2811
+ indices: /* @__PURE__ */ new Map()
2812
+ };
2036
2813
  hoveredNodeIds = /* @__PURE__ */ new Set();
2037
2814
  focusedNodeIds = /* @__PURE__ */ new Set();
2038
2815
  dimmedNodeIds = /* @__PURE__ */ new Set();
@@ -2053,6 +2830,12 @@ var Graphraum = class {
2053
2830
  edgeSegmentCapacity = 0;
2054
2831
  mode;
2055
2832
  frameRequest = null;
2833
+ autoOrbit = null;
2834
+ flight = null;
2835
+ flightDirection = new Vector3();
2836
+ /** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
2837
+ orbitUpdating = false;
2838
+ destroyed = false;
2056
2839
  cpuFrameMilliseconds = 0;
2057
2840
  gpuFrameMilliseconds = null;
2058
2841
  gpuTimer = null;
@@ -2092,6 +2875,7 @@ var Graphraum = class {
2092
2875
  this.container.append(this.renderer.domElement);
2093
2876
  this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
2094
2877
  this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
2878
+ for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
2095
2879
  this.camera = this.createCamera();
2096
2880
  this.controls = this.createControls();
2097
2881
  this.resizeObserver = new ResizeObserver(() => this.resize());
@@ -2148,6 +2932,7 @@ var Graphraum = class {
2148
2932
  }))
2149
2933
  };
2150
2934
  this.nodeIds = compiled.nodeIds;
2935
+ this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
2151
2936
  this.nodeIndices = new Map(compiled.nodeIndices);
2152
2937
  this.edgeNodeIndices = compiled.edgeNodeIndices;
2153
2938
  this.canonicalEdgePositions = compiled.edgePositions;
@@ -2157,12 +2942,11 @@ var Graphraum = class {
2157
2942
  this.spatialGrid2d = new SpatialGrid2D();
2158
2943
  for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
2159
2944
  const liveNodeIds = new Set(this.nodeIds);
2160
- const liveEdgeIds = new Set(this.data.edges.map((edge) => edge.id));
2161
2945
  this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
2162
2946
  this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
2163
2947
  this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
2164
2948
  this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
2165
- this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => liveEdgeIds.has(id)));
2949
+ this.pruneEdgeStates();
2166
2950
  this.materializeViewport();
2167
2951
  this.requestRender();
2168
2952
  }
@@ -2226,6 +3010,7 @@ var Graphraum = class {
2226
3010
  edges: nextEdges
2227
3011
  };
2228
3012
  this.nodeIds = nextNodes.map((node) => node.id);
3013
+ this.hasGlowNodes = nextNodes.some(hasNodeGlow);
2229
3014
  this.nodeIndices = nextNodeIndices;
2230
3015
  this.edgeNodeIndices = nextEdgeIndices;
2231
3016
  this.canonicalEdgePositions = nextEdgePositions;
@@ -2255,6 +3040,7 @@ var Graphraum = class {
2255
3040
  }))
2256
3041
  };
2257
3042
  this.nodeIds = compiled.nodeIds;
3043
+ this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
2258
3044
  this.nodeIndices = new Map(compiled.nodeIndices);
2259
3045
  this.edgeNodeIndices = compiled.edgeNodeIndices;
2260
3046
  this.canonicalEdgePositions = compiled.edgePositions;
@@ -2263,11 +3049,12 @@ var Graphraum = class {
2263
3049
  this.edgePresentations = new Map(compiled.edgePresentations);
2264
3050
  this.spatialGrid2d = new SpatialGrid2D();
2265
3051
  for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
3052
+ this.pruneEdgeStates();
2266
3053
  this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
2267
3054
  const nodeCapacity = this.nodeCapacity;
2268
3055
  const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
2269
3056
  allocateNodeInstanceColors(nodeMesh, nodeCapacity);
2270
- nodeMesh.renderOrder = this.mode === "2d" ? 1 : 0;
3057
+ nodeMesh.renderOrder = 1;
2271
3058
  this.nodeMesh = nodeMesh;
2272
3059
  this.scene.add(nodeMesh);
2273
3060
  const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
@@ -2277,9 +3064,9 @@ var Graphraum = class {
2277
3064
  this.edgeSegmentCapacity = capacities.segmentCapacity;
2278
3065
  this.markerCapacity = capacities.markerCapacity;
2279
3066
  this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
2280
- const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(this.mode === "3d"), this.edgeInstanceCapacity);
3067
+ const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(), this.edgeInstanceCapacity);
2281
3068
  edgeMesh.frustumCulled = false;
2282
- edgeMesh.renderOrder = this.mode === "2d" ? -1 : 0;
3069
+ edgeMesh.renderOrder = -1;
2283
3070
  this.edgeMesh = edgeMesh;
2284
3071
  this.scene.add(edgeMesh);
2285
3072
  if (fitView) this.fitView();
@@ -2376,6 +3163,8 @@ var Graphraum = class {
2376
3163
  }
2377
3164
  }
2378
3165
  if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
3166
+ const glowChanged = prepared.some((update) => update.glowChanged);
3167
+ if (glowChanged) this.hasGlowNodes = nodes.some(hasNodeGlow);
2379
3168
  this.data = {
2380
3169
  ...this.data,
2381
3170
  nodes
@@ -2394,6 +3183,8 @@ var Graphraum = class {
2394
3183
  }
2395
3184
  if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
2396
3185
  if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
3186
+ if (glowChanged) this.syncGlow();
3187
+ else if (prepared.some((update) => update.positionChanged || update.sizeChanged || update.colorChanged)) this.glowLayer.rewrite(this.describeGlowNode);
2397
3188
  if (prepared.some((update) => update.strokeChanged)) {
2398
3189
  nodeStrokeWidth.needsUpdate = true;
2399
3190
  nodeStrokeColor.needsUpdate = true;
@@ -2405,10 +3196,6 @@ var Graphraum = class {
2405
3196
  if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
2406
3197
  const worldPerPixel = this.worldUnitsPerPixel();
2407
3198
  const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
2408
- const edgeVisuals = this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
2409
- ...edge,
2410
- color: this.theme.selectedEdge
2411
- } : edge);
2412
3199
  const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
2413
3200
  changedEdgeIndices,
2414
3201
  defaults: {
@@ -2417,11 +3204,13 @@ var Graphraum = class {
2417
3204
  width: this.theme.edgeWidth
2418
3205
  },
2419
3206
  edgeNodeIndices: this.edgeNodeIndices,
2420
- edgeVisuals,
3207
+ edgeStates: this.edgeStateStyling(),
3208
+ edgeVisuals: this.data.edges,
2421
3209
  endpointAttach: this.theme.endpointAttach,
2422
3210
  endpointPositions: this.canonicalEdgePositions,
2423
3211
  layouts: this.visibleEdgeLayouts,
2424
3212
  minHitSlop,
3213
+ mode: this.mode,
2425
3214
  nodeOutlines: this.data.nodes.map((node) => ({
2426
3215
  height: node.height,
2427
3216
  shape: node.shape,
@@ -2439,12 +3228,80 @@ var Graphraum = class {
2439
3228
  setSelection(nodeIds) {
2440
3229
  this.setNodeState("selected", nodeIds);
2441
3230
  }
2442
- /** Highlights edges without changing node selection or source graph data. */
3231
+ /**
3232
+ * Highlights edges without changing node selection or source graph data. Unknown IDs are ignored.
3233
+ * Only the instance colors of edges whose selection changed are rewritten and uploaded.
3234
+ */
2443
3235
  setEdgeSelection(edgeIds) {
2444
- this.selectedEdgeIds = new Set(edgeIds);
2445
- this.materializeViewport();
3236
+ const next = this.knownEdgeIds(edgeIds);
3237
+ const changed = changedIds(this.selectedEdgeIds, next);
3238
+ this.selectedEdgeIds = next;
3239
+ this.repaintEdgeStates(changed);
3240
+ }
3241
+ /**
3242
+ * Applies an application-owned edge state without changing source graph data. `dimmed` uses
3243
+ * `theme.dimmedEdge` / `theme.dimmedEdgeOpacity`; selected edges stay selected. Unknown IDs are ignored.
3244
+ */
3245
+ setEdgeState(state, edgeIds) {
3246
+ if (state !== "dimmed") throw new Error(`Unknown edge state "${String(state)}". Use "dimmed".`);
3247
+ const next = this.knownEdgeIds(edgeIds);
3248
+ const changed = changedIds(this.dimmedEdgeIds, next);
3249
+ this.dimmedEdgeIds = next;
3250
+ this.repaintEdgeStates(changed);
3251
+ }
3252
+ knownEdgeIds(edgeIds) {
3253
+ const indices = this.edgeIndicesById();
3254
+ return new Set([...edgeIds].filter((id) => indices.has(id)));
3255
+ }
3256
+ /**
3257
+ * Visible slots, LOD tier, and geometry only change through materialize or endpoint patches,
3258
+ * which keep `visibleEdgeLayouts` current, so a state change repaints in place. If a layout no
3259
+ * longer fits the drawn instances, that invariant broke: fall back to a full materialize.
3260
+ */
3261
+ repaintEdgeStates(changedEdgeIds) {
3262
+ if (!this.edgeMesh || changedEdgeIds.length === 0) return;
3263
+ const indices = this.edgeIndicesById();
3264
+ if (!patchVisibleEdgePaint(this.edgeMesh.geometry, {
3265
+ changedEdgeIndices: changedEdgeIds.flatMap((id) => indices.get(id) ?? []),
3266
+ defaults: {
3267
+ color: this.theme.edge,
3268
+ opacity: this.theme.edgeOpacity
3269
+ },
3270
+ edgeStates: this.edgeStateStyling(),
3271
+ edgeVisuals: this.data.edges,
3272
+ instanceCount: this.edgeMesh.count,
3273
+ layouts: this.visibleEdgeLayouts,
3274
+ tier: this.lastEdgeLodTier
3275
+ }).ok) this.materializeViewport();
2446
3276
  this.requestRender();
2447
3277
  }
3278
+ edgeStateStyling() {
3279
+ return {
3280
+ dimmedColor: this.theme.dimmedEdge,
3281
+ dimmedOpacity: this.theme.dimmedEdgeOpacity,
3282
+ selectedColor: this.theme.selectedEdge,
3283
+ stateOf: (edgeIndex) => {
3284
+ const id = this.data.edges[edgeIndex]?.id;
3285
+ if (id === void 0) return null;
3286
+ if (this.selectedEdgeIds.has(id)) return "selected";
3287
+ return this.dimmedEdgeIds.has(id) ? "dimmed" : null;
3288
+ }
3289
+ };
3290
+ }
3291
+ /** Edge ID to compiled index, rebuilt only when the edge list is replaced. */
3292
+ edgeIndicesById() {
3293
+ if (this.edgeIndexCache.edges !== this.data.edges) this.edgeIndexCache = {
3294
+ edges: this.data.edges,
3295
+ indices: new Map(this.data.edges.map((edge, index) => [edge.id, index]))
3296
+ };
3297
+ return this.edgeIndexCache.indices;
3298
+ }
3299
+ /** Drops edge states whose edges left the graph. */
3300
+ pruneEdgeStates() {
3301
+ const indices = this.edgeIndicesById();
3302
+ this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => indices.has(id)));
3303
+ this.dimmedEdgeIds = new Set([...this.dimmedEdgeIds].filter((id) => indices.has(id)));
3304
+ }
2448
3305
  /** Applies an application-owned visual state without changing source graph data. */
2449
3306
  setNodeState(state, nodeIds) {
2450
3307
  const nextSelection = new Set(nodeIds);
@@ -2481,6 +3338,8 @@ var Graphraum = class {
2481
3338
  }
2482
3339
  setMode(mode) {
2483
3340
  if (mode === this.mode) return;
3341
+ this.cancelFlight();
3342
+ this.stopAutoOrbit();
2484
3343
  this.mode = mode;
2485
3344
  this.controls.removeEventListener("change", this.handleViewChange);
2486
3345
  this.controls.dispose();
@@ -2490,19 +3349,30 @@ var Graphraum = class {
2490
3349
  this.nodeMesh.material.depthTest = mode === "3d";
2491
3350
  this.nodeMesh.material.depthWrite = mode === "3d";
2492
3351
  this.nodeMesh.material.needsUpdate = true;
2493
- this.nodeMesh.renderOrder = mode === "2d" ? 1 : 0;
2494
- }
2495
- if (this.edgeMesh && !Array.isArray(this.edgeMesh.material)) {
2496
- this.edgeMesh.material.depthTest = mode === "3d";
2497
- this.edgeMesh.material.depthWrite = mode === "3d";
2498
- this.edgeMesh.material.needsUpdate = true;
2499
- this.edgeMesh.renderOrder = mode === "2d" ? -1 : 0;
2500
3352
  }
2501
3353
  this.fitView();
2502
3354
  }
2503
3355
  getMode() {
2504
3356
  return this.mode;
2505
3357
  }
3358
+ /**
3359
+ * Slowly orbits the 3D camera around its target until called with `false`. The orbit pauses while
3360
+ * the camera controls handle a drag, pinch or wheel step and resumes after `resumeAfterMs` of idle;
3361
+ * it also stops while the document is hidden or the canvas is off screen. While it is active a
3362
+ * frame loop renders every frame (no viewport rematerialization), so check `prefers-reduced-motion`
3363
+ * before enabling it. Throws in 2D mode; `setMode("2d")` turns it off. Does nothing after `destroy()`.
3364
+ */
3365
+ setAutoOrbit(options) {
3366
+ if (options === false || this.destroyed) {
3367
+ this.stopAutoOrbit();
3368
+ return;
3369
+ }
3370
+ if (this.mode !== "3d") throw new Error("Auto-orbit requires 3D mode. Call setMode(\"3d\") before setAutoOrbit(options).");
3371
+ const { resumeAfterMs, speed } = resolveAutoOrbitOptions(options);
3372
+ this.controls.autoRotateSpeed = autoRotateSpeedFor(speed);
3373
+ if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
3374
+ else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
3375
+ }
2506
3376
  getNodePresentation(nodeId) {
2507
3377
  return this.nodePresentations.get(nodeId) ?? null;
2508
3378
  }
@@ -2595,6 +3465,7 @@ var Graphraum = class {
2595
3465
  getDiagnostics() {
2596
3466
  return {
2597
3467
  aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
3468
+ autoOrbit: this.autoOrbit?.status ?? "off",
2598
3469
  cpuFrameMilliseconds: this.cpuFrameMilliseconds,
2599
3470
  gpuFrameMilliseconds: this.gpuFrameMilliseconds,
2600
3471
  gpuDrawCalls: this.renderer.info.render.calls,
@@ -2602,6 +3473,7 @@ var Graphraum = class {
2602
3473
  gpuTextures: this.renderer.info.memory.textures,
2603
3474
  lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
2604
3475
  pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
3476
+ dimmedEdges: this.dimmedEdgeIds.size,
2605
3477
  selectedEdges: this.selectedEdgeIds.size,
2606
3478
  selectedNodes: this.selectedNodeIds.size,
2607
3479
  totalEdges: this.data.edges.length,
@@ -2610,6 +3482,7 @@ var Graphraum = class {
2610
3482
  visibleEdgeMarkers: this.visibleEdgeMarkerCount,
2611
3483
  visibleEdgeSegments: this.visibleEdgeSegmentCount,
2612
3484
  visibleEdges: this.visibleEdgeCount,
3485
+ visibleGlowNodes: this.glowLayer.count,
2613
3486
  visibleNodes: this.visibleNodeCount,
2614
3487
  visibleNodeCandidates: this.visibleNodeCandidateCount
2615
3488
  };
@@ -2636,52 +3509,207 @@ var Graphraum = class {
2636
3509
  id: edgeId
2637
3510
  } : null;
2638
3511
  }
2639
- fitView() {
3512
+ /** Frames every node, or only `nodeIds`; unknown ids are ignored and no match frames all nodes. Cancels a running `flyTo`. */
3513
+ fitView(options) {
3514
+ this.cancelFlight();
2640
3515
  if (!this.nodeMesh || this.data.nodes.length === 0) {
2641
3516
  this.requestRender();
2642
3517
  return;
2643
3518
  }
2644
- const bounds = new Box3();
2645
- const minimum = new Vector3();
2646
- const maximum = new Vector3();
2647
- for (const node of this.data.nodes) {
2648
- const { height, width } = resolveNodeAxes({
2649
- height: node.height,
2650
- nodeId: node.id,
2651
- size: node.size,
2652
- width: node.width
2653
- });
2654
- const radius = Math.max(width, height);
2655
- const z = node.position.z ?? 0;
2656
- bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
2657
- bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
2658
- }
2659
- const center = bounds.getCenter(new Vector3());
2660
- const size = bounds.getSize(new Vector3());
3519
+ const subset = this.nodesWithIds(options?.nodeIds);
3520
+ const nodes = subset.length > 0 ? subset : this.data.nodes;
2661
3521
  const width = Math.max(this.container.clientWidth, 1);
2662
3522
  const height = Math.max(this.container.clientHeight, 1);
2663
- const aspect = width / height;
2664
3523
  if (this.camera instanceof OrthographicCamera) {
2665
- const visibleHeight = Math.max(size.y, size.x / aspect, 1) * 1.15;
2666
- this.camera.left = -visibleHeight * aspect / 2;
2667
- this.camera.right = visibleHeight * aspect / 2;
2668
- this.camera.top = visibleHeight / 2;
2669
- this.camera.bottom = -visibleHeight / 2;
2670
- this.camera.position.set(center.x, center.y, center.z + Math.max(size.z, 1e3));
3524
+ const aspect = width / height;
3525
+ const fit = orthographicFit(aspect, nodes);
3526
+ setOrthographicFrustum(this.camera, aspect, fit.visibleHeight);
3527
+ this.camera.position.set(fit.center.x, fit.center.y, fit.cameraZ);
3528
+ this.camera.lookAt(fit.center);
3529
+ this.controls.target.copy(fit.center);
2671
3530
  } else {
2672
- applyPerspectiveContainerAspect(this.camera, width, height);
2673
- const distance = Math.max(size.length() / 2, 1) / Math.sin(this.camera.fov * Math.PI / 360);
2674
- this.camera.position.set(center.x, center.y, center.z + distance * 1.15);
2675
- this.camera.near = Math.max(distance / 1e4, .1);
2676
- this.camera.far = distance * 10;
3531
+ const fit = this.perspectiveFit(this.camera, width, height, nodes);
3532
+ this.camera.near = fit.near;
3533
+ this.camera.far = fit.far;
3534
+ this.camera.position.set(fit.target.x, fit.target.y, fit.cameraZ);
3535
+ this.controls.target.set(fit.target.x, fit.target.y, fit.target.z);
3536
+ this.camera.lookAt(this.controls.target);
2677
3537
  }
2678
- this.camera.lookAt(center);
2679
3538
  this.camera.updateProjectionMatrix();
2680
- this.controls.target.copy(center);
2681
3539
  this.controls.update();
2682
3540
  this.materializeViewport();
2683
3541
  this.requestRender();
2684
3542
  }
3543
+ /**
3544
+ * Animates the camera to frame `nodeIds` as `fitView({ nodeIds })` would, with an ease-in-out
3545
+ * over `durationMs`. In 3D the camera and the orbit target move together and keep the current
3546
+ * view direction, so a running auto-orbit carries on around the new target, at the fitted
3547
+ * distance times `distanceScale`. In 2D it pans and zooms. Resolves `true` on arrival and
3548
+ * `false` when cancelled by canvas pointer, wheel or key input, another `flyTo`, `fitView`,
3549
+ * `setData`, `setMode`, a 2D resize or `destroy()`. Unknown IDs are ignored; when none match,
3550
+ * or after `destroy()`, it resolves `false` without moving. With `prefers-reduced-motion:
3551
+ * reduce` or a zero duration it jumps. Throws for an invalid duration or distance scale.
3552
+ */
3553
+ flyTo(options) {
3554
+ const { distanceScale, durationMs } = resolveFlyToOptions(options);
3555
+ this.cancelFlight();
3556
+ if (this.destroyed || !this.nodeMesh) return Promise.resolve(false);
3557
+ const nodes = this.nodesWithIds(options.nodeIds);
3558
+ if (nodes.length === 0) return Promise.resolve(false);
3559
+ const width = Math.max(this.container.clientWidth, 1);
3560
+ const height = Math.max(this.container.clientHeight, 1);
3561
+ const flight = this.camera instanceof OrthographicCamera ? this.planOrthographicFlight(this.camera, width / height, nodes, distanceScale) : this.planPerspectiveFlight(this.camera, width, height, nodes, distanceScale);
3562
+ if (durationMs === 0 || prefersReducedMotion()) {
3563
+ flight.step(1);
3564
+ flight.arrive();
3565
+ this.requestRender();
3566
+ return Promise.resolve(true);
3567
+ }
3568
+ return new Promise((resolve) => {
3569
+ this.flight = {
3570
+ ...flight,
3571
+ durationMs,
3572
+ resolve,
3573
+ startMs: null
3574
+ };
3575
+ this.requestRender();
3576
+ });
3577
+ }
3578
+ /** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
3579
+ nodesWithIds(nodeIds) {
3580
+ if (!nodeIds) return this.data.nodes;
3581
+ const wanted = new Set(nodeIds);
3582
+ return this.data.nodes.filter((node) => wanted.has(node.id));
3583
+ }
3584
+ /** The closest front-view camera that frames `nodes` (issue #124); applies the container aspect. */
3585
+ perspectiveFit(camera, width, height, nodes) {
3586
+ applyPerspectiveContainerAspect(camera, width, height);
3587
+ return fitPerspective({
3588
+ aspect: camera.aspect,
3589
+ fill: PERSPECTIVE_FIT_FILL,
3590
+ fovDegrees: camera.fov
3591
+ }, {
3592
+ count: nodes.length,
3593
+ read(index, out) {
3594
+ const node = nodes[index];
3595
+ const axes = resolveNodeAxes({
3596
+ height: node.height,
3597
+ nodeId: node.id,
3598
+ size: node.size,
3599
+ width: node.width
3600
+ });
3601
+ out.x = node.position.x;
3602
+ out.y = node.position.y;
3603
+ out.z = node.position.z ?? 0;
3604
+ const extent = Math.max(axes.width, axes.height);
3605
+ const glow = node.glow ?? 0;
3606
+ const halo = glow > 0 ? glowHaloRadius(extent, glow) : 0;
3607
+ out.halfWidth = Math.max(axes.width, halo);
3608
+ out.halfHeight = Math.max(axes.height, halo);
3609
+ }
3610
+ });
3611
+ }
3612
+ /**
3613
+ * A 3D flight keeps the view direction it reads from the camera every frame, so an orbit step
3614
+ * between frames turns the flight with it. The destination distance is the front-view fit
3615
+ * distance; near and far span both ends while flying, and the far plane at arrival still holds
3616
+ * the whole graph so the rest of it stays visible around the framed nodes.
3617
+ */
3618
+ planPerspectiveFlight(camera, width, height, nodes, distanceScale) {
3619
+ const fit = this.perspectiveFit(camera, width, height, nodes);
3620
+ const fromTarget = this.controls.target.clone();
3621
+ const toTarget = new Vector3(fit.target.x, fit.target.y, fit.target.z);
3622
+ const fromDistance = Math.max(camera.position.distanceTo(fromTarget), Number.EPSILON);
3623
+ const toDistance = (fit.cameraZ - fit.target.z) * distanceScale;
3624
+ const near = fit.near * Math.min(distanceScale, 1);
3625
+ const far = Math.max(fit.far * distanceScale, (toDistance + this.graphRadiusAround(toTarget)) * 1.1);
3626
+ camera.near = Math.min(camera.near, near);
3627
+ camera.far = Math.max(camera.far, far);
3628
+ camera.updateProjectionMatrix();
3629
+ return {
3630
+ arrive: () => {
3631
+ camera.near = near;
3632
+ camera.far = far;
3633
+ camera.updateProjectionMatrix();
3634
+ this.materializeViewport();
3635
+ },
3636
+ step: (eased) => {
3637
+ const direction = this.flightDirection.subVectors(camera.position, this.controls.target);
3638
+ if (direction.lengthSq() === 0) direction.set(0, 0, 1);
3639
+ direction.normalize();
3640
+ this.controls.target.lerpVectors(fromTarget, toTarget, eased);
3641
+ camera.position.copy(this.controls.target).addScaledVector(direction, interpolateScale(fromDistance, toDistance, eased));
3642
+ camera.lookAt(this.controls.target);
3643
+ }
3644
+ };
3645
+ }
3646
+ /**
3647
+ * A 2D flight switches to the destination frustum up front with a zoom that keeps the current
3648
+ * view, then pans and eases the zoom back to 1, so it lands exactly on the fitted frustum.
3649
+ */
3650
+ planOrthographicFlight(camera, aspect, nodes, distanceScale) {
3651
+ const fit = orthographicFit(aspect, nodes);
3652
+ const fromVisibleHeight = (camera.top - camera.bottom) / camera.zoom;
3653
+ const toVisibleHeight = fit.visibleHeight * distanceScale;
3654
+ setOrthographicFrustum(camera, aspect, toVisibleHeight);
3655
+ const fromZoom = toVisibleHeight / fromVisibleHeight;
3656
+ camera.zoom = fromZoom;
3657
+ camera.updateProjectionMatrix();
3658
+ const fromPosition = camera.position.clone();
3659
+ const toPosition = new Vector3(fit.center.x, fit.center.y, fit.cameraZ);
3660
+ const fromTarget = this.controls.target.clone();
3661
+ return {
3662
+ arrive: () => this.materializeViewport(),
3663
+ step: (eased) => {
3664
+ camera.zoom = interpolateScale(fromZoom, 1, eased);
3665
+ camera.position.lerpVectors(fromPosition, toPosition, eased);
3666
+ this.controls.target.lerpVectors(fromTarget, fit.center, eased);
3667
+ camera.updateProjectionMatrix();
3668
+ }
3669
+ };
3670
+ }
3671
+ /** Largest distance from `center` to any node's far edge. */
3672
+ graphRadiusAround(center) {
3673
+ let radius = 0;
3674
+ for (const node of this.data.nodes) {
3675
+ const axes = resolveNodeAxes({
3676
+ height: node.height,
3677
+ nodeId: node.id,
3678
+ size: node.size,
3679
+ width: node.width
3680
+ });
3681
+ const distance = Math.hypot(node.position.x - center.x, node.position.y - center.y, (node.position.z ?? 0) - center.z);
3682
+ radius = Math.max(radius, distance + Math.max(axes.width, axes.height));
3683
+ }
3684
+ return radius;
3685
+ }
3686
+ /** Steps a running flight to the frame time; settles and resolves it at the end. */
3687
+ advanceFlight(nowMs) {
3688
+ const flight = this.flight;
3689
+ if (!flight) return;
3690
+ flight.startMs ??= nowMs;
3691
+ const progress = flightProgress(nowMs - flight.startMs, flight.durationMs);
3692
+ try {
3693
+ flight.step(easeInOutCubic(progress));
3694
+ if (progress < 1) {
3695
+ if (this.mode === "2d") this.materializeViewport();
3696
+ this.requestRender();
3697
+ return;
3698
+ }
3699
+ this.flight = null;
3700
+ flight.arrive();
3701
+ } catch (error) {
3702
+ this.cancelFlight();
3703
+ throw error;
3704
+ }
3705
+ flight.resolve(true);
3706
+ }
3707
+ cancelFlight = () => {
3708
+ const flight = this.flight;
3709
+ if (!flight) return;
3710
+ this.flight = null;
3711
+ flight.resolve(false);
3712
+ };
2685
3713
  setTheme(theme) {
2686
3714
  this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
2687
3715
  this.applyBackground();
@@ -2706,9 +3734,13 @@ var Graphraum = class {
2706
3734
  this.requestRender();
2707
3735
  }
2708
3736
  destroy() {
3737
+ this.destroyed = true;
3738
+ this.cancelFlight();
3739
+ this.stopAutoOrbit();
2709
3740
  this.resizeObserver.disconnect();
2710
3741
  this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
2711
3742
  this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
3743
+ for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.removeEventListener(type, this.cancelFlight);
2712
3744
  this.controls.removeEventListener("change", this.handleViewChange);
2713
3745
  this.controls.dispose();
2714
3746
  if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
@@ -2720,22 +3752,63 @@ var Graphraum = class {
2720
3752
  }
2721
3753
  requestRender = () => {
2722
3754
  if (this.frameRequest !== null) return;
2723
- this.frameRequest = requestAnimationFrame(() => {
3755
+ this.frameRequest = requestAnimationFrame((nowMs) => {
2724
3756
  this.frameRequest = null;
2725
- this.readGpuTimers();
2726
- const timer = this.gpuTimer;
2727
- const query = timer?.context.createQuery() ?? null;
2728
- if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
2729
- const startedAt = performance.now();
2730
- this.renderer.render(this.scene, this.camera);
2731
- this.cpuFrameMilliseconds = performance.now() - startedAt;
2732
- if (timer && query) {
2733
- timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
2734
- timer.pending.push(query);
2735
- }
2736
- for (const listener of this.viewListeners) listener();
3757
+ this.advanceFlight(nowMs);
3758
+ this.renderFrame();
2737
3759
  });
2738
3760
  };
3761
+ /** Renders a pending frame now instead of on the next animation frame. */
3762
+ flushRender() {
3763
+ if (this.frameRequest === null) return;
3764
+ cancelAnimationFrame(this.frameRequest);
3765
+ this.frameRequest = null;
3766
+ this.renderFrame();
3767
+ }
3768
+ renderFrame() {
3769
+ this.readGpuTimers();
3770
+ const timer = this.gpuTimer;
3771
+ const query = timer?.context.createQuery() ?? null;
3772
+ if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
3773
+ const startedAt = performance.now();
3774
+ this.renderer.render(this.scene, this.camera);
3775
+ this.cpuFrameMilliseconds = performance.now() - startedAt;
3776
+ if (timer && query) {
3777
+ timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
3778
+ timer.pending.push(query);
3779
+ }
3780
+ for (const listener of this.viewListeners) listener();
3781
+ }
3782
+ stopAutoOrbit() {
3783
+ this.autoOrbit?.dispose();
3784
+ this.autoOrbit = null;
3785
+ }
3786
+ /**
3787
+ * One orbit frame. `autoRotate` is on only for this `update(dt)` call: OrbitControls also calls
3788
+ * `update()` without a delta from wheel and key handlers and `fitView`, which would otherwise add
3789
+ * a fixed per-call rotation. A moved camera emits `change` (a render request only, see
3790
+ * `handleViewChange`); flushing that request renders this frame instead of the next one. During
3791
+ * a `flyTo` the orbit still turns the camera but leaves the pending frame to the flight, which
3792
+ * steps from the turned direction before it renders. A
3793
+ * throwing update, render or view listener turns the orbit off rather than leaving it enabled
3794
+ * without a loop.
3795
+ */
3796
+ stepAutoOrbit = (deltaSeconds) => {
3797
+ try {
3798
+ this.controls.autoRotate = true;
3799
+ this.orbitUpdating = true;
3800
+ try {
3801
+ this.controls.update(deltaSeconds);
3802
+ } finally {
3803
+ this.controls.autoRotate = false;
3804
+ this.orbitUpdating = false;
3805
+ }
3806
+ if (this.flight === null) this.flushRender();
3807
+ } catch (error) {
3808
+ this.stopAutoOrbit();
3809
+ throw error;
3810
+ }
3811
+ };
2739
3812
  readGpuTimers() {
2740
3813
  const timer = this.gpuTimer;
2741
3814
  if (!timer) return;
@@ -2747,8 +3820,15 @@ var Graphraum = class {
2747
3820
  timer.context.deleteQuery(query);
2748
3821
  }
2749
3822
  }
3823
+ /**
3824
+ * Orbit frames skip materialization. In 3D the packed instances do not depend on camera
3825
+ * orientation: there is no viewport culling, density LOD and edge tiers follow counts only,
3826
+ * and the one orientation-dependent value (`worldUnitsPerPixel`, for edge hit slop) is used
3827
+ * only by 2D picking. 3D picking raycasts the instances and screen positions are projected per
3828
+ * call, so an orbit frame needs only a render, whose view listeners still run every frame.
3829
+ */
2750
3830
  handleViewChange = () => {
2751
- this.materializeViewport();
3831
+ if (!this.orbitUpdating) this.materializeViewport();
2752
3832
  this.requestRender();
2753
3833
  };
2754
3834
  createCamera() {
@@ -2758,12 +3838,8 @@ var Graphraum = class {
2758
3838
  createControls() {
2759
3839
  const controls = new OrbitControls(this.camera, this.renderer.domElement);
2760
3840
  controls.enableDamping = false;
2761
- controls.enableRotate = this.mode === "3d";
2762
3841
  controls.screenSpacePanning = true;
2763
- if (this.mode === "2d") {
2764
- controls.minZoom = .01;
2765
- controls.mouseButtons.LEFT = MOUSE.PAN;
2766
- }
3842
+ configureControlsForMode(controls, this.mode);
2767
3843
  controls.addEventListener("change", this.handleViewChange);
2768
3844
  return controls;
2769
3845
  }
@@ -2854,7 +3930,35 @@ var Graphraum = class {
2854
3930
  slots.push(slot);
2855
3931
  }
2856
3932
  markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
3933
+ this.glowLayer.rewrite(this.describeGlowNode);
3934
+ }
3935
+ /** Rebuilds the opt-in halo pass; graphs without glow release it and skip the visible scan. */
3936
+ syncGlow() {
3937
+ if (!this.hasGlowNodes) {
3938
+ this.glowLayer.dispose();
3939
+ return;
3940
+ }
3941
+ this.glowLayer.setBlending(glowBlendModeFor(this.theme.background));
3942
+ this.glowLayer.sync(this.visibleNodeIndices, this.glowAt, this.densityLodActive, this.describeGlowNode);
2857
3943
  }
3944
+ glowAt = (nodeIndex) => this.data.nodes[nodeIndex]?.glow ?? 0;
3945
+ describeGlowNode = (nodeIndex, target) => {
3946
+ const node = this.data.nodes[nodeIndex];
3947
+ if (!node) return false;
3948
+ const { height, width } = resolveNodeAxes({
3949
+ height: node.height,
3950
+ nodeId: node.id,
3951
+ size: node.size,
3952
+ width: node.width
3953
+ });
3954
+ target.color.copy(this.getNodeColor(node));
3955
+ target.extent = Math.max(width, height);
3956
+ target.glow = node.glow ?? 0;
3957
+ target.x = node.position.x;
3958
+ target.y = node.position.y;
3959
+ target.z = node.position.z ?? 0;
3960
+ return true;
3961
+ };
2858
3962
  getNodeStateIds(state) {
2859
3963
  switch (state) {
2860
3964
  case "dimmed": return this.dimmedNodeIds;
@@ -3000,10 +4104,8 @@ var Graphraum = class {
3000
4104
  const worldPerPixel = this.worldUnitsPerPixel();
3001
4105
  const packed = packEdgeInstances({
3002
4106
  edgeIndices: visibleEdgeIndices,
3003
- edgeVisuals: this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
3004
- ...edge,
3005
- color: this.theme.selectedEdge
3006
- } : edge),
4107
+ edgeStates: this.edgeStateStyling(),
4108
+ edgeVisuals: this.data.edges,
3007
4109
  endpointPositions: this.canonicalEdgePositions,
3008
4110
  defaults: {
3009
4111
  color: this.theme.edge,
@@ -3014,6 +4116,7 @@ var Graphraum = class {
3014
4116
  maxSegments: this.edgeSegmentCapacity,
3015
4117
  endpointAttach: this.theme.endpointAttach,
3016
4118
  edgeNodeIndices: this.edgeNodeIndices,
4119
+ mode: this.mode,
3017
4120
  nodeOutlines: this.data.nodes.map((node) => ({
3018
4121
  height: node.height,
3019
4122
  shape: node.shape,
@@ -3063,6 +4166,7 @@ var Graphraum = class {
3063
4166
  this.visibleEdgeSegmentCount = segmentCount;
3064
4167
  this.visibleEdgeCount = distinctEdges.size;
3065
4168
  this.visibleEdgeMarkerCount = packed.markers.length;
4169
+ this.syncGlow();
3066
4170
  }
3067
4171
  materializeNodes(indices, bounds) {
3068
4172
  if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
@@ -3124,6 +4228,7 @@ var Graphraum = class {
3124
4228
  return [...clusters.values()].slice(0, this.maxVisibleNodes);
3125
4229
  }
3126
4230
  disposeGraphObjects() {
4231
+ this.glowLayer.dispose();
3127
4232
  this.pickableEdgeSegments = [];
3128
4233
  this.visibleEdgeSegmentEdgeIndices = [];
3129
4234
  this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
@@ -3146,6 +4251,42 @@ var Graphraum = class {
3146
4251
  }
3147
4252
  }
3148
4253
  };
4254
+ /** The 2D fit: the nodes' padded bounds, framed with 15% slack on the tighter axis. */
4255
+ function orthographicFit(aspect, nodes) {
4256
+ const bounds = new Box3();
4257
+ const minimum = new Vector3();
4258
+ const maximum = new Vector3();
4259
+ for (const node of nodes) {
4260
+ const { height, width } = resolveNodeAxes({
4261
+ height: node.height,
4262
+ nodeId: node.id,
4263
+ size: node.size,
4264
+ width: node.width
4265
+ });
4266
+ const radius = Math.max(width, height);
4267
+ const z = node.position.z ?? 0;
4268
+ bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
4269
+ bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
4270
+ }
4271
+ const center = bounds.getCenter(new Vector3());
4272
+ const size = bounds.getSize(new Vector3());
4273
+ return {
4274
+ cameraZ: center.z + Math.max(size.z, 1e3),
4275
+ center,
4276
+ visibleHeight: Math.max(size.y, size.x / aspect, 1) * 1.15
4277
+ };
4278
+ }
4279
+ /** Sets a frustum that shows `visibleHeight` world units at zoom 1 and resets the zoom. */
4280
+ function setOrthographicFrustum(camera, aspect, visibleHeight) {
4281
+ camera.left = -visibleHeight * aspect / 2;
4282
+ camera.right = visibleHeight * aspect / 2;
4283
+ camera.top = visibleHeight / 2;
4284
+ camera.bottom = -visibleHeight / 2;
4285
+ camera.zoom = 1;
4286
+ }
4287
+ function hasNodeGlow(node) {
4288
+ return (node.glow ?? 0) > 0;
4289
+ }
3149
4290
  function nextCapacity(length) {
3150
4291
  let capacity = 1;
3151
4292
  while (capacity < length) capacity *= 2;