@yggy-tech/graphraum 0.28.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js 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,28 @@ 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;
114
125
  }
115
126
  /**
116
127
  * Walk from the node center toward the far point and stop on the outline,
117
128
  * then pull back by `clearance` so the stroke is not stabbed.
129
+ *
130
+ * In `2d` the outline lies in the xy plane, so only the xy part of the chord counts. In `3d` the
131
+ * billboard faces the camera, so the edge stops one outline radius from the center along the 3D
132
+ * segment. The radius is measured along the chord's world xy heading (or +x when the chord is
133
+ * parallel to z), not along its on-screen heading. For circles the radius is the same in every
134
+ * direction, so the projected end lies on the outline or inside it (where the node covers the
135
+ * edge) from every view. Other shapes are approximate in 3D: an elongated or angular node can end
136
+ * inside its outline or short of it, depending on the camera.
118
137
  */
119
- function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
138
+ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE, mode = "2d") {
120
139
  const dx = far.x - center.x;
121
140
  const dy = far.y - center.y;
122
141
  const dz = (far.z ?? 0) - (center.z ?? 0);
@@ -126,15 +145,12 @@ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT
126
145
  y: center.y,
127
146
  z: center.z ?? 0
128
147
  };
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
- }
148
+ const planar = Math.hypot(dx, dy);
149
+ const reach = mode === "3d" ? length : planar;
150
+ const radius = planar < 1e-6 ? outlineRadius(outline, 1, 0) : outlineRadius(outline, dx / planar, dy / planar);
151
+ const outlineT = reach < 1e-6 ? 1 : Math.min(1, radius / reach);
136
152
  const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
137
- const t = Math.max(0, low - clearanceT);
153
+ const t = Math.max(0, outlineT - clearanceT);
138
154
  return {
139
155
  x: center.x + dx * t,
140
156
  y: center.y + dy * t,
@@ -156,8 +172,8 @@ function trimEdgeEndpoints(input) {
156
172
  }
157
173
  };
158
174
  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);
175
+ const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance, input.mode);
176
+ const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance, input.mode);
161
177
  const ox = input.target.x - input.source.x;
162
178
  const oy = input.target.y - input.source.y;
163
179
  const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
