@yggy-tech/graphraum 0.35.0 → 0.36.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,32 @@ interface GraphraumSetDataOptions {
80
80
  */
81
81
  fit?: boolean;
82
82
  }
83
+ interface GraphraumCameraPoint3d {
84
+ x: number;
85
+ y: number;
86
+ z: number;
87
+ }
88
+ /**
89
+ * A 2D view: where the camera looks and how much of the world it shows from top to bottom. The
90
+ * visible width follows the canvas aspect, so a state read on one canvas size fits another.
91
+ */
92
+ interface GraphraumCameraState2d {
93
+ center: {
94
+ x: number;
95
+ y: number;
96
+ };
97
+ mode: "2d";
98
+ /** World units from the top to the bottom edge of the canvas, with the current zoom applied. */
99
+ visibleHeight: number;
100
+ }
101
+ /** A 3D view: the camera position and the point it orbits and looks at. */
102
+ interface GraphraumCameraState3d {
103
+ mode: "3d";
104
+ position: GraphraumCameraPoint3d;
105
+ target: GraphraumCameraPoint3d;
106
+ }
107
+ /** The camera pose as plain JSON, for saving a view and restoring it with `setCameraState()`. */
108
+ type GraphraumCameraState = GraphraumCameraState2d | GraphraumCameraState3d;
83
109
  interface GraphraumDataPatch<NodeAttributes = undefined, EdgeAttributes = undefined> {
84
110
  addedNodes?: readonly GraphraumNode<NodeAttributes>[];
85
111
  addedEdges?: readonly GraphraumEdge<EdgeAttributes>[];
@@ -240,8 +266,60 @@ type GraphraumPickHit = {
240
266
  kind: "node";
241
267
  id: string;
242
268
  };
269
+ /** `linear`, cubic `easeIn` / `easeOut` / `easeInOut`, or a function from progress 0..1 to eased 0..1. */
270
+ type GraphraumEasing = "easeIn" | "easeInOut" | "easeOut" | "linear" | ((t: number) => number);
271
+ /** How depth of field animates focus moves and blur fades. */
272
+ interface GraphraumDepthOfFieldTransition {
273
+ /** Default 400; 0 jumps. `prefers-reduced-motion: reduce` always jumps. */
274
+ durationMs?: number;
275
+ /** Default `"easeInOut"`. */
276
+ easing?: GraphraumEasing;
277
+ }
278
+ /**
279
+ * Opt-in 3D depth of field. The focal plane follows the selection, so selected nodes stay sharp and
280
+ * the rest of the graph blurs with its distance from them, in front and behind, like a thin lens:
281
+ * the closer the camera is to the focus, the shallower the depth of field. A no-op in 2D. See
282
+ * `Graphraum.setDepthOfField`.
283
+ */
284
+ interface GraphraumDepthOfFieldOptions {
285
+ /** Default `false`. While disabled no extra passes or render targets exist. Toggling fades the blur. */
286
+ enabled?: boolean;
287
+ /**
288
+ * `"selection"` (default) focuses the mean position of the selected nodes and fades the blur out
289
+ * when nothing is selected. A number fixes the focal plane at that camera distance in world units.
290
+ */
291
+ focus?: "selection" | number;
292
+ /**
293
+ * Aperture, 0..1, default 0.5. A surface blurs by `strength * 24 * |d - f| / d` CSS pixels, where
294
+ * `d` is its camera distance and `f` the focal distance, clamped to `maxBlur`.
295
+ */
296
+ strength?: number;
297
+ /** Largest blur radius in CSS pixels. Default 12. */
298
+ maxBlur?: number;
299
+ /** Blur radii below this many CSS pixels render sharp, which widens the in-focus band. Default 0. */
300
+ minBlur?: number;
301
+ /** Camera-to-focus distance in world units up to which `strength` applies fully. Default 0. */
302
+ nearDistance?: number;
303
+ /** Camera-to-focus distance in world units at which the blur has faded out. Default `Infinity` (no fade). */
304
+ farDistance?: number;
305
+ /** Animation for focus moves between selections and for blur fades. */
306
+ transition?: GraphraumDepthOfFieldTransition;
307
+ }
308
+ /** Options for one `Graphraum.setDepthOfField` call. */
309
+ interface GraphraumDepthOfFieldUpdate {
310
+ /** Default `true`: fade the blur to the new strength over the transition. `false` jumps. */
311
+ animate?: boolean;
312
+ }
313
+ /**
314
+ * `off`: disabled. `inactive`: enabled with nothing to blur (2D mode, or no selection with
315
+ * `focus: "selection"`), so frames render directly. `active`: the last frame ran the blur passes.
316
+ * `unsupported`: enabled, but the WebGL context cannot run it, so frames render directly.
317
+ */
318
+ type GraphraumDepthOfFieldStatus = "active" | "inactive" | "off" | "unsupported";
243
319
  interface GraphraumOptions<NodeAttributes = undefined, EdgeAttributes = undefined> {
244
320
  antialias?: boolean;
321
+ /** Opt-in 3D depth of field; off by default. Change it later with `setDepthOfField`. */
322
+ depthOfField?: GraphraumDepthOfFieldOptions;
245
323
  maxVisibleEdges?: number;
246
324
  maxVisibleNodes?: number;
247
325
  maxPixelRatio?: number;
@@ -274,6 +352,7 @@ interface GraphraumDiagnostics {
274
352
  aggregatedNodeClusters: number;
275
353
  autoOrbit: GraphraumAutoOrbitStatus;
276
354
  cpuFrameMilliseconds: number;
355
+ depthOfField: GraphraumDepthOfFieldStatus;
277
356
  gpuFrameMilliseconds: number | null;
278
357
  gpuDrawCalls: number;
279
358
  gpuGeometries: number;
@@ -532,12 +611,21 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
532
611
  /** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
533
612
  private orbitUpdating;
534
613
  private destroyed;
614
+ private canvasWidth;
615
+ private canvasHeight;
535
616
  private cpuFrameMilliseconds;
536
617
  private gpuFrameMilliseconds;
537
618
  private gpuTimer;
538
619
  private readonly viewListeners;
539
620
  private readonly focusListeners;
540
621
  private lastHoveredNodeId;
622
+ private readonly antialias;
623
+ private depthOfField;
624
+ private readonly depthOfFieldFocus;
625
+ /** Strength actually applied: fades toward `strength` while enabled and toward 0 when disabled. */
626
+ private readonly depthOfFieldStrength;
627
+ private depthOfFieldPass;
628
+ private depthOfFieldStatus;
541
629
  constructor(container: HTMLElement, options?: GraphraumOptions<NodeAttributes, EdgeAttributes>);
542
630
  /** Replaces the whole graph. Fits the camera unless `options.fit` is `false`. */
543
631
  setData(data: GraphraumData<NodeAttributes, EdgeAttributes>, options?: GraphraumSetDataOptions): void;
@@ -596,6 +684,19 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
596
684
  * before enabling it. Throws in 2D mode; `setMode("2d")` turns it off. Does nothing after `destroy()`.
597
685
  */
598
686
  setAutoOrbit(options: GraphraumAutoOrbitOptions | false): void;
687
+ /**
688
+ * Updates 3D depth of field; omitted fields keep their current values. Enabling, disabling and a
689
+ * new `strength` fade the blur over `transition` unless `update.animate` is `false`; a change
690
+ * during a fade continues from the blur shown last. With `focus: "selection"` the focal plane
691
+ * follows the mean position of the selected nodes, re-measured from the camera every frame, and
692
+ * moves over `transition` when the selection changes; with nothing selected the blur fades out.
693
+ * `prefers-reduced-motion: reduce` jumps. Resolves `true` when the blur reaches the new strength
694
+ * and `false` when a later call or `destroy()` interrupts the fade. While it blurs each frame adds
695
+ * three draw calls; disabled, or with nothing to blur, it costs nothing. A no-op in 2D. Picking
696
+ * and DOM overlays are unaffected. Throws for invalid values.
697
+ */
698
+ setDepthOfField(options: GraphraumDepthOfFieldOptions, update?: GraphraumDepthOfFieldUpdate): Promise<boolean>;
699
+ private depthOfFieldTargetStrength;
599
700
  getNodePresentation(nodeId: string): CompiledGraphraumPresentation | null;
600
701
  /** Returns canvas-relative screen coordinates for a node, or null when the node does not exist. */
601
702
  getNodeScreenPosition(nodeId: string): GraphraumScreenPosition | null;
@@ -644,6 +745,20 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
644
745
  * reduce` or a zero duration it jumps. Throws for an invalid duration or distance scale.
645
746
  */
646
747
  flyTo(options: GraphraumFlyToOptions): Promise<boolean>;
748
+ /**
749
+ * The camera pose as plain JSON. 2D reports the centre and the world height the canvas shows; 3D
750
+ * reports the camera position and the point it orbits. Pass it to `setCameraState()` to return
751
+ * to the same view, for example after reloading a saved one.
752
+ */
753
+ getCameraState(): GraphraumCameraState;
754
+ /**
755
+ * Puts the camera on a pose from `getCameraState()` at once, without fitting or animating, and
756
+ * cancels a running `flyTo`. Throws for a state of the other mode (switch with `setMode()` first)
757
+ * or with a non-finite number. 2D keeps the saved height and takes the width from the canvas, so a
758
+ * view saved on one canvas size fits another. 3D derives the clip planes from the pose so the whole
759
+ * graph stays in view. Apply it after the canvas has its size: a 2D resize to a new size refits.
760
+ */
761
+ setCameraState(state: GraphraumCameraState): void;
647
762
  /** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
648
763
  private nodesWithIds;
649
764
  /** The closest front-view camera that frames `nodes` (issue #124); applies the container aspect. */
@@ -673,6 +788,14 @@ declare class Graphraum<NodeAttributes = undefined, EdgeAttributes = undefined>
673
788
  /** Renders a pending frame now instead of on the next animation frame. */
674
789
  private flushRender;
675
790
  private renderFrame;
791
+ /**
792
+ * Decides whether this frame blurs and records why in the diagnostics status. Returns null to
793
+ * render directly. Requests another frame while a fade or focus move runs; both end on their own.
794
+ */
795
+ private prepareDepthOfField;
796
+ /** Mean world position of the selected nodes, read every frame so a running layout keeps them in focus. */
797
+ private selectionFocusPoint;
798
+ private disposeDepthOfFieldPass;
676
799
  private stopAutoOrbit;
677
800
  /**
678
801
  * One orbit frame. `autoRotate` is on only for this `update(dt)` call: OrbitControls also calls
@@ -860,4 +983,4 @@ declare function isTransparentGraphraumBackground(background: GraphraumBackgroun
860
983
  /** Defines typed, serializable graph visuals without coupling application data to the render loop. */
861
984
  declare function defineVisuals<NodeAttributes = undefined, EdgeAttributes = undefined>(visuals: GraphraumVisualMapper<NodeAttributes, EdgeAttributes>): GraphraumVisualMapper<NodeAttributes, EdgeAttributes>;
862
985
  //#endregion
863
- 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 GraphraumColor, type GraphraumData, type GraphraumDataPatch, type GraphraumDiagnostics, 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 };
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 };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { AdditiveBlending, Box3, BufferGeometry, CanvasTexture, Color, DepthTexture, Float32BufferAttribute, GLSL3, InstancedBufferAttribute, InstancedMesh, MOUSE, Matrix4, Mesh, MeshBasicMaterial, NoBlending, NormalBlending, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, ShaderMaterial, SphereGeometry, TOUCH, Texture, UnsignedByteType, Vector2, Vector3, WebGLRenderTarget, 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) {
@@ -904,6 +904,63 @@ function interpolateScale(from, to, t) {
904
904
  return from * (to / from) ** t;
905
905
  }
906
906
  //#endregion
907
+ //#region src/camera-state.ts
908
+ /**
909
+ * Checks a camera state before it touches the camera: an object of the graph's mode with finite
910
+ * numbers, a positive visible height in 2D, and a position apart from the target in 3D. The state
911
+ * is usually parsed JSON, so nothing about its shape is taken on trust.
912
+ */
913
+ function validateCameraState(state, mode) {
914
+ if (typeof state !== "object" || state === null) throw new Error("A camera state must be an object returned by getCameraState().");
915
+ const stateMode = state.mode;
916
+ if (stateMode !== "2d" && stateMode !== "3d") throw new Error(`A camera state needs mode "2d" or "3d"; received ${JSON.stringify(stateMode)}.`);
917
+ if (state.mode !== mode) throw new Error(`Cannot apply a ${state.mode} camera state to a graph in ${mode} mode; call setMode("${state.mode}") first.`);
918
+ if (state.mode === "2d") {
919
+ const x = finite(state.center?.x, "center.x");
920
+ const y = finite(state.center?.y, "center.y");
921
+ const visibleHeight = finite(state.visibleHeight, "visibleHeight");
922
+ if (visibleHeight <= 0) throw new Error(`Camera state visibleHeight must be above 0; received ${visibleHeight}.`);
923
+ return {
924
+ center: {
925
+ x,
926
+ y
927
+ },
928
+ mode: "2d",
929
+ visibleHeight
930
+ };
931
+ }
932
+ const position = point3d(state.position, "position");
933
+ const target = point3d(state.target, "target");
934
+ if (position.x === target.x && position.y === target.y && position.z === target.z) throw new Error("Camera state position and target must differ.");
935
+ return {
936
+ mode: "3d",
937
+ position,
938
+ target
939
+ };
940
+ }
941
+ function point3d(value, name) {
942
+ return {
943
+ x: finite(value?.x, `${name}.x`),
944
+ y: finite(value?.y, `${name}.y`),
945
+ z: finite(value?.z, `${name}.z`)
946
+ };
947
+ }
948
+ function finite(value, name) {
949
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`Camera state ${name} must be a finite number; received ${String(value)}.`);
950
+ return value;
951
+ }
952
+ /**
953
+ * Clip planes for a perspective camera `distance` from its target with the graph reaching
954
+ * `graphRadius` around it: the far plane holds the whole graph with the slack a fit leaves for
955
+ * zooming out, and the near plane stays close enough not to cut nodes in front of the camera.
956
+ */
957
+ function perspectiveClipRange(distance, graphRadius) {
958
+ return {
959
+ far: Math.max(distance * 10, (distance + graphRadius) * 1.1),
960
+ near: Math.min(Math.max(distance / 1e4, .1), distance / 2)
961
+ };
962
+ }
963
+ //#endregion
907
964
  //#region src/theme.ts
908
965
  /** Dark canvas: Ink Black, Turf Green, Porcelain. */
909
966
  const graphraumThemeDark = Object.freeze({
@@ -1203,6 +1260,501 @@ function isAppendOnlyDataPatch(patch) {
1203
1260
  return (patch.removedNodeIds?.length ?? 0) === 0 && (patch.removedEdgeIds?.length ?? 0) === 0;
1204
1261
  }
1205
1262
  //#endregion
1263
+ //#region src/depth-of-field.ts
1264
+ const DEPTH_OF_FIELD_DEFAULTS = {
1265
+ enabled: false,
1266
+ farDistance: Number.POSITIVE_INFINITY,
1267
+ focus: "selection",
1268
+ maxBlur: 12,
1269
+ minBlur: 0,
1270
+ nearDistance: 0,
1271
+ strength: .5,
1272
+ transition: {
1273
+ durationMs: 400,
1274
+ easing: "easeInOut"
1275
+ }
1276
+ };
1277
+ const EASINGS = {
1278
+ easeIn: (t) => t * t * t,
1279
+ easeInOut: easeInOutCubic,
1280
+ easeOut: (t) => 1 - (1 - t) ** 3,
1281
+ linear: (t) => t
1282
+ };
1283
+ function resolveEasing(easing) {
1284
+ return typeof easing === "function" ? easing : EASINGS[easing];
1285
+ }
1286
+ function isEasing(value) {
1287
+ return typeof value === "function" || typeof value === "string" && Object.hasOwn(EASINGS, value);
1288
+ }
1289
+ function isNonNegativeNumber(value) {
1290
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
1291
+ }
1292
+ /** Validates `options` and merges them onto `base`. Throws an actionable error for invalid values. */
1293
+ function resolveDepthOfFieldOptions(options, base = DEPTH_OF_FIELD_DEFAULTS) {
1294
+ if (typeof options !== "object" || options === null) throw new Error("Depth of field options must be an object, e.g. { enabled: true, strength: 0.5 }.");
1295
+ if (options.transition !== void 0 && (typeof options.transition !== "object" || options.transition === null)) throw new Error("Depth of field transition must be an object, e.g. { durationMs: 400, easing: \"easeInOut\" }.");
1296
+ const resolved = {
1297
+ enabled: options.enabled ?? base.enabled,
1298
+ farDistance: options.farDistance ?? base.farDistance,
1299
+ focus: options.focus ?? base.focus,
1300
+ maxBlur: options.maxBlur ?? base.maxBlur,
1301
+ minBlur: options.minBlur ?? base.minBlur,
1302
+ nearDistance: options.nearDistance ?? base.nearDistance,
1303
+ strength: options.strength ?? base.strength,
1304
+ transition: {
1305
+ durationMs: options.transition?.durationMs ?? base.transition.durationMs,
1306
+ easing: options.transition?.easing ?? base.transition.easing
1307
+ }
1308
+ };
1309
+ const { enabled, farDistance, focus, maxBlur, minBlur, nearDistance, strength, transition } = resolved;
1310
+ if (typeof enabled !== "boolean") throw new Error(`Depth of field enabled must be a boolean; received ${String(enabled)}.`);
1311
+ if (!isNonNegativeNumber(strength) || strength > 1) throw new Error(`Depth of field strength must be a number from 0 to 1; received ${String(strength)}.`);
1312
+ 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
+ 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
+ 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)}.`);
1315
+ if (!isNonNegativeNumber(transition.durationMs)) throw new Error(`Depth of field transition durationMs must be a non-negative finite number of milliseconds; received ${String(transition.durationMs)}.`);
1316
+ if (!isEasing(transition.easing)) throw new Error(`Depth of field transition easing must be "linear", "easeIn", "easeOut", "easeInOut" or a function; received ${String(transition.easing)}.`);
1317
+ return resolved;
1318
+ }
1319
+ /**
1320
+ * How much of `strength` applies when the focus is `focusDistance` from the camera: 1 up to
1321
+ * `nearDistance`, easing to 0 at `farDistance`, so a zoomed-out overview stays sharp.
1322
+ */
1323
+ function distanceFade(focusDistance, nearDistance, farDistance) {
1324
+ if (focusDistance <= nearDistance) return 1;
1325
+ if (focusDistance >= farDistance) return 0;
1326
+ const t = (focusDistance - nearDistance) / (farDistance - nearDistance);
1327
+ return 1 - t * t * (3 - 2 * t);
1328
+ }
1329
+ /** Distance in front of the camera along its view axis, from a column-major view matrix (`matrixWorldInverse`). */
1330
+ function viewDistance(point, viewMatrix) {
1331
+ const m = (index) => viewMatrix[index] ?? 0;
1332
+ return -(m(2) * point.x + m(6) * point.y + m(10) * point.z + m(14));
1333
+ }
1334
+ /** Mean position of `points`, or null when there are none. */
1335
+ function meanPoint(points) {
1336
+ let count = 0;
1337
+ const sum = {
1338
+ x: 0,
1339
+ y: 0,
1340
+ z: 0
1341
+ };
1342
+ for (const point of points) {
1343
+ count += 1;
1344
+ sum.x += point.x;
1345
+ sum.y += point.y;
1346
+ sum.z += point.z;
1347
+ }
1348
+ return count === 0 ? null : {
1349
+ x: sum.x / count,
1350
+ y: sum.y / count,
1351
+ z: sum.z / count
1352
+ };
1353
+ }
1354
+ /**
1355
+ * A scalar animated toward a target. Retargeting starts from the value shown last, so an interrupted
1356
+ * transition continues smoothly instead of restarting. Each retarget returns a promise that resolves
1357
+ * `true` when the value arrives and `false` when another retarget or `cancel` replaces it.
1358
+ */
1359
+ var Tween = class {
1360
+ from;
1361
+ to;
1362
+ value;
1363
+ startMs = null;
1364
+ durationMs = 0;
1365
+ easing = EASINGS.linear;
1366
+ settle = null;
1367
+ constructor(value) {
1368
+ this.from = value;
1369
+ this.to = value;
1370
+ this.value = value;
1371
+ }
1372
+ get target() {
1373
+ return this.to;
1374
+ }
1375
+ get settled() {
1376
+ return this.settle === null && this.value === this.to;
1377
+ }
1378
+ retarget(to, durationMs, easing) {
1379
+ this.settle?.(false);
1380
+ this.settle = null;
1381
+ this.from = this.value;
1382
+ this.to = to;
1383
+ this.startMs = null;
1384
+ this.durationMs = durationMs;
1385
+ this.easing = easing;
1386
+ if (durationMs === 0 || this.from === to) {
1387
+ this.value = to;
1388
+ return Promise.resolve(true);
1389
+ }
1390
+ return new Promise((resolve) => {
1391
+ this.settle = resolve;
1392
+ });
1393
+ }
1394
+ /** Value at `nowMs`; the first sample after a retarget starts its clock. */
1395
+ sample(nowMs) {
1396
+ if (this.settle === null) return this.value;
1397
+ this.startMs ??= nowMs;
1398
+ const progress = flightProgress(nowMs - this.startMs, this.durationMs);
1399
+ this.value = progress >= 1 ? this.to : this.from + (this.to - this.from) * this.easing(progress);
1400
+ if (progress >= 1) this.finish(true);
1401
+ return this.value;
1402
+ }
1403
+ /** Arrives at the target now, e.g. for reduced motion or when nothing renders the frames. */
1404
+ jump() {
1405
+ this.value = this.to;
1406
+ this.finish(true);
1407
+ }
1408
+ cancel() {
1409
+ this.finish(false);
1410
+ }
1411
+ finish(arrived) {
1412
+ const settle = this.settle;
1413
+ this.settle = null;
1414
+ settle?.(arrived);
1415
+ }
1416
+ };
1417
+ /**
1418
+ * Animates the focus between selections. The focus is a world point rather than a depth, so its
1419
+ * distance is re-measured from the camera every frame and the selected nodes stay sharp while the
1420
+ * camera orbits, pans or zooms; only a selection change animates. With no target the blur fades out
1421
+ * around the last focus point. A retarget mid-transition continues from the focus shown last.
1422
+ */
1423
+ var DepthOfFieldFocus = class {
1424
+ transition = null;
1425
+ lastAmount = 0;
1426
+ lastPoint = null;
1427
+ /** Starts a transition from the focus shown in the last frame, e.g. after a selection change. */
1428
+ retarget(durationMs, easing) {
1429
+ this.transition = {
1430
+ durationMs,
1431
+ easing,
1432
+ fromAmount: this.lastAmount,
1433
+ fromPoint: this.lastAmount > 0 ? this.lastPoint : null,
1434
+ startMs: null
1435
+ };
1436
+ }
1437
+ /** Focus for the frame at `nowMs`. `jump` (reduced motion) skips the animation. */
1438
+ evaluate(nowMs, target, jump) {
1439
+ const transition = this.transition;
1440
+ const toAmount = target ? 1 : 0;
1441
+ let eased = 1;
1442
+ let progress = 1;
1443
+ if (transition && !jump && transition.durationMs > 0) {
1444
+ transition.startMs ??= nowMs;
1445
+ progress = flightProgress(nowMs - transition.startMs, transition.durationMs);
1446
+ eased = progress >= 1 ? 1 : transition.easing(progress);
1447
+ }
1448
+ const fromAmount = transition?.fromAmount ?? toAmount;
1449
+ const fromPoint = transition?.fromPoint ?? null;
1450
+ const amount = fromAmount + (toAmount - fromAmount) * eased;
1451
+ const point = target ? fromPoint ? lerpPoint(fromPoint, target, eased) : target : fromPoint ?? this.lastPoint;
1452
+ const settled = progress >= 1;
1453
+ if (settled) this.transition = null;
1454
+ this.lastAmount = amount;
1455
+ this.lastPoint = point;
1456
+ return {
1457
+ amount,
1458
+ point,
1459
+ settled
1460
+ };
1461
+ }
1462
+ };
1463
+ function lerpPoint(from, to, t) {
1464
+ return {
1465
+ x: from.x + (to.x - from.x) * t,
1466
+ y: from.y + (to.y - from.y) * t,
1467
+ z: from.z + (to.z - from.z) * t
1468
+ };
1469
+ }
1470
+ //#endregion
1471
+ //#region src/shader-output.ts
1472
+ /**
1473
+ * Encodes `gl_FragColor.rgb` from the linear working space into the renderer's output color space:
1474
+ * sRGB on the canvas (so a `#8a9096` theme color draws as `#8a9096`), unchanged on linear render
1475
+ * targets. Alpha is left untouched, so anti-aliased coverage and blending are unaffected.
1476
+ *
1477
+ * Append once to every display fragment shader, after its final `gl_FragColor` assignment.
1478
+ * Requires a `ShaderMaterial` (not `RawShaderMaterial`), whose prefix defines `linearToOutputTexel`.
1479
+ */
1480
+ const OUTPUT_COLOR_SPACE_FRAGMENT = "#include <colorspace_fragment>";
1481
+ //#endregion
1482
+ //#region src/depth-of-field-pass.ts
1483
+ /** Layer the edge mesh joins so the edge depth pass can draw it alone. Picking raycasts layer 0. */
1484
+ const EDGE_DEPTH_LAYER = 1;
1485
+ /** Disk taps around the centre of each half-resolution blur texel. */
1486
+ const BLUR_TAPS = 48;
1487
+ const vertexShader$3 = `
1488
+ varying vec2 vUv;
1489
+
1490
+ void main() {
1491
+ vUv = uv;
1492
+ gl_Position = vec4(position.xy, 0.0, 1.0);
1493
+ }
1494
+ `;
1495
+ const blurRadiusChunk = `
1496
+ #include <packing>
1497
+ uniform sampler2D nodeDepth;
1498
+ uniform sampler2D edgeDepth;
1499
+ uniform ivec2 depthSize;
1500
+ uniform float cameraNear;
1501
+ uniform float cameraFar;
1502
+ uniform float focusDistance;
1503
+ uniform float aperture;
1504
+ uniform float minBlur;
1505
+ uniform float maxBlur;
1506
+
1507
+ float surfaceDistance(ivec2 pixel) {
1508
+ pixel = clamp(pixel, ivec2(0), depthSize - 1);
1509
+ float depth = texelFetch(nodeDepth, pixel, 0).x;
1510
+ if (depth >= 1.0) depth = texelFetch(edgeDepth, pixel, 0).x;
1511
+ return -perspectiveDepthToViewZ(depth, cameraNear, cameraFar);
1512
+ }
1513
+
1514
+ float radiusAt(float surface) {
1515
+ float radius = aperture * abs(surface - focusDistance) / max(surface, 1e-6);
1516
+ return radius < minBlur ? 0.0 : min(radius, maxBlur);
1517
+ }
1518
+
1519
+ float blurRadius(ivec2 pixel) {
1520
+ return radiusAt(surfaceDistance(pixel));
1521
+ }
1522
+ `;
1523
+ const blurFragmentShader = `
1524
+ ${blurRadiusChunk}
1525
+ uniform sampler2D colorTexture;
1526
+ layout(location = 0) out highp vec4 blurColor;
1527
+ layout(location = 1) out highp vec4 nearColor;
1528
+
1529
+ const float GOLDEN_ANGLE = 2.39996323;
1530
+
1531
+ void main() {
1532
+ ivec2 base = ivec2(gl_FragCoord.xy) * 2;
1533
+ float centreSurface = surfaceDistance(base);
1534
+ vec4 sum = texelFetch(colorTexture, base, 0);
1535
+ float total = 1.0;
1536
+ vec4 near = vec4(0.0);
1537
+ // Interleaved gradient noise turns the spiral's fixed tap pattern into fine noise that the
1538
+ // 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))));
1540
+ 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);
1544
+ float surface = surfaceDistance(pixel);
1545
+ float radius = radiusAt(surface);
1546
+ float weight = clamp(radius - distanceFromCentre + 1.0, 0.0, 1.0);
1547
+ vec4 color = texelFetch(colorTexture, pixel, 0);
1548
+ sum += color * weight;
1549
+ total += weight;
1550
+ if (surface < focusDistance && surface < centreSurface) {
1551
+ near += color * weight * (maxBlur * maxBlur / max(radius * radius, 1.0));
1552
+ }
1553
+ }
1554
+ blurColor = sum / total;
1555
+ near /= ${BLUR_TAPS}.0;
1556
+ nearColor = near.a > 1.0 ? near / near.a : near;
1557
+ }
1558
+ `;
1559
+ const compositeFragmentShader = `
1560
+ ${blurRadiusChunk}
1561
+ uniform sampler2D sharpTexture;
1562
+ uniform sampler2D blurTexture;
1563
+ uniform sampler2D nearTexture;
1564
+ varying vec2 vUv;
1565
+
1566
+ void main() {
1567
+ ivec2 pixel = ivec2(gl_FragCoord.xy);
1568
+ vec4 near = texture2D(nearTexture, vUv);
1569
+ vec4 sharp = texelFetch(sharpTexture, pixel, 0);
1570
+ sharp = near + sharp * (1.0 - near.a);
1571
+ vec4 blurred = texture2D(blurTexture, vUv);
1572
+ gl_FragColor = mix(sharp, blurred, smoothstep(0.5, 2.0, blurRadius(pixel)));
1573
+ ${OUTPUT_COLOR_SPACE_FRAGMENT}
1574
+ }
1575
+ `;
1576
+ function blurUniforms() {
1577
+ return {
1578
+ cameraFar: { value: 1 },
1579
+ aperture: { value: 0 },
1580
+ cameraNear: { value: .1 },
1581
+ depthSize: { value: new Vector2() },
1582
+ edgeDepth: { value: null },
1583
+ focusDistance: { value: 1 },
1584
+ maxBlur: { value: 0 },
1585
+ minBlur: { value: 0 },
1586
+ nodeDepth: { value: null }
1587
+ };
1588
+ }
1589
+ function fullscreenMaterial(fragmentShader, uniforms, glslVersion = null) {
1590
+ return new ShaderMaterial({
1591
+ blending: NoBlending,
1592
+ depthTest: false,
1593
+ depthWrite: false,
1594
+ fragmentShader,
1595
+ glslVersion,
1596
+ uniforms,
1597
+ vertexShader: vertexShader$3
1598
+ });
1599
+ }
1600
+ /** One triangle that covers the viewport. */
1601
+ function fullscreenTriangle() {
1602
+ const geometry = new BufferGeometry();
1603
+ geometry.setAttribute("position", new Float32BufferAttribute([
1604
+ -1,
1605
+ -1,
1606
+ 0,
1607
+ 3,
1608
+ -1,
1609
+ 0,
1610
+ -1,
1611
+ 3,
1612
+ 0
1613
+ ], 3));
1614
+ geometry.setAttribute("uv", new Float32BufferAttribute([
1615
+ 0,
1616
+ 0,
1617
+ 2,
1618
+ 0,
1619
+ 0,
1620
+ 2
1621
+ ], 2));
1622
+ return geometry;
1623
+ }
1624
+ /**
1625
+ * Renders the scene into a target, the edges into a depth-only target, blurs at half resolution and
1626
+ * composites onto the canvas: three extra draw calls per frame while it runs.
1627
+ * Created only while depth of field has something to blur; `dispose` releases every GPU resource.
1628
+ */
1629
+ var DepthOfFieldPass = class {
1630
+ sceneTarget;
1631
+ edgeDepthTarget;
1632
+ blurTarget;
1633
+ blurMaterial = fullscreenMaterial(blurFragmentShader, {
1634
+ ...blurUniforms(),
1635
+ colorTexture: { value: null }
1636
+ }, GLSL3);
1637
+ compositeMaterial = fullscreenMaterial(compositeFragmentShader, {
1638
+ ...blurUniforms(),
1639
+ blurTexture: { value: null },
1640
+ nearTexture: { value: null },
1641
+ sharpTexture: { value: null }
1642
+ });
1643
+ quad = new Mesh(fullscreenTriangle(), this.blurMaterial);
1644
+ quadScene = new Scene().add(this.quad);
1645
+ quadCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
1646
+ size = new Vector2();
1647
+ /** Depth of field needs WebGL 2 for depth textures and sRGB render targets. */
1648
+ static isSupported(renderer) {
1649
+ return typeof WebGL2RenderingContext !== "undefined" && renderer.getContext() instanceof WebGL2RenderingContext;
1650
+ }
1651
+ constructor(antialias) {
1652
+ const color = {
1653
+ colorSpace: SRGBColorSpace,
1654
+ type: UnsignedByteType
1655
+ };
1656
+ this.sceneTarget = new WebGLRenderTarget(1, 1, {
1657
+ ...color,
1658
+ depthTexture: new DepthTexture(1, 1),
1659
+ samples: antialias ? 4 : 0
1660
+ });
1661
+ this.edgeDepthTarget = new WebGLRenderTarget(1, 1, { depthTexture: new DepthTexture(1, 1) });
1662
+ this.blurTarget = new WebGLRenderTarget(1, 1, {
1663
+ ...color,
1664
+ count: 2
1665
+ });
1666
+ this.quad.frustumCulled = false;
1667
+ }
1668
+ render(renderer, scene, camera, edgeMesh, frame) {
1669
+ const { x: width, y: height } = renderer.getDrawingBufferSize(this.size);
1670
+ this.setSize(width, height);
1671
+ const previousTarget = renderer.getRenderTarget();
1672
+ renderer.setRenderTarget(this.sceneTarget);
1673
+ renderer.render(scene, camera);
1674
+ this.renderEdgeDepth(renderer, scene, camera, edgeMesh);
1675
+ const shared = {
1676
+ aperture: frame.aperturePixels,
1677
+ cameraFar: camera.far,
1678
+ cameraNear: camera.near,
1679
+ depthSize: this.size,
1680
+ edgeDepth: this.edgeDepthTarget.depthTexture,
1681
+ focusDistance: frame.focusDistance,
1682
+ maxBlur: frame.maxBlurPixels,
1683
+ minBlur: frame.minBlurPixels,
1684
+ nodeDepth: this.sceneTarget.depthTexture
1685
+ };
1686
+ this.quad.material = this.blurMaterial;
1687
+ this.setUniforms(this.blurMaterial, {
1688
+ ...shared,
1689
+ colorTexture: this.sceneTarget.texture
1690
+ });
1691
+ this.drawQuad(renderer, this.blurTarget);
1692
+ this.quad.material = this.compositeMaterial;
1693
+ this.setUniforms(this.compositeMaterial, {
1694
+ ...shared,
1695
+ blurTexture: this.blurTarget.textures[0],
1696
+ nearTexture: this.blurTarget.textures[1],
1697
+ sharpTexture: this.sceneTarget.texture
1698
+ });
1699
+ this.drawQuad(renderer, previousTarget);
1700
+ }
1701
+ dispose() {
1702
+ this.sceneTarget.depthTexture?.dispose();
1703
+ this.sceneTarget.dispose();
1704
+ this.edgeDepthTarget.depthTexture?.dispose();
1705
+ this.edgeDepthTarget.dispose();
1706
+ this.blurTarget.dispose();
1707
+ this.blurMaterial.dispose();
1708
+ this.compositeMaterial.dispose();
1709
+ this.quad.geometry.dispose();
1710
+ }
1711
+ /** Draws the edges alone with depth writes on and colour writes off, then restores their material. */
1712
+ renderEdgeDepth(renderer, scene, camera, edgeMesh) {
1713
+ renderer.setRenderTarget(this.edgeDepthTarget);
1714
+ if (!edgeMesh || edgeMesh.count === 0 || Array.isArray(edgeMesh.material)) {
1715
+ renderer.clear();
1716
+ return;
1717
+ }
1718
+ const material = edgeMesh.material;
1719
+ const { colorWrite, depthTest, depthWrite } = material;
1720
+ const layerMask = camera.layers.mask;
1721
+ const background = scene.background;
1722
+ edgeMesh.layers.enable(EDGE_DEPTH_LAYER);
1723
+ material.colorWrite = false;
1724
+ material.depthTest = true;
1725
+ material.depthWrite = true;
1726
+ camera.layers.set(EDGE_DEPTH_LAYER);
1727
+ scene.background = null;
1728
+ try {
1729
+ renderer.render(scene, camera);
1730
+ } finally {
1731
+ material.colorWrite = colorWrite;
1732
+ material.depthTest = depthTest;
1733
+ material.depthWrite = depthWrite;
1734
+ camera.layers.mask = layerMask;
1735
+ scene.background = background;
1736
+ }
1737
+ }
1738
+ drawQuad(renderer, target) {
1739
+ renderer.setRenderTarget(target);
1740
+ renderer.render(this.quadScene, this.quadCamera);
1741
+ }
1742
+ setUniforms(material, values) {
1743
+ for (const [name, value] of Object.entries(values)) {
1744
+ const uniform = material.uniforms[name];
1745
+ if (uniform) uniform.value = value;
1746
+ }
1747
+ }
1748
+ setSize(width, height) {
1749
+ if (this.sceneTarget.width === width && this.sceneTarget.height === height) return;
1750
+ this.sceneTarget.setSize(width, height);
1751
+ this.edgeDepthTarget.setSize(width, height);
1752
+ const halfWidth = Math.max(1, Math.ceil(width / 2));
1753
+ const halfHeight = Math.max(1, Math.ceil(height / 2));
1754
+ this.blurTarget.setSize(halfWidth, halfHeight);
1755
+ }
1756
+ };
1757
+ //#endregion
1206
1758
  //#region src/edge-paths.ts
1207
1759
  const AUTO_OFFSET_FRACTION = .18;
1208
1760
  function segmentCountForPath(path, tier) {
@@ -1492,17 +2044,6 @@ function pickClosestEdgeIndex(point, segments) {
1492
2044
  return nearestEdgeIndex;
1493
2045
  }
1494
2046
  //#endregion
1495
- //#region src/shader-output.ts
1496
- /**
1497
- * Encodes `gl_FragColor.rgb` from the linear working space into the renderer's output color space:
1498
- * sRGB on the canvas (so a `#8a9096` theme color draws as `#8a9096`), unchanged on linear render
1499
- * targets. Alpha is left untouched, so anti-aliased coverage and blending are unaffected.
1500
- *
1501
- * Append once to every display fragment shader, after its final `gl_FragColor` assignment.
1502
- * Requires a `ShaderMaterial` (not `RawShaderMaterial`), whose prefix defines `linearToOutputTexel`.
1503
- */
1504
- const OUTPUT_COLOR_SPACE_FRAGMENT = "#include <colorspace_fragment>";
1505
- //#endregion
1506
2047
  //#region src/edge-rendering.ts
1507
2048
  const vertexShader$2 = `
1508
2049
  attribute float instanceKind;
@@ -2756,6 +3297,8 @@ function prefersReducedMotion() {
2756
3297
  function disposeMaterial(material) {
2757
3298
  for (const item of Array.isArray(material) ? material : [material]) item.dispose();
2758
3299
  }
3300
+ /** One warning per page when a context cannot run depth of field; diagnostics report it per instance. */
3301
+ let warnedDepthOfFieldUnsupported = false;
2759
3302
  /** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
2760
3303
  const FLIGHT_CANCEL_EVENTS = [
2761
3304
  "keydown",
@@ -2836,12 +3379,21 @@ var Graphraum = class {
2836
3379
  /** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
2837
3380
  orbitUpdating = false;
2838
3381
  destroyed = false;
3382
+ canvasWidth = 0;
3383
+ canvasHeight = 0;
2839
3384
  cpuFrameMilliseconds = 0;
2840
3385
  gpuFrameMilliseconds = null;
2841
3386
  gpuTimer = null;
2842
3387
  viewListeners = /* @__PURE__ */ new Set();
2843
3388
  focusListeners = /* @__PURE__ */ new Set();
2844
3389
  lastHoveredNodeId = null;
3390
+ antialias;
3391
+ depthOfField = DEPTH_OF_FIELD_DEFAULTS;
3392
+ depthOfFieldFocus = new DepthOfFieldFocus();
3393
+ /** Strength actually applied: fades toward `strength` while enabled and toward 0 when disabled. */
3394
+ depthOfFieldStrength = new Tween(0);
3395
+ depthOfFieldPass = null;
3396
+ depthOfFieldStatus = "off";
2845
3397
  constructor(container, options = {}) {
2846
3398
  this.container = container;
2847
3399
  this.mode = options.mode ?? "2d";
@@ -2854,8 +3406,13 @@ var Graphraum = class {
2854
3406
  if (!Number.isSafeInteger(this.maxVisibleNodes) || this.maxVisibleNodes < 1) throw new Error("Maximum visible nodes must be a positive integer.");
2855
3407
  if (!Number.isFinite(this.viewportOverscan) || this.viewportOverscan < 0) throw new Error("Viewport overscan must be a non-negative finite number.");
2856
3408
  this.theme = resolveGraphraumTheme(options.theme);
3409
+ if (options.depthOfField !== void 0) {
3410
+ this.depthOfField = resolveDepthOfFieldOptions(options.depthOfField);
3411
+ this.depthOfFieldStrength.retarget(this.depthOfFieldTargetStrength(), 0, resolveEasing("linear"));
3412
+ }
3413
+ this.antialias = options.antialias ?? false;
2857
3414
  this.renderer = new WebGLRenderer({
2858
- antialias: options.antialias ?? false,
3415
+ antialias: this.antialias,
2859
3416
  alpha: true
2860
3417
  });
2861
3418
  this.applyBackground();
@@ -3312,6 +3869,10 @@ var Graphraum = class {
3312
3869
  const changedNodeIds = new Set([...currentSelection, ...nextSelection].filter((nodeId) => currentSelection.has(nodeId) !== nextSelection.has(nodeId)));
3313
3870
  if (changedNodeIds.size === 0) return;
3314
3871
  this.setNodeStateIds(state, nextSelection);
3872
+ if (state === "selected" && this.depthOfField.enabled) {
3873
+ const { durationMs, easing } = this.depthOfField.transition;
3874
+ this.depthOfFieldFocus.retarget(durationMs, resolveEasing(easing));
3875
+ }
3315
3876
  this.applySelectionColors(changedNodeIds);
3316
3877
  this.requestRender();
3317
3878
  if (state === "selected" || state === "hovered") this.notifyFocusChange();
@@ -3376,6 +3937,33 @@ var Graphraum = class {
3376
3937
  if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
3377
3938
  else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
3378
3939
  }
3940
+ /**
3941
+ * Updates 3D depth of field; omitted fields keep their current values. Enabling, disabling and a
3942
+ * new `strength` fade the blur over `transition` unless `update.animate` is `false`; a change
3943
+ * during a fade continues from the blur shown last. With `focus: "selection"` the focal plane
3944
+ * follows the mean position of the selected nodes, re-measured from the camera every frame, and
3945
+ * moves over `transition` when the selection changes; with nothing selected the blur fades out.
3946
+ * `prefers-reduced-motion: reduce` jumps. Resolves `true` when the blur reaches the new strength
3947
+ * and `false` when a later call or `destroy()` interrupts the fade. While it blurs each frame adds
3948
+ * three draw calls; disabled, or with nothing to blur, it costs nothing. A no-op in 2D. Picking
3949
+ * and DOM overlays are unaffected. Throws for invalid values.
3950
+ */
3951
+ setDepthOfField(options, update = {}) {
3952
+ const animate = update.animate ?? true;
3953
+ if (typeof animate !== "boolean") throw new Error(`setDepthOfField option animate must be a boolean; received ${String(animate)}.`);
3954
+ const previous = this.depthOfField;
3955
+ this.depthOfField = resolveDepthOfFieldOptions(options, previous);
3956
+ if (this.destroyed) return Promise.resolve(false);
3957
+ const { durationMs, easing } = this.depthOfField.transition;
3958
+ if (this.depthOfField.enabled && !previous.enabled) this.depthOfFieldFocus.retarget(0, resolveEasing(easing));
3959
+ const fadeMs = animate && !prefersReducedMotion() ? durationMs : 0;
3960
+ const done = this.depthOfFieldStrength.retarget(this.depthOfFieldTargetStrength(), fadeMs, resolveEasing(easing));
3961
+ this.requestRender();
3962
+ return done;
3963
+ }
3964
+ depthOfFieldTargetStrength() {
3965
+ return this.depthOfField.enabled ? this.depthOfField.strength : 0;
3966
+ }
3379
3967
  getNodePresentation(nodeId) {
3380
3968
  return this.nodePresentations.get(nodeId) ?? null;
3381
3969
  }
@@ -3470,6 +4058,7 @@ var Graphraum = class {
3470
4058
  aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
3471
4059
  autoOrbit: this.autoOrbit?.status ?? "off",
3472
4060
  cpuFrameMilliseconds: this.cpuFrameMilliseconds,
4061
+ depthOfField: this.depthOfFieldStatus,
3473
4062
  gpuFrameMilliseconds: this.gpuFrameMilliseconds,
3474
4063
  gpuDrawCalls: this.renderer.info.render.calls,
3475
4064
  gpuGeometries: this.renderer.info.memory.geometries,
@@ -3578,6 +4167,71 @@ var Graphraum = class {
3578
4167
  this.requestRender();
3579
4168
  });
3580
4169
  }
4170
+ /**
4171
+ * The camera pose as plain JSON. 2D reports the centre and the world height the canvas shows; 3D
4172
+ * reports the camera position and the point it orbits. Pass it to `setCameraState()` to return
4173
+ * to the same view, for example after reloading a saved one.
4174
+ */
4175
+ getCameraState() {
4176
+ const camera = this.camera;
4177
+ if (camera instanceof OrthographicCamera) return {
4178
+ center: {
4179
+ x: camera.position.x,
4180
+ y: camera.position.y
4181
+ },
4182
+ mode: "2d",
4183
+ visibleHeight: (camera.top - camera.bottom) / camera.zoom
4184
+ };
4185
+ const { target } = this.controls;
4186
+ return {
4187
+ mode: "3d",
4188
+ position: {
4189
+ x: camera.position.x,
4190
+ y: camera.position.y,
4191
+ z: camera.position.z
4192
+ },
4193
+ target: {
4194
+ x: target.x,
4195
+ y: target.y,
4196
+ z: target.z
4197
+ }
4198
+ };
4199
+ }
4200
+ /**
4201
+ * Puts the camera on a pose from `getCameraState()` at once, without fitting or animating, and
4202
+ * cancels a running `flyTo`. Throws for a state of the other mode (switch with `setMode()` first)
4203
+ * or with a non-finite number. 2D keeps the saved height and takes the width from the canvas, so a
4204
+ * view saved on one canvas size fits another. 3D derives the clip planes from the pose so the whole
4205
+ * graph stays in view. Apply it after the canvas has its size: a 2D resize to a new size refits.
4206
+ */
4207
+ setCameraState(state) {
4208
+ const next = validateCameraState(state, this.mode);
4209
+ this.cancelFlight();
4210
+ const camera = this.camera;
4211
+ const width = Math.max(this.container.clientWidth, 1);
4212
+ const height = Math.max(this.container.clientHeight, 1);
4213
+ if (next.mode === "2d" && camera instanceof OrthographicCamera) {
4214
+ setOrthographicFrustum(camera, width / height, next.visibleHeight);
4215
+ camera.position.set(next.center.x, next.center.y, camera.position.z);
4216
+ this.controls.target.set(next.center.x, next.center.y, this.controls.target.z);
4217
+ camera.lookAt(this.controls.target);
4218
+ camera.updateProjectionMatrix();
4219
+ this.controls.update();
4220
+ this.materializeViewport();
4221
+ } else if (next.mode === "3d" && camera instanceof PerspectiveCamera) {
4222
+ const target = new Vector3(next.target.x, next.target.y, next.target.z);
4223
+ camera.position.set(next.position.x, next.position.y, next.position.z);
4224
+ this.controls.target.copy(target);
4225
+ camera.lookAt(target);
4226
+ applyPerspectiveContainerAspect(camera, width, height);
4227
+ const clip = perspectiveClipRange(camera.position.distanceTo(target), this.graphRadiusAround(target));
4228
+ camera.near = clip.near;
4229
+ camera.far = clip.far;
4230
+ camera.updateProjectionMatrix();
4231
+ this.controls.update();
4232
+ }
4233
+ this.requestRender();
4234
+ }
3581
4235
  /** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
3582
4236
  nodesWithIds(nodeIds) {
3583
4237
  if (!nodeIds) return this.data.nodes;
@@ -3725,11 +4379,14 @@ var Graphraum = class {
3725
4379
  resize() {
3726
4380
  const width = Math.max(this.container.clientWidth, 1);
3727
4381
  const height = Math.max(this.container.clientHeight, 1);
4382
+ const sizeChanged = this.canvasWidth !== width || this.canvasHeight !== height;
4383
+ this.canvasWidth = width;
4384
+ this.canvasHeight = height;
3728
4385
  this.renderer.setSize(width, height, false);
3729
4386
  if (this.camera instanceof PerspectiveCamera) {
3730
4387
  applyPerspectiveContainerAspect(this.camera, width, height);
3731
4388
  this.camera.updateProjectionMatrix();
3732
- } else if (this.camera instanceof OrthographicCamera) {
4389
+ } else if (this.camera instanceof OrthographicCamera && sizeChanged) {
3733
4390
  this.fitView();
3734
4391
  return;
3735
4392
  }
@@ -3749,6 +4406,8 @@ var Graphraum = class {
3749
4406
  if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
3750
4407
  if (this.gpuTimer) for (const query of this.gpuTimer.pending) this.gpuTimer.context.deleteQuery(query);
3751
4408
  this.disposeBackgroundTexture();
4409
+ this.depthOfFieldStrength.cancel();
4410
+ this.disposeDepthOfFieldPass();
3752
4411
  this.disposeGraphObjects();
3753
4412
  this.renderer.dispose();
3754
4413
  this.renderer.domElement.remove();
@@ -3774,7 +4433,16 @@ var Graphraum = class {
3774
4433
  const query = timer?.context.createQuery() ?? null;
3775
4434
  if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
3776
4435
  const startedAt = performance.now();
3777
- this.renderer.render(this.scene, this.camera);
4436
+ const depthOfField = this.prepareDepthOfField(startedAt);
4437
+ if (depthOfField && this.depthOfFieldPass && this.camera instanceof PerspectiveCamera) {
4438
+ this.renderer.info.autoReset = false;
4439
+ this.renderer.info.reset();
4440
+ try {
4441
+ this.depthOfFieldPass.render(this.renderer, this.scene, this.camera, this.edgeMesh, depthOfField);
4442
+ } finally {
4443
+ this.renderer.info.autoReset = true;
4444
+ }
4445
+ } else this.renderer.render(this.scene, this.camera);
3778
4446
  this.cpuFrameMilliseconds = performance.now() - startedAt;
3779
4447
  if (timer && query) {
3780
4448
  timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
@@ -3782,6 +4450,81 @@ var Graphraum = class {
3782
4450
  }
3783
4451
  for (const listener of this.viewListeners) listener();
3784
4452
  }
4453
+ /**
4454
+ * Decides whether this frame blurs and records why in the diagnostics status. Returns null to
4455
+ * render directly. Requests another frame while a fade or focus move runs; both end on their own.
4456
+ */
4457
+ prepareDepthOfField(nowMs) {
4458
+ const options = this.depthOfField;
4459
+ const strengthTween = this.depthOfFieldStrength;
4460
+ if (!options.enabled && strengthTween.settled) {
4461
+ this.depthOfFieldStatus = "off";
4462
+ this.disposeDepthOfFieldPass();
4463
+ return null;
4464
+ }
4465
+ if (!(this.camera instanceof PerspectiveCamera)) {
4466
+ strengthTween.jump();
4467
+ this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4468
+ this.disposeDepthOfFieldPass();
4469
+ return null;
4470
+ }
4471
+ if (!DepthOfFieldPass.isSupported(this.renderer)) {
4472
+ strengthTween.jump();
4473
+ this.depthOfFieldStatus = options.enabled ? "unsupported" : "off";
4474
+ if (options.enabled && !warnedDepthOfFieldUnsupported) {
4475
+ warnedDepthOfFieldUnsupported = true;
4476
+ console.warn("graphraum: depth of field needs a WebGL 2 context and is turned off for this canvas. getDiagnostics().depthOfField reports \"unsupported\".");
4477
+ }
4478
+ return null;
4479
+ }
4480
+ const strength = strengthTween.sample(nowMs);
4481
+ if (!strengthTween.settled) this.requestRender();
4482
+ this.camera.updateMatrixWorld();
4483
+ let focusDistance = typeof options.focus === "number" ? options.focus : null;
4484
+ let amount = 1;
4485
+ if (options.focus === "selection") {
4486
+ const focus = this.depthOfFieldFocus.evaluate(nowMs, this.selectionFocusPoint(), prefersReducedMotion());
4487
+ if (!focus.settled) this.requestRender();
4488
+ amount = focus.amount;
4489
+ if (focus.point) focusDistance = viewDistance(focus.point, this.camera.matrixWorldInverse.elements);
4490
+ }
4491
+ if (focusDistance === null) {
4492
+ this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4493
+ return null;
4494
+ }
4495
+ focusDistance = Math.max(focusDistance, this.camera.near);
4496
+ const pixelRatio = this.renderer.getPixelRatio();
4497
+ const aperturePixels = strength * amount * distanceFade(focusDistance, options.nearDistance, options.farDistance) * 24 * pixelRatio;
4498
+ const maxBlurPixels = options.maxBlur * pixelRatio;
4499
+ if (aperturePixels < .25 || maxBlurPixels < .25) {
4500
+ this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4501
+ return null;
4502
+ }
4503
+ this.depthOfFieldPass ??= new DepthOfFieldPass(this.antialias);
4504
+ this.depthOfFieldStatus = "active";
4505
+ return {
4506
+ aperturePixels,
4507
+ focusDistance,
4508
+ maxBlurPixels,
4509
+ minBlurPixels: options.minBlur * pixelRatio
4510
+ };
4511
+ }
4512
+ /** Mean world position of the selected nodes, read every frame so a running layout keeps them in focus. */
4513
+ selectionFocusPoint() {
4514
+ return meanPoint([...this.selectedNodeIds].flatMap((id) => {
4515
+ const index = this.nodeIndices.get(id);
4516
+ const node = index === void 0 ? void 0 : this.data.nodes[index];
4517
+ return node ? [{
4518
+ x: node.position.x,
4519
+ y: node.position.y,
4520
+ z: node.position.z ?? 0
4521
+ }] : [];
4522
+ }));
4523
+ }
4524
+ disposeDepthOfFieldPass() {
4525
+ this.depthOfFieldPass?.dispose();
4526
+ this.depthOfFieldPass = null;
4527
+ }
3785
4528
  stopAutoOrbit() {
3786
4529
  this.autoOrbit?.dispose();
3787
4530
  this.autoOrbit = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yggy-tech/graphraum",
3
- "version": "0.35.0",
3
+ "version": "0.36.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",