@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.
- package/dist/index.d.ts +146 -1
- package/dist/index.js +1036 -15
- 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:
|
|
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.
|
|
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;
|