@@ -245,6 +261,7 @@ function encodeEdgeStyle(style) {
245
261
  //#region src/force-layout.ts
246
262
  const DEFAULT_FORCE_SETTINGS = {
247
263
  centerAttraction: .002,
264
+ clusterStrength: .02,
248
265
  damping: .82,
249
266
  linkDistance: 24,
250
267
  repulsion: 400,
@@ -253,6 +270,7 @@ const DEFAULT_FORCE_SETTINGS = {
253
270
  function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
254
271
  return {
255
272
  centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
273
+ clusterStrength: Math.max(0, Math.min(.1, settings.clusterStrength ?? DEFAULT_FORCE_SETTINGS.clusterStrength)),
256
274
  damping: Math.max(.1, Math.min(.99, settings.damping)),
257
275
  linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
258
276
  repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
@@ -311,6 +329,60 @@ function random(seed) {
311
329
  value = Math.imul(value ^ value >>> 13, 3266489909);
312
330
  return ((value ^ value >>> 16) >>> 0) / 4294967296;
313
331
  }
332
+ function assertClusterCount(clusters, expected, subject) {
333
+ if (clusters.length !== expected) throw new Error(`Cluster index count (${clusters.length}) must match ${subject} (${expected}).`);
334
+ }
335
+ /**
336
+ * Live simulations index per-cluster scratch arrays by id, so ids must be dense: below the node count.
337
+ * Validate before allocating anything.
338
+ */
339
+ function assertDenseClusterIds(clusters, totalNodeCount) {
340
+ for (const cluster of clusters) if (cluster >= totalNodeCount) throw new Error(`Cluster index ${cluster} exceeds the node count ${totalNodeCount}; use dense ids starting at 0.`);
341
+ }
342
+ function clusterCountOf(clusters) {
343
+ let clusterCount = 0;
344
+ for (const cluster of clusters) clusterCount = Math.max(clusterCount, cluster + 1);
345
+ return clusterCount;
346
+ }
347
+ /**
348
+ * Seeds nodes around cluster centres: centres come from a force layout of the inter-cluster graph
349
+ * and each node is scattered around its centre. Shared by the one-shot and live clustered layouts.
350
+ */
351
+ function clusteredInitialPositions(request) {
352
+ const clusterCount = clusterCountOf(request.clusters);
353
+ const clusterEdges = new Uint32Array(request.edges.length);
354
+ let edgeCursor = 0;
355
+ for (let index = 0; index < request.edges.length; index += 2) {
356
+ const source = request.clusters[request.edges[index] ?? 0];
357
+ const target = request.clusters[request.edges[index + 1] ?? 0];
358
+ if (source === void 0 || target === void 0 || source === target) continue;
359
+ clusterEdges[edgeCursor++] = source;
360
+ clusterEdges[edgeCursor++] = target;
361
+ }
362
+ const centers = computeForcePositions({
363
+ dimensions: request.dimensions,
364
+ edges: clusterEdges.subarray(0, edgeCursor),
365
+ nodeCount: clusterCount,
366
+ settings: request.settings
367
+ });
368
+ const clusterSizes = new Uint32Array(clusterCount);
369
+ for (const cluster of request.clusters) clusterSizes[cluster] += 1;
370
+ const positions = new Float32Array(request.nodeCount * 3);
371
+ for (let index = 0; index < request.nodeCount; index += 1) {
372
+ const offset = index * 3;
373
+ const cluster = request.clusters[index] ?? 0;
374
+ const center = cluster * 3;
375
+ const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
376
+ const angle = random(index * 4 + 1) * Math.PI * 2;
377
+ positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
378
+ positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
379
+ if (request.dimensions === 3) {
380
+ const zDirection = random(index * 4 + 3) * 2 - 1;
381
+ positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
382
+ }
383
+ }
384
+ return positions;
385
+ }
314
386
  function createForceSimulation(request) {
315
387
  const settings = normalizeForceSettings(request.settings);
316
388
  let nodeCount = request.nodeCount;
@@ -319,10 +391,41 @@ function createForceSimulation(request) {
319
391
  const edgeCount = edges.length / 2;
320
392
  let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
321
393
  let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
322
- let positions = initialPositions(request);
394
+ let clusters = null;
395
+ if (request.clusters) {
396
+ assertClusterCount(request.clusters, request.nodeCount, "node count");
397
+ assertDenseClusterIds(request.clusters, request.nodeCount);
398
+ clusters = new Uint32Array(request.clusters);
399
+ }
400
+ let positions = clusters ? clusteredInitialPositions({
401
+ ...request,
402
+ clusters
403
+ }) : initialPositions(request);
323
404
  let velocities = new Float32Array(positions.length);
324
405
  let forces = new Float32Array(positions.length);
325
406
  let iteration = 0;
407
+ let clusterCount = clusters ? clusterCountOf(clusters) : 0;
408
+ let clusterCentroids = new Float64Array(clusterCount * 3);
409
+ let clusterSizes = new Uint32Array(clusterCount);
410
+ /** Mean position per cluster in O(N): no per-node allocation. */
411
+ function updateClusterCentroids(activeClusters) {
412
+ clusterCentroids.fill(0);
413
+ clusterSizes.fill(0);
414
+ for (let index = 0; index < nodeCount; index += 1) {
415
+ const cluster = activeClusters[index] ?? 0;
416
+ const offset = index * 3;
417
+ const centroid = cluster * 3;
418
+ clusterSizes[cluster] += 1;
419
+ clusterCentroids[centroid] += positions[offset] ?? 0;
420
+ clusterCentroids[centroid + 1] += positions[offset + 1] ?? 0;
421
+ if (request.dimensions === 3) clusterCentroids[centroid + 2] += positions[offset + 2] ?? 0;
422
+ }
423
+ for (let cluster = 0; cluster < clusterCount; cluster += 1) {
424
+ const size = clusterSizes[cluster] ?? 0;
425
+ if (size === 0) continue;
426
+ for (let axis = 0; axis < request.dimensions; axis += 1) clusterCentroids[cluster * 3 + axis] /= size;
427
+ }
428
+ }
326
429
  return {
327
430
  get nodeCount() {
328
431
  return nodeCount;
@@ -333,10 +436,20 @@ function createForceSimulation(request) {
333
436
  get positions() {
334
437
  return positions;
335
438
  },
336
- /** Add nodes while preserving every existing position and velocity. */
337
- addNodes(count, initial) {
439
+ /**
440
+ * Add nodes while preserving every existing position and velocity. A simulation created with
441
+ * `clusters` requires one cluster index per added node (a new id starts a new cluster); a
442
+ * simulation created without clusters rejects them.
443
+ */
444
+ addNodes(count, initial, addedClusters) {
338
445
  if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
339
446
  if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
447
+ if (clusters && !addedClusters) throw new Error("This simulation was created with clusters: addNodes() requires a clusters array.");
448
+ if (!clusters && addedClusters) throw new Error("This simulation was created without clusters: addNodes() cannot take a clusters array.");
449
+ if (addedClusters) {
450
+ assertClusterCount(addedClusters, count, "added node count");
451
+ assertDenseClusterIds(addedClusters, nodeCount + count);
452
+ }
340
453
  const start = nodeCount;
341
454
  const nextPositions = new Float32Array((nodeCount + count) * 3);
342
455
  nextPositions.set(positions);
@@ -352,6 +465,24 @@ function createForceSimulation(request) {
352
465
  });
353
466
  nextPositions.set(added, positions.length);
354
467
  }
468
+ let nextClusters = clusters;
469
+ let nextClusterCount = clusterCount;
470
+ let nextCentroids = clusterCentroids;
471
+ let nextSizes = clusterSizes;
472
+ if (clusters && addedClusters) {
473
+ nextClusters = new Uint32Array(nodeCount + count);
474
+ nextClusters.set(clusters);
475
+ nextClusters.set(addedClusters, nodeCount);
476
+ nextClusterCount = Math.max(clusterCount, clusterCountOf(addedClusters));
477
+ if (nextClusterCount !== clusterCount) {
478
+ nextCentroids = new Float64Array(nextClusterCount * 3);
479
+ nextSizes = new Uint32Array(nextClusterCount);
480
+ }
481
+ }
482
+ clusters = nextClusters;
483
+ clusterCount = nextClusterCount;
484
+ clusterCentroids = nextCentroids;
485
+ clusterSizes = nextSizes;
355
486
  positions = nextPositions;
356
487
  velocities = nextVelocities;
357
488
  forces = nextForces;
@@ -383,11 +514,18 @@ function createForceSimulation(request) {
383
514
  step(alpha) {
384
515
  if (nodeCount === 0) return;
385
516
  forces.fill(0);
517
+ const pullClusters = clusters && settings.clusterStrength > 0 ? clusters : null;
518
+ const clusterPull = settings.clusterStrength * alpha;
519
+ if (pullClusters) updateClusterCentroids(pullClusters);
386
520
  for (let index = 0; index < nodeCount; index += 1) {
387
521
  const offset = index * 3;
388
522
  forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
389
523
  forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
390
524
  forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
525
+ if (pullClusters) {
526
+ const centroid = (pullClusters[index] ?? 0) * 3;
527
+ for (let axis = 0; axis < request.dimensions; axis += 1) forces[offset + axis] += ((clusterCentroids[centroid + axis] ?? 0) - (positions[offset + axis] ?? 0)) * clusterPull;
528
+ }
391
529
  for (let sample = 0; sample < 8; sample += 1) {
392
530
  const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
393
531
  if (targetIndex === index) continue;
@@ -469,41 +607,8 @@ function computeForcePositions(request) {
469
607
  return simulation.positions;
470
608
  }
471
609
  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;
610
+ assertClusterCount(request.clusters, request.nodeCount, "node count");
611
+ return clusteredInitialPositions(request);
507
612
  }
508
613
  //#endregion
509
614
  //#region src/graphology.ts
@@ -570,6 +675,188 @@ function bindGraphology(renderer, graph) {
570
675
  graph.off("edgesCleared", schedule);
571
676
  };
572
677
  }
678
+ /** Longest frame step the orbit applies, so a throttled or backgrounded tab does not jump on return. */
679
+ const AUTO_ORBIT_MAX_DELTA_SECONDS = .1;
680
+ const AUTO_ORBIT_OFF = {
681
+ enabled: false,
682
+ hidden: false,
683
+ interacting: false,
684
+ offscreen: false
685
+ };
686
+ function resolveAutoOrbitOptions(options) {
687
+ const speed = options.speed ?? .07;
688
+ const resumeAfterMs = options.resumeAfterMs ?? 3e3;
689
+ if (!Number.isFinite(speed) || speed <= 0 || speed > 2) throw new Error(`Auto-orbit speed must be a finite number above 0 and at most 2 radians per second; received ${speed}.`);
690
+ if (!Number.isFinite(resumeAfterMs) || resumeAfterMs < 0) throw new Error(`Auto-orbit resumeAfterMs must be a non-negative finite number of milliseconds; received ${resumeAfterMs}.`);
691
+ return {
692
+ speed,
693
+ resumeAfterMs
694
+ };
695
+ }
696
+ function nextAutoOrbitState(state, event) {
697
+ if (event.type === "enable") return state.enabled ? {
698
+ ...state,
699
+ hidden: event.hidden
700
+ } : {
701
+ ...AUTO_ORBIT_OFF,
702
+ enabled: true,
703
+ hidden: event.hidden
704
+ };
705
+ if (!state.enabled) return state;
706
+ switch (event.type) {
707
+ case "disable": return AUTO_ORBIT_OFF;
708
+ case "interaction": return {
709
+ ...state,
710
+ interacting: true
711
+ };
712
+ case "idle": return {
713
+ ...state,
714
+ interacting: false
715
+ };
716
+ case "visibility": return {
717
+ ...state,
718
+ hidden: event.hidden
719
+ };
720
+ case "intersection": return {
721
+ ...state,
722
+ offscreen: event.offscreen
723
+ };
724
+ }
725
+ }
726
+ function autoOrbitStatus(state) {
727
+ if (!state.enabled) return "off";
728
+ return state.hidden || state.interacting || state.offscreen ? "paused" : "active";
729
+ }
730
+ /** Seconds between two animation-frame timestamps, zero on the first frame, clamped to `[0, AUTO_ORBIT_MAX_DELTA_SECONDS]`. */
731
+ function autoOrbitDeltaSeconds(previousMs, nowMs) {
732
+ if (previousMs === null) return 0;
733
+ return Math.min(Math.max((nowMs - previousMs) / 1e3, 0), AUTO_ORBIT_MAX_DELTA_SECONDS);
734
+ }
735
+ /**
736
+ * OrbitControls (three r185) rotates `(2π / 60) * autoRotateSpeed * deltaTime` radians when
737
+ * `update(deltaTime)` receives seconds (and a fixed per-call step without it). Inverting that
738
+ * gives an `autoRotateSpeed` for which `update(dtSeconds)` turns exactly `radiansPerSecond`,
739
+ * independent of frame rate.
740
+ */
741
+ function autoRotateSpeedFor(radiansPerSecond) {
742
+ return radiansPerSecond * 60 / (2 * Math.PI);
743
+ }
744
+ //#endregion
745
+ //#region src/auto-orbit-loop.ts
746
+ /**
747
+ * Owns the auto-orbit frame loop and the browser signals that stop it. The loop exists only
748
+ * while the orbit is active: pointer, wheel and key input pause it until `resumeAfterMs` of
749
+ * idle, and a hidden document or an off-screen element stop it until they change back.
750
+ * Decisions come from the pure state in `auto-orbit.ts`; this class only wires DOM events,
751
+ * the resume timer and `requestAnimationFrame`. Interaction comes from the controls' `start`
752
+ * and `end` events, so multi-touch and lost pointer capture end the pause only once the last
753
+ * pointer lifts. DOM overlays above the canvas do not reach the controls and do not pause it.
754
+ */
755
+ var AutoOrbitLoop = class {
756
+ element;
757
+ interactions;
758
+ resumeAfterMs;
759
+ step;
760
+ state;
761
+ frame = null;
762
+ lastFrameMs = null;
763
+ resumeTimer = null;
764
+ observer;
765
+ constructor(element, interactions, resumeAfterMs, step) {
766
+ this.element = element;
767
+ this.interactions = interactions;
768
+ this.resumeAfterMs = resumeAfterMs;
769
+ this.step = step;
770
+ this.state = nextAutoOrbitState(AUTO_ORBIT_OFF, {
771
+ type: "enable",
772
+ hidden: element.ownerDocument.hidden
773
+ });
774
+ interactions.addEventListener("start", this.handleInteractionStart);
775
+ interactions.addEventListener("end", this.handleInteractionEnd);
776
+ element.ownerDocument.addEventListener("visibilitychange", this.handleVisibilityChange);
777
+ this.observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(this.handleIntersection);
778
+ this.observer?.observe(element);
779
+ this.sync();
780
+ }
781
+ get status() {
782
+ return autoOrbitStatus(this.state);
783
+ }
784
+ setResumeAfterMs(resumeAfterMs) {
785
+ this.resumeAfterMs = resumeAfterMs;
786
+ }
787
+ dispose() {
788
+ this.interactions.removeEventListener("start", this.handleInteractionStart);
789
+ this.interactions.removeEventListener("end", this.handleInteractionEnd);
790
+ this.element.ownerDocument.removeEventListener("visibilitychange", this.handleVisibilityChange);
791
+ this.observer?.disconnect();
792
+ this.clearResumeTimer();
793
+ this.dispatch({ type: "disable" });
794
+ }
795
+ dispatch(event) {
796
+ this.state = nextAutoOrbitState(this.state, event);
797
+ this.sync();
798
+ }
799
+ /** Starts the frame loop when the orbit becomes active and cancels it otherwise. */
800
+ sync() {
801
+ const active = this.status === "active";
802
+ if (active && this.frame === null) {
803
+ this.lastFrameMs = null;
804
+ this.frame = requestAnimationFrame(this.tick);
805
+ } else if (!active && this.frame !== null) {
806
+ cancelAnimationFrame(this.frame);
807
+ this.frame = null;
808
+ }
809
+ }
810
+ tick = (nowMs) => {
811
+ this.frame = null;
812
+ const deltaSeconds = autoOrbitDeltaSeconds(this.lastFrameMs, nowMs);
813
+ this.lastFrameMs = nowMs;
814
+ try {
815
+ this.step(deltaSeconds);
816
+ } catch (error) {
817
+ this.dispose();
818
+ throw error;
819
+ }
820
+ if (this.frame === null && this.status === "active") this.frame = requestAnimationFrame(this.tick);
821
+ };
822
+ armResumeTimer() {
823
+ this.clearResumeTimer();
824
+ this.resumeTimer = setTimeout(() => {
825
+ this.resumeTimer = null;
826
+ this.dispatch({ type: "idle" });
827
+ }, this.resumeAfterMs);
828
+ }
829
+ clearResumeTimer() {
830
+ if (this.resumeTimer === null) return;
831
+ clearTimeout(this.resumeTimer);
832
+ this.resumeTimer = null;
833
+ }
834
+ /**
835
+ * A drag, pinch or wheel step pauses the orbit; `end` restarts the idle countdown. `start` arms
836
+ * it too, so a swallowed pointerup cannot pause the orbit forever. Resuming mid-drag is harmless:
837
+ * OrbitControls (r185) applies autoRotate in `update()` only while its state is NONE.
838
+ */
839
+ handleInteractionStart = () => {
840
+ this.dispatch({ type: "interaction" });
841
+ this.armResumeTimer();
842
+ };
843
+ handleInteractionEnd = () => {
844
+ if (this.state.interacting) this.armResumeTimer();
845
+ };
846
+ handleVisibilityChange = () => {
847
+ this.dispatch({
848
+ type: "visibility",
849
+ hidden: this.element.ownerDocument.hidden
850
+ });
851
+ };
852
+ handleIntersection = (entries) => {
853
+ const entry = entries.at(-1);
854
+ if (entry) this.dispatch({
855
+ type: "intersection",
856
+ offscreen: !entry.isIntersecting
857
+ });
858
+ };
859
+ };
573
860
  //#endregion
574
861
  //#region src/camera-aspect.ts
575
862
  /** Canvas aspect used by perspective cameras. Never 0. */
@@ -580,6 +867,170 @@ function containerAspect(width, height) {
580
867
  function applyPerspectiveContainerAspect(camera, width, height) {
581
868
  camera.aspect = containerAspect(width, height);
582
869
  }
870
+ function resolveFlyToOptions(options) {
871
+ const durationMs = options.durationMs ?? 1200;
872
+ const distanceScale = options.distanceScale ?? 1;
873
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error(`flyTo durationMs must be a non-negative finite number of milliseconds; received ${durationMs}.`);
874
+ if (!Number.isFinite(distanceScale) || distanceScale <= 0) throw new Error(`flyTo distanceScale must be a finite number above 0; received ${distanceScale}.`);
875
+ return {
876
+ distanceScale,
877
+ durationMs
878
+ };
879
+ }
880
+ /** Cubic ease-in-out on `[0, 1]`: slow start, slow arrival, symmetric around 0.5. */
881
+ function easeInOutCubic(t) {
882
+ return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
883
+ }
884
+ /** Linear progress of a flight, clamped to `[0, 1]`; a zero duration is already done. */
885
+ function flightProgress(elapsedMs, durationMs) {
886
+ if (durationMs <= 0) return 1;
887
+ return Math.min(Math.max(elapsedMs / durationMs, 0), 1);
888
+ }
889
+ /**
890
+ * Interpolates a positive scale (camera distance, orthographic zoom) geometrically, so each
891
+ * step zooms by the same factor and a long zoom does not rush its last part. Exact at both ends.
892
+ */
893
+ function interpolateScale(from, to, t) {
894
+ if (t <= 0) return from;
895
+ if (t >= 1) return to;
896
+ return from * (to / from) ** t;
897
+ }
898
+ //#endregion
899
+ //#region src/theme.ts
900
+ /** Dark canvas: Ink Black, Turf Green, Porcelain. */
901
+ const graphraumThemeDark = Object.freeze({
902
+ background: "#040f0f",
903
+ dimmedEdge: "#315a51",
904
+ dimmedEdgeOpacity: .25,
905
+ dimmedNode: "#315a51",
906
+ edge: "#226f54",
907
+ edgeOpacity: .55,
908
+ edgeWidth: 1.5,
909
+ endpointAttach: "boundary",
910
+ focusedNode: "#73c7a5",
911
+ hoveredNode: "#e4a853",
912
+ node: "#226f54",
913
+ nodeStroke: "#fcfffc",
914
+ selectedEdge: "#fcfffc",
915
+ selectedNode: "#fcfffc"
916
+ });
917
+ /** Light canvas: Porcelain field with Turf marks and Ink selection. */
918
+ const graphraumThemeLight = Object.freeze({
919
+ background: "#fcfffc",
920
+ dimmedEdge: "#9bb5ac",
921
+ dimmedEdgeOpacity: .35,
922
+ dimmedNode: "#9bb5ac",
923
+ edge: "#226f54",
924
+ edgeOpacity: .65,
925
+ edgeWidth: 1.5,
926
+ endpointAttach: "boundary",
927
+ focusedNode: "#1a5c45",
928
+ hoveredNode: "#c47a1a",
929
+ node: "#226f54",
930
+ nodeStroke: "#040f0f",
931
+ selectedEdge: "#040f0f",
932
+ selectedNode: "#040f0f"
933
+ });
934
+ const graphraumThemes = Object.freeze({
935
+ dark: graphraumThemeDark,
936
+ light: graphraumThemeLight
937
+ });
938
+ /** Alias of {@link graphraumThemeDark} for backward compatibility. */
939
+ const graphraumTheme = graphraumThemeDark;
940
+ function resolveGraphraumTheme(input, base = graphraumThemeDark) {
941
+ if (input === void 0) return { ...base };
942
+ if (typeof input === "string") {
943
+ const preset = graphraumThemes[input];
944
+ if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
945
+ return { ...preset };
946
+ }
947
+ return assertGraphraumTheme({
948
+ ...base,
949
+ ...input
950
+ });
951
+ }
952
+ function assertGraphraumTheme(theme) {
953
+ const opacity = theme.dimmedEdgeOpacity;
954
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`Theme dimmedEdgeOpacity must be a finite number between 0 and 1, received ${opacity}.`);
955
+ return theme;
956
+ }
957
+ /** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
958
+ function normalizeGraphraumBackground(background) {
959
+ return background === "transparent" ? null : background;
960
+ }
961
+ function isTransparentGraphraumBackground(background) {
962
+ return normalizeGraphraumBackground(background) === null;
963
+ }
964
+ /** Halo radius as a multiple of the node's half-extent at `glow: 1`. */
965
+ const GLOW_RADIUS_SCALE = 3.4;
966
+ /** Halo opacity multiplier under normal blending, so a light background shows a soft tint. */
967
+ const GLOW_NORMAL_OPACITY_SCALE = .6;
968
+ function assertNodeGlow(nodeId, glow) {
969
+ if (glow !== void 0 && (!Number.isFinite(glow) || glow < 0 || glow > 1)) throw new Error(`Node "${nodeId}" glow must be a finite number between 0 and 1`);
970
+ }
971
+ /**
972
+ * Halo radius in world units: `extent × (1 + (GLOW_RADIUS_SCALE − 1) × glow)`, where `extent` is
973
+ * the node's half-extent `max(width, height)`. Glow 0 collapses the halo under the node; glow 1
974
+ * reaches 3.4× the node extent.
975
+ */
976
+ function glowHaloRadius(extent, glow) {
977
+ return extent * (1 + (GLOW_RADIUS_SCALE - 1) * glow);
978
+ }
979
+ /** Peak halo opacity at the node center; the shader fades it radially to 0 at the halo radius. */
980
+ function glowHaloOpacity(glow) {
981
+ return glow;
982
+ }
983
+ /** Keeps `current` while it fits, otherwise grows to the next power of two capped at `maximum`. */
984
+ function glowBufferCapacity(required, current, maximum) {
985
+ if (required <= current) return current;
986
+ let capacity = Math.max(current, 1);
987
+ while (capacity < required) capacity *= 2;
988
+ return Math.min(capacity, maximum);
989
+ }
990
+ function createGlowSelection() {
991
+ return {
992
+ count: 0,
993
+ indices: /* @__PURE__ */ new Uint32Array(0)
994
+ };
995
+ }
996
+ /**
997
+ * Selects visible nodes with `glow > 0` into `selection`, highest glow first and ties by node
998
+ * index, capped at {@link GLOW_MAX_INSTANCES}. Selects nothing in the density LOD tier. Reuses
999
+ * the selection buffer and grows it geometrically; allocates no per-node objects.
1000
+ */
1001
+ function selectGlowNodes(visibleNodeIndices, glowAt, densityLod, selection) {
1002
+ selection.count = 0;
1003
+ if (densityLod) return selection;
1004
+ let candidates = 0;
1005
+ for (const nodeIndex of visibleNodeIndices) {
1006
+ if (!(glowAt(nodeIndex) > 0)) continue;
1007
+ if (candidates === selection.indices.length) {
1008
+ const grown = new Uint32Array(glowBufferCapacity(candidates + 1, candidates, Number.POSITIVE_INFINITY));
1009
+ grown.set(selection.indices);
1010
+ selection.indices = grown;
1011
+ }
1012
+ selection.indices[candidates] = nodeIndex;
1013
+ candidates += 1;
1014
+ }
1015
+ selection.indices.subarray(0, candidates).sort((a, b) => glowAt(b) - glowAt(a) || a - b);
1016
+ selection.count = Math.min(candidates, 256);
1017
+ return selection;
1018
+ }
1019
+ /**
1020
+ * Additive light reads on dark backgrounds but saturates to nothing on light ones. A solid
1021
+ * background whose relative luminance (linear sRGB, 0.2126 R + 0.7152 G + 0.0722 B) reaches
1022
+ * {@link GLOW_LIGHT_BACKGROUND_LUMINANCE} uses normal blending; transparent and pattern
1023
+ * backgrounds keep additive blending because their brightness is unknown.
1024
+ */
1025
+ function glowBlendModeFor(background) {
1026
+ const normalized = normalizeGraphraumBackground(background);
1027
+ if (normalized === null || typeof normalized === "object") return "additive";
1028
+ const { b, g, r } = new Color(normalized);
1029
+ return .2126 * r + .7152 * g + .0722 * b >= .5 ? "normal" : "additive";
1030
+ }
1031
+ function glowOpacityScale(mode) {
1032
+ return mode === "normal" ? GLOW_NORMAL_OPACITY_SCALE : 1;
1033
+ }
583
1034
  //#endregion
584
1035
  //#region src/compile-graph.ts
585
1036
  function assertFinitePosition(nodeId, axis, value) {
@@ -632,6 +1083,7 @@ function compileNodeVisual(id, visual) {
632
1083
  assertPositiveFiniteVisual(id, "width", visual.width);
633
1084
  assertPositiveFiniteVisual(id, "height", visual.height);
634
1085
  assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
1086
+ assertNodeGlow(id, visual.glow);
635
1087
  if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
636
1088
  return Object.freeze({ ...visual });
637
1089
  }
@@ -653,6 +1105,7 @@ function compileGraph(data, visuals) {
653
1105
  const encoding = visuals?.node?.(node);
654
1106
  const visual = compileNodeVisual(node.id, encoding?.visual ?? {
655
1107
  color: node.color,
1108
+ glow: node.glow,
656
1109
  height: node.height,
657
1110
  shape: node.shape,
658
1111
  size: node.size,
@@ -838,6 +1291,32 @@ function sampleEdgePath(input) {
838
1291
  return points;
839
1292
  }
840
1293
  //#endregion
1294
+ //#region src/edge-state.ts
1295
+ /**
1296
+ * Single source of truth for an edge instance's color and opacity. Full packing and the
1297
+ * incremental state repaint both call it, so both paths write identical instance colors.
1298
+ */
1299
+ function resolveEdgePaint(edgeIndex, visual, defaults, tier, edgeStates) {
1300
+ const opacity = tier === "overview" ? defaults.opacity : visual.opacity ?? defaults.opacity;
1301
+ const state = edgeStates?.stateOf(edgeIndex) ?? null;
1302
+ if (edgeStates && state === "selected") return {
1303
+ color: edgeStates.selectedColor,
1304
+ opacity
1305
+ };
1306
+ if (edgeStates && state === "dimmed") return {
1307
+ color: edgeStates.dimmedColor,
1308
+ opacity: Math.min(opacity, edgeStates.dimmedOpacity)
1309
+ };
1310
+ return {
1311
+ color: visual.color ?? defaults.color,
1312
+ opacity
1313
+ };
1314
+ }
1315
+ /** IDs present in exactly one of the two sets. */
1316
+ function changedIds(current, next) {
1317
+ return [...[...current].filter((id) => !next.has(id)), ...[...next].filter((id) => !current.has(id))];
1318
+ }
1319
+ //#endregion
841
1320
  //#region src/edge-materialize.ts
842
1321
  function packEdgeInstances(input) {
843
1322
  const segments = [];
@@ -862,6 +1341,7 @@ function packEdgeInstances(input) {
862
1341
  const trimmed = trimEdgeEndpoints({
863
1342
  attach,
864
1343
  clearance,
1344
+ mode: input.mode,
865
1345
  source: {
866
1346
  x: x1,
867
1347
  y: y1,
@@ -882,9 +1362,8 @@ function packEdgeInstances(input) {
882
1362
  y2 = trimmed.target.y;
883
1363
  z2 = trimmed.target.z ?? 0;
884
1364
  }
885
- const color = visual.color ?? input.defaults.color;
1365
+ const { color, opacity } = resolveEdgePaint(edgeIndex, visual, input.defaults, input.tier, input.edgeStates);
886
1366
  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
1367
  const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
889
1368
  const points = sampleEdgePath({
890
1369
  path: visual.path,
@@ -996,8 +1475,19 @@ function pickClosestEdgeIndex(point, segments) {
996
1475
  return nearestEdgeIndex;
997
1476
  }
998
1477
  //#endregion
1478
+ //#region src/shader-output.ts
1479
+ /**
1480
+ * Encodes `gl_FragColor.rgb` from the linear working space into the renderer's output color space:
1481
+ * sRGB on the canvas (so a `#8a9096` theme color draws as `#8a9096`), unchanged on linear render
1482
+ * targets. Alpha is left untouched, so anti-aliased coverage and blending are unaffected.
1483
+ *
1484
+ * Append once to every display fragment shader, after its final `gl_FragColor` assignment.
1485
+ * Requires a `ShaderMaterial` (not `RawShaderMaterial`), whose prefix defines `linearToOutputTexel`.
1486
+ */
1487
+ const OUTPUT_COLOR_SPACE_FRAGMENT = "#include <colorspace_fragment>";
1488
+ //#endregion
999
1489
  //#region src/edge-rendering.ts
1000
- const vertexShader$1 = `
1490
+ const vertexShader$2 = `
1001
1491
  attribute float instanceKind;
1002
1492
  attribute vec3 instanceEndA;
1003
1493
  attribute vec3 instanceEndB;
@@ -1048,7 +1538,7 @@ void main() {
1048
1538
  gl_Position = projectionMatrix * viewPosition;
1049
1539
  }
1050
1540
  `;
1051
- const fragmentShader$1 = `
1541
+ const fragmentShader$2 = `
1052
1542
  varying vec4 edgeColor;
1053
1543
  varying float edgeKind;
1054
1544
  varying float edgeStyle;
@@ -1072,6 +1562,7 @@ void main() {
1072
1562
  }
1073
1563
  if (edgeColor.a <= 0.0) discard;
1074
1564
  gl_FragColor = edgeColor;
1565
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1075
1566
  }
1076
1567
  `;
1077
1568
  function createEdgeGeometry(capacity) {
@@ -1084,13 +1575,13 @@ function createEdgeGeometry(capacity) {
1084
1575
  geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1085
1576
  return geometry;
1086
1577
  }
1087
- function createEdgeMaterial(depthTest) {
1578
+ function createEdgeMaterial() {
1088
1579
  return new ShaderMaterial({
1089
- depthTest,
1090
- depthWrite: depthTest,
1091
- fragmentShader: fragmentShader$1,
1580
+ depthTest: false,
1581
+ depthWrite: false,
1582
+ fragmentShader: fragmentShader$2,
1092
1583
  transparent: true,
1093
- vertexShader: vertexShader$1
1584
+ vertexShader: vertexShader$2
1094
1585
  });
1095
1586
  }
1096
1587
  function getInstancedAttribute(geometry, name) {
@@ -1108,8 +1599,7 @@ function writeEdgeSegmentInstance(geometry, slot, segment) {
1108
1599
  kind.setX(slot, 0);
1109
1600
  endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
1110
1601
  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);
1602
+ writeEdgeColorSlots(color, slot, 1, segment);
1113
1603
  width.setX(slot, segment.width);
1114
1604
  style.setX(slot, encodeEdgeStyle(segment.style));
1115
1605
  }
@@ -1123,11 +1613,15 @@ function writeEdgeMarkerInstance(geometry, slot, marker) {
1123
1613
  kind.setX(slot, 1);
1124
1614
  endA.setXYZ(slot, marker.x, marker.y, marker.z);
1125
1615
  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);
1616
+ writeEdgeColorSlots(color, slot, 1, marker);
1128
1617
  width.setX(slot, marker.size);
1129
1618
  style.setX(slot, 0);
1130
1619
  }
1620
+ /** Writes one color and opacity into `count` consecutive instance slots starting at `start`. */
1621
+ function writeEdgeColorSlots(color, start, count, paint) {
1622
+ const rgb = new Color(paint.color);
1623
+ for (let slot = start; slot < start + count; slot += 1) color.setXYZW(slot, rgb.r, rgb.g, rgb.b, paint.opacity);
1624
+ }
1131
1625
  //#endregion
1132
1626
  //#region src/edge-viewport-patch.ts
1133
1627
  const EDGE_INSTANCE_ATTRIBUTES$1 = [
@@ -1188,6 +1682,8 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1188
1682
  tier: input.tier,
1189
1683
  endpointAttach: input.endpointAttach,
1190
1684
  edgeNodeIndices: input.edgeNodeIndices,
1685
+ edgeStates: input.edgeStates,
1686
+ mode: input.mode,
1191
1687
  nodeOutlines: input.nodeOutlines
1192
1688
  });
1193
1689
  if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
@@ -1225,7 +1721,6 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1225
1721
  };
1226
1722
  for (const name of EDGE_INSTANCE_ATTRIBUTES$1) {
1227
1723
  const attribute = geometry.getAttribute(name);
1228
- attribute.clearUpdateRanges();
1229
1724
  attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
1230
1725
  attribute.needsUpdate = true;
1231
1726
  }
@@ -1236,14 +1731,146 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
1236
1731
  pickableSegments: nextPickable
1237
1732
  };
1238
1733
  }
1734
+ /**
1735
+ * Repaints the color and opacity of visible edges whose host state changed. Slot layout,
1736
+ * geometry, and picking stay untouched, so only the changed `instanceColor` ranges upload.
1737
+ * Edges without visible slots are skipped; the next materialize paints them from the same state.
1738
+ */
1739
+ function patchVisibleEdgePaint(geometry, input) {
1740
+ const visible = [...input.changedEdgeIndices].flatMap((edgeIndex) => {
1741
+ const layout = input.layouts.get(edgeIndex);
1742
+ return layout ? [{
1743
+ edgeIndex,
1744
+ layout
1745
+ }] : [];
1746
+ });
1747
+ if (!visible.every(({ layout }) => layout.segmentStart + layout.segmentCount <= input.instanceCount && layout.markerStart + layout.markerCount <= input.instanceCount)) return {
1748
+ ok: false,
1749
+ patchedSlots: 0
1750
+ };
1751
+ const color = getInstancedAttribute(geometry, "instanceColor");
1752
+ let patchedSlots = 0;
1753
+ for (const { edgeIndex, layout } of visible) {
1754
+ const paint = resolveEdgePaint(edgeIndex, input.edgeVisuals[edgeIndex] ?? {}, input.defaults, input.tier, input.edgeStates);
1755
+ for (const [start, count] of [[layout.segmentStart, layout.segmentCount], [layout.markerStart, layout.markerCount]]) {
1756
+ if (count === 0) continue;
1757
+ writeEdgeColorSlots(color, start, count, paint);
1758
+ color.addUpdateRange(start * color.itemSize, count * color.itemSize);
1759
+ patchedSlots += count;
1760
+ }
1761
+ }
1762
+ if (patchedSlots > 0) color.needsUpdate = true;
1763
+ return {
1764
+ ok: true,
1765
+ patchedSlots
1766
+ };
1767
+ }
1768
+ //#endregion
1769
+ //#region src/fit-3d.ts
1770
+ /** Newton steps on a piecewise-linear function before falling back to the plane-space centre. */
1771
+ const MAX_CENTERING_STEPS = 32;
1772
+ /**
1773
+ * The closest front-view camera that frames every node. Throws without nodes. `maxCenteringSteps`
1774
+ * exists so tests can exercise the fallback.
1775
+ */
1776
+ function fitPerspective(frame, nodes, maxCenteringSteps = MAX_CENTERING_STEPS) {
1777
+ if (nodes.count === 0) throw new Error("fitPerspective needs at least one node");
1778
+ const tanY = Math.tan(frame.fovDegrees * Math.PI / 360) * frame.fill;
1779
+ const tanX = tanY * frame.aspect;
1780
+ const node = {
1781
+ halfHeight: 0,
1782
+ halfWidth: 0,
1783
+ x: 0,
1784
+ y: 0,
1785
+ z: 0
1786
+ };
1787
+ let right = Number.NEGATIVE_INFINITY;
1788
+ let left = Number.NEGATIVE_INFINITY;
1789
+ let top = Number.NEGATIVE_INFINITY;
1790
+ let bottom = Number.NEGATIVE_INFINITY;
1791
+ let minZ = Number.POSITIVE_INFINITY;
1792
+ let maxZ = Number.NEGATIVE_INFINITY;
1793
+ for (let index = 0; index < nodes.count; index += 1) {
1794
+ nodes.read(index, node);
1795
+ right = Math.max(right, node.x + node.halfWidth + tanX * node.z);
1796
+ left = Math.max(left, -node.x + node.halfWidth + tanX * node.z);
1797
+ top = Math.max(top, node.y + node.halfHeight + tanY * node.z);
1798
+ bottom = Math.max(bottom, -node.y + node.halfHeight + tanY * node.z);
1799
+ minZ = Math.min(minZ, node.z);
1800
+ maxZ = Math.max(maxZ, node.z);
1801
+ }
1802
+ const cameraZX = (right + left) / (2 * tanX);
1803
+ const cameraZY = (top + bottom) / (2 * tanY);
1804
+ const cameraZ = Math.max(cameraZX, cameraZY);
1805
+ let x = (right - left) / 2;
1806
+ let y = (top - bottom) / 2;
1807
+ if (cameraZX < cameraZY) x = centerOnScreen(nodes, node, "x", cameraZ, x, maxCenteringSteps);
1808
+ else if (cameraZY < cameraZX) y = centerOnScreen(nodes, node, "y", cameraZ, y, maxCenteringSteps);
1809
+ const target = {
1810
+ x,
1811
+ y,
1812
+ z: (minZ + maxZ) / 2
1813
+ };
1814
+ const distance = cameraZ - target.z;
1815
+ const nearestDepth = cameraZ - maxZ;
1816
+ return {
1817
+ cameraZ,
1818
+ near: Math.min(Math.max(distance / 1e4, .1), nearestDepth / 2),
1819
+ far: distance * 10,
1820
+ target
1821
+ };
1822
+ }
1823
+ /**
1824
+ * Centre on the looser axis that makes the farthest screen reach equal on both sides.
1825
+ *
1826
+ * With depth `d = cameraZ - z`, a centre `c` keeps every node within screen reach `r` when
1827
+ * `max(p + h - r * d) <= c <= min(p - h + r * d)`. The gap between those bounds is convex and
1828
+ * decreasing in `r`, so Newton steps from `r = 0` rise monotonically to the smallest feasible
1829
+ * reach. `planeCenter` already fits, so it is the answer if the steps do not settle.
1830
+ */
1831
+ function centerOnScreen(nodes, node, axis, cameraZ, planeCenter, maxSteps) {
1832
+ let reach = 0;
1833
+ for (let step = 0; step < maxSteps; step += 1) {
1834
+ let low = Number.NEGATIVE_INFINITY;
1835
+ let high = Number.POSITIVE_INFINITY;
1836
+ let lowValue = 0;
1837
+ let lowDepth = 0;
1838
+ let highValue = 0;
1839
+ let highDepth = 0;
1840
+ for (let index = 0; index < nodes.count; index += 1) {
1841
+ nodes.read(index, node);
1842
+ const position = axis === "x" ? node.x : node.y;
1843
+ const half = axis === "x" ? node.halfWidth : node.halfHeight;
1844
+ const depth = cameraZ - node.z;
1845
+ const lowBound = position + half - reach * depth;
1846
+ const highBound = position - half + reach * depth;
1847
+ if (lowBound > low) {
1848
+ low = lowBound;
1849
+ lowValue = position + half;
1850
+ lowDepth = depth;
1851
+ }
1852
+ if (highBound < high) {
1853
+ high = highBound;
1854
+ highValue = position - half;
1855
+ highDepth = depth;
1856
+ }
1857
+ }
1858
+ if (low <= high) return (low + high) / 2;
1859
+ const next = (lowValue - highValue) / (lowDepth + highDepth);
1860
+ if (next <= reach) return (low + high) / 2;
1861
+ reach = next;
1862
+ }
1863
+ return planeCenter;
1864
+ }
1239
1865
  //#endregion
1240
1866
  //#region src/instance-color-ranges.ts
1241
1867
  /**
1242
1868
  * Marks RGB update ranges for visible instance slots.
1869
+ * Keeps earlier ranges that have not been uploaded yet (a viewport materialize in the same frame);
1870
+ * three.js merges and clears ranges on upload.
1243
1871
  * Returns how many slots were marked.
1244
1872
  */
1245
1873
  function markInstanceColorSlots(attribute, slots) {
1246
- attribute.clearUpdateRanges();
1247
1874
  let count = 0;
1248
1875
  for (const slot of slots) {
1249
1876
  if (!Number.isSafeInteger(slot) || slot < 0) continue;
@@ -1275,6 +1902,7 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1275
1902
  for (const [axis, value] of Object.entries(position)) if (!Number.isFinite(value)) throw new Error(`Layout node "${id}" has a non-finite ${axis} position`);
1276
1903
  return {
1277
1904
  colorChanged: false,
1905
+ glowChanged: false,
1278
1906
  index: nodeIndex,
1279
1907
  next: {
1280
1908
  ...current,
@@ -1287,6 +1915,145 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1287
1915
  };
1288
1916
  });
1289
1917
  }
1918
+ const vertexShader$1 = `
1919
+ attribute float instanceGlowOpacity;
1920
+ varying vec3 glowColor;
1921
+ varying vec2 glowPoint;
1922
+ varying float glowOpacity;
1923
+
1924
+ void main() {
1925
+ glowColor = instanceColor;
1926
+ glowPoint = position.xy;
1927
+ glowOpacity = instanceGlowOpacity;
1928
+ vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1929
+ center.xy += position.xy * length(instanceMatrix[0].xyz);
1930
+ gl_Position = projectionMatrix * center;
1931
+ }
1932
+ `;
1933
+ const fragmentShader$1 = `
1934
+ uniform float opacityScale;
1935
+ varying vec3 glowColor;
1936
+ varying vec2 glowPoint;
1937
+ varying float glowOpacity;
1938
+
1939
+ void main() {
1940
+ float distanceFromCenter = length(glowPoint);
1941
+ if (distanceFromCenter >= 1.0) discard;
1942
+ float falloff = 1.0 - distanceFromCenter;
1943
+ gl_FragColor = vec4(glowColor, falloff * falloff * glowOpacity * opacityScale);
1944
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1945
+ }
1946
+ `;
1947
+ function applyBlendMode(material, mode) {
1948
+ material.blending = mode === "normal" ? NormalBlending : AdditiveBlending;
1949
+ const opacityScale = material.uniforms.opacityScale;
1950
+ if (opacityScale) opacityScale.value = glowOpacityScale(mode);
1951
+ material.needsUpdate = true;
1952
+ }
1953
+ function createNodeGlowMaterial(mode) {
1954
+ const material = new ShaderMaterial({
1955
+ depthTest: false,
1956
+ depthWrite: false,
1957
+ fragmentShader: fragmentShader$1,
1958
+ transparent: true,
1959
+ uniforms: { opacityScale: { value: 1 } },
1960
+ vertexShader: vertexShader$1
1961
+ });
1962
+ applyBlendMode(material, mode);
1963
+ return material;
1964
+ }
1965
+ function createGlowMesh(capacity, mode) {
1966
+ const geometry = new PlaneGeometry(2, 2);
1967
+ geometry.setAttribute("instanceGlowOpacity", new InstancedBufferAttribute(new Float32Array(capacity), 1));
1968
+ const mesh = new InstancedMesh(geometry, createNodeGlowMaterial(mode), capacity);
1969
+ mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
1970
+ mesh.name = "graphraum-node-glow";
1971
+ mesh.frustumCulled = false;
1972
+ mesh.renderOrder = -2;
1973
+ return mesh;
1974
+ }
1975
+ /**
1976
+ * Opt-in batched halo pass: one additive instanced mesh, created lazily on the first glowing
1977
+ * node and hidden while no halo is selected, so graphs without glow keep two draw calls.
1978
+ */
1979
+ var NodeGlowLayer = class {
1980
+ scene;
1981
+ selection = createGlowSelection();
1982
+ target = {
1983
+ color: new Color(),
1984
+ extent: 0,
1985
+ glow: 0,
1986
+ x: 0,
1987
+ y: 0,
1988
+ z: 0
1989
+ };
1990
+ matrix = new Matrix4();
1991
+ mesh = null;
1992
+ blendMode = "additive";
1993
+ constructor(scene) {
1994
+ this.scene = scene;
1995
+ }
1996
+ /** Additive on dark or unknown backgrounds, normal on light solid ones; see `glowBlendModeFor`. */
1997
+ setBlending(mode) {
1998
+ if (mode === this.blendMode) return;
1999
+ this.blendMode = mode;
2000
+ if (this.mesh) applyBlendMode(this.mesh.material, mode);
2001
+ }
2002
+ /** Halo instances in the current frame. */
2003
+ get count() {
2004
+ return this.mesh?.visible ? this.mesh.count : 0;
2005
+ }
2006
+ /** Reselects glowing visible nodes and rewrites every halo instance. */
2007
+ sync(visibleNodeIndices, glowAt, densityLod, describe) {
2008
+ selectGlowNodes(visibleNodeIndices, glowAt, densityLod, this.selection);
2009
+ if (this.selection.count > 0) this.ensureCapacity(this.selection.count);
2010
+ this.rewrite(describe);
2011
+ }
2012
+ /** Rewrites positions, radii, opacities, and colors of the current selection in place. */
2013
+ rewrite(describe) {
2014
+ const mesh = this.mesh;
2015
+ if (!mesh) return;
2016
+ const opacity = mesh.geometry.getAttribute("instanceGlowOpacity");
2017
+ let slot = 0;
2018
+ for (let index = 0; index < this.selection.count; index += 1) {
2019
+ const nodeIndex = this.selection.indices[index];
2020
+ if (nodeIndex === void 0 || !describe(nodeIndex, this.target)) continue;
2021
+ const { color, extent, glow, x, y, z } = this.target;
2022
+ const radius = glowHaloRadius(extent, glow);
2023
+ this.matrix.makeScale(radius, radius, radius).setPosition(x, y, z);
2024
+ mesh.setMatrixAt(slot, this.matrix);
2025
+ mesh.setColorAt(slot, color);
2026
+ opacity.setX(slot, glowHaloOpacity(glow));
2027
+ slot += 1;
2028
+ }
2029
+ mesh.count = slot;
2030
+ mesh.visible = slot > 0;
2031
+ mesh.instanceMatrix.needsUpdate = true;
2032
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
2033
+ opacity.needsUpdate = true;
2034
+ }
2035
+ /** Releases the GPU mesh; the layer stays reusable and recreates it on the next glowing node. */
2036
+ dispose() {
2037
+ this.selection.count = 0;
2038
+ this.releaseMesh();
2039
+ }
2040
+ releaseMesh() {
2041
+ if (!this.mesh) return;
2042
+ this.scene.remove(this.mesh);
2043
+ this.mesh.geometry.dispose();
2044
+ this.mesh.material.dispose();
2045
+ this.mesh.dispose();
2046
+ this.mesh = null;
2047
+ }
2048
+ ensureCapacity(required) {
2049
+ const current = this.mesh?.instanceMatrix.count ?? 0;
2050
+ const capacity = glowBufferCapacity(required, current, 256);
2051
+ if (this.mesh && capacity === current) return;
2052
+ this.releaseMesh();
2053
+ this.mesh = createGlowMesh(capacity, this.blendMode);
2054
+ this.scene.add(this.mesh);
2055
+ }
2056
+ };
1290
2057
  //#endregion
1291
2058
  //#region src/node-rendering.ts
1292
2059
  const vertexShader = `
@@ -1399,6 +2166,7 @@ void main() {
1399
2166
  fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
1400
2167
  }
1401
2168
  gl_FragColor = vec4(fillColor, outerAlpha);
2169
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1402
2170
  }
1403
2171
  `;
1404
2172
  function createNodeGeometry(capacity) {
@@ -1476,6 +2244,7 @@ function assertValidNode(node) {
1476
2244
  });
1477
2245
  if (node.strokeWidth !== void 0 && (!Number.isFinite(node.strokeWidth) || node.strokeWidth < 0)) throw new Error(`Node "${node.id}" must have a finite non-negative strokeWidth`);
1478
2246
  if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
2247
+ assertNodeGlow(node.id, node.glow);
1479
2248
  }
1480
2249
  /** Validates a complete update batch before the renderer mutates CPU or GPU state. */
1481
2250
  function prepareNodeUpdates(nodes, nodeIndices, updates) {
@@ -1490,6 +2259,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1490
2259
  const next = {
1491
2260
  ...current,
1492
2261
  ...Object.hasOwn(update, "color") ? { color: update.color } : {},
2262
+ ...Object.hasOwn(update, "glow") ? { glow: update.glow } : {},
1493
2263
  ...update.position ? { position: { ...update.position } } : {},
1494
2264
  ...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
1495
2265
  ...Object.hasOwn(update, "size") ? { size: update.size } : {},
@@ -1501,6 +2271,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1501
2271
  assertValidNode(next);
1502
2272
  return {
1503
2273
  colorChanged: Object.hasOwn(update, "color"),
2274
+ glowChanged: Object.hasOwn(update, "glow"),
1504
2275
  index,
1505
2276
  next,
1506
2277
  positionChanged: update.position !== void 0,
@@ -1511,6 +2282,22 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
1511
2282
  });
1512
2283
  }
1513
2284
  //#endregion
2285
+ //#region src/orbit-controls-mode.ts
2286
+ /**
2287
+ * Sets the controls for a 2D or 3D view. 2D turns rotation off, so one finger and the left
2288
+ * mouse button pan instead. One finger must not stay on ROTATE: when a pinch ends with one finger
2289
+ * still down, OrbitControls (r185) re-reads `touches.ONE`, returns early for a disabled rotate and
2290
+ * keeps the two-finger state, and the next move reads the lifted finger's position and throws.
2291
+ */
2292
+ function configureControlsForMode(controls, mode) {
2293
+ controls.enableRotate = mode === "3d";
2294
+ if (mode === "2d") {
2295
+ controls.minZoom = .01;
2296
+ controls.mouseButtons.LEFT = MOUSE.PAN;
2297
+ controls.touches.ONE = TOUCH.PAN;
2298
+ }
2299
+ }
2300
+ //#endregion
1514
2301
  //#region src/label-budget.ts
1515
2302
  /** Picks visible label ids by importance within a hard DOM budget. */
1516
2303
  function selectBudgetedLabelIds({ candidates, maxLabels }) {
@@ -1895,63 +2682,6 @@ var SpatialGrid2D = class {
1895
2682
  }
1896
2683
  };
1897
2684
  //#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
2685
  //#region src/viewport-lod.ts
1956
2686
  /** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
1957
2687
  function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
@@ -1981,9 +2711,20 @@ function shouldUseDensityLod(candidateCount, maximum, active) {
1981
2711
  //#region src/graphraum.ts
1982
2712
  /** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
1983
2713
  const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
2714
+ /** The 3D fit leaves 4% of the tighter canvas axis free on each side, e.g. for edge labels. */
2715
+ const PERSPECTIVE_FIT_FILL = .92;
2716
+ function prefersReducedMotion() {
2717
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2718
+ }
1984
2719
  function disposeMaterial(material) {
1985
2720
  for (const item of Array.isArray(material) ? material : [material]) item.dispose();
1986
2721
  }
2722
+ /** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
2723
+ const FLIGHT_CANCEL_EVENTS = [
2724
+ "keydown",
2725
+ "pointerdown",
2726
+ "wheel"
2727
+ ];
1987
2728
  const EDGE_INSTANCE_ATTRIBUTES = [
1988
2729
  "instanceKind",
1989
2730
  "instanceEndA",
@@ -2027,12 +2768,19 @@ var Graphraum = class {
2027
2768
  nodeMesh = null;
2028
2769
  nodePickMesh = null;
2029
2770
  edgeMesh = null;
2771
+ glowLayer = new NodeGlowLayer(this.scene);
2772
+ hasGlowNodes = false;
2030
2773
  nodeCapacity = 0;
2031
2774
  edgeCapacity = 0;
2032
2775
  markerCapacity = 0;
2033
2776
  edgeInstanceCapacity = 0;
2034
2777
  selectedNodeIds = /* @__PURE__ */ new Set();
2035
2778
  selectedEdgeIds = /* @__PURE__ */ new Set();
2779
+ dimmedEdgeIds = /* @__PURE__ */ new Set();
2780
+ edgeIndexCache = {
2781
+ edges: [],
2782
+ indices: /* @__PURE__ */ new Map()
2783
+ };
2036
2784
  hoveredNodeIds = /* @__PURE__ */ new Set();
2037
2785
  focusedNodeIds = /* @__PURE__ */ new Set();
2038
2786
  dimmedNodeIds = /* @__PURE__ */ new Set();
@@ -2053,6 +2801,12 @@ var Graphraum = class {
2053
2801
  edgeSegmentCapacity = 0;
2054
2802
  mode;
2055
2803
  frameRequest = null;
2804
+ autoOrbit = null;
2805
+ flight = null;
2806
+ flightDirection = new Vector3();
2807
+ /** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
2808
+ orbitUpdating = false;
2809
+ destroyed = false;
2056
2810
  cpuFrameMilliseconds = 0;
2057
2811
  gpuFrameMilliseconds = null;
2058
2812
  gpuTimer = null;
@@ -2092,6 +2846,7 @@ var Graphraum = class {
2092
2846
  this.container.append(this.renderer.domElement);
2093
2847
  this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
2094
2848
  this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
2849
+ for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
2095
2850
  this.camera = this.createCamera();
2096
2851
  this.controls = this.createControls();
2097
2852
  this.resizeObserver = new ResizeObserver(() => this.resize());
@@ -2148,6 +2903,7 @@ var Graphraum = class {
2148
2903
  }))
2149
2904
  };
2150
2905
  this.nodeIds = compiled.nodeIds;
2906
+ this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
2151
2907
  this.nodeIndices = new Map(compiled.nodeIndices);
2152
2908
  this.edgeNodeIndices = compiled.edgeNodeIndices;
2153
2909
  this.canonicalEdgePositions = compiled.edgePositions;
@@ -2157,12 +2913,11 @@ var Graphraum = class {
2157
2913
  this.spatialGrid2d = new SpatialGrid2D();
2158
2914
  for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
2159
2915
  const liveNodeIds = new Set(this.nodeIds);
2160
- const liveEdgeIds = new Set(this.data.edges.map((edge) => edge.id));
2161
2916
  this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
2162
2917
  this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
2163
2918
  this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
2164
2919
  this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
2165
- this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => liveEdgeIds.has(id)));
2920
+ this.pruneEdgeStates();
2166
2921
  this.materializeViewport();
2167
2922
  this.requestRender();
2168
2923
  }
@@ -2226,6 +2981,7 @@ var Graphraum = class {
2226
2981
  edges: nextEdges
2227
2982
  };
2228
2983
  this.nodeIds = nextNodes.map((node) => node.id);
2984
+ this.hasGlowNodes = nextNodes.some(hasNodeGlow);
2229
2985
  this.nodeIndices = nextNodeIndices;
2230
2986
  this.edgeNodeIndices = nextEdgeIndices;
2231
2987
  this.canonicalEdgePositions = nextEdgePositions;
@@ -2255,6 +3011,7 @@ var Graphraum = class {
2255
3011
  }))
2256
3012
  };
2257
3013
  this.nodeIds = compiled.nodeIds;
3014
+ this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
2258
3015
  this.nodeIndices = new Map(compiled.nodeIndices);
2259
3016
  this.edgeNodeIndices = compiled.edgeNodeIndices;
2260
3017
  this.canonicalEdgePositions = compiled.edgePositions;
@@ -2263,11 +3020,12 @@ var Graphraum = class {
2263
3020
  this.edgePresentations = new Map(compiled.edgePresentations);
2264
3021
  this.spatialGrid2d = new SpatialGrid2D();
2265
3022
  for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
3023
+ this.pruneEdgeStates();
2266
3024
  this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
2267
3025
  const nodeCapacity = this.nodeCapacity;
2268
3026
  const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
2269
3027
  allocateNodeInstanceColors(nodeMesh, nodeCapacity);
2270
- nodeMesh.renderOrder = this.mode === "2d" ? 1 : 0;
3028
+ nodeMesh.renderOrder = 1;
2271
3029
  this.nodeMesh = nodeMesh;
2272
3030
  this.scene.add(nodeMesh);
2273
3031
  const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
@@ -2277,9 +3035,9 @@ var Graphraum = class {
2277
3035
  this.edgeSegmentCapacity = capacities.segmentCapacity;
2278
3036
  this.markerCapacity = capacities.markerCapacity;
2279
3037
  this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
2280
- const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(this.mode === "3d"), this.edgeInstanceCapacity);
3038
+ const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(), this.edgeInstanceCapacity);
2281
3039
  edgeMesh.frustumCulled = false;
2282
- edgeMesh.renderOrder = this.mode === "2d" ? -1 : 0;
3040
+ edgeMesh.renderOrder = -1;
2283
3041
  this.edgeMesh = edgeMesh;
2284
3042
  this.scene.add(edgeMesh);
2285
3043
  if (fitView) this.fitView();
@@ -2376,6 +3134,8 @@ var Graphraum = class {
2376
3134
  }
2377
3135
  }
2378
3136
  if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
3137
+ const glowChanged = prepared.some((update) => update.glowChanged);
3138
+ if (glowChanged) this.hasGlowNodes = nodes.some(hasNodeGlow);
2379
3139
  this.data = {
2380
3140
  ...this.data,
2381
3141
  nodes
@@ -2394,6 +3154,8 @@ var Graphraum = class {
2394
3154
  }
2395
3155
  if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
2396
3156
  if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
3157
+ if (glowChanged) this.syncGlow();
3158
+ else if (prepared.some((update) => update.positionChanged || update.sizeChanged || update.colorChanged)) this.glowLayer.rewrite(this.describeGlowNode);
2397
3159
  if (prepared.some((update) => update.strokeChanged)) {
2398
3160
  nodeStrokeWidth.needsUpdate = true;
2399
3161
  nodeStrokeColor.needsUpdate = true;
@@ -2405,10 +3167,6 @@ var Graphraum = class {
2405
3167
  if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
2406
3168
  const worldPerPixel = this.worldUnitsPerPixel();
2407
3169
  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
3170
  const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
2413
3171
  changedEdgeIndices,
2414
3172
  defaults: {
@@ -2417,11 +3175,13 @@ var Graphraum = class {
2417
3175
  width: this.theme.edgeWidth
2418
3176
  },
2419
3177
  edgeNodeIndices: this.edgeNodeIndices,
2420
- edgeVisuals,
3178
+ edgeStates: this.edgeStateStyling(),
3179
+ edgeVisuals: this.data.edges,
2421
3180
  endpointAttach: this.theme.endpointAttach,
2422
3181
  endpointPositions: this.canonicalEdgePositions,
2423
3182
  layouts: this.visibleEdgeLayouts,
2424
3183
  minHitSlop,
3184
+ mode: this.mode,
2425
3185
  nodeOutlines: this.data.nodes.map((node) => ({
2426
3186
  height: node.height,
2427
3187
  shape: node.shape,
@@ -2439,12 +3199,80 @@ var Graphraum = class {
2439
3199
  setSelection(nodeIds) {
2440
3200
  this.setNodeState("selected", nodeIds);
2441
3201
  }
2442
- /** Highlights edges without changing node selection or source graph data. */
3202
+ /**
3203
+ * Highlights edges without changing node selection or source graph data. Unknown IDs are ignored.
3204
+ * Only the instance colors of edges whose selection changed are rewritten and uploaded.
3205
+ */
2443
3206
  setEdgeSelection(edgeIds) {
2444
- this.selectedEdgeIds = new Set(edgeIds);
2445
- this.materializeViewport();
3207
+ const next = this.knownEdgeIds(edgeIds);
3208
+ const changed = changedIds(this.selectedEdgeIds, next);
3209
+ this.selectedEdgeIds = next;
3210
+ this.repaintEdgeStates(changed);
3211
+ }
3212
+ /**
3213
+ * Applies an application-owned edge state without changing source graph data. `dimmed` uses
3214
+ * `theme.dimmedEdge` / `theme.dimmedEdgeOpacity`; selected edges stay selected. Unknown IDs are ignored.
3215
+ */
3216
+ setEdgeState(state, edgeIds) {
3217
+ if (state !== "dimmed") throw new Error(`Unknown edge state "${String(state)}". Use "dimmed".`);
3218
+ const next = this.knownEdgeIds(edgeIds);
3219
+ const changed = changedIds(this.dimmedEdgeIds, next);
3220
+ this.dimmedEdgeIds = next;
3221
+ this.repaintEdgeStates(changed);
3222
+ }
3223
+ knownEdgeIds(edgeIds) {
3224
+ const indices = this.edgeIndicesById();
3225
+ return new Set([...edgeIds].filter((id) => indices.has(id)));
3226
+ }
3227
+ /**
3228
+ * Visible slots, LOD tier, and geometry only change through materialize or endpoint patches,
3229
+ * which keep `visibleEdgeLayouts` current, so a state change repaints in place. If a layout no
3230
+ * longer fits the drawn instances, that invariant broke: fall back to a full materialize.
3231
+ */
3232
+ repaintEdgeStates(changedEdgeIds) {
3233
+ if (!this.edgeMesh || changedEdgeIds.length === 0) return;
3234
+ const indices = this.edgeIndicesById();
3235
+ if (!patchVisibleEdgePaint(this.edgeMesh.geometry, {
3236
+ changedEdgeIndices: changedEdgeIds.flatMap((id) => indices.get(id) ?? []),
3237
+ defaults: {
3238
+ color: this.theme.edge,
3239
+ opacity: this.theme.edgeOpacity
3240
+ },
3241
+ edgeStates: this.edgeStateStyling(),
3242
+ edgeVisuals: this.data.edges,
3243
+ instanceCount: this.edgeMesh.count,
3244
+ layouts: this.visibleEdgeLayouts,
3245
+ tier: this.lastEdgeLodTier
3246
+ }).ok) this.materializeViewport();
2446
3247
  this.requestRender();
2447
3248
  }
3249
+ edgeStateStyling() {
3250
+ return {
3251
+ dimmedColor: this.theme.dimmedEdge,
3252
+ dimmedOpacity: this.theme.dimmedEdgeOpacity,
3253
+ selectedColor: this.theme.selectedEdge,
3254
+ stateOf: (edgeIndex) => {
3255
+ const id = this.data.edges[edgeIndex]?.id;
3256
+ if (id === void 0) return null;
3257
+ if (this.selectedEdgeIds.has(id)) return "selected";
3258
+ return this.dimmedEdgeIds.has(id) ? "dimmed" : null;
3259
+ }
3260
+ };
3261
+ }
3262
+ /** Edge ID to compiled index, rebuilt only when the edge list is replaced. */
3263
+ edgeIndicesById() {
3264
+ if (this.edgeIndexCache.edges !== this.data.edges) this.edgeIndexCache = {
3265
+ edges: this.data.edges,
3266
+ indices: new Map(this.data.edges.map((edge, index) => [edge.id, index]))
3267
+ };
3268
+ return this.edgeIndexCache.indices;
3269
+ }
3270
+ /** Drops edge states whose edges left the graph. */
3271
+ pruneEdgeStates() {
3272
+ const indices = this.edgeIndicesById();
3273
+ this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => indices.has(id)));
3274
+ this.dimmedEdgeIds = new Set([...this.dimmedEdgeIds].filter((id) => indices.has(id)));
3275
+ }
2448
3276
  /** Applies an application-owned visual state without changing source graph data. */
2449
3277
  setNodeState(state, nodeIds) {
2450
3278
  const nextSelection = new Set(nodeIds);
@@ -2481,6 +3309,8 @@ var Graphraum = class {
2481
3309
  }
2482
3310
  setMode(mode) {
2483
3311
  if (mode === this.mode) return;
3312
+ this.cancelFlight();
3313
+ this.stopAutoOrbit();
2484
3314
  this.mode = mode;
2485
3315
  this.controls.removeEventListener("change", this.handleViewChange);
2486
3316
  this.controls.dispose();
@@ -2490,19 +3320,30 @@ var Graphraum = class {
2490
3320
  this.nodeMesh.material.depthTest = mode === "3d";
2491
3321
  this.nodeMesh.material.depthWrite = mode === "3d";
2492
3322
  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
3323
  }
2501
3324
  this.fitView();
2502
3325
  }
2503
3326
  getMode() {
2504
3327
  return this.mode;
2505
3328
  }
3329
+ /**
3330
+ * Slowly orbits the 3D camera around its target until called with `false`. The orbit pauses while
3331
+ * the camera controls handle a drag, pinch or wheel step and resumes after `resumeAfterMs` of idle;
3332
+ * it also stops while the document is hidden or the canvas is off screen. While it is active a
3333
+ * frame loop renders every frame (no viewport rematerialization), so check `prefers-reduced-motion`
3334
+ * before enabling it. Throws in 2D mode; `setMode("2d")` turns it off. Does nothing after `destroy()`.
3335
+ */
3336
+ setAutoOrbit(options) {
3337
+ if (options === false || this.destroyed) {
3338
+ this.stopAutoOrbit();
3339
+ return;
3340
+ }
3341
+ if (this.mode !== "3d") throw new Error("Auto-orbit requires 3D mode. Call setMode(\"3d\") before setAutoOrbit(options).");
3342
+ const { resumeAfterMs, speed } = resolveAutoOrbitOptions(options);
3343
+ this.controls.autoRotateSpeed = autoRotateSpeedFor(speed);
3344
+ if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
3345
+ else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
3346
+ }
2506
3347
  getNodePresentation(nodeId) {
2507
3348
  return this.nodePresentations.get(nodeId) ?? null;
2508
3349
  }
@@ -2595,6 +3436,7 @@ var Graphraum = class {
2595
3436
  getDiagnostics() {
2596
3437
  return {
2597
3438
  aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
3439
+ autoOrbit: this.autoOrbit?.status ?? "off",
2598
3440
  cpuFrameMilliseconds: this.cpuFrameMilliseconds,
2599
3441
  gpuFrameMilliseconds: this.gpuFrameMilliseconds,
2600
3442
  gpuDrawCalls: this.renderer.info.render.calls,
@@ -2602,6 +3444,7 @@ var Graphraum = class {
2602
3444
  gpuTextures: this.renderer.info.memory.textures,
2603
3445
  lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
2604
3446
  pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
3447
+ dimmedEdges: this.dimmedEdgeIds.size,
2605
3448
  selectedEdges: this.selectedEdgeIds.size,
2606
3449
  selectedNodes: this.selectedNodeIds.size,
2607
3450
  totalEdges: this.data.edges.length,
@@ -2610,6 +3453,7 @@ var Graphraum = class {
2610
3453
  visibleEdgeMarkers: this.visibleEdgeMarkerCount,
2611
3454
  visibleEdgeSegments: this.visibleEdgeSegmentCount,
2612
3455
  visibleEdges: this.visibleEdgeCount,
3456
+ visibleGlowNodes: this.glowLayer.count,
2613
3457
  visibleNodes: this.visibleNodeCount,
2614
3458
  visibleNodeCandidates: this.visibleNodeCandidateCount
2615
3459
  };
@@ -2636,52 +3480,207 @@ var Graphraum = class {
2636
3480
  id: edgeId
2637
3481
  } : null;
2638
3482
  }
2639
- fitView() {
3483
+ /** Frames every node, or only `nodeIds`; unknown ids are ignored and no match frames all nodes. Cancels a running `flyTo`. */
3484
+ fitView(options) {
3485
+ this.cancelFlight();
2640
3486
  if (!this.nodeMesh || this.data.nodes.length === 0) {
2641
3487
  this.requestRender();
2642
3488
  return;
2643
3489
  }
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());
3490
+ const subset = this.nodesWithIds(options?.nodeIds);
3491
+ const nodes = subset.length > 0 ? subset : this.data.nodes;
2661
3492
  const width = Math.max(this.container.clientWidth, 1);
2662
3493
  const height = Math.max(this.container.clientHeight, 1);
2663
- const aspect = width / height;
2664
3494
  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));
3495
+ const aspect = width / height;
3496
+ const fit = orthographicFit(aspect, nodes);
3497
+ setOrthographicFrustum(this.camera, aspect, fit.visibleHeight);
3498
+ this.camera.position.set(fit.center.x, fit.center.y, fit.cameraZ);
3499
+ this.camera.lookAt(fit.center);
3500
+ this.controls.target.copy(fit.center);
2671
3501
  } 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;
3502
+ const fit = this.perspectiveFit(this.camera, width, height, nodes);
3503
+ this.camera.near = fit.near;
3504
+ this.camera.far = fit.far;
3505
+ this.camera.position.set(fit.target.x, fit.target.y, fit.cameraZ);
3506
+ this.controls.target.set(fit.target.x, fit.target.y, fit.target.z);
3507
+ this.camera.lookAt(this.controls.target);
2677
3508
  }
2678
- this.camera.lookAt(center);
2679
3509
  this.camera.updateProjectionMatrix();
2680
- this.controls.target.copy(center);
2681
3510
  this.controls.update();
2682
3511
  this.materializeViewport();
2683
3512
  this.requestRender();
2684
3513
  }
3514
+ /**
3515
+ * Animates the camera to frame `nodeIds` as `fitView({ nodeIds })` would, with an ease-in-out
3516
+ * over `durationMs`. In 3D the camera and the orbit target move together and keep the current
3517
+ * view direction, so a running auto-orbit carries on around the new target, at the fitted
3518
+ * distance times `distanceScale`. In 2D it pans and zooms. Resolves `true` on arrival and
3519
+ * `false` when cancelled by canvas pointer, wheel or key input, another `flyTo`, `fitView`,
3520
+ * `setData`, `setMode`, a 2D resize or `destroy()`. Unknown IDs are ignored; when none match,
3521
+ * or after `destroy()`, it resolves `false` without moving. With `prefers-reduced-motion:
3522
+ * reduce` or a zero duration it jumps. Throws for an invalid duration or distance scale.
3523
+ */
3524
+ flyTo(options) {
3525
+ const { distanceScale, durationMs } = resolveFlyToOptions(options);
3526
+ this.cancelFlight();
3527
+ if (this.destroyed || !this.nodeMesh) return Promise.resolve(false);
3528
+ const nodes = this.nodesWithIds(options.nodeIds);
3529
+ if (nodes.length === 0) return Promise.resolve(false);
3530
+ const width = Math.max(this.container.clientWidth, 1);
3531
+ const height = Math.max(this.container.clientHeight, 1);
3532
+ const flight = this.camera instanceof OrthographicCamera ? this.planOrthographicFlight(this.camera, width / height, nodes, distanceScale) : this.planPerspectiveFlight(this.camera, width, height, nodes, distanceScale);
3533
+ if (durationMs === 0 || prefersReducedMotion()) {
3534
+ flight.step(1);
3535
+ flight.arrive();
3536
+ this.requestRender();
3537
+ return Promise.resolve(true);
3538
+ }
3539
+ return new Promise((resolve) => {
3540
+ this.flight = {
3541
+ ...flight,
3542
+ durationMs,
3543
+ resolve,
3544
+ startMs: null
3545
+ };
3546
+ this.requestRender();
3547
+ });
3548
+ }
3549
+ /** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
3550
+ nodesWithIds(nodeIds) {
3551
+ if (!nodeIds) return this.data.nodes;
3552
+ const wanted = new Set(nodeIds);
3553
+ return this.data.nodes.filter((node) => wanted.has(node.id));
3554
+ }
3555
+ /** The closest front-view camera that frames `nodes` (issue #124); applies the container aspect. */
3556
+ perspectiveFit(camera, width, height, nodes) {
3557
+ applyPerspectiveContainerAspect(camera, width, height);
3558
+ return fitPerspective({
3559
+ aspect: camera.aspect,
3560
+ fill: PERSPECTIVE_FIT_FILL,
3561
+ fovDegrees: camera.fov
3562
+ }, {
3563
+ count: nodes.length,
3564
+ read(index, out) {
3565
+ const node = nodes[index];
3566
+ const axes = resolveNodeAxes({
3567
+ height: node.height,
3568
+ nodeId: node.id,
3569
+ size: node.size,
3570
+ width: node.width
3571
+ });
3572
+ out.x = node.position.x;
3573
+ out.y = node.position.y;
3574
+ out.z = node.position.z ?? 0;
3575
+ const extent = Math.max(axes.width, axes.height);
3576
+ const glow = node.glow ?? 0;
3577
+ const halo = glow > 0 ? glowHaloRadius(extent, glow) : 0;
3578
+ out.halfWidth = Math.max(axes.width, halo);
3579
+ out.halfHeight = Math.max(axes.height, halo);
3580
+ }
3581
+ });
3582
+ }
3583
+ /**
3584
+ * A 3D flight keeps the view direction it reads from the camera every frame, so an orbit step
3585
+ * between frames turns the flight with it. The destination distance is the front-view fit
3586
+ * distance; near and far span both ends while flying, and the far plane at arrival still holds
3587
+ * the whole graph so the rest of it stays visible around the framed nodes.
3588
+ */
3589
+ planPerspectiveFlight(camera, width, height, nodes, distanceScale) {
3590
+ const fit = this.perspectiveFit(camera, width, height, nodes);
3591
+ const fromTarget = this.controls.target.clone();
3592
+ const toTarget = new Vector3(fit.target.x, fit.target.y, fit.target.z);
3593
+ const fromDistance = Math.max(camera.position.distanceTo(fromTarget), Number.EPSILON);
3594
+ const toDistance = (fit.cameraZ - fit.target.z) * distanceScale;
3595
+ const near = fit.near * Math.min(distanceScale, 1);
3596
+ const far = Math.max(fit.far * distanceScale, (toDistance + this.graphRadiusAround(toTarget)) * 1.1);
3597
+ camera.near = Math.min(camera.near, near);
3598
+ camera.far = Math.max(camera.far, far);
3599
+ camera.updateProjectionMatrix();
3600
+ return {
3601
+ arrive: () => {
3602
+ camera.near = near;
3603
+ camera.far = far;
3604
+ camera.updateProjectionMatrix();
3605
+ this.materializeViewport();
3606
+ },
3607
+ step: (eased) => {
3608
+ const direction = this.flightDirection.subVectors(camera.position, this.controls.target);
3609
+ if (direction.lengthSq() === 0) direction.set(0, 0, 1);
3610
+ direction.normalize();
3611
+ this.controls.target.lerpVectors(fromTarget, toTarget, eased);
3612
+ camera.position.copy(this.controls.target).addScaledVector(direction, interpolateScale(fromDistance, toDistance, eased));
3613
+ camera.lookAt(this.controls.target);
3614
+ }
3615
+ };
3616
+ }
3617
+ /**
3618
+ * A 2D flight switches to the destination frustum up front with a zoom that keeps the current
3619
+ * view, then pans and eases the zoom back to 1, so it lands exactly on the fitted frustum.
3620
+ */
3621
+ planOrthographicFlight(camera, aspect, nodes, distanceScale) {
3622
+ const fit = orthographicFit(aspect, nodes);
3623
+ const fromVisibleHeight = (camera.top - camera.bottom) / camera.zoom;
3624
+ const toVisibleHeight = fit.visibleHeight * distanceScale;
3625
+ setOrthographicFrustum(camera, aspect, toVisibleHeight);
3626
+ const fromZoom = toVisibleHeight / fromVisibleHeight;
3627
+ camera.zoom = fromZoom;
3628
+ camera.updateProjectionMatrix();
3629
+ const fromPosition = camera.position.clone();
3630
+ const toPosition = new Vector3(fit.center.x, fit.center.y, fit.cameraZ);
3631
+ const fromTarget = this.controls.target.clone();
3632
+ return {
3633
+ arrive: () => this.materializeViewport(),
3634
+ step: (eased) => {
3635
+ camera.zoom = interpolateScale(fromZoom, 1, eased);
3636
+ camera.position.lerpVectors(fromPosition, toPosition, eased);
3637
+ this.controls.target.lerpVectors(fromTarget, fit.center, eased);
3638
+ camera.updateProjectionMatrix();
3639
+ }
3640
+ };
3641
+ }
3642
+ /** Largest distance from `center` to any node's far edge. */
3643
+ graphRadiusAround(center) {
3644
+ let radius = 0;
3645
+ for (const node of this.data.nodes) {
3646
+ const axes = resolveNodeAxes({
3647
+ height: node.height,
3648
+ nodeId: node.id,
3649
+ size: node.size,
3650
+ width: node.width
3651
+ });
3652
+ const distance = Math.hypot(node.position.x - center.x, node.position.y - center.y, (node.position.z ?? 0) - center.z);
3653
+ radius = Math.max(radius, distance + Math.max(axes.width, axes.height));
3654
+ }
3655
+ return radius;
3656
+ }
3657
+ /** Steps a running flight to the frame time; settles and resolves it at the end. */
3658
+ advanceFlight(nowMs) {
3659
+ const flight = this.flight;
3660
+ if (!flight) return;
3661
+ flight.startMs ??= nowMs;
3662
+ const progress = flightProgress(nowMs - flight.startMs, flight.durationMs);
3663
+ try {
3664
+ flight.step(easeInOutCubic(progress));
3665
+ if (progress < 1) {
3666
+ if (this.mode === "2d") this.materializeViewport();
3667
+ this.requestRender();
3668
+ return;
3669
+ }
3670
+ this.flight = null;
3671
+ flight.arrive();
3672
+ } catch (error) {
3673
+ this.cancelFlight();
3674
+ throw error;
3675
+ }
3676
+ flight.resolve(true);
3677
+ }
3678
+ cancelFlight = () => {
3679
+ const flight = this.flight;
3680
+ if (!flight) return;
3681
+ this.flight = null;
3682
+ flight.resolve(false);
3683
+ };
2685
3684
  setTheme(theme) {
2686
3685
  this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
2687
3686
  this.applyBackground();
@@ -2706,9 +3705,13 @@ var Graphraum = class {
2706
3705
  this.requestRender();
2707
3706
  }
2708
3707
  destroy() {
3708
+ this.destroyed = true;
3709
+ this.cancelFlight();
3710
+ this.stopAutoOrbit();
2709
3711
  this.resizeObserver.disconnect();
2710
3712
  this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
2711
3713
  this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
3714
+ for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.removeEventListener(type, this.cancelFlight);
2712
3715
  this.controls.removeEventListener("change", this.handleViewChange);
2713
3716
  this.controls.dispose();
2714
3717
  if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
@@ -2720,22 +3723,63 @@ var Graphraum = class {
2720
3723
  }
2721
3724
  requestRender = () => {
2722
3725
  if (this.frameRequest !== null) return;
2723
- this.frameRequest = requestAnimationFrame(() => {
3726
+ this.frameRequest = requestAnimationFrame((nowMs) => {
2724
3727
  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();
3728
+ this.advanceFlight(nowMs);
3729
+ this.renderFrame();
2737
3730
  });
2738
3731
  };
3732
+ /** Renders a pending frame now instead of on the next animation frame. */
3733
+ flushRender() {
3734
+ if (this.frameRequest === null) return;
3735
+ cancelAnimationFrame(this.frameRequest);
3736
+ this.frameRequest = null;
3737
+ this.renderFrame();
3738
+ }
3739
+ renderFrame() {
3740
+ this.readGpuTimers();
3741
+ const timer = this.gpuTimer;
3742
+ const query = timer?.context.createQuery() ?? null;
3743
+ if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
3744
+ const startedAt = performance.now();
3745
+ this.renderer.render(this.scene, this.camera);
3746
+ this.cpuFrameMilliseconds = performance.now() - startedAt;
3747
+ if (timer && query) {
3748
+ timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
3749
+ timer.pending.push(query);
3750
+ }
3751
+ for (const listener of this.viewListeners) listener();
3752
+ }
3753
+ stopAutoOrbit() {
3754
+ this.autoOrbit?.dispose();
3755
+ this.autoOrbit = null;
3756
+ }
3757
+ /**
3758
+ * One orbit frame. `autoRotate` is on only for this `update(dt)` call: OrbitControls also calls
3759
+ * `update()` without a delta from wheel and key handlers and `fitView`, which would otherwise add
3760
+ * a fixed per-call rotation. A moved camera emits `change` (a render request only, see
3761
+ * `handleViewChange`); flushing that request renders this frame instead of the next one. During
3762
+ * a `flyTo` the orbit still turns the camera but leaves the pending frame to the flight, which
3763
+ * steps from the turned direction before it renders. A
3764
+ * throwing update, render or view listener turns the orbit off rather than leaving it enabled
3765
+ * without a loop.
3766
+ */
3767
+ stepAutoOrbit = (deltaSeconds) => {
3768
+ try {
3769
+ this.controls.autoRotate = true;
3770
+ this.orbitUpdating = true;
3771
+ try {
3772
+ this.controls.update(deltaSeconds);
3773
+ } finally {
3774
+ this.controls.autoRotate = false;
3775
+ this.orbitUpdating = false;
3776
+ }
3777
+ if (this.flight === null) this.flushRender();
3778
+ } catch (error) {
3779
+ this.stopAutoOrbit();
3780
+ throw error;
3781
+ }
3782
+ };
2739
3783
  readGpuTimers() {
2740
3784
  const timer = this.gpuTimer;
2741
3785
  if (!timer) return;
@@ -2747,8 +3791,15 @@ var Graphraum = class {
2747
3791
  timer.context.deleteQuery(query);
2748
3792
  }
2749
3793
  }
3794
+ /**
3795
+ * Orbit frames skip materialization. In 3D the packed instances do not depend on camera
3796
+ * orientation: there is no viewport culling, density LOD and edge tiers follow counts only,
3797
+ * and the one orientation-dependent value (`worldUnitsPerPixel`, for edge hit slop) is used
3798
+ * only by 2D picking. 3D picking raycasts the instances and screen positions are projected per
3799
+ * call, so an orbit frame needs only a render, whose view listeners still run every frame.
3800
+ */
2750
3801
  handleViewChange = () => {
2751
- this.materializeViewport();
3802
+ if (!this.orbitUpdating) this.materializeViewport();
2752
3803
  this.requestRender();
2753
3804
  };
2754
3805
  createCamera() {
@@ -2758,12 +3809,8 @@ var Graphraum = class {
2758
3809
  createControls() {
2759
3810
  const controls = new OrbitControls(this.camera, this.renderer.domElement);
2760
3811
  controls.enableDamping = false;
2761
- controls.enableRotate = this.mode === "3d";
2762
3812
  controls.screenSpacePanning = true;
2763
- if (this.mode === "2d") {
2764
- controls.minZoom = .01;
2765
- controls.mouseButtons.LEFT = MOUSE.PAN;
2766
- }
3813
+ configureControlsForMode(controls, this.mode);
2767
3814
  controls.addEventListener("change", this.handleViewChange);
2768
3815
  return controls;
2769
3816
  }
@@ -2854,7 +3901,35 @@ var Graphraum = class {
2854
3901
  slots.push(slot);
2855
3902
  }
2856
3903
  markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
3904
+ this.glowLayer.rewrite(this.describeGlowNode);
2857
3905
  }
3906
+ /** Rebuilds the opt-in halo pass; graphs without glow release it and skip the visible scan. */
3907
+ syncGlow() {
3908
+ if (!this.hasGlowNodes) {
3909
+ this.glowLayer.dispose();
3910
+ return;
3911
+ }
3912
+ this.glowLayer.setBlending(glowBlendModeFor(this.theme.background));
3913
+ this.glowLayer.sync(this.visibleNodeIndices, this.glowAt, this.densityLodActive, this.describeGlowNode);
3914
+ }
3915
+ glowAt = (nodeIndex) => this.data.nodes[nodeIndex]?.glow ?? 0;
3916
+ describeGlowNode = (nodeIndex, target) => {
3917
+ const node = this.data.nodes[nodeIndex];
3918
+ if (!node) return false;
3919
+ const { height, width } = resolveNodeAxes({
3920
+ height: node.height,
3921
+ nodeId: node.id,
3922
+ size: node.size,
3923
+ width: node.width
3924
+ });
3925
+ target.color.copy(this.getNodeColor(node));
3926
+ target.extent = Math.max(width, height);
3927
+ target.glow = node.glow ?? 0;
3928
+ target.x = node.position.x;
3929
+ target.y = node.position.y;
3930
+ target.z = node.position.z ?? 0;
3931
+ return true;
3932
+ };
2858
3933
  getNodeStateIds(state) {
2859
3934
  switch (state) {
2860
3935
  case "dimmed": return this.dimmedNodeIds;
@@ -3000,10 +4075,8 @@ var Graphraum = class {
3000
4075
  const worldPerPixel = this.worldUnitsPerPixel();
3001
4076
  const packed = packEdgeInstances({
3002
4077
  edgeIndices: visibleEdgeIndices,
3003
- edgeVisuals: this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
3004
- ...edge,
3005
- color: this.theme.selectedEdge
3006
- } : edge),
4078
+ edgeStates: this.edgeStateStyling(),
4079
+ edgeVisuals: this.data.edges,
3007
4080
  endpointPositions: this.canonicalEdgePositions,
3008
4081
  defaults: {
3009
4082
  color: this.theme.edge,
@@ -3014,6 +4087,7 @@ var Graphraum = class {
3014
4087
  maxSegments: this.edgeSegmentCapacity,
3015
4088
  endpointAttach: this.theme.endpointAttach,
3016
4089
  edgeNodeIndices: this.edgeNodeIndices,
4090
+ mode: this.mode,
3017
4091
  nodeOutlines: this.data.nodes.map((node) => ({
3018
4092
  height: node.height,
3019
4093
  shape: node.shape,
@@ -3063,6 +4137,7 @@ var Graphraum = class {
3063
4137
  this.visibleEdgeSegmentCount = segmentCount;
3064
4138
  this.visibleEdgeCount = distinctEdges.size;
3065
4139
  this.visibleEdgeMarkerCount = packed.markers.length;
4140
+ this.syncGlow();
3066
4141
  }
3067
4142
  materializeNodes(indices, bounds) {
3068
4143
  if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
@@ -3124,6 +4199,7 @@ var Graphraum = class {
3124
4199
  return [...clusters.values()].slice(0, this.maxVisibleNodes);
3125
4200
  }
3126
4201
  disposeGraphObjects() {
4202
+ this.glowLayer.dispose();
3127
4203
  this.pickableEdgeSegments = [];
3128
4204
  this.visibleEdgeSegmentEdgeIndices = [];
3129
4205
  this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
@@ -3146,6 +4222,42 @@ var Graphraum = class {
3146
4222
  }
3147
4223
  }
3148
4224
  };
4225
+ /** The 2D fit: the nodes' padded bounds, framed with 15% slack on the tighter axis. */
4226
+ function orthographicFit(aspect, nodes) {
4227
+ const bounds = new Box3();
4228
+ const minimum = new Vector3();
4229
+ const maximum = new Vector3();
4230
+ for (const node of nodes) {
4231
+ const { height, width } = resolveNodeAxes({
4232
+ height: node.height,
4233
+ nodeId: node.id,
4234
+ size: node.size,
4235
+ width: node.width
4236
+ });
4237
+ const radius = Math.max(width, height);
4238
+ const z = node.position.z ?? 0;
4239
+ bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
4240
+ bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
4241
+ }
4242
+ const center = bounds.getCenter(new Vector3());
4243
+ const size = bounds.getSize(new Vector3());
4244
+ return {
4245
+ cameraZ: center.z + Math.max(size.z, 1e3),
4246
+ center,
4247
+ visibleHeight: Math.max(size.y, size.x / aspect, 1) * 1.15
4248
+ };
4249
+ }
4250
+ /** Sets a frustum that shows `visibleHeight` world units at zoom 1 and resets the zoom. */
4251
+ function setOrthographicFrustum(camera, aspect, visibleHeight) {
4252
+ camera.left = -visibleHeight * aspect / 2;
4253
+ camera.right = visibleHeight * aspect / 2;
4254
+ camera.top = visibleHeight / 2;
4255
+ camera.bottom = -visibleHeight / 2;
4256
+ camera.zoom = 1;
4257
+ }
4258
+ function hasNodeGlow(node) {
4259
+ return (node.glow ?? 0) > 0;
4260
+ }
3149
4261
  function nextCapacity(length) {
3150
4262
  let capacity = 1;
3151
4263
  while (capacity < length) capacity *= 2;