@yggy-tech/graphraum 0.35.0 → 0.37.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +146 -1
  2. package/dist/index.js +1036 -15
  3. package/package.json +1 -1
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;
@@ -1952,6 +2493,231 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
1952
2493
  };
1953
2494
  });
1954
2495
  }
2496
+ /**
2497
+ * Drags one node with the pointer. A press on a node arms a gesture; once the pointer leaves the
2498
+ * click tolerance (a finger must rest on the node first) the node follows it until release.
2499
+ * Camera controls are switched off for the gesture and restored when it ends, so a press that stays
2500
+ * a click behaves as before. Escape, a cancelled pointer or lost pointer capture put the node back.
2501
+ */
2502
+ var NodeDragController = class {
2503
+ host;
2504
+ holdMs;
2505
+ enabled = false;
2506
+ gesture = null;
2507
+ unlisten;
2508
+ unlistenKeys = null;
2509
+ constructor(host, holdMs = 350) {
2510
+ this.host = host;
2511
+ this.holdMs = holdMs;
2512
+ this.unlisten = [
2513
+ listen(host.element, "pointerdown", this.handleDown, { capture: true }),
2514
+ listen(host.element, "pointermove", this.handleMove),
2515
+ listen(host.element, "pointerup", this.handleUp),
2516
+ listen(host.element, "pointercancel", this.handleCancel),
2517
+ listen(host.element, "lostpointercapture", this.handleCancel)
2518
+ ];
2519
+ }
2520
+ /** True from the first drag step until the node is dropped or the drag is cancelled. */
2521
+ get active() {
2522
+ return this.gesture?.phase === "dragging";
2523
+ }
2524
+ setEnabled(enabled) {
2525
+ if (!enabled) this.cancel({ restore: true });
2526
+ this.enabled = enabled;
2527
+ }
2528
+ /** Ends a running gesture as cancelled. `restore` puts the node back where it was grabbed. */
2529
+ cancel({ restore }) {
2530
+ const gesture = this.gesture;
2531
+ if (!gesture) return;
2532
+ if (gesture.phase === "dragging") {
2533
+ const restored = restore && this.host.nodePosition(gesture.nodeId) !== null;
2534
+ if (restored) this.host.moveNode(gesture.nodeId, gesture.origin);
2535
+ this.finish(gesture);
2536
+ this.host.emit({
2537
+ nodeId: gesture.nodeId,
2538
+ phase: "cancel",
2539
+ position: restored ? gesture.origin : gesture.last
2540
+ });
2541
+ return;
2542
+ }
2543
+ this.finish(gesture);
2544
+ }
2545
+ dispose() {
2546
+ this.cancel({ restore: false });
2547
+ this.enabled = false;
2548
+ for (const stop of this.unlisten) stop();
2549
+ }
2550
+ handleDown = (event) => {
2551
+ if (!this.enabled || this.gesture || event.button !== 0 || !this.host.canStart()) return;
2552
+ const nodeId = this.host.pickNode(event.clientX, event.clientY);
2553
+ if (nodeId === null) return;
2554
+ const origin = this.host.nodePosition(nodeId);
2555
+ if (!origin) return;
2556
+ const surface = this.host.createSurface(origin);
2557
+ const grabbed = surface?.(event.clientX, event.clientY);
2558
+ if (!surface || !grabbed) return;
2559
+ const touch = event.pointerType === "touch";
2560
+ const gesture = {
2561
+ controlsHeld: false,
2562
+ holdTimer: null,
2563
+ last: origin,
2564
+ nodeId,
2565
+ offset: {
2566
+ x: origin.x - grabbed.x,
2567
+ y: origin.y - grabbed.y,
2568
+ z: (origin.z ?? 0) - (grabbed.z ?? 0)
2569
+ },
2570
+ origin,
2571
+ phase: "pressed",
2572
+ pointerId: event.pointerId,
2573
+ startX: event.clientX,
2574
+ startY: event.clientY,
2575
+ surface,
2576
+ touch
2577
+ };
2578
+ this.gesture = gesture;
2579
+ this.unlistenKeys = listen(this.host.document, "keydown", this.handleKey);
2580
+ if (touch) {
2581
+ gesture.holdTimer = setTimeout(() => this.beginDrag(gesture), this.holdMs);
2582
+ return;
2583
+ }
2584
+ this.holdControls(gesture);
2585
+ capture(this.host.element, event.pointerId);
2586
+ };
2587
+ handleMove = (event) => {
2588
+ const gesture = this.gesture;
2589
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
2590
+ if (gesture.phase === "pressed") {
2591
+ if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= 5) return;
2592
+ if (gesture.touch) {
2593
+ this.finish(gesture);
2594
+ return;
2595
+ }
2596
+ this.beginDrag(gesture);
2597
+ }
2598
+ this.follow(gesture, event.clientX, event.clientY);
2599
+ };
2600
+ handleUp = (event) => {
2601
+ const gesture = this.gesture;
2602
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
2603
+ this.finish(gesture);
2604
+ if (gesture.phase === "dragging") this.host.emit({
2605
+ nodeId: gesture.nodeId,
2606
+ phase: "end",
2607
+ position: gesture.last
2608
+ });
2609
+ };
2610
+ handleCancel = (event) => {
2611
+ if (this.gesture && event.pointerId === this.gesture.pointerId) this.cancel({ restore: true });
2612
+ };
2613
+ handleKey = (event) => {
2614
+ if (event.key === "Escape") this.cancel({ restore: true });
2615
+ };
2616
+ beginDrag(gesture) {
2617
+ if (this.gesture !== gesture || gesture.phase === "dragging") return;
2618
+ gesture.phase = "dragging";
2619
+ clearHold(gesture);
2620
+ this.holdControls(gesture);
2621
+ if (gesture.touch) capture(this.host.element, gesture.pointerId);
2622
+ this.host.emit({
2623
+ nodeId: gesture.nodeId,
2624
+ phase: "start",
2625
+ position: gesture.origin
2626
+ });
2627
+ }
2628
+ follow(gesture, clientX, clientY) {
2629
+ if (!this.host.nodePosition(gesture.nodeId)) {
2630
+ this.cancel({ restore: false });
2631
+ return;
2632
+ }
2633
+ const point = gesture.surface(clientX, clientY);
2634
+ if (!point) return;
2635
+ const z = point.z === void 0 ? gesture.origin.z : point.z + gesture.offset.z;
2636
+ const position = {
2637
+ x: point.x + gesture.offset.x,
2638
+ y: point.y + gesture.offset.y,
2639
+ ...z === void 0 ? {} : { z }
2640
+ };
2641
+ gesture.last = position;
2642
+ this.host.moveNode(gesture.nodeId, position);
2643
+ this.host.emit({
2644
+ nodeId: gesture.nodeId,
2645
+ phase: "move",
2646
+ position
2647
+ });
2648
+ }
2649
+ holdControls(gesture) {
2650
+ if (gesture.controlsHeld) return;
2651
+ gesture.controlsHeld = true;
2652
+ this.host.setControlsEnabled(false);
2653
+ }
2654
+ /** Drops the gesture and everything it held: timer, pointer capture, key listener, camera controls. */
2655
+ finish(gesture) {
2656
+ if (this.gesture !== gesture) return;
2657
+ this.gesture = null;
2658
+ clearHold(gesture);
2659
+ this.unlistenKeys?.();
2660
+ this.unlistenKeys = null;
2661
+ release(this.host.element, gesture.pointerId);
2662
+ if (gesture.controlsHeld) this.host.setControlsEnabled(true);
2663
+ }
2664
+ };
2665
+ /** Typed `addEventListener` for the pointer and key events this module handles; returns the unsubscribe. */
2666
+ function listen(target, type, handler, options) {
2667
+ const listener = handler;
2668
+ target.addEventListener(type, listener, options);
2669
+ return () => target.removeEventListener(type, listener, options);
2670
+ }
2671
+ function clearHold(gesture) {
2672
+ if (gesture.holdTimer === null) return;
2673
+ clearTimeout(gesture.holdTimer);
2674
+ gesture.holdTimer = null;
2675
+ }
2676
+ function capture(element, pointerId) {
2677
+ try {
2678
+ element.setPointerCapture(pointerId);
2679
+ } catch {}
2680
+ }
2681
+ function release(element, pointerId) {
2682
+ try {
2683
+ element.releasePointerCapture(pointerId);
2684
+ } catch {}
2685
+ }
2686
+ //#endregion
2687
+ //#region src/node-drag-surface.ts
2688
+ /**
2689
+ * Where the pointer is in world space on the plane a dragged node moves in. 2D unprojects onto the
2690
+ * view plane and reports no depth. 3D intersects the pointer ray with a plane through the anchor
2691
+ * that faces the camera as it was at the press, so the node keeps its depth and follows the pointer
2692
+ * across the screen. Returns `null` for an empty canvas, and from the surface when the ray misses.
2693
+ */
2694
+ function createNodeDragSurface({ anchor, bounds, camera, raycaster }) {
2695
+ const initial = bounds();
2696
+ if (initial.width === 0 || initial.height === 0) return null;
2697
+ const ndc = new Vector2();
2698
+ const toNdc = (clientX, clientY) => {
2699
+ const { height, left, top, width } = bounds();
2700
+ return ndc.set((clientX - left) / width * 2 - 1, -((clientY - top) / height) * 2 + 1);
2701
+ };
2702
+ if (camera.type === "OrthographicCamera") return (clientX, clientY) => {
2703
+ const point = toNdc(clientX, clientY);
2704
+ const world = new Vector3(point.x, point.y, 0).unproject(camera);
2705
+ return {
2706
+ x: world.x,
2707
+ y: world.y
2708
+ };
2709
+ };
2710
+ const plane = new Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(new Vector3()), new Vector3(anchor.x, anchor.y, anchor.z ?? 0));
2711
+ const hit = new Vector3();
2712
+ return (clientX, clientY) => {
2713
+ raycaster.setFromCamera(toNdc(clientX, clientY), camera);
2714
+ return raycaster.ray.intersectPlane(plane, hit) ? {
2715
+ x: hit.x,
2716
+ y: hit.y,
2717
+ z: hit.z
2718
+ } : null;
2719
+ };
2720
+ }
1955
2721
  const vertexShader$1 = `
1956
2722
  attribute float instanceGlowOpacity;
1957
2723
  varying vec3 glowColor;
@@ -2756,6 +3522,8 @@ function prefersReducedMotion() {
2756
3522
  function disposeMaterial(material) {
2757
3523
  for (const item of Array.isArray(material) ? material : [material]) item.dispose();
2758
3524
  }
3525
+ /** One warning per page when a context cannot run depth of field; diagnostics report it per instance. */
3526
+ let warnedDepthOfFieldUnsupported = false;
2759
3527
  /** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
2760
3528
  const FLIGHT_CANCEL_EVENTS = [
2761
3529
  "keydown",
@@ -2836,12 +3604,23 @@ var Graphraum = class {
2836
3604
  /** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
2837
3605
  orbitUpdating = false;
2838
3606
  destroyed = false;
3607
+ canvasWidth = 0;
3608
+ canvasHeight = 0;
2839
3609
  cpuFrameMilliseconds = 0;
2840
3610
  gpuFrameMilliseconds = null;
2841
3611
  gpuTimer = null;
2842
3612
  viewListeners = /* @__PURE__ */ new Set();
2843
3613
  focusListeners = /* @__PURE__ */ new Set();
3614
+ nodeDragListeners = /* @__PURE__ */ new Set();
3615
+ nodeDrag;
2844
3616
  lastHoveredNodeId = null;
3617
+ antialias;
3618
+ depthOfField = DEPTH_OF_FIELD_DEFAULTS;
3619
+ depthOfFieldFocus = new DepthOfFieldFocus();
3620
+ /** Strength actually applied: fades toward `strength` while enabled and toward 0 when disabled. */
3621
+ depthOfFieldStrength = new Tween(0);
3622
+ depthOfFieldPass = null;
3623
+ depthOfFieldStatus = "off";
2845
3624
  constructor(container, options = {}) {
2846
3625
  this.container = container;
2847
3626
  this.mode = options.mode ?? "2d";
@@ -2854,8 +3633,13 @@ var Graphraum = class {
2854
3633
  if (!Number.isSafeInteger(this.maxVisibleNodes) || this.maxVisibleNodes < 1) throw new Error("Maximum visible nodes must be a positive integer.");
2855
3634
  if (!Number.isFinite(this.viewportOverscan) || this.viewportOverscan < 0) throw new Error("Viewport overscan must be a non-negative finite number.");
2856
3635
  this.theme = resolveGraphraumTheme(options.theme);
3636
+ if (options.depthOfField !== void 0) {
3637
+ this.depthOfField = resolveDepthOfFieldOptions(options.depthOfField);
3638
+ this.depthOfFieldStrength.retarget(this.depthOfFieldTargetStrength(), 0, resolveEasing("linear"));
3639
+ }
3640
+ this.antialias = options.antialias ?? false;
2857
3641
  this.renderer = new WebGLRenderer({
2858
- antialias: options.antialias ?? false,
3642
+ antialias: this.antialias,
2859
3643
  alpha: true
2860
3644
  });
2861
3645
  this.applyBackground();
@@ -2876,6 +3660,7 @@ var Graphraum = class {
2876
3660
  this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
2877
3661
  this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
2878
3662
  for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
3663
+ this.nodeDrag = new NodeDragController(this.createNodeDragHost());
2879
3664
  this.camera = this.createCamera();
2880
3665
  this.controls = this.createControls();
2881
3666
  this.resizeObserver = new ResizeObserver(() => this.resize());
@@ -3029,6 +3814,7 @@ var Graphraum = class {
3029
3814
  this.requestRender();
3030
3815
  }
3031
3816
  replaceData(data, fitView) {
3817
+ this.nodeDrag.cancel({ restore: false });
3032
3818
  const compiled = compileGraph(data, this.visuals);
3033
3819
  this.disposeGraphObjects();
3034
3820
  this.data = {
@@ -3312,6 +4098,10 @@ var Graphraum = class {
3312
4098
  const changedNodeIds = new Set([...currentSelection, ...nextSelection].filter((nodeId) => currentSelection.has(nodeId) !== nextSelection.has(nodeId)));
3313
4099
  if (changedNodeIds.size === 0) return;
3314
4100
  this.setNodeStateIds(state, nextSelection);
4101
+ if (state === "selected" && this.depthOfField.enabled) {
4102
+ const { durationMs, easing } = this.depthOfField.transition;
4103
+ this.depthOfFieldFocus.retarget(durationMs, resolveEasing(easing));
4104
+ }
3315
4105
  this.applySelectionColors(changedNodeIds);
3316
4106
  this.requestRender();
3317
4107
  if (state === "selected" || state === "hovered") this.notifyFocusChange();
@@ -3342,6 +4132,7 @@ var Graphraum = class {
3342
4132
  setMode(mode) {
3343
4133
  if (mode === this.mode) return;
3344
4134
  this.cancelFlight();
4135
+ this.nodeDrag.cancel({ restore: true });
3345
4136
  this.stopAutoOrbit();
3346
4137
  this.mode = mode;
3347
4138
  this.controls.removeEventListener("change", this.handleViewChange);
@@ -3376,6 +4167,33 @@ var Graphraum = class {
3376
4167
  if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
3377
4168
  else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
3378
4169
  }
4170
+ /**
4171
+ * Updates 3D depth of field; omitted fields keep their current values. Enabling, disabling and a
4172
+ * new `strength` fade the blur over `transition` unless `update.animate` is `false`; a change
4173
+ * during a fade continues from the blur shown last. With `focus: "selection"` the focal plane
4174
+ * follows the mean position of the selected nodes, re-measured from the camera every frame, and
4175
+ * moves over `transition` when the selection changes; with nothing selected the blur fades out.
4176
+ * `prefers-reduced-motion: reduce` jumps. Resolves `true` when the blur reaches the new strength
4177
+ * and `false` when a later call or `destroy()` interrupts the fade. While it blurs each frame adds
4178
+ * three draw calls; disabled, or with nothing to blur, it costs nothing. A no-op in 2D. Picking
4179
+ * and DOM overlays are unaffected. Throws for invalid values.
4180
+ */
4181
+ setDepthOfField(options, update = {}) {
4182
+ const animate = update.animate ?? true;
4183
+ if (typeof animate !== "boolean") throw new Error(`setDepthOfField option animate must be a boolean; received ${String(animate)}.`);
4184
+ const previous = this.depthOfField;
4185
+ this.depthOfField = resolveDepthOfFieldOptions(options, previous);
4186
+ if (this.destroyed) return Promise.resolve(false);
4187
+ const { durationMs, easing } = this.depthOfField.transition;
4188
+ if (this.depthOfField.enabled && !previous.enabled) this.depthOfFieldFocus.retarget(0, resolveEasing(easing));
4189
+ const fadeMs = animate && !prefersReducedMotion() ? durationMs : 0;
4190
+ const done = this.depthOfFieldStrength.retarget(this.depthOfFieldTargetStrength(), fadeMs, resolveEasing(easing));
4191
+ this.requestRender();
4192
+ return done;
4193
+ }
4194
+ depthOfFieldTargetStrength() {
4195
+ return this.depthOfField.enabled ? this.depthOfField.strength : 0;
4196
+ }
3379
4197
  getNodePresentation(nodeId) {
3380
4198
  return this.nodePresentations.get(nodeId) ?? null;
3381
4199
  }
@@ -3442,6 +4260,51 @@ var Graphraum = class {
3442
4260
  listener();
3443
4261
  return () => this.viewListeners.delete(listener);
3444
4262
  }
4263
+ /**
4264
+ * Turns node dragging on or off (default off). A drag starts when the pointer moves past a small
4265
+ * tolerance after a press on a node (a finger must rest on the node first, so a swipe still pans
4266
+ * the camera), moves the node on a plane facing the camera, and ends on release. Escape or a
4267
+ * cancelled pointer put the node back. Not available while the density LOD shows clusters.
4268
+ */
4269
+ setNodeDrag(options) {
4270
+ if (typeof options?.enabled !== "boolean") throw new Error("setNodeDrag option enabled must be a boolean.");
4271
+ this.nodeDrag.setEnabled(options.enabled);
4272
+ }
4273
+ /** Subscribes to node drag steps (`start`, `move`, `end`, `cancel`). Returns an unsubscribe function. */
4274
+ onNodeDrag(listener) {
4275
+ this.nodeDragListeners.add(listener);
4276
+ return () => this.nodeDragListeners.delete(listener);
4277
+ }
4278
+ createNodeDragHost() {
4279
+ const element = this.renderer.domElement;
4280
+ return {
4281
+ canStart: () => !this.destroyed && this.nodeMesh !== null && !this.densityLodActive,
4282
+ createSurface: (anchor) => createNodeDragSurface({
4283
+ anchor,
4284
+ bounds: () => element.getBoundingClientRect(),
4285
+ camera: this.camera,
4286
+ raycaster: this.raycaster
4287
+ }),
4288
+ document: element.ownerDocument,
4289
+ element,
4290
+ emit: (event) => {
4291
+ for (const listener of this.nodeDragListeners) listener(event);
4292
+ },
4293
+ moveNode: (nodeId, position) => this.updateNodes([{
4294
+ id: nodeId,
4295
+ position
4296
+ }]),
4297
+ nodePosition: (nodeId) => {
4298
+ const index = this.nodeIndices.get(nodeId);
4299
+ const node = index === void 0 ? void 0 : this.data.nodes[index];
4300
+ return node ? { ...node.position } : null;
4301
+ },
4302
+ pickNode: (clientX, clientY) => this.pickNode(clientX, clientY),
4303
+ setControlsEnabled: (enabled) => {
4304
+ this.controls.enabled = enabled;
4305
+ }
4306
+ };
4307
+ }
3445
4308
  /** Subscribes to selection and hover changes that affect overlay focus chrome. */
3446
4309
  onFocusChange(listener) {
3447
4310
  this.focusListeners.add(listener);
@@ -3452,6 +4315,7 @@ var Graphraum = class {
3452
4315
  for (const listener of this.focusListeners) listener();
3453
4316
  }
3454
4317
  handlePointerMove = (event) => {
4318
+ if (this.nodeDrag.active) return;
3455
4319
  const nodeId = this.pickNode(event.clientX, event.clientY);
3456
4320
  if (nodeId === this.lastHoveredNodeId) return;
3457
4321
  this.lastHoveredNodeId = nodeId;
@@ -3470,6 +4334,7 @@ var Graphraum = class {
3470
4334
  aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
3471
4335
  autoOrbit: this.autoOrbit?.status ?? "off",
3472
4336
  cpuFrameMilliseconds: this.cpuFrameMilliseconds,
4337
+ depthOfField: this.depthOfFieldStatus,
3473
4338
  gpuFrameMilliseconds: this.gpuFrameMilliseconds,
3474
4339
  gpuDrawCalls: this.renderer.info.render.calls,
3475
4340
  gpuGeometries: this.renderer.info.memory.geometries,
@@ -3578,6 +4443,71 @@ var Graphraum = class {
3578
4443
  this.requestRender();
3579
4444
  });
3580
4445
  }
4446
+ /**
4447
+ * The camera pose as plain JSON. 2D reports the centre and the world height the canvas shows; 3D
4448
+ * reports the camera position and the point it orbits. Pass it to `setCameraState()` to return
4449
+ * to the same view, for example after reloading a saved one.
4450
+ */
4451
+ getCameraState() {
4452
+ const camera = this.camera;
4453
+ if (camera instanceof OrthographicCamera) return {
4454
+ center: {
4455
+ x: camera.position.x,
4456
+ y: camera.position.y
4457
+ },
4458
+ mode: "2d",
4459
+ visibleHeight: (camera.top - camera.bottom) / camera.zoom
4460
+ };
4461
+ const { target } = this.controls;
4462
+ return {
4463
+ mode: "3d",
4464
+ position: {
4465
+ x: camera.position.x,
4466
+ y: camera.position.y,
4467
+ z: camera.position.z
4468
+ },
4469
+ target: {
4470
+ x: target.x,
4471
+ y: target.y,
4472
+ z: target.z
4473
+ }
4474
+ };
4475
+ }
4476
+ /**
4477
+ * Puts the camera on a pose from `getCameraState()` at once, without fitting or animating, and
4478
+ * cancels a running `flyTo`. Throws for a state of the other mode (switch with `setMode()` first)
4479
+ * or with a non-finite number. 2D keeps the saved height and takes the width from the canvas, so a
4480
+ * view saved on one canvas size fits another. 3D derives the clip planes from the pose so the whole
4481
+ * graph stays in view. Apply it after the canvas has its size: a 2D resize to a new size refits.
4482
+ */
4483
+ setCameraState(state) {
4484
+ const next = validateCameraState(state, this.mode);
4485
+ this.cancelFlight();
4486
+ const camera = this.camera;
4487
+ const width = Math.max(this.container.clientWidth, 1);
4488
+ const height = Math.max(this.container.clientHeight, 1);
4489
+ if (next.mode === "2d" && camera instanceof OrthographicCamera) {
4490
+ setOrthographicFrustum(camera, width / height, next.visibleHeight);
4491
+ camera.position.set(next.center.x, next.center.y, camera.position.z);
4492
+ this.controls.target.set(next.center.x, next.center.y, this.controls.target.z);
4493
+ camera.lookAt(this.controls.target);
4494
+ camera.updateProjectionMatrix();
4495
+ this.controls.update();
4496
+ this.materializeViewport();
4497
+ } else if (next.mode === "3d" && camera instanceof PerspectiveCamera) {
4498
+ const target = new Vector3(next.target.x, next.target.y, next.target.z);
4499
+ camera.position.set(next.position.x, next.position.y, next.position.z);
4500
+ this.controls.target.copy(target);
4501
+ camera.lookAt(target);
4502
+ applyPerspectiveContainerAspect(camera, width, height);
4503
+ const clip = perspectiveClipRange(camera.position.distanceTo(target), this.graphRadiusAround(target));
4504
+ camera.near = clip.near;
4505
+ camera.far = clip.far;
4506
+ camera.updateProjectionMatrix();
4507
+ this.controls.update();
4508
+ }
4509
+ this.requestRender();
4510
+ }
3581
4511
  /** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
3582
4512
  nodesWithIds(nodeIds) {
3583
4513
  if (!nodeIds) return this.data.nodes;
@@ -3725,11 +4655,14 @@ var Graphraum = class {
3725
4655
  resize() {
3726
4656
  const width = Math.max(this.container.clientWidth, 1);
3727
4657
  const height = Math.max(this.container.clientHeight, 1);
4658
+ const sizeChanged = this.canvasWidth !== width || this.canvasHeight !== height;
4659
+ this.canvasWidth = width;
4660
+ this.canvasHeight = height;
3728
4661
  this.renderer.setSize(width, height, false);
3729
4662
  if (this.camera instanceof PerspectiveCamera) {
3730
4663
  applyPerspectiveContainerAspect(this.camera, width, height);
3731
4664
  this.camera.updateProjectionMatrix();
3732
- } else if (this.camera instanceof OrthographicCamera) {
4665
+ } else if (this.camera instanceof OrthographicCamera && sizeChanged) {
3733
4666
  this.fitView();
3734
4667
  return;
3735
4668
  }
@@ -3739,6 +4672,7 @@ var Graphraum = class {
3739
4672
  destroy() {
3740
4673
  this.destroyed = true;
3741
4674
  this.cancelFlight();
4675
+ this.nodeDrag.dispose();
3742
4676
  this.stopAutoOrbit();
3743
4677
  this.resizeObserver.disconnect();
3744
4678
  this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
@@ -3749,6 +4683,8 @@ var Graphraum = class {
3749
4683
  if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
3750
4684
  if (this.gpuTimer) for (const query of this.gpuTimer.pending) this.gpuTimer.context.deleteQuery(query);
3751
4685
  this.disposeBackgroundTexture();
4686
+ this.depthOfFieldStrength.cancel();
4687
+ this.disposeDepthOfFieldPass();
3752
4688
  this.disposeGraphObjects();
3753
4689
  this.renderer.dispose();
3754
4690
  this.renderer.domElement.remove();
@@ -3774,7 +4710,16 @@ var Graphraum = class {
3774
4710
  const query = timer?.context.createQuery() ?? null;
3775
4711
  if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
3776
4712
  const startedAt = performance.now();
3777
- this.renderer.render(this.scene, this.camera);
4713
+ const depthOfField = this.prepareDepthOfField(startedAt);
4714
+ if (depthOfField && this.depthOfFieldPass && this.camera instanceof PerspectiveCamera) {
4715
+ this.renderer.info.autoReset = false;
4716
+ this.renderer.info.reset();
4717
+ try {
4718
+ this.depthOfFieldPass.render(this.renderer, this.scene, this.camera, this.edgeMesh, depthOfField);
4719
+ } finally {
4720
+ this.renderer.info.autoReset = true;
4721
+ }
4722
+ } else this.renderer.render(this.scene, this.camera);
3778
4723
  this.cpuFrameMilliseconds = performance.now() - startedAt;
3779
4724
  if (timer && query) {
3780
4725
  timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
@@ -3782,6 +4727,81 @@ var Graphraum = class {
3782
4727
  }
3783
4728
  for (const listener of this.viewListeners) listener();
3784
4729
  }
4730
+ /**
4731
+ * Decides whether this frame blurs and records why in the diagnostics status. Returns null to
4732
+ * render directly. Requests another frame while a fade or focus move runs; both end on their own.
4733
+ */
4734
+ prepareDepthOfField(nowMs) {
4735
+ const options = this.depthOfField;
4736
+ const strengthTween = this.depthOfFieldStrength;
4737
+ if (!options.enabled && strengthTween.settled) {
4738
+ this.depthOfFieldStatus = "off";
4739
+ this.disposeDepthOfFieldPass();
4740
+ return null;
4741
+ }
4742
+ if (!(this.camera instanceof PerspectiveCamera)) {
4743
+ strengthTween.jump();
4744
+ this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4745
+ this.disposeDepthOfFieldPass();
4746
+ return null;
4747
+ }
4748
+ if (!DepthOfFieldPass.isSupported(this.renderer)) {
4749
+ strengthTween.jump();
4750
+ this.depthOfFieldStatus = options.enabled ? "unsupported" : "off";
4751
+ if (options.enabled && !warnedDepthOfFieldUnsupported) {
4752
+ warnedDepthOfFieldUnsupported = true;
4753
+ console.warn("graphraum: depth of field needs a WebGL 2 context and is turned off for this canvas. getDiagnostics().depthOfField reports \"unsupported\".");
4754
+ }
4755
+ return null;
4756
+ }
4757
+ const strength = strengthTween.sample(nowMs);
4758
+ if (!strengthTween.settled) this.requestRender();
4759
+ this.camera.updateMatrixWorld();
4760
+ let focusDistance = typeof options.focus === "number" ? options.focus : null;
4761
+ let amount = 1;
4762
+ if (options.focus === "selection") {
4763
+ const focus = this.depthOfFieldFocus.evaluate(nowMs, this.selectionFocusPoint(), prefersReducedMotion());
4764
+ if (!focus.settled) this.requestRender();
4765
+ amount = focus.amount;
4766
+ if (focus.point) focusDistance = viewDistance(focus.point, this.camera.matrixWorldInverse.elements);
4767
+ }
4768
+ if (focusDistance === null) {
4769
+ this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4770
+ return null;
4771
+ }
4772
+ focusDistance = Math.max(focusDistance, this.camera.near);
4773
+ const pixelRatio = this.renderer.getPixelRatio();
4774
+ const aperturePixels = strength * amount * distanceFade(focusDistance, options.nearDistance, options.farDistance) * 24 * pixelRatio;
4775
+ const maxBlurPixels = options.maxBlur * pixelRatio;
4776
+ if (aperturePixels < .25 || maxBlurPixels < .25) {
4777
+ this.depthOfFieldStatus = options.enabled ? "inactive" : "off";
4778
+ return null;
4779
+ }
4780
+ this.depthOfFieldPass ??= new DepthOfFieldPass(this.antialias);
4781
+ this.depthOfFieldStatus = "active";
4782
+ return {
4783
+ aperturePixels,
4784
+ focusDistance,
4785
+ maxBlurPixels,
4786
+ minBlurPixels: options.minBlur * pixelRatio
4787
+ };
4788
+ }
4789
+ /** Mean world position of the selected nodes, read every frame so a running layout keeps them in focus. */
4790
+ selectionFocusPoint() {
4791
+ return meanPoint([...this.selectedNodeIds].flatMap((id) => {
4792
+ const index = this.nodeIndices.get(id);
4793
+ const node = index === void 0 ? void 0 : this.data.nodes[index];
4794
+ return node ? [{
4795
+ x: node.position.x,
4796
+ y: node.position.y,
4797
+ z: node.position.z ?? 0
4798
+ }] : [];
4799
+ }));
4800
+ }
4801
+ disposeDepthOfFieldPass() {
4802
+ this.depthOfFieldPass?.dispose();
4803
+ this.depthOfFieldPass = null;
4804
+ }
3785
4805
  stopAutoOrbit() {
3786
4806
  this.autoOrbit?.dispose();
3787
4807
  this.autoOrbit = null;
@@ -3797,6 +4817,7 @@ var Graphraum = class {
3797
4817
  * without a loop.
3798
4818
  */
3799
4819
  stepAutoOrbit = (deltaSeconds) => {
4820
+ if (this.nodeDrag.active) return;
3800
4821
  try {
3801
4822
  this.controls.autoRotate = true;
3802
4823
  this.orbitUpdating = true;