@yggy-tech/graphraum 0.36.0 → 0.38.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.d.ts CHANGED
@@ -80,6 +80,16 @@ interface GraphraumSetDataOptions {
80
80
  */
81
81
  fit?: boolean;
82
82
  }
83
+ type GraphraumNodeDragPhase = "start" | "move" | "end" | "cancel";
84
+ /** One step of a node drag. `cancel` carries the position the node was restored to (or left at). */
85
+ interface GraphraumNodeDragEvent {
86
+ nodeId: string;
87
+ phase: GraphraumNodeDragPhase;
88
+ position: GraphraumPosition;
89
+ }
90
+ interface GraphraumNodeDragOptions {
91
+ enabled: boolean;
92
+ }
83
93
  interface GraphraumCameraPoint3d {
84
94
  x: number;
85
95
  y: number;
@@ -294,6 +304,12 @@ interface GraphraumDepthOfFieldOptions {
294
304
  * `d` is its camera distance and `f` the focal distance, clamped to `maxBlur`.
295
305
  */
296
306
  strength?: number;
307
+ /**
308
+ * Aperture for surfaces between the camera and the focus, 0..1. Defaults to `strength`. Set it
309
+ * above `strength` (with a larger `maxBlur`) to melt the foreground while the background behind
310
+ * the focus keeps the softer `strength` blur.
311
+ */
312
+ nearStrength?: number;
297
313
  /** Largest blur radius in CSS pixels. Default 12. */
298
314
  maxBlur?: number;
299
315
  /** Blur radii below this many CSS pixels render sharp, which widens the in-focus band. Default 0. */
@@ -618,12 +634,16 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
618
634
  private gpuTimer;
619
635
  private readonly viewListeners;
620
636
  private readonly focusListeners;
637
+ private readonly nodeDragListeners;
638
+ private readonly nodeDrag;
621
639
  private lastHoveredNodeId;
622
640
  private readonly antialias;
623
641
  private depthOfField;
624
642
  private readonly depthOfFieldFocus;
625
643
  /** Strength actually applied: fades toward `strength` while enabled and toward 0 when disabled. */
626
644
  private readonly depthOfFieldStrength;
645
+ /** The same for `nearStrength`, faded alongside. */
646
+ private readonly depthOfFieldNearStrength;
627
647
  private depthOfFieldPass;
628
648
  private depthOfFieldStatus;
629
649
  constructor(container: HTMLElement, options?: GraphraumOptions<NodeAttributes, EdgeAttributes>);
@@ -635,6 +655,13 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
635
655
  private canMutateDataInPlace;
636
656
  private mutateDataInPlace;
637
657
  private appendDataPatch;
658
+ /**
659
+ * Draws appended nodes and edges without repacking the ones already drawn. It applies only when the
660
+ * last pass drew the whole graph in index order, so the appended ones land after the existing ones
661
+ * exactly where a full pass would put them: 3D, or 2D without viewport culling, and no density or
662
+ * overview tier before or after. Returns false, having written nothing, when it does not apply.
663
+ */
664
+ private materializeAppended;
638
665
  private replaceData;
639
666
  updateNodes(updates: readonly GraphraumNodeUpdate[]): void;
640
667
  /** Applies a transferable XYZ layout batch. Call repeatedly as a worker produces progressive positions. */
@@ -697,6 +724,7 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
697
724
  */
698
725
  setDepthOfField(options: GraphraumDepthOfFieldOptions, update?: GraphraumDepthOfFieldUpdate): Promise<boolean>;
699
726
  private depthOfFieldTargetStrength;
727
+ private depthOfFieldTargetNearStrength;
700
728
  getNodePresentation(nodeId: string): CompiledGraphraumPresentation | null;
701
729
  /** Returns canvas-relative screen coordinates for a node, or null when the node does not exist. */
702
730
  getNodeScreenPosition(nodeId: string): GraphraumScreenPosition | null;
@@ -715,6 +743,16 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
715
743
  createOverlay(options?: GraphraumOverlayOptions): GraphraumOverlay<NodeAttributes, EdgeAttributes>;
716
744
  /** Subscribes to camera and viewport changes. Returns an unsubscribe function. */
717
745
  onViewChange(listener: () => void): () => boolean;
746
+ /**
747
+ * Turns node dragging on or off (default off). A drag starts when the pointer moves past a small
748
+ * tolerance after a press on a node (a finger must rest on the node first, so a swipe still pans
749
+ * the camera), moves the node on a plane facing the camera, and ends on release. Escape or a
750
+ * cancelled pointer put the node back. Not available while the density LOD shows clusters.
751
+ */
752
+ setNodeDrag(options: GraphraumNodeDragOptions): void;
753
+ /** Subscribes to node drag steps (`start`, `move`, `end`, `cancel`). Returns an unsubscribe function. */
754
+ onNodeDrag(listener: (event: GraphraumNodeDragEvent) => void): () => boolean;
755
+ private createNodeDragHost;
718
756
  /** Subscribes to selection and hover changes that affect overlay focus chrome. */
719
757
  onFocusChange(listener: () => void): () => boolean;
720
758
  private notifyFocusChange;
@@ -836,6 +874,16 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
836
874
  private createBackgroundTexture;
837
875
  private applyBackground;
838
876
  private materializeViewport;
877
+ /** A node drawn at its own position and size, which is every node outside the density tier. */
878
+ private plainMaterializedNode;
879
+ private nodeInstanceTargets;
880
+ /** Writes one node's matrix, pick matrix, color, shape and stroke into `slot`. */
881
+ private writeNodeInstance;
882
+ /**
883
+ * Writes packed edge instances at the given first slots and records what picking needs for them.
884
+ * Returns how many distinct edges got a segment.
885
+ */
886
+ private writeEdgeInstances;
839
887
  private materializeNodes;
840
888
  private disposeGraphObjects;
841
889
  }
@@ -983,4 +1031,4 @@ declare function isTransparentGraphraumBackground(background: GraphraumBackgroun
983
1031
  /** Defines typed, serializable graph visuals without coupling application data to the render loop. */
984
1032
  declare function defineVisuals<NodeAttributes = undefined, EdgeAttributes = undefined>(visuals: GraphraumVisualMapper<NodeAttributes, EdgeAttributes>): GraphraumVisualMapper<NodeAttributes, EdgeAttributes>;
985
1033
  //#endregion
986
- export { type CompiledGraphraumPresentation, DEFAULT_ENDPOINT_ATTACH, DEFAULT_ENDPOINT_CLEARANCE, DEFAULT_FORCE_SETTINGS, type EndpointAttach, type EndpointOutline, type ForceLayoutRequest, type ForceSettings, type GraphologyEdgeAttributes, type GraphologyNodeAttributes, type GraphologyRenderer, Graphraum, type GraphraumAutoOrbitOptions, type GraphraumAutoOrbitStatus, type GraphraumBackground, type GraphraumCameraPoint3d, type GraphraumCameraState, type GraphraumCameraState2d, type GraphraumCameraState3d, type GraphraumColor, type GraphraumData, type GraphraumDataPatch, type GraphraumDepthOfFieldOptions, type GraphraumDepthOfFieldStatus, type GraphraumDepthOfFieldTransition, type GraphraumDepthOfFieldUpdate, type GraphraumDiagnostics, type GraphraumEasing, type GraphraumEdge, type GraphraumEdgeEncoding, type GraphraumEdgeMarker, type GraphraumEdgeMarkerEnd, type GraphraumEdgePath, type GraphraumEdgeState, type GraphraumEdgeStyle, type GraphraumEdgeVisual, type GraphraumFlyToOptions, type GraphraumLabelCandidate, type GraphraumLayoutPositions, type GraphraumMode, type GraphraumNode, type GraphraumNodeEncoding, type GraphraumNodeGeometry, type GraphraumNodeShape, type GraphraumNodeState, type GraphraumNodeUpdate, type GraphraumNodeVisual, type GraphraumOptions, GraphraumOverlay, type GraphraumOverlayNode, type GraphraumOverlayOptions, type GraphraumPatternBackground, type GraphraumPickHit, type GraphraumPosition, type GraphraumPresentation, type GraphraumPresentationAction, type GraphraumPresentationProperty, type GraphraumPropertyValue, type GraphraumScreenPosition, type GraphraumSetDataOptions, type GraphraumTheme, type GraphraumThemeName, type GraphraumVisualMapper, type LabelBudgetCandidate, type RichNodePolicy, type SelectBudgetedLabelIdsInput, type SelectFocusLabelIdsInput, attachPointOnOutline, bindGraphology, boundOverlayIds, computeClusteredForcePositions, computeForcePositions, createForceSimulation, defineVisuals, forceIterationCount, graphraumEdgePaths, graphraumNodeShapes, graphraumTheme, graphraumThemeDark, graphraumThemeLight, graphraumThemes, isTransparentGraphraumBackground, normalizeGraphraumBackground, orderFocusNodeIds, resolveGraphraumTheme, selectBudgetedLabelIds, selectFocusLabelIds, selectRichNodeIds, trimEdgeEndpoints };
1034
+ export { type CompiledGraphraumPresentation, DEFAULT_ENDPOINT_ATTACH, DEFAULT_ENDPOINT_CLEARANCE, DEFAULT_FORCE_SETTINGS, type EndpointAttach, type EndpointOutline, type ForceLayoutRequest, type ForceSettings, type GraphologyEdgeAttributes, type GraphologyNodeAttributes, type GraphologyRenderer, Graphraum, type GraphraumAutoOrbitOptions, type GraphraumAutoOrbitStatus, type GraphraumBackground, type GraphraumCameraPoint3d, type GraphraumCameraState, type GraphraumCameraState2d, type GraphraumCameraState3d, type GraphraumColor, type GraphraumData, type GraphraumDataPatch, type GraphraumDepthOfFieldOptions, type GraphraumDepthOfFieldStatus, type GraphraumDepthOfFieldTransition, type GraphraumDepthOfFieldUpdate, type GraphraumDiagnostics, type GraphraumEasing, type GraphraumEdge, type GraphraumEdgeEncoding, type GraphraumEdgeMarker, type GraphraumEdgeMarkerEnd, type GraphraumEdgePath, type GraphraumEdgeState, type GraphraumEdgeStyle, type GraphraumEdgeVisual, type GraphraumFlyToOptions, type GraphraumLabelCandidate, type GraphraumLayoutPositions, type GraphraumMode, type GraphraumNode, type GraphraumNodeDragEvent, type GraphraumNodeDragOptions, type GraphraumNodeDragPhase, type GraphraumNodeEncoding, type GraphraumNodeGeometry, type GraphraumNodeShape, type GraphraumNodeState, type GraphraumNodeUpdate, type GraphraumNodeVisual, type GraphraumOptions, GraphraumOverlay, type GraphraumOverlayNode, type GraphraumOverlayOptions, type GraphraumPatternBackground, type GraphraumPickHit, type GraphraumPosition, type GraphraumPresentation, type GraphraumPresentationAction, type GraphraumPresentationProperty, type GraphraumPropertyValue, type GraphraumScreenPosition, type GraphraumSetDataOptions, type GraphraumTheme, type GraphraumThemeName, type GraphraumVisualMapper, type LabelBudgetCandidate, type RichNodePolicy, type SelectBudgetedLabelIdsInput, type SelectFocusLabelIdsInput, attachPointOnOutline, bindGraphology, boundOverlayIds, computeClusteredForcePositions, computeForcePositions, createForceSimulation, defineVisuals, forceIterationCount, graphraumEdgePaths, graphraumNodeShapes, graphraumTheme, graphraumThemeDark, graphraumThemeLight, graphraumThemes, isTransparentGraphraumBackground, normalizeGraphraumBackground, orderFocusNodeIds, resolveGraphraumTheme, selectBudgetedLabelIds, selectFocusLabelIds, selectRichNodeIds, trimEdgeEndpoints };
package/dist/index.js CHANGED
@@ -1268,6 +1268,7 @@ const DEPTH_OF_FIELD_DEFAULTS = {
1268
1268
  maxBlur: 12,
1269
1269
  minBlur: 0,
1270
1270
  nearDistance: 0,
1271
+ nearStrength: null,
1271
1272
  strength: .5,
1272
1273
  transition: {
1273
1274
  durationMs: 400,
@@ -1300,15 +1301,17 @@ function resolveDepthOfFieldOptions(options, base = DEPTH_OF_FIELD_DEFAULTS) {
1300
1301
  maxBlur: options.maxBlur ?? base.maxBlur,
1301
1302
  minBlur: options.minBlur ?? base.minBlur,
1302
1303
  nearDistance: options.nearDistance ?? base.nearDistance,
1304
+ nearStrength: options.nearStrength ?? base.nearStrength,
1303
1305
  strength: options.strength ?? base.strength,
1304
1306
  transition: {
1305
1307
  durationMs: options.transition?.durationMs ?? base.transition.durationMs,
1306
1308
  easing: options.transition?.easing ?? base.transition.easing
1307
1309
  }
1308
1310
  };
1309
- const { enabled, farDistance, focus, maxBlur, minBlur, nearDistance, strength, transition } = resolved;
1311
+ const { enabled, farDistance, focus, maxBlur, minBlur, nearDistance, nearStrength, strength, transition } = resolved;
1310
1312
  if (typeof enabled !== "boolean") throw new Error(`Depth of field enabled must be a boolean; received ${String(enabled)}.`);
1311
1313
  if (!isNonNegativeNumber(strength) || strength > 1) throw new Error(`Depth of field strength must be a number from 0 to 1; received ${String(strength)}.`);
1314
+ if (nearStrength !== null && (!isNonNegativeNumber(nearStrength) || nearStrength > 1)) throw new Error(`Depth of field nearStrength must be a number from 0 to 1; received ${String(nearStrength)}.`);
1312
1315
  if (focus !== "selection" && (typeof focus !== "number" || !Number.isFinite(focus) || focus <= 0)) throw new Error(`Depth of field focus must be "selection" or a camera distance above 0 in world units; received ${String(focus)}.`);
1313
1316
  if (!isNonNegativeNumber(maxBlur) || !isNonNegativeNumber(minBlur) || minBlur > maxBlur) throw new Error(`Depth of field minBlur and maxBlur must be non-negative CSS pixel radii with minBlur <= maxBlur; received ${String(minBlur)} and ${String(maxBlur)}.`);
1314
1317
  if (!isNonNegativeNumber(nearDistance) || typeof farDistance !== "number" || Number.isNaN(farDistance) || farDistance <= nearDistance) throw new Error(`Depth of field nearDistance must be a non-negative world distance below farDistance; received ${String(nearDistance)} and ${String(farDistance)}.`);
@@ -1501,6 +1504,7 @@ uniform float cameraNear;
1501
1504
  uniform float cameraFar;
1502
1505
  uniform float focusDistance;
1503
1506
  uniform float aperture;
1507
+ uniform float nearAperture;
1504
1508
  uniform float minBlur;
1505
1509
  uniform float maxBlur;
1506
1510
 
@@ -1512,7 +1516,8 @@ float surfaceDistance(ivec2 pixel) {
1512
1516
  }
1513
1517
 
1514
1518
  float radiusAt(float surface) {
1515
- float radius = aperture * abs(surface - focusDistance) / max(surface, 1e-6);
1519
+ float lens = surface < focusDistance ? nearAperture : aperture;
1520
+ float radius = lens * abs(surface - focusDistance) / max(surface, 1e-6);
1516
1521
  return radius < minBlur ? 0.0 : min(radius, maxBlur);
1517
1522
  }
1518
1523
 
@@ -1528,27 +1533,57 @@ layout(location = 1) out highp vec4 nearColor;
1528
1533
 
1529
1534
  const float GOLDEN_ANGLE = 2.39996323;
1530
1535
 
1536
+ ivec2 base;
1537
+ float centreSurface;
1538
+ float rotation;
1539
+
1540
+ float tapDistance(int index, float reach) {
1541
+ return sqrt((float(index) + 0.5) / ${BLUR_TAPS}.0) * reach;
1542
+ }
1543
+
1544
+ ivec2 tapPixel(int index, float distanceFromCentre) {
1545
+ float angle = float(index) * GOLDEN_ANGLE + rotation;
1546
+ return clamp(base + ivec2(floor(vec2(cos(angle), sin(angle)) * distanceFromCentre + 0.5)), ivec2(0), depthSize - 1);
1547
+ }
1548
+
1549
+ vec4 nearTerm(float surface, float radius, float weight, vec4 color) {
1550
+ if (surface >= focusDistance || surface >= centreSurface) return vec4(0.0);
1551
+ return color * weight * (maxBlur * maxBlur / max(radius * radius, 1.0));
1552
+ }
1553
+
1531
1554
  void main() {
1532
- ivec2 base = ivec2(gl_FragCoord.xy) * 2;
1533
- float centreSurface = surfaceDistance(base);
1555
+ base = ivec2(gl_FragCoord.xy) * 2;
1556
+ centreSurface = surfaceDistance(base);
1534
1557
  vec4 sum = texelFetch(colorTexture, base, 0);
1535
1558
  float total = 1.0;
1536
1559
  vec4 near = vec4(0.0);
1537
1560
  // Interleaved gradient noise turns the spiral's fixed tap pattern into fine noise that the
1538
1561
  // composite's bilinear upsample smooths, instead of hatching along blurred edges.
1539
- float rotation = 6.28318531 * fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
1562
+ rotation = 6.28318531 * fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
1563
+ // Mirrors blurGatherRadius in depth-of-field.ts.
1564
+ float centreRadius = radiusAt(centreSurface);
1565
+ float blurReach = min(maxBlur, max(centreRadius, aperture));
1566
+ bool nearInBlurLoop = blurReach >= maxBlur;
1540
1567
  for (int index = 0; index < ${BLUR_TAPS}; index++) {
1541
- float distanceFromCentre = sqrt((float(index) + 0.5) / ${BLUR_TAPS}.0) * maxBlur;
1542
- float angle = float(index) * GOLDEN_ANGLE + rotation;
1543
- ivec2 pixel = clamp(base + ivec2(floor(vec2(cos(angle), sin(angle)) * distanceFromCentre + 0.5)), ivec2(0), depthSize - 1);
1568
+ float distanceFromCentre = tapDistance(index, blurReach);
1569
+ ivec2 pixel = tapPixel(index, distanceFromCentre);
1544
1570
  float surface = surfaceDistance(pixel);
1545
1571
  float radius = radiusAt(surface);
1546
- float weight = clamp(radius - distanceFromCentre + 1.0, 0.0, 1.0);
1572
+ float reach = surface > centreSurface ? max(radius, centreRadius) : radius;
1573
+ float weight = clamp(reach - distanceFromCentre + 1.0, 0.0, 1.0);
1547
1574
  vec4 color = texelFetch(colorTexture, pixel, 0);
1548
1575
  sum += color * weight;
1549
1576
  total += weight;
1550
- if (surface < focusDistance && surface < centreSurface) {
1551
- near += color * weight * (maxBlur * maxBlur / max(radius * radius, 1.0));
1577
+ if (nearInBlurLoop) near += nearTerm(surface, radius, weight, color);
1578
+ }
1579
+ if (!nearInBlurLoop) {
1580
+ for (int index = 0; index < ${BLUR_TAPS}; index++) {
1581
+ float distanceFromCentre = tapDistance(index, maxBlur);
1582
+ ivec2 pixel = tapPixel(index, distanceFromCentre);
1583
+ float surface = surfaceDistance(pixel);
1584
+ float radius = radiusAt(surface);
1585
+ float weight = clamp(radius - distanceFromCentre + 1.0, 0.0, 1.0);
1586
+ near += nearTerm(surface, radius, weight, texelFetch(colorTexture, pixel, 0));
1552
1587
  }
1553
1588
  }
1554
1589
  blurColor = sum / total;
@@ -1583,6 +1618,7 @@ function blurUniforms() {
1583
1618
  focusDistance: { value: 1 },
1584
1619
  maxBlur: { value: 0 },
1585
1620
  minBlur: { value: 0 },
1621
+ nearAperture: { value: 0 },
1586
1622
  nodeDepth: { value: null }
1587
1623
  };
1588
1624
  }
@@ -1681,6 +1717,7 @@ var DepthOfFieldPass = class {
1681
1717
  focusDistance: frame.focusDistance,
1682
1718
  maxBlur: frame.maxBlurPixels,
1683
1719
  minBlur: frame.minBlurPixels,
1720
+ nearAperture: frame.nearAperturePixels,
1684
1721
  nodeDepth: this.sceneTarget.depthTexture
1685
1722
  };
1686
1723
  this.quad.material = this.blurMaterial;
@@ -2210,13 +2247,18 @@ function writeEdgeColorSlots(color, start, count, paint) {
2210
2247
  }
2211
2248
  //#endregion
2212
2249
  //#region src/edge-viewport-patch.ts
2213
- /** Records GPU slot ranges for each visible edge after a full viewport materialize. */
2214
- function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
2215
- const layouts = /* @__PURE__ */ new Map();
2216
- for (const [slot, segment] of segments.entries()) {
2250
+ /**
2251
+ * Adds the slot ranges of appended edges to `layouts`. Appended segments take the place of the
2252
+ * first markers, which move up by `segments.length` slots, so every existing marker range shifts
2253
+ * with them. The result equals `buildVisibleEdgeLayouts` over the whole edge set.
2254
+ */
2255
+ function appendVisibleEdgeLayouts(layouts, segments, markers, slots) {
2256
+ if (segments.length > 0) for (const layout of layouts.values()) layout.markerStart += segments.length;
2257
+ for (const [offset, segment] of segments.entries()) {
2258
+ const slot = slots.segmentStart + offset;
2217
2259
  const layout = layouts.get(segment.edgeIndex) ?? {
2218
2260
  markerCount: 0,
2219
- markerStart: segmentInstanceCount,
2261
+ markerStart: slots.segmentTotal,
2220
2262
  segmentCount: 0,
2221
2263
  segmentStart: slot
2222
2264
  };
@@ -2225,7 +2267,7 @@ function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
2225
2267
  layouts.set(segment.edgeIndex, layout);
2226
2268
  }
2227
2269
  for (const [offset, marker] of markers.entries()) {
2228
- const slot = segmentInstanceCount + offset;
2270
+ const slot = slots.markerStart + offset;
2229
2271
  const layout = layouts.get(marker.edgeIndex) ?? {
2230
2272
  markerCount: 0,
2231
2273
  markerStart: slot,
@@ -2238,6 +2280,14 @@ function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
2238
2280
  }
2239
2281
  return layouts;
2240
2282
  }
2283
+ /** Records GPU slot ranges for each visible edge after a full viewport materialize. */
2284
+ function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
2285
+ return appendVisibleEdgeLayouts(/* @__PURE__ */ new Map(), segments, markers, {
2286
+ markerStart: segmentInstanceCount,
2287
+ segmentStart: 0,
2288
+ segmentTotal: segmentInstanceCount
2289
+ });
2290
+ }
2241
2291
  /**
2242
2292
  * Rewrites GPU edge instances for moved endpoints without repacking the whole viewport.
2243
2293
  * Returns ok=false when any visible edge would change segment or marker counts.
@@ -2273,7 +2323,7 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
2273
2323
  for (const [offset, segment] of packed.segments.entries()) {
2274
2324
  const slot = layout.segmentStart + offset;
2275
2325
  writeEdgeSegmentInstance(geometry, slot, segment);
2276
- nextPickable[slot] = {
2326
+ if (input.pickable !== false) nextPickable[slot] = {
2277
2327
  edgeIndex,
2278
2328
  hitSlop: Math.max(input.minHitSlop, segment.width * input.worldPerPixel / 2 + 2 * input.worldPerPixel),
2279
2329
  x1: segment.x1,
@@ -2493,6 +2543,231 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
2493
2543
  };
2494
2544
  });
2495
2545
  }
2546
+ /**
2547
+ * Drags one node with the pointer. A press on a node arms a gesture; once the pointer leaves the
2548
+ * click tolerance (a finger must rest on the node first) the node follows it until release.
2549
+ * Camera controls are switched off for the gesture and restored when it ends, so a press that stays
2550
+ * a click behaves as before. Escape, a cancelled pointer or lost pointer capture put the node back.
2551
+ */
2552
+ var NodeDragController = class {
2553
+ host;
2554
+ holdMs;
2555
+ enabled = false;
2556
+ gesture = null;
2557
+ unlisten;
2558
+ unlistenKeys = null;
2559
+ constructor(host, holdMs = 350) {
2560
+ this.host = host;
2561
+ this.holdMs = holdMs;
2562
+ this.unlisten = [
2563
+ listen(host.element, "pointerdown", this.handleDown, { capture: true }),
2564
+ listen(host.element, "pointermove", this.handleMove),
2565
+ listen(host.element, "pointerup", this.handleUp),
2566
+ listen(host.element, "pointercancel", this.handleCancel),
2567
+ listen(host.element, "lostpointercapture", this.handleCancel)
2568
+ ];
2569
+ }
2570
+ /** True from the first drag step until the node is dropped or the drag is cancelled. */
2571
+ get active() {
2572
+ return this.gesture?.phase === "dragging";
2573
+ }
2574
+ setEnabled(enabled) {
2575
+ if (!enabled) this.cancel({ restore: true });
2576
+ this.enabled = enabled;
2577
+ }
2578
+ /** Ends a running gesture as cancelled. `restore` puts the node back where it was grabbed. */
2579
+ cancel({ restore }) {
2580
+ const gesture = this.gesture;
2581
+ if (!gesture) return;
2582
+ if (gesture.phase === "dragging") {
2583
+ const restored = restore && this.host.nodePosition(gesture.nodeId) !== null;
2584
+ if (restored) this.host.moveNode(gesture.nodeId, gesture.origin);
2585
+ this.finish(gesture);
2586
+ this.host.emit({
2587
+ nodeId: gesture.nodeId,
2588
+ phase: "cancel",
2589
+ position: restored ? gesture.origin : gesture.last
2590
+ });
2591
+ return;
2592
+ }
2593
+ this.finish(gesture);
2594
+ }
2595
+ dispose() {
2596
+ this.cancel({ restore: false });
2597
+ this.enabled = false;
2598
+ for (const stop of this.unlisten) stop();
2599
+ }
2600
+ handleDown = (event) => {
2601
+ if (!this.enabled || this.gesture || event.button !== 0 || !this.host.canStart()) return;
2602
+ const nodeId = this.host.pickNode(event.clientX, event.clientY);
2603
+ if (nodeId === null) return;
2604
+ const origin = this.host.nodePosition(nodeId);
2605
+ if (!origin) return;
2606
+ const surface = this.host.createSurface(origin);
2607
+ const grabbed = surface?.(event.clientX, event.clientY);
2608
+ if (!surface || !grabbed) return;
2609
+ const touch = event.pointerType === "touch";
2610
+ const gesture = {
2611
+ controlsHeld: false,
2612
+ holdTimer: null,
2613
+ last: origin,
2614
+ nodeId,
2615
+ offset: {
2616
+ x: origin.x - grabbed.x,
2617
+ y: origin.y - grabbed.y,
2618
+ z: (origin.z ?? 0) - (grabbed.z ?? 0)
2619
+ },
2620
+ origin,
2621
+ phase: "pressed",
2622
+ pointerId: event.pointerId,
2623
+ startX: event.clientX,
2624
+ startY: event.clientY,
2625
+ surface,
2626
+ touch
2627
+ };
2628
+ this.gesture = gesture;
2629
+ this.unlistenKeys = listen(this.host.document, "keydown", this.handleKey);
2630
+ if (touch) {
2631
+ gesture.holdTimer = setTimeout(() => this.beginDrag(gesture), this.holdMs);
2632
+ return;
2633
+ }
2634
+ this.holdControls(gesture);
2635
+ capture(this.host.element, event.pointerId);
2636
+ };
2637
+ handleMove = (event) => {
2638
+ const gesture = this.gesture;
2639
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
2640
+ if (gesture.phase === "pressed") {
2641
+ if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= 5) return;
2642
+ if (gesture.touch) {
2643
+ this.finish(gesture);
2644
+ return;
2645
+ }
2646
+ this.beginDrag(gesture);
2647
+ }
2648
+ this.follow(gesture, event.clientX, event.clientY);
2649
+ };
2650
+ handleUp = (event) => {
2651
+ const gesture = this.gesture;
2652
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
2653
+ this.finish(gesture);
2654
+ if (gesture.phase === "dragging") this.host.emit({
2655
+ nodeId: gesture.nodeId,
2656
+ phase: "end",
2657
+ position: gesture.last
2658
+ });
2659
+ };
2660
+ handleCancel = (event) => {
2661
+ if (this.gesture && event.pointerId === this.gesture.pointerId) this.cancel({ restore: true });
2662
+ };
2663
+ handleKey = (event) => {
2664
+ if (event.key === "Escape") this.cancel({ restore: true });
2665
+ };
2666
+ beginDrag(gesture) {
2667
+ if (this.gesture !== gesture || gesture.phase === "dragging") return;
2668
+ gesture.phase = "dragging";
2669
+ clearHold(gesture);
2670
+ this.holdControls(gesture);
2671
+ if (gesture.touch) capture(this.host.element, gesture.pointerId);
2672
+ this.host.emit({
2673
+ nodeId: gesture.nodeId,
2674
+ phase: "start",
2675
+ position: gesture.origin
2676
+ });
2677
+ }
2678
+ follow(gesture, clientX, clientY) {
2679
+ if (!this.host.nodePosition(gesture.nodeId)) {
2680
+ this.cancel({ restore: false });
2681
+ return;
2682
+ }
2683
+ const point = gesture.surface(clientX, clientY);
2684
+ if (!point) return;
2685
+ const z = point.z === void 0 ? gesture.origin.z : point.z + gesture.offset.z;
2686
+ const position = {
2687
+ x: point.x + gesture.offset.x,
2688
+ y: point.y + gesture.offset.y,
2689
+ ...z === void 0 ? {} : { z }
2690
+ };
2691
+ gesture.last = position;
2692
+ this.host.moveNode(gesture.nodeId, position);
2693
+ this.host.emit({
2694
+ nodeId: gesture.nodeId,
2695
+ phase: "move",
2696
+ position
2697
+ });
2698
+ }
2699
+ holdControls(gesture) {
2700
+ if (gesture.controlsHeld) return;
2701
+ gesture.controlsHeld = true;
2702
+ this.host.setControlsEnabled(false);
2703
+ }
2704
+ /** Drops the gesture and everything it held: timer, pointer capture, key listener, camera controls. */
2705
+ finish(gesture) {
2706
+ if (this.gesture !== gesture) return;
2707
+ this.gesture = null;
2708
+ clearHold(gesture);
2709
+ this.unlistenKeys?.();
2710
+ this.unlistenKeys = null;
2711
+ release(this.host.element, gesture.pointerId);
2712
+ if (gesture.controlsHeld) this.host.setControlsEnabled(true);
2713
+ }
2714
+ };
2715
+ /** Typed `addEventListener` for the pointer and key events this module handles; returns the unsubscribe. */
2716
+ function listen(target, type, handler, options) {
2717
+ const listener = handler;
2718
+ target.addEventListener(type, listener, options);
2719
+ return () => target.removeEventListener(type, listener, options);
2720
+ }
2721
+ function clearHold(gesture) {
2722
+ if (gesture.holdTimer === null) return;
2723
+ clearTimeout(gesture.holdTimer);
2724
+ gesture.holdTimer = null;
2725
+ }
2726
+ function capture(element, pointerId) {
2727
+ try {
2728
+ element.setPointerCapture(pointerId);
2729
+ } catch {}
2730
+ }
2731
+ function release(element, pointerId) {
2732
+ try {
2733
+ element.releasePointerCapture(pointerId);
2734
+ } catch {}
2735
+ }
2736
+ //#endregion
2737
+ //#region src/node-drag-surface.ts
2738
+ /**
2739
+ * Where the pointer is in world space on the plane a dragged node moves in. 2D unprojects onto the
2740
+ * view plane and reports no depth. 3D intersects the pointer ray with a plane through the anchor
2741
+ * that faces the camera as it was at the press, so the node keeps its depth and follows the pointer
2742
+ * across the screen. Returns `null` for an empty canvas, and from the surface when the ray misses.
2743
+ */
2744
+ function createNodeDragSurface({ anchor, bounds, camera, raycaster }) {
2745
+ const initial = bounds();
2746
+ if (initial.width === 0 || initial.height === 0) return null;
2747
+ const ndc = new Vector2();
2748
+ const toNdc = (clientX, clientY) => {
2749
+ const { height, left, top, width } = bounds();
2750
+ return ndc.set((clientX - left) / width * 2 - 1, -((clientY - top) / height) * 2 + 1);
2751
+ };
2752
+ if (camera.type === "OrthographicCamera") return (clientX, clientY) => {
2753
+ const point = toNdc(clientX, clientY);
2754
+ const world = new Vector3(point.x, point.y, 0).unproject(camera);
2755
+ return {
2756
+ x: world.x,
2757
+ y: world.y
2758
+ };
2759
+ };
2760
+ const plane = new Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(new Vector3()), new Vector3(anchor.x, anchor.y, anchor.z ?? 0));
2761
+ const hit = new Vector3();
2762
+ return (clientX, clientY) => {
2763
+ raycaster.setFromCamera(toNdc(clientX, clientY), camera);
2764
+ return raycaster.ray.intersectPlane(plane, hit) ? {
2765
+ x: hit.x,
2766
+ y: hit.y,
2767
+ z: hit.z
2768
+ } : null;
2769
+ };
2770
+ }
2496
2771
  const vertexShader$1 = `
2497
2772
  attribute float instanceGlowOpacity;
2498
2773
  varying vec3 glowColor;
@@ -3386,12 +3661,16 @@ var Graphraum = class {
3386
3661
  gpuTimer = null;
3387
3662
  viewListeners = /* @__PURE__ */ new Set();
3388
3663
  focusListeners = /* @__PURE__ */ new Set();
3664
+ nodeDragListeners = /* @__PURE__ */ new Set();
3665
+ nodeDrag;
3389
3666
  lastHoveredNodeId = null;
3390
3667
  antialias;
3391
3668
  depthOfField = DEPTH_OF_FIELD_DEFAULTS;
3392
3669
  depthOfFieldFocus = new DepthOfFieldFocus();
3393
3670
  /** Strength actually applied: fades toward `strength` while enabled and toward 0 when disabled. */
3394
3671
  depthOfFieldStrength = new Tween(0);
3672
+ /** The same for `nearStrength`, faded alongside. */
3673
+ depthOfFieldNearStrength = new Tween(0);
3395
3674
  depthOfFieldPass = null;
3396
3675
  depthOfFieldStatus = "off";
3397
3676
  constructor(container, options = {}) {
@@ -3409,6 +3688,7 @@ var Graphraum = class {
3409
3688
  if (options.depthOfField !== void 0) {
3410
3689
  this.depthOfField = resolveDepthOfFieldOptions(options.depthOfField);
3411
3690
  this.depthOfFieldStrength.retarget(this.depthOfFieldTargetStrength(), 0, resolveEasing("linear"));
3691
+ this.depthOfFieldNearStrength.retarget(this.depthOfFieldTargetNearStrength(), 0, resolveEasing("linear"));
3412
3692
  }
3413
3693
  this.antialias = options.antialias ?? false;
3414
3694
  this.renderer = new WebGLRenderer({
@@ -3433,6 +3713,7 @@ var Graphraum = class {
3433
3713
  this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
3434
3714
  this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
3435
3715
  for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
3716
+ this.nodeDrag = new NodeDragController(this.createNodeDragHost());
3436
3717
  this.camera = this.createCamera();
3437
3718
  this.controls = this.createControls();
3438
3719
  this.resizeObserver = new ResizeObserver(() => this.resize());
@@ -3514,78 +3795,170 @@ var Graphraum = class {
3514
3795
  const addedNodes = patch.addedNodes ?? [];
3515
3796
  const addedEdges = patch.addedEdges ?? [];
3516
3797
  if (addedNodes.length === 0 && addedEdges.length === 0) return;
3517
- const nodeById = new Map(this.data.nodes.map((node) => [node.id, node]));
3518
- for (const node of addedNodes) nodeById.set(node.id, node);
3519
- const edgeEndpointNodes = addedEdges.flatMap((edge) => {
3520
- const source = nodeById.get(edge.source);
3521
- const target = nodeById.get(edge.target);
3522
- return source && target ? [source, target] : [];
3523
- });
3524
- const patchNodes = [...addedNodes, ...edgeEndpointNodes].filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index);
3798
+ const nodes = this.data.nodes;
3799
+ const edges = this.data.edges;
3800
+ const previous = {
3801
+ edges: edges.length,
3802
+ nodes: nodes.length
3803
+ };
3804
+ const added = new Map(addedNodes.map((node) => [node.id, node]));
3805
+ const nodeById = (id) => added.get(id) ?? nodes[this.nodeIndices.get(id) ?? -1];
3806
+ const patchNodes = new Map(added);
3807
+ for (const edge of addedEdges) for (const id of [edge.source, edge.target]) {
3808
+ const node = nodeById(id);
3809
+ if (node && !patchNodes.has(id)) patchNodes.set(id, node);
3810
+ }
3525
3811
  const compiled = compileGraph({
3526
- nodes: patchNodes,
3812
+ nodes: [...patchNodes.values()],
3527
3813
  edges: addedEdges
3528
3814
  }, this.visuals);
3529
- const nodeVisuals = new Map(patchNodes.map((node, index) => [node.id, compiled.nodeVisuals[index]]));
3530
- const nextNodes = [...this.data.nodes, ...addedNodes.map((node) => ({
3531
- ...node,
3532
- ...nodeVisuals.get(node.id)
3533
- }))];
3534
- const nextNodeIndices = new Map(this.nodeIndices);
3535
- for (const [index, node] of nextNodes.entries()) nextNodeIndices.set(node.id, index);
3536
- const nextEdges = [...this.data.edges, ...addedEdges.map((edge, index) => ({
3537
- ...edge,
3538
- ...compiled.edgeVisuals[index]
3539
- }))];
3540
- const nextEdgeIndices = new Uint32Array(nextEdges.length * 2);
3541
- nextEdgeIndices.set(this.edgeNodeIndices);
3542
- const nextEdgePositions = new Float32Array(nextEdges.length * 6);
3543
- nextEdgePositions.set(this.canonicalEdgePositions);
3544
- const nextIncidentEdges = this.incidentEdgeIndices.map((indices) => [...indices]);
3545
- for (const [index, edge] of nextEdges.entries()) {
3546
- const source = nextNodeIndices.get(edge.source);
3547
- const target = nextNodeIndices.get(edge.target);
3548
- if (source === void 0 || target === void 0) continue;
3549
- nextEdgeIndices[index * 2] = source;
3550
- nextEdgeIndices[index * 2 + 1] = target;
3551
- if (!nextIncidentEdges[source]) nextIncidentEdges[source] = [];
3552
- if (!nextIncidentEdges[target]) nextIncidentEdges[target] = [];
3553
- if (index >= this.data.edges.length) {
3554
- nextIncidentEdges[source]?.push(index);
3555
- nextIncidentEdges[target]?.push(index);
3556
- const sourceNode = nextNodes[source];
3557
- const targetNode = nextNodes[target];
3558
- if (sourceNode && targetNode) nextEdgePositions.set([
3559
- sourceNode.position.x,
3560
- sourceNode.position.y,
3561
- sourceNode.position.z ?? 0,
3562
- targetNode.position.x,
3563
- targetNode.position.y,
3564
- targetNode.position.z ?? 0
3565
- ], index * 6);
3566
- }
3567
- }
3568
- this.data = {
3569
- nodes: nextNodes,
3570
- edges: nextEdges
3571
- };
3572
- this.nodeIds = nextNodes.map((node) => node.id);
3573
- this.hasGlowNodes = nextNodes.some(hasNodeGlow);
3574
- this.nodeIndices = nextNodeIndices;
3575
- this.edgeNodeIndices = nextEdgeIndices;
3576
- this.canonicalEdgePositions = nextEdgePositions;
3577
- this.incidentEdgeIndices = nextIncidentEdges;
3815
+ const nodeVisuals = new Map([...patchNodes.keys()].map((id, index) => [id, compiled.nodeVisuals[index]]));
3816
+ const nodeIds = this.nodeIds;
3817
+ const incidentEdges = this.incidentEdgeIndices;
3578
3818
  for (const node of addedNodes) {
3579
- const index = nextNodeIndices.get(node.id);
3580
- const nextNode = index === void 0 ? void 0 : this.data.nodes[index];
3581
- if (index !== void 0 && nextNode) this.spatialGrid2d.set(index, nextNode);
3819
+ const index = nodes.length;
3820
+ this.nodeIndices.set(node.id, index);
3821
+ nodeIds.push(node.id);
3822
+ nodes.push({
3823
+ ...node,
3824
+ ...nodeVisuals.get(node.id),
3825
+ position: { ...node.position }
3826
+ });
3827
+ incidentEdges.push([]);
3828
+ this.spatialGrid2d.set(index, nodes[index]);
3829
+ }
3830
+ this.edgeNodeIndices = grown(this.edgeNodeIndices, (previous.edges + addedEdges.length) * 2);
3831
+ this.canonicalEdgePositions = grown(this.canonicalEdgePositions, (previous.edges + addedEdges.length) * 6);
3832
+ for (const [offset, edge] of addedEdges.entries()) {
3833
+ const index = previous.edges + offset;
3834
+ const source = this.nodeIndices.get(edge.source);
3835
+ const target = this.nodeIndices.get(edge.target);
3836
+ edges.push({
3837
+ ...edge,
3838
+ ...compiled.edgeVisuals[offset]
3839
+ });
3840
+ if (source === void 0 || target === void 0) continue;
3841
+ this.edgeNodeIndices[index * 2] = source;
3842
+ this.edgeNodeIndices[index * 2 + 1] = target;
3843
+ incidentEdges[source]?.push(index);
3844
+ if (target !== source) incidentEdges[target]?.push(index);
3845
+ const sourceNode = nodes[source];
3846
+ const targetNode = nodes[target];
3847
+ if (!sourceNode || !targetNode) continue;
3848
+ this.canonicalEdgePositions.set([
3849
+ sourceNode.position.x,
3850
+ sourceNode.position.y,
3851
+ sourceNode.position.z ?? 0,
3852
+ targetNode.position.x,
3853
+ targetNode.position.y,
3854
+ targetNode.position.z ?? 0
3855
+ ], index * 6);
3582
3856
  }
3857
+ if (this.edgeIndexCache.edges === edges) for (const [offset, edge] of addedEdges.entries()) this.edgeIndexCache.indices.set(edge.id, previous.edges + offset);
3858
+ const addedGlow = addedNodes.some(hasNodeGlow);
3859
+ this.hasGlowNodes ||= addedGlow;
3583
3860
  for (const [id, presentation] of compiled.nodePresentations) this.nodePresentations.set(id, presentation);
3584
3861
  for (const [id, presentation] of compiled.edgePresentations) this.edgePresentations.set(id, presentation);
3585
- this.materializeViewport();
3862
+ if (this.materializeAppended(previous)) {
3863
+ if (addedGlow) this.syncGlow();
3864
+ } else this.materializeViewport();
3586
3865
  this.requestRender();
3587
3866
  }
3867
+ /**
3868
+ * Draws appended nodes and edges without repacking the ones already drawn. It applies only when the
3869
+ * last pass drew the whole graph in index order, so the appended ones land after the existing ones
3870
+ * exactly where a full pass would put them: 3D, or 2D without viewport culling, and no density or
3871
+ * overview tier before or after. Returns false, having written nothing, when it does not apply.
3872
+ */
3873
+ materializeAppended(previous) {
3874
+ const { edgeMesh, nodeMesh, nodePickMesh } = this;
3875
+ if (!nodeMesh || !nodePickMesh || !edgeMesh) return false;
3876
+ const total = {
3877
+ edges: this.data.edges.length,
3878
+ nodes: this.data.nodes.length
3879
+ };
3880
+ const drewEverything = this.visibleNodeCount === previous.nodes && this.visibleEdgeCandidateCount === previous.edges;
3881
+ if (this.getViewportBounds2d() !== null || this.densityLodActive || this.lastEdgeLodTier !== "detail" || !drewEverything || total.nodes > this.maxVisibleNodes || total.edges > this.maxVisibleEdges) return false;
3882
+ const segmentsBefore = this.visibleEdgeSegmentCount;
3883
+ const markersBefore = this.visibleEdgeMarkerCount;
3884
+ const packed = packEdgeInstances({
3885
+ edgeIndices: Array.from({ length: total.edges - previous.edges }, (_, offset) => previous.edges + offset),
3886
+ edgeStates: this.edgeStateStyling(),
3887
+ edgeVisuals: this.data.edges,
3888
+ endpointPositions: this.canonicalEdgePositions,
3889
+ defaults: {
3890
+ color: this.theme.edge,
3891
+ opacity: this.theme.edgeOpacity,
3892
+ width: this.theme.edgeWidth
3893
+ },
3894
+ tier: this.lastEdgeLodTier,
3895
+ maxSegments: this.edgeSegmentCapacity - segmentsBefore,
3896
+ endpointAttach: this.theme.endpointAttach,
3897
+ edgeNodeIndices: this.edgeNodeIndices,
3898
+ mode: this.mode,
3899
+ nodeOutlines: this.data.nodes
3900
+ });
3901
+ const segmentsTotal = segmentsBefore + packed.segments.length;
3902
+ const markersTotal = markersBefore + packed.markers.length;
3903
+ if (packed.truncated || markersTotal > this.markerCapacity) return false;
3904
+ const firstNodeSlot = this.visibleNodeCount;
3905
+ const targets = this.nodeInstanceTargets();
3906
+ for (let index = previous.nodes; index < total.nodes; index += 1) {
3907
+ const materialized = this.plainMaterializedNode(index);
3908
+ const node = this.data.nodes[index];
3909
+ if (!materialized || !node) continue;
3910
+ const slot = firstNodeSlot + (index - previous.nodes);
3911
+ this.visibleNodeSlots.set(index, slot);
3912
+ this.visibleNodeIndices.push(index);
3913
+ this.writeNodeInstance(targets, slot, materialized, node);
3914
+ }
3915
+ const addedNodeCount = total.nodes - previous.nodes;
3916
+ nodeMesh.count = total.nodes;
3917
+ nodePickMesh.count = total.nodes;
3918
+ nodeMesh.instanceMatrix.addUpdateRange(firstNodeSlot * 16, addedNodeCount * 16);
3919
+ nodeMesh.instanceMatrix.needsUpdate = true;
3920
+ nodePickMesh.instanceMatrix.addUpdateRange(firstNodeSlot * 16, addedNodeCount * 16);
3921
+ nodePickMesh.instanceMatrix.needsUpdate = true;
3922
+ if (nodeMesh.instanceColor) {
3923
+ nodeMesh.instanceColor.addUpdateRange(firstNodeSlot * 3, addedNodeCount * 3);
3924
+ nodeMesh.instanceColor.needsUpdate = true;
3925
+ }
3926
+ targets.shape.addUpdateRange(firstNodeSlot, addedNodeCount);
3927
+ targets.shape.needsUpdate = true;
3928
+ targets.strokeWidth.addUpdateRange(firstNodeSlot, addedNodeCount);
3929
+ targets.strokeWidth.needsUpdate = true;
3930
+ targets.strokeColor.addUpdateRange(firstNodeSlot * 3, addedNodeCount * 3);
3931
+ targets.strokeColor.needsUpdate = true;
3932
+ const edgeGeometry = edgeMesh.geometry;
3933
+ if (packed.segments.length > 0 && markersBefore > 0) for (const name of EDGE_INSTANCE_ATTRIBUTES) {
3934
+ const attribute = getInstancedAttribute(edgeGeometry, name);
3935
+ attribute.array.copyWithin((segmentsBefore + packed.segments.length) * attribute.itemSize, segmentsBefore * attribute.itemSize, (segmentsBefore + markersBefore) * attribute.itemSize);
3936
+ }
3937
+ appendVisibleEdgeLayouts(this.visibleEdgeLayouts, packed.segments, packed.markers, {
3938
+ markerStart: segmentsTotal + markersBefore,
3939
+ segmentStart: segmentsBefore,
3940
+ segmentTotal: segmentsTotal
3941
+ });
3942
+ const addedEdgeCount = this.writeEdgeInstances(packed, {
3943
+ markerStart: segmentsTotal + markersBefore,
3944
+ segmentStart: segmentsBefore
3945
+ }, this.worldUnitsPerPixel());
3946
+ edgeMesh.count = segmentsTotal + markersTotal;
3947
+ for (const name of EDGE_INSTANCE_ATTRIBUTES) {
3948
+ const attribute = getInstancedAttribute(edgeGeometry, name);
3949
+ attribute.addUpdateRange(segmentsBefore * attribute.itemSize, (segmentsTotal + markersTotal - segmentsBefore) * attribute.itemSize);
3950
+ attribute.needsUpdate = true;
3951
+ }
3952
+ this.visibleNodeCandidateCount = total.nodes;
3953
+ this.visibleNodeCount = total.nodes;
3954
+ this.visibleEdgeCandidateCount = total.edges;
3955
+ this.visibleEdgeSegmentCount = segmentsTotal;
3956
+ this.visibleEdgeCount += addedEdgeCount;
3957
+ this.visibleEdgeMarkerCount = markersTotal;
3958
+ return true;
3959
+ }
3588
3960
  replaceData(data, fitView) {
3961
+ this.nodeDrag.cancel({ restore: false });
3589
3962
  const compiled = compileGraph(data, this.visuals);
3590
3963
  this.disposeGraphObjects();
3591
3964
  this.data = {
@@ -3771,13 +4144,8 @@ var Graphraum = class {
3771
4144
  layouts: this.visibleEdgeLayouts,
3772
4145
  minHitSlop,
3773
4146
  mode: this.mode,
3774
- nodeOutlines: this.data.nodes.map((node) => ({
3775
- height: node.height,
3776
- shape: node.shape,
3777
- size: node.size,
3778
- strokeWidth: node.strokeWidth,
3779
- width: node.width
3780
- })),
4147
+ nodeOutlines: this.data.nodes,
4148
+ pickable: this.camera instanceof OrthographicCamera,
3781
4149
  tier: this.lastEdgeLodTier,
3782
4150
  worldPerPixel
3783
4151
  }, this.pickableEdgeSegments);
@@ -3903,6 +4271,7 @@ var Graphraum = class {
3903
4271
  setMode(mode) {
3904
4272
  if (mode === this.mode) return;
3905
4273
  this.cancelFlight();
4274
+ this.nodeDrag.cancel({ restore: true });
3906
4275
  this.stopAutoOrbit();
3907
4276
  this.mode = mode;
3908
4277
  this.controls.removeEventListener("change", this.handleViewChange);
@@ -3958,12 +4327,17 @@ var Graphraum = class {
3958
4327
  if (this.depthOfField.enabled && !previous.enabled) this.depthOfFieldFocus.retarget(0, resolveEasing(easing));
3959
4328
  const fadeMs = animate && !prefersReducedMotion() ? durationMs : 0;
3960
4329
  const done = this.depthOfFieldStrength.retarget(this.depthOfFieldTargetStrength(), fadeMs, resolveEasing(easing));
4330
+ this.depthOfFieldNearStrength.retarget(this.depthOfFieldTargetNearStrength(), fadeMs, resolveEasing(easing));
3961
4331
  this.requestRender();
3962
4332
  return done;
3963
4333
  }
3964
4334
  depthOfFieldTargetStrength() {
3965
4335
  return this.depthOfField.enabled ? this.depthOfField.strength : 0;
3966
4336
  }
4337
+ depthOfFieldTargetNearStrength() {
4338
+ const { enabled, nearStrength, strength } = this.depthOfField;
4339
+ return enabled ? nearStrength ?? strength : 0;
4340
+ }
3967
4341
  getNodePresentation(nodeId) {
3968
4342
  return this.nodePresentations.get(nodeId) ?? null;
3969
4343
  }
@@ -4030,6 +4404,51 @@ var Graphraum = class {
4030
4404
  listener();
4031
4405
  return () => this.viewListeners.delete(listener);
4032
4406
  }
4407
+ /**
4408
+ * Turns node dragging on or off (default off). A drag starts when the pointer moves past a small
4409
+ * tolerance after a press on a node (a finger must rest on the node first, so a swipe still pans
4410
+ * the camera), moves the node on a plane facing the camera, and ends on release. Escape or a
4411
+ * cancelled pointer put the node back. Not available while the density LOD shows clusters.
4412
+ */
4413
+ setNodeDrag(options) {
4414
+ if (typeof options?.enabled !== "boolean") throw new Error("setNodeDrag option enabled must be a boolean.");
4415
+ this.nodeDrag.setEnabled(options.enabled);
4416
+ }
4417
+ /** Subscribes to node drag steps (`start`, `move`, `end`, `cancel`). Returns an unsubscribe function. */
4418
+ onNodeDrag(listener) {
4419
+ this.nodeDragListeners.add(listener);
4420
+ return () => this.nodeDragListeners.delete(listener);
4421
+ }
4422
+ createNodeDragHost() {
4423
+ const element = this.renderer.domElement;
4424
+ return {
4425
+ canStart: () => !this.destroyed && this.nodeMesh !== null && !this.densityLodActive,
4426
+ createSurface: (anchor) => createNodeDragSurface({
4427
+ anchor,
4428
+ bounds: () => element.getBoundingClientRect(),
4429
+ camera: this.camera,
4430
+ raycaster: this.raycaster
4431
+ }),
4432
+ document: element.ownerDocument,
4433
+ element,
4434
+ emit: (event) => {
4435
+ for (const listener of this.nodeDragListeners) listener(event);
4436
+ },
4437
+ moveNode: (nodeId, position) => this.updateNodes([{
4438
+ id: nodeId,
4439
+ position
4440
+ }]),
4441
+ nodePosition: (nodeId) => {
4442
+ const index = this.nodeIndices.get(nodeId);
4443
+ const node = index === void 0 ? void 0 : this.data.nodes[index];
4444
+ return node ? { ...node.position } : null;
4445
+ },
4446
+ pickNode: (clientX, clientY) => this.pickNode(clientX, clientY),
4447
+ setControlsEnabled: (enabled) => {
4448
+ this.controls.enabled = enabled;
4449
+ }
4450
+ };
4451
+ }
4033
4452
  /** Subscribes to selection and hover changes that affect overlay focus chrome. */
4034
4453
  onFocusChange(listener) {
4035
4454
  this.focusListeners.add(listener);
@@ -4040,6 +4459,7 @@ var Graphraum = class {
4040
4459
  for (const listener of this.focusListeners) listener();
4041
4460
  }
4042
4461
  handlePointerMove = (event) => {
4462
+ if (this.nodeDrag.active) return;
4043
4463
  const nodeId = this.pickNode(event.clientX, event.clientY);
4044
4464
  if (nodeId === this.lastHoveredNodeId) return;
4045
4465
  this.lastHoveredNodeId = nodeId;
@@ -4396,6 +4816,7 @@ var Graphraum = class {
4396
4816
  destroy() {
4397
4817
  this.destroyed = true;
4398
4818
  this.cancelFlight();
4819
+ this.nodeDrag.dispose();
4399
4820
  this.stopAutoOrbit();
4400
4821
  this.resizeObserver.disconnect();
4401
4822
  this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
@@ -4407,6 +4828,7 @@ var Graphraum = class {
4407
4828
  if (this.gpuTimer) for (const query of this.gpuTimer.pending) this.gpuTimer.context.deleteQuery(query);
4408
4829
  this.disposeBackgroundTexture();
4409
4830
  this.depthOfFieldStrength.cancel();
4831
+ this.depthOfFieldNearStrength.cancel();
4410
4832
  this.disposeDepthOfFieldPass();
4411
4833
  this.disposeGraphObjects();
4412
4834
  this.renderer.dispose();
@@ -4457,19 +4879,22 @@ var Graphraum = class {
4457
4879
  prepareDepthOfField(nowMs) {
4458
4880
  const options = this.depthOfField;
4459
4881
  const strengthTween = this.depthOfFieldStrength;
4460
- if (!options.enabled && strengthTween.settled) {
4882
+ const nearStrengthTween = this.depthOfFieldNearStrength;
4883
+ if (!options.enabled && strengthTween.settled && nearStrengthTween.settled) {
4461
4884
  this.depthOfFieldStatus = "off";
4462
4885
  this.disposeDepthOfFieldPass();
4463
4886
  return null;
4464
4887
  }
4465
4888
  if (!(this.camera instanceof PerspectiveCamera)) {
4466
4889
  strengthTween.jump();
4890
+ nearStrengthTween.jump();
4467
4891
  this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4468
4892
  this.disposeDepthOfFieldPass();
4469
4893
  return null;
4470
4894
  }
4471
4895
  if (!DepthOfFieldPass.isSupported(this.renderer)) {
4472
4896
  strengthTween.jump();
4897
+ nearStrengthTween.jump();
4473
4898
  this.depthOfFieldStatus = options.enabled ? "unsupported" : "off";
4474
4899
  if (options.enabled && !warnedDepthOfFieldUnsupported) {
4475
4900
  warnedDepthOfFieldUnsupported = true;
@@ -4478,7 +4903,8 @@ var Graphraum = class {
4478
4903
  return null;
4479
4904
  }
4480
4905
  const strength = strengthTween.sample(nowMs);
4481
- if (!strengthTween.settled) this.requestRender();
4906
+ const nearStrength = nearStrengthTween.sample(nowMs);
4907
+ if (!strengthTween.settled || !nearStrengthTween.settled) this.requestRender();
4482
4908
  this.camera.updateMatrixWorld();
4483
4909
  let focusDistance = typeof options.focus === "number" ? options.focus : null;
4484
4910
  let amount = 1;
@@ -4494,9 +4920,11 @@ var Graphraum = class {
4494
4920
  }
4495
4921
  focusDistance = Math.max(focusDistance, this.camera.near);
4496
4922
  const pixelRatio = this.renderer.getPixelRatio();
4497
- const aperturePixels = strength * amount * distanceFade(focusDistance, options.nearDistance, options.farDistance) * 24 * pixelRatio;
4923
+ const pixelsPerStrength = amount * distanceFade(focusDistance, options.nearDistance, options.farDistance) * 24 * pixelRatio;
4924
+ const aperturePixels = strength * pixelsPerStrength;
4925
+ const nearAperturePixels = nearStrength * pixelsPerStrength;
4498
4926
  const maxBlurPixels = options.maxBlur * pixelRatio;
4499
- if (aperturePixels < .25 || maxBlurPixels < .25) {
4927
+ if (Math.max(aperturePixels, nearAperturePixels) < .25 || maxBlurPixels < .25) {
4500
4928
  this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4501
4929
  return null;
4502
4930
  }
@@ -4506,7 +4934,8 @@ var Graphraum = class {
4506
4934
  aperturePixels,
4507
4935
  focusDistance,
4508
4936
  maxBlurPixels,
4509
- minBlurPixels: options.minBlur * pixelRatio
4937
+ minBlurPixels: options.minBlur * pixelRatio,
4938
+ nearAperturePixels
4510
4939
  };
4511
4940
  }
4512
4941
  /** Mean world position of the selected nodes, read every frame so a running layout keeps them in focus. */
@@ -4540,6 +4969,7 @@ var Graphraum = class {
4540
4969
  * without a loop.
4541
4970
  */
4542
4971
  stepAutoOrbit = (deltaSeconds) => {
4972
+ if (this.nodeDrag.active) return;
4543
4973
  try {
4544
4974
  this.controls.autoRotate = true;
4545
4975
  this.orbitUpdating = true;
@@ -4631,7 +5061,7 @@ var Graphraum = class {
4631
5061
  return null;
4632
5062
  }
4633
5063
  pickEdge(clientX, clientY) {
4634
- if (!this.edgeMesh || this.pickableEdgeSegments.length === 0) return null;
5064
+ if (!this.edgeMesh || this.visibleEdgeSegmentCount === 0) return null;
4635
5065
  const bounds = this.renderer.domElement.getBoundingClientRect();
4636
5066
  if (bounds.width === 0 || bounds.height === 0) return null;
4637
5067
  this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
@@ -4799,35 +5229,20 @@ var Graphraum = class {
4799
5229
  const visibleNodeIndices = materializedNodes.map(({ index }) => index);
4800
5230
  const edgeCandidates = bounds ? collectIncidentEdges(visibleNodeIndices, this.incidentEdgeIndices) : this.data.edges.map((_, index) => index);
4801
5231
  const visibleEdgeIndices = applyEdgeBudget(edgeCandidates, this.maxVisibleEdges);
4802
- const matrix = new Matrix4();
4803
5232
  this.visibleNodeSlots = /* @__PURE__ */ new Map();
4804
5233
  this.visibleNodeIndices = visibleNodeIndices;
4805
5234
  this.nodeMesh.instanceMatrix.clearUpdateRanges();
4806
5235
  this.nodePickMesh.instanceMatrix.clearUpdateRanges();
4807
5236
  this.nodeMesh.instanceColor?.clearUpdateRanges();
4808
- const nodeShape = this.nodeMesh.geometry.getAttribute("instanceShape");
4809
- const nodeStrokeWidth = this.nodeMesh.geometry.getAttribute("instanceStrokeWidth");
4810
- const nodeStrokeColor = this.nodeMesh.geometry.getAttribute("instanceStrokeColor");
4811
- nodeShape.clearUpdateRanges();
4812
- nodeStrokeWidth.clearUpdateRanges();
4813
- nodeStrokeColor.clearUpdateRanges();
5237
+ const targets = this.nodeInstanceTargets();
5238
+ targets.shape.clearUpdateRanges();
5239
+ targets.strokeWidth.clearUpdateRanges();
5240
+ targets.strokeColor.clearUpdateRanges();
4814
5241
  for (const [slot, materialized] of materializedNodes.entries()) {
4815
- const nodeIndex = materialized.index;
4816
- const node = this.data.nodes[nodeIndex];
5242
+ const node = this.data.nodes[materialized.index];
4817
5243
  if (!node) continue;
4818
- this.visibleNodeSlots.set(nodeIndex, slot);
4819
- const { height, width } = materialized;
4820
- const maxAxis = Math.max(width, height);
4821
- matrix.makeScale(width, height, maxAxis);
4822
- matrix.setPosition(materialized.x, materialized.y, materialized.z);
4823
- this.nodeMesh.setMatrixAt(slot, matrix);
4824
- const pickScale = maxAxis * Math.SQRT2;
4825
- matrix.makeScale(pickScale, pickScale, pickScale);
4826
- matrix.setPosition(materialized.x, materialized.y, materialized.z);
4827
- this.nodePickMesh.setMatrixAt(slot, matrix);
4828
- this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
4829
- setNodeShapeAt(nodeShape, slot, node.shape);
4830
- setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, slot, resolveNodeStroke(node, this.theme));
5244
+ this.visibleNodeSlots.set(materialized.index, slot);
5245
+ this.writeNodeInstance(targets, slot, materialized, node);
4831
5246
  }
4832
5247
  this.nodeMesh.count = materializedNodes.length;
4833
5248
  this.nodePickMesh.count = materializedNodes.length;
@@ -4839,12 +5254,12 @@ var Graphraum = class {
4839
5254
  this.nodeMesh.instanceColor.addUpdateRange(0, materializedNodes.length * 3);
4840
5255
  this.nodeMesh.instanceColor.needsUpdate = true;
4841
5256
  }
4842
- nodeShape.addUpdateRange(0, materializedNodes.length);
4843
- nodeShape.needsUpdate = true;
4844
- nodeStrokeWidth.addUpdateRange(0, materializedNodes.length);
4845
- nodeStrokeWidth.needsUpdate = true;
4846
- nodeStrokeColor.addUpdateRange(0, materializedNodes.length * 3);
4847
- nodeStrokeColor.needsUpdate = true;
5257
+ targets.shape.addUpdateRange(0, materializedNodes.length);
5258
+ targets.shape.needsUpdate = true;
5259
+ targets.strokeWidth.addUpdateRange(0, materializedNodes.length);
5260
+ targets.strokeWidth.needsUpdate = true;
5261
+ targets.strokeColor.addUpdateRange(0, materializedNodes.length * 3);
5262
+ targets.strokeColor.needsUpdate = true;
4848
5263
  const lodLevel = resolveLodLevel(this.densityLodActive, edgeCandidates.length, visibleEdgeIndices.length);
4849
5264
  this.lastEdgeLodTier = edgeTierFromDiagnosticsLod(lodLevel);
4850
5265
  const worldPerPixel = this.worldUnitsPerPixel();
@@ -4863,42 +5278,19 @@ var Graphraum = class {
4863
5278
  endpointAttach: this.theme.endpointAttach,
4864
5279
  edgeNodeIndices: this.edgeNodeIndices,
4865
5280
  mode: this.mode,
4866
- nodeOutlines: this.data.nodes.map((node) => ({
4867
- height: node.height,
4868
- shape: node.shape,
4869
- size: node.size,
4870
- strokeWidth: node.strokeWidth,
4871
- width: node.width
4872
- }))
5281
+ nodeOutlines: this.data.nodes
4873
5282
  });
4874
5283
  const edgeGeometry = this.edgeMesh.geometry;
4875
5284
  this.visibleEdgeSlots = /* @__PURE__ */ new Map();
4876
5285
  this.visibleEdgeLayouts = buildVisibleEdgeLayouts(packed.segments, packed.markers, packed.segments.length);
4877
5286
  this.pickableEdgeSegments = [];
4878
5287
  this.visibleEdgeSegmentEdgeIndices = [];
4879
- let edgeSlot = 0;
4880
- const distinctEdges = /* @__PURE__ */ new Set();
4881
- const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
4882
- for (const segment of packed.segments) {
4883
- writeEdgeSegmentInstance(edgeGeometry, edgeSlot, segment);
4884
- if (!this.visibleEdgeSlots.has(segment.edgeIndex)) this.visibleEdgeSlots.set(segment.edgeIndex, edgeSlot);
4885
- distinctEdges.add(segment.edgeIndex);
4886
- this.visibleEdgeSegmentEdgeIndices.push(segment.edgeIndex);
4887
- this.pickableEdgeSegments.push({
4888
- edgeIndex: segment.edgeIndex,
4889
- hitSlop: Math.max(minHitSlop, segment.width * worldPerPixel / 2 + 2 * worldPerPixel),
4890
- x1: segment.x1,
4891
- y1: segment.y1,
4892
- x2: segment.x2,
4893
- y2: segment.y2
4894
- });
4895
- edgeSlot += 1;
4896
- }
4897
- const segmentCount = edgeSlot;
4898
- for (const marker of packed.markers) {
4899
- writeEdgeMarkerInstance(edgeGeometry, edgeSlot, marker);
4900
- edgeSlot += 1;
4901
- }
5288
+ const distinctEdgeCount = this.writeEdgeInstances(packed, {
5289
+ markerStart: packed.segments.length,
5290
+ segmentStart: 0
5291
+ }, worldPerPixel);
5292
+ const segmentCount = packed.segments.length;
5293
+ const edgeSlot = segmentCount + packed.markers.length;
4902
5294
  this.edgeMesh.count = edgeSlot;
4903
5295
  for (const name of EDGE_INSTANCE_ATTRIBUTES) {
4904
5296
  const attribute = edgeGeometry.getAttribute(name);
@@ -4910,28 +5302,85 @@ var Graphraum = class {
4910
5302
  this.visibleNodeCount = materializedNodes.length;
4911
5303
  this.visibleEdgeCandidateCount = edgeCandidates.length;
4912
5304
  this.visibleEdgeSegmentCount = segmentCount;
4913
- this.visibleEdgeCount = distinctEdges.size;
5305
+ this.visibleEdgeCount = distinctEdgeCount;
4914
5306
  this.visibleEdgeMarkerCount = packed.markers.length;
4915
5307
  this.syncGlow();
4916
5308
  }
5309
+ /** A node drawn at its own position and size, which is every node outside the density tier. */
5310
+ plainMaterializedNode(index) {
5311
+ const node = this.data.nodes[index];
5312
+ if (!node) return null;
5313
+ const { height, width } = resolveNodeAxes({
5314
+ height: node.height,
5315
+ nodeId: node.id,
5316
+ size: node.size,
5317
+ width: node.width
5318
+ });
5319
+ return {
5320
+ height,
5321
+ index,
5322
+ width,
5323
+ x: node.position.x,
5324
+ y: node.position.y,
5325
+ z: node.position.z ?? 0
5326
+ };
5327
+ }
5328
+ nodeInstanceTargets() {
5329
+ const geometry = this.nodeMesh.geometry;
5330
+ return {
5331
+ matrix: new Matrix4(),
5332
+ shape: geometry.getAttribute("instanceShape"),
5333
+ strokeColor: geometry.getAttribute("instanceStrokeColor"),
5334
+ strokeWidth: geometry.getAttribute("instanceStrokeWidth")
5335
+ };
5336
+ }
5337
+ /** Writes one node's matrix, pick matrix, color, shape and stroke into `slot`. */
5338
+ writeNodeInstance({ matrix, shape, strokeColor, strokeWidth }, slot, materialized, node) {
5339
+ const { height, width } = materialized;
5340
+ const maxAxis = Math.max(width, height);
5341
+ matrix.makeScale(width, height, maxAxis);
5342
+ matrix.setPosition(materialized.x, materialized.y, materialized.z);
5343
+ this.nodeMesh.setMatrixAt(slot, matrix);
5344
+ const pickScale = maxAxis * Math.SQRT2;
5345
+ matrix.makeScale(pickScale, pickScale, pickScale);
5346
+ matrix.setPosition(materialized.x, materialized.y, materialized.z);
5347
+ this.nodePickMesh.setMatrixAt(slot, matrix);
5348
+ this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
5349
+ setNodeShapeAt(shape, slot, node.shape);
5350
+ setNodeStrokeAt(strokeWidth, strokeColor, slot, resolveNodeStroke(node, this.theme));
5351
+ }
5352
+ /**
5353
+ * Writes packed edge instances at the given first slots and records what picking needs for them.
5354
+ * Returns how many distinct edges got a segment.
5355
+ */
5356
+ writeEdgeInstances(packed, start, worldPerPixel) {
5357
+ const edgeGeometry = this.edgeMesh.geometry;
5358
+ const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
5359
+ const distinctEdges = /* @__PURE__ */ new Set();
5360
+ const keepPickable = this.camera instanceof OrthographicCamera;
5361
+ for (const [offset, segment] of packed.segments.entries()) {
5362
+ const slot = start.segmentStart + offset;
5363
+ writeEdgeSegmentInstance(edgeGeometry, slot, segment);
5364
+ if (!this.visibleEdgeSlots.has(segment.edgeIndex)) this.visibleEdgeSlots.set(segment.edgeIndex, slot);
5365
+ distinctEdges.add(segment.edgeIndex);
5366
+ this.visibleEdgeSegmentEdgeIndices.push(segment.edgeIndex);
5367
+ if (!keepPickable) continue;
5368
+ this.pickableEdgeSegments.push({
5369
+ edgeIndex: segment.edgeIndex,
5370
+ hitSlop: Math.max(minHitSlop, segment.width * worldPerPixel / 2 + 2 * worldPerPixel),
5371
+ x1: segment.x1,
5372
+ y1: segment.y1,
5373
+ x2: segment.x2,
5374
+ y2: segment.y2
5375
+ });
5376
+ }
5377
+ for (const [offset, marker] of packed.markers.entries()) writeEdgeMarkerInstance(edgeGeometry, start.markerStart + offset, marker);
5378
+ return distinctEdges.size;
5379
+ }
4917
5380
  materializeNodes(indices, bounds) {
4918
5381
  if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
4919
- const node = this.data.nodes[index];
4920
- if (!node) return [];
4921
- const { height, width } = resolveNodeAxes({
4922
- height: node.height,
4923
- nodeId: node.id,
4924
- size: node.size,
4925
- width: node.width
4926
- });
4927
- return [{
4928
- height,
4929
- index,
4930
- width,
4931
- x: node.position.x,
4932
- y: node.position.y,
4933
- z: node.position.z ?? 0
4934
- }];
5382
+ const materialized = this.plainMaterializedNode(index);
5383
+ return materialized ? [materialized] : [];
4935
5384
  });
4936
5385
  const aspect = Math.max(this.container.clientWidth, 1) / Math.max(this.container.clientHeight, 1);
4937
5386
  const columns = Math.max(1, Math.floor(Math.sqrt(this.maxVisibleNodes * aspect)));
@@ -5033,6 +5482,13 @@ function setOrthographicFrustum(camera, aspect, visibleHeight) {
5033
5482
  function hasNodeGlow(node) {
5034
5483
  return (node.glow ?? 0) > 0;
5035
5484
  }
5485
+ /** `array`, or a copy with room for at least `length` entries, doubling so repeated appends stay cheap. */
5486
+ function grown(array, length) {
5487
+ if (array.length >= length) return array;
5488
+ const next = new array.constructor(nextCapacity(length));
5489
+ next.set(array);
5490
+ return next;
5491
+ }
5036
5492
  function nextCapacity(length) {
5037
5493
  let capacity = 1;
5038
5494
  while (capacity < length) capacity *= 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yggy-tech/graphraum",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "yggy's WebGL engine for interactive graphs in 2D and 3D",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",