@yggy-tech/graphraum 0.28.0 → 0.34.1
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/README.md +11 -16
- package/dist/index.d.ts +184 -7
- package/dist/index.js +1383 -242
- package/package.json +54 -55
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box3, CanvasTexture, Color, InstancedBufferAttribute, InstancedMesh, MOUSE, Matrix4, MeshBasicMaterial, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, ShaderMaterial, SphereGeometry, Texture, Vector2, Vector3, WebGLRenderer } from "three";
|
|
1
|
+
import { AdditiveBlending, Box3, CanvasTexture, Color, InstancedBufferAttribute, InstancedMesh, MOUSE, Matrix4, MeshBasicMaterial, NormalBlending, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, ShaderMaterial, SphereGeometry, TOUCH, Texture, Vector2, Vector3, WebGLRenderer } from "three";
|
|
2
2
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
3
3
|
//#region src/node-axes.ts
|
|
4
4
|
function assertPositiveFinite(value, axis, nodeId) {
|
|
@@ -100,7 +100,12 @@ function containsNodePoint(shape, x, y) {
|
|
|
100
100
|
//#region src/edge-endpoint-attach.ts
|
|
101
101
|
const DEFAULT_ENDPOINT_ATTACH = "boundary";
|
|
102
102
|
const DEFAULT_ENDPOINT_CLEARANCE = .75;
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Distance from the node center to its outline (stroke included) along a unit heading in the
|
|
105
|
+
* billboard plane, in world units. Every shape fits its `[-1, 1]` box, so the box diagonal bounds
|
|
106
|
+
* the search.
|
|
107
|
+
*/
|
|
108
|
+
function outlineRadius(outline, headingX, headingY) {
|
|
104
109
|
const { height, width } = resolveNodeAxes({
|
|
105
110
|
height: outline.height,
|
|
106
111
|
size: outline.size,
|
|
@@ -109,14 +114,36 @@ function isInsideOutline(outline, worldX, worldY, center) {
|
|
|
109
114
|
const stroke = Math.max(0, outline.strokeWidth ?? 0);
|
|
110
115
|
const halfWidth = width + stroke;
|
|
111
116
|
const halfHeight = height + stroke;
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
let low = 0;
|
|
118
|
+
let high = Math.hypot(halfWidth, halfHeight);
|
|
119
|
+
for (let step = 0; step < 24; step += 1) {
|
|
120
|
+
const mid = (low + high) / 2;
|
|
121
|
+
if (containsNodePoint(outline.shape, headingX * mid / halfWidth, headingY * mid / halfHeight)) low = mid;
|
|
122
|
+
else high = mid;
|
|
123
|
+
}
|
|
124
|
+
return low;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How far an edge end stops short of a 3D node's center, in world units: the outline radius (stroke
|
|
128
|
+
* included) less `clearance`. The renderer applies it on screen, where the camera-facing outline
|
|
129
|
+
* is, so it is exact for circles from every view. Other shapes use their radius along +x.
|
|
130
|
+
*/
|
|
131
|
+
function outlineReach(outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
|
|
132
|
+
return Math.max(0, outlineRadius(outline, 1, 0) - Math.max(clearance, 0));
|
|
114
133
|
}
|
|
115
134
|
/**
|
|
116
135
|
* Walk from the node center toward the far point and stop on the outline,
|
|
117
136
|
* then pull back by `clearance` so the stroke is not stabbed.
|
|
137
|
+
*
|
|
138
|
+
* In `2d` the outline lies in the xy plane, so only the xy part of the chord counts. In `3d` the
|
|
139
|
+
* billboard faces the camera, so the edge stops one outline radius from the center along the 3D
|
|
140
|
+
* segment. The radius is measured along the chord's world xy heading (or +x when the chord is
|
|
141
|
+
* parallel to z), not along its on-screen heading. For circles the radius is the same in every
|
|
142
|
+
* direction, so the projected end lies on the outline or inside it (where the node covers the
|
|
143
|
+
* edge) from every view. Other shapes are approximate in 3D: an elongated or angular node can end
|
|
144
|
+
* inside its outline or short of it, depending on the camera.
|
|
118
145
|
*/
|
|
119
|
-
function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
|
|
146
|
+
function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE, mode = "2d") {
|
|
120
147
|
const dx = far.x - center.x;
|
|
121
148
|
const dy = far.y - center.y;
|
|
122
149
|
const dz = (far.z ?? 0) - (center.z ?? 0);
|
|
@@ -126,15 +153,12 @@ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT
|
|
|
126
153
|
y: center.y,
|
|
127
154
|
z: center.z ?? 0
|
|
128
155
|
};
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (isInsideOutline(outline, center.x + dx * mid, center.y + dy * mid, center)) low = mid;
|
|
134
|
-
else high = mid;
|
|
135
|
-
}
|
|
156
|
+
const planar = Math.hypot(dx, dy);
|
|
157
|
+
const reach = mode === "3d" ? length : planar;
|
|
158
|
+
const radius = planar < 1e-6 ? outlineRadius(outline, 1, 0) : outlineRadius(outline, dx / planar, dy / planar);
|
|
159
|
+
const outlineT = reach < 1e-6 ? 1 : Math.min(1, radius / reach);
|
|
136
160
|
const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
|
|
137
|
-
const t = Math.max(0,
|
|
161
|
+
const t = Math.max(0, outlineT - clearanceT);
|
|
138
162
|
return {
|
|
139
163
|
x: center.x + dx * t,
|
|
140
164
|
y: center.y + dy * t,
|
|
@@ -156,8 +180,8 @@ function trimEdgeEndpoints(input) {
|
|
|
156
180
|
}
|
|
157
181
|
};
|
|
158
182
|
const clearance = input.clearance ?? .75;
|
|
159
|
-
const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance);
|
|
160
|
-
const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance);
|
|
183
|
+
const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance, input.mode);
|
|
184
|
+
const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance, input.mode);
|
|
161
185
|
const ox = input.target.x - input.source.x;
|
|
162
186
|
const oy = input.target.y - input.source.y;
|
|
163
187
|
const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
|
|
@@ -245,6 +269,7 @@ function encodeEdgeStyle(style) {
|
|
|
245
269
|
//#region src/force-layout.ts
|
|
246
270
|
const DEFAULT_FORCE_SETTINGS = {
|
|
247
271
|
centerAttraction: .002,
|
|
272
|
+
clusterStrength: .02,
|
|
248
273
|
damping: .82,
|
|
249
274
|
linkDistance: 24,
|
|
250
275
|
repulsion: 400,
|
|
@@ -253,6 +278,7 @@ const DEFAULT_FORCE_SETTINGS = {
|
|
|
253
278
|
function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
|
|
254
279
|
return {
|
|
255
280
|
centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
|
|
281
|
+
clusterStrength: Math.max(0, Math.min(.1, settings.clusterStrength ?? DEFAULT_FORCE_SETTINGS.clusterStrength)),
|
|
256
282
|
damping: Math.max(.1, Math.min(.99, settings.damping)),
|
|
257
283
|
linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
|
|
258
284
|
repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
|
|
@@ -311,6 +337,60 @@ function random(seed) {
|
|
|
311
337
|
value = Math.imul(value ^ value >>> 13, 3266489909);
|
|
312
338
|
return ((value ^ value >>> 16) >>> 0) / 4294967296;
|
|
313
339
|
}
|
|
340
|
+
function assertClusterCount(clusters, expected, subject) {
|
|
341
|
+
if (clusters.length !== expected) throw new Error(`Cluster index count (${clusters.length}) must match ${subject} (${expected}).`);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Live simulations index per-cluster scratch arrays by id, so ids must be dense: below the node count.
|
|
345
|
+
* Validate before allocating anything.
|
|
346
|
+
*/
|
|
347
|
+
function assertDenseClusterIds(clusters, totalNodeCount) {
|
|
348
|
+
for (const cluster of clusters) if (cluster >= totalNodeCount) throw new Error(`Cluster index ${cluster} exceeds the node count ${totalNodeCount}; use dense ids starting at 0.`);
|
|
349
|
+
}
|
|
350
|
+
function clusterCountOf(clusters) {
|
|
351
|
+
let clusterCount = 0;
|
|
352
|
+
for (const cluster of clusters) clusterCount = Math.max(clusterCount, cluster + 1);
|
|
353
|
+
return clusterCount;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Seeds nodes around cluster centres: centres come from a force layout of the inter-cluster graph
|
|
357
|
+
* and each node is scattered around its centre. Shared by the one-shot and live clustered layouts.
|
|
358
|
+
*/
|
|
359
|
+
function clusteredInitialPositions(request) {
|
|
360
|
+
const clusterCount = clusterCountOf(request.clusters);
|
|
361
|
+
const clusterEdges = new Uint32Array(request.edges.length);
|
|
362
|
+
let edgeCursor = 0;
|
|
363
|
+
for (let index = 0; index < request.edges.length; index += 2) {
|
|
364
|
+
const source = request.clusters[request.edges[index] ?? 0];
|
|
365
|
+
const target = request.clusters[request.edges[index + 1] ?? 0];
|
|
366
|
+
if (source === void 0 || target === void 0 || source === target) continue;
|
|
367
|
+
clusterEdges[edgeCursor++] = source;
|
|
368
|
+
clusterEdges[edgeCursor++] = target;
|
|
369
|
+
}
|
|
370
|
+
const centers = computeForcePositions({
|
|
371
|
+
dimensions: request.dimensions,
|
|
372
|
+
edges: clusterEdges.subarray(0, edgeCursor),
|
|
373
|
+
nodeCount: clusterCount,
|
|
374
|
+
settings: request.settings
|
|
375
|
+
});
|
|
376
|
+
const clusterSizes = new Uint32Array(clusterCount);
|
|
377
|
+
for (const cluster of request.clusters) clusterSizes[cluster] += 1;
|
|
378
|
+
const positions = new Float32Array(request.nodeCount * 3);
|
|
379
|
+
for (let index = 0; index < request.nodeCount; index += 1) {
|
|
380
|
+
const offset = index * 3;
|
|
381
|
+
const cluster = request.clusters[index] ?? 0;
|
|
382
|
+
const center = cluster * 3;
|
|
383
|
+
const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
|
|
384
|
+
const angle = random(index * 4 + 1) * Math.PI * 2;
|
|
385
|
+
positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
|
|
386
|
+
positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
|
|
387
|
+
if (request.dimensions === 3) {
|
|
388
|
+
const zDirection = random(index * 4 + 3) * 2 - 1;
|
|
389
|
+
positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return positions;
|
|
393
|
+
}
|
|
314
394
|
function createForceSimulation(request) {
|
|
315
395
|
const settings = normalizeForceSettings(request.settings);
|
|
316
396
|
let nodeCount = request.nodeCount;
|
|
@@ -319,10 +399,41 @@ function createForceSimulation(request) {
|
|
|
319
399
|
const edgeCount = edges.length / 2;
|
|
320
400
|
let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
|
|
321
401
|
let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
|
|
322
|
-
let
|
|
402
|
+
let clusters = null;
|
|
403
|
+
if (request.clusters) {
|
|
404
|
+
assertClusterCount(request.clusters, request.nodeCount, "node count");
|
|
405
|
+
assertDenseClusterIds(request.clusters, request.nodeCount);
|
|
406
|
+
clusters = new Uint32Array(request.clusters);
|
|
407
|
+
}
|
|
408
|
+
let positions = clusters ? clusteredInitialPositions({
|
|
409
|
+
...request,
|
|
410
|
+
clusters
|
|
411
|
+
}) : initialPositions(request);
|
|
323
412
|
let velocities = new Float32Array(positions.length);
|
|
324
413
|
let forces = new Float32Array(positions.length);
|
|
325
414
|
let iteration = 0;
|
|
415
|
+
let clusterCount = clusters ? clusterCountOf(clusters) : 0;
|
|
416
|
+
let clusterCentroids = new Float64Array(clusterCount * 3);
|
|
417
|
+
let clusterSizes = new Uint32Array(clusterCount);
|
|
418
|
+
/** Mean position per cluster in O(N): no per-node allocation. */
|
|
419
|
+
function updateClusterCentroids(activeClusters) {
|
|
420
|
+
clusterCentroids.fill(0);
|
|
421
|
+
clusterSizes.fill(0);
|
|
422
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
423
|
+
const cluster = activeClusters[index] ?? 0;
|
|
424
|
+
const offset = index * 3;
|
|
425
|
+
const centroid = cluster * 3;
|
|
426
|
+
clusterSizes[cluster] += 1;
|
|
427
|
+
clusterCentroids[centroid] += positions[offset] ?? 0;
|
|
428
|
+
clusterCentroids[centroid + 1] += positions[offset + 1] ?? 0;
|
|
429
|
+
if (request.dimensions === 3) clusterCentroids[centroid + 2] += positions[offset + 2] ?? 0;
|
|
430
|
+
}
|
|
431
|
+
for (let cluster = 0; cluster < clusterCount; cluster += 1) {
|
|
432
|
+
const size = clusterSizes[cluster] ?? 0;
|
|
433
|
+
if (size === 0) continue;
|
|
434
|
+
for (let axis = 0; axis < request.dimensions; axis += 1) clusterCentroids[cluster * 3 + axis] /= size;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
326
437
|
return {
|
|
327
438
|
get nodeCount() {
|
|
328
439
|
return nodeCount;
|
|
@@ -333,10 +444,20 @@ function createForceSimulation(request) {
|
|
|
333
444
|
get positions() {
|
|
334
445
|
return positions;
|
|
335
446
|
},
|
|
336
|
-
/**
|
|
337
|
-
|
|
447
|
+
/**
|
|
448
|
+
* Add nodes while preserving every existing position and velocity. A simulation created with
|
|
449
|
+
* `clusters` requires one cluster index per added node (a new id starts a new cluster); a
|
|
450
|
+
* simulation created without clusters rejects them.
|
|
451
|
+
*/
|
|
452
|
+
addNodes(count, initial, addedClusters) {
|
|
338
453
|
if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
|
|
339
454
|
if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
|
|
455
|
+
if (clusters && !addedClusters) throw new Error("This simulation was created with clusters: addNodes() requires a clusters array.");
|
|
456
|
+
if (!clusters && addedClusters) throw new Error("This simulation was created without clusters: addNodes() cannot take a clusters array.");
|
|
457
|
+
if (addedClusters) {
|
|
458
|
+
assertClusterCount(addedClusters, count, "added node count");
|
|
459
|
+
assertDenseClusterIds(addedClusters, nodeCount + count);
|
|
460
|
+
}
|
|
340
461
|
const start = nodeCount;
|
|
341
462
|
const nextPositions = new Float32Array((nodeCount + count) * 3);
|
|
342
463
|
nextPositions.set(positions);
|
|
@@ -352,6 +473,24 @@ function createForceSimulation(request) {
|
|
|
352
473
|
});
|
|
353
474
|
nextPositions.set(added, positions.length);
|
|
354
475
|
}
|
|
476
|
+
let nextClusters = clusters;
|
|
477
|
+
let nextClusterCount = clusterCount;
|
|
478
|
+
let nextCentroids = clusterCentroids;
|
|
479
|
+
let nextSizes = clusterSizes;
|
|
480
|
+
if (clusters && addedClusters) {
|
|
481
|
+
nextClusters = new Uint32Array(nodeCount + count);
|
|
482
|
+
nextClusters.set(clusters);
|
|
483
|
+
nextClusters.set(addedClusters, nodeCount);
|
|
484
|
+
nextClusterCount = Math.max(clusterCount, clusterCountOf(addedClusters));
|
|
485
|
+
if (nextClusterCount !== clusterCount) {
|
|
486
|
+
nextCentroids = new Float64Array(nextClusterCount * 3);
|
|
487
|
+
nextSizes = new Uint32Array(nextClusterCount);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
clusters = nextClusters;
|
|
491
|
+
clusterCount = nextClusterCount;
|
|
492
|
+
clusterCentroids = nextCentroids;
|
|
493
|
+
clusterSizes = nextSizes;
|
|
355
494
|
positions = nextPositions;
|
|
356
495
|
velocities = nextVelocities;
|
|
357
496
|
forces = nextForces;
|
|
@@ -383,11 +522,18 @@ function createForceSimulation(request) {
|
|
|
383
522
|
step(alpha) {
|
|
384
523
|
if (nodeCount === 0) return;
|
|
385
524
|
forces.fill(0);
|
|
525
|
+
const pullClusters = clusters && settings.clusterStrength > 0 ? clusters : null;
|
|
526
|
+
const clusterPull = settings.clusterStrength * alpha;
|
|
527
|
+
if (pullClusters) updateClusterCentroids(pullClusters);
|
|
386
528
|
for (let index = 0; index < nodeCount; index += 1) {
|
|
387
529
|
const offset = index * 3;
|
|
388
530
|
forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
|
|
389
531
|
forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
|
|
390
532
|
forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
|
|
533
|
+
if (pullClusters) {
|
|
534
|
+
const centroid = (pullClusters[index] ?? 0) * 3;
|
|
535
|
+
for (let axis = 0; axis < request.dimensions; axis += 1) forces[offset + axis] += ((clusterCentroids[centroid + axis] ?? 0) - (positions[offset + axis] ?? 0)) * clusterPull;
|
|
536
|
+
}
|
|
391
537
|
for (let sample = 0; sample < 8; sample += 1) {
|
|
392
538
|
const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
|
|
393
539
|
if (targetIndex === index) continue;
|
|
@@ -469,41 +615,8 @@ function computeForcePositions(request) {
|
|
|
469
615
|
return simulation.positions;
|
|
470
616
|
}
|
|
471
617
|
function computeClusteredForcePositions(request) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
for (const cluster of request.clusters) clusterCount = Math.max(clusterCount, cluster + 1);
|
|
475
|
-
const clusterEdges = new Uint32Array(request.edges.length);
|
|
476
|
-
let edgeCursor = 0;
|
|
477
|
-
for (let index = 0; index < request.edges.length; index += 2) {
|
|
478
|
-
const source = request.clusters[request.edges[index] ?? 0];
|
|
479
|
-
const target = request.clusters[request.edges[index + 1] ?? 0];
|
|
480
|
-
if (source === void 0 || target === void 0 || source === target) continue;
|
|
481
|
-
clusterEdges[edgeCursor++] = source;
|
|
482
|
-
clusterEdges[edgeCursor++] = target;
|
|
483
|
-
}
|
|
484
|
-
const centers = computeForcePositions({
|
|
485
|
-
dimensions: request.dimensions,
|
|
486
|
-
edges: clusterEdges.subarray(0, edgeCursor),
|
|
487
|
-
nodeCount: clusterCount,
|
|
488
|
-
settings: request.settings
|
|
489
|
-
});
|
|
490
|
-
const clusterSizes = new Uint32Array(clusterCount);
|
|
491
|
-
for (const cluster of request.clusters) clusterSizes[cluster] += 1;
|
|
492
|
-
const positions = new Float32Array(request.nodeCount * 3);
|
|
493
|
-
for (let index = 0; index < request.nodeCount; index += 1) {
|
|
494
|
-
const offset = index * 3;
|
|
495
|
-
const cluster = request.clusters[index] ?? 0;
|
|
496
|
-
const center = cluster * 3;
|
|
497
|
-
const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
|
|
498
|
-
const angle = random(index * 4 + 1) * Math.PI * 2;
|
|
499
|
-
positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
|
|
500
|
-
positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
|
|
501
|
-
if (request.dimensions === 3) {
|
|
502
|
-
const zDirection = random(index * 4 + 3) * 2 - 1;
|
|
503
|
-
positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return positions;
|
|
618
|
+
assertClusterCount(request.clusters, request.nodeCount, "node count");
|
|
619
|
+
return clusteredInitialPositions(request);
|
|
507
620
|
}
|
|
508
621
|
//#endregion
|
|
509
622
|
//#region src/graphology.ts
|
|
@@ -570,6 +683,188 @@ function bindGraphology(renderer, graph) {
|
|
|
570
683
|
graph.off("edgesCleared", schedule);
|
|
571
684
|
};
|
|
572
685
|
}
|
|
686
|
+
/** Longest frame step the orbit applies, so a throttled or backgrounded tab does not jump on return. */
|
|
687
|
+
const AUTO_ORBIT_MAX_DELTA_SECONDS = .1;
|
|
688
|
+
const AUTO_ORBIT_OFF = {
|
|
689
|
+
enabled: false,
|
|
690
|
+
hidden: false,
|
|
691
|
+
interacting: false,
|
|
692
|
+
offscreen: false
|
|
693
|
+
};
|
|
694
|
+
function resolveAutoOrbitOptions(options) {
|
|
695
|
+
const speed = options.speed ?? .07;
|
|
696
|
+
const resumeAfterMs = options.resumeAfterMs ?? 3e3;
|
|
697
|
+
if (!Number.isFinite(speed) || speed <= 0 || speed > 2) throw new Error(`Auto-orbit speed must be a finite number above 0 and at most 2 radians per second; received ${speed}.`);
|
|
698
|
+
if (!Number.isFinite(resumeAfterMs) || resumeAfterMs < 0) throw new Error(`Auto-orbit resumeAfterMs must be a non-negative finite number of milliseconds; received ${resumeAfterMs}.`);
|
|
699
|
+
return {
|
|
700
|
+
speed,
|
|
701
|
+
resumeAfterMs
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function nextAutoOrbitState(state, event) {
|
|
705
|
+
if (event.type === "enable") return state.enabled ? {
|
|
706
|
+
...state,
|
|
707
|
+
hidden: event.hidden
|
|
708
|
+
} : {
|
|
709
|
+
...AUTO_ORBIT_OFF,
|
|
710
|
+
enabled: true,
|
|
711
|
+
hidden: event.hidden
|
|
712
|
+
};
|
|
713
|
+
if (!state.enabled) return state;
|
|
714
|
+
switch (event.type) {
|
|
715
|
+
case "disable": return AUTO_ORBIT_OFF;
|
|
716
|
+
case "interaction": return {
|
|
717
|
+
...state,
|
|
718
|
+
interacting: true
|
|
719
|
+
};
|
|
720
|
+
case "idle": return {
|
|
721
|
+
...state,
|
|
722
|
+
interacting: false
|
|
723
|
+
};
|
|
724
|
+
case "visibility": return {
|
|
725
|
+
...state,
|
|
726
|
+
hidden: event.hidden
|
|
727
|
+
};
|
|
728
|
+
case "intersection": return {
|
|
729
|
+
...state,
|
|
730
|
+
offscreen: event.offscreen
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function autoOrbitStatus(state) {
|
|
735
|
+
if (!state.enabled) return "off";
|
|
736
|
+
return state.hidden || state.interacting || state.offscreen ? "paused" : "active";
|
|
737
|
+
}
|
|
738
|
+
/** Seconds between two animation-frame timestamps, zero on the first frame, clamped to `[0, AUTO_ORBIT_MAX_DELTA_SECONDS]`. */
|
|
739
|
+
function autoOrbitDeltaSeconds(previousMs, nowMs) {
|
|
740
|
+
if (previousMs === null) return 0;
|
|
741
|
+
return Math.min(Math.max((nowMs - previousMs) / 1e3, 0), AUTO_ORBIT_MAX_DELTA_SECONDS);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* OrbitControls (three r185) rotates `(2π / 60) * autoRotateSpeed * deltaTime` radians when
|
|
745
|
+
* `update(deltaTime)` receives seconds (and a fixed per-call step without it). Inverting that
|
|
746
|
+
* gives an `autoRotateSpeed` for which `update(dtSeconds)` turns exactly `radiansPerSecond`,
|
|
747
|
+
* independent of frame rate.
|
|
748
|
+
*/
|
|
749
|
+
function autoRotateSpeedFor(radiansPerSecond) {
|
|
750
|
+
return radiansPerSecond * 60 / (2 * Math.PI);
|
|
751
|
+
}
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/auto-orbit-loop.ts
|
|
754
|
+
/**
|
|
755
|
+
* Owns the auto-orbit frame loop and the browser signals that stop it. The loop exists only
|
|
756
|
+
* while the orbit is active: pointer, wheel and key input pause it until `resumeAfterMs` of
|
|
757
|
+
* idle, and a hidden document or an off-screen element stop it until they change back.
|
|
758
|
+
* Decisions come from the pure state in `auto-orbit.ts`; this class only wires DOM events,
|
|
759
|
+
* the resume timer and `requestAnimationFrame`. Interaction comes from the controls' `start`
|
|
760
|
+
* and `end` events, so multi-touch and lost pointer capture end the pause only once the last
|
|
761
|
+
* pointer lifts. DOM overlays above the canvas do not reach the controls and do not pause it.
|
|
762
|
+
*/
|
|
763
|
+
var AutoOrbitLoop = class {
|
|
764
|
+
element;
|
|
765
|
+
interactions;
|
|
766
|
+
resumeAfterMs;
|
|
767
|
+
step;
|
|
768
|
+
state;
|
|
769
|
+
frame = null;
|
|
770
|
+
lastFrameMs = null;
|
|
771
|
+
resumeTimer = null;
|
|
772
|
+
observer;
|
|
773
|
+
constructor(element, interactions, resumeAfterMs, step) {
|
|
774
|
+
this.element = element;
|
|
775
|
+
this.interactions = interactions;
|
|
776
|
+
this.resumeAfterMs = resumeAfterMs;
|
|
777
|
+
this.step = step;
|
|
778
|
+
this.state = nextAutoOrbitState(AUTO_ORBIT_OFF, {
|
|
779
|
+
type: "enable",
|
|
780
|
+
hidden: element.ownerDocument.hidden
|
|
781
|
+
});
|
|
782
|
+
interactions.addEventListener("start", this.handleInteractionStart);
|
|
783
|
+
interactions.addEventListener("end", this.handleInteractionEnd);
|
|
784
|
+
element.ownerDocument.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
785
|
+
this.observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(this.handleIntersection);
|
|
786
|
+
this.observer?.observe(element);
|
|
787
|
+
this.sync();
|
|
788
|
+
}
|
|
789
|
+
get status() {
|
|
790
|
+
return autoOrbitStatus(this.state);
|
|
791
|
+
}
|
|
792
|
+
setResumeAfterMs(resumeAfterMs) {
|
|
793
|
+
this.resumeAfterMs = resumeAfterMs;
|
|
794
|
+
}
|
|
795
|
+
dispose() {
|
|
796
|
+
this.interactions.removeEventListener("start", this.handleInteractionStart);
|
|
797
|
+
this.interactions.removeEventListener("end", this.handleInteractionEnd);
|
|
798
|
+
this.element.ownerDocument.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
799
|
+
this.observer?.disconnect();
|
|
800
|
+
this.clearResumeTimer();
|
|
801
|
+
this.dispatch({ type: "disable" });
|
|
802
|
+
}
|
|
803
|
+
dispatch(event) {
|
|
804
|
+
this.state = nextAutoOrbitState(this.state, event);
|
|
805
|
+
this.sync();
|
|
806
|
+
}
|
|
807
|
+
/** Starts the frame loop when the orbit becomes active and cancels it otherwise. */
|
|
808
|
+
sync() {
|
|
809
|
+
const active = this.status === "active";
|
|
810
|
+
if (active && this.frame === null) {
|
|
811
|
+
this.lastFrameMs = null;
|
|
812
|
+
this.frame = requestAnimationFrame(this.tick);
|
|
813
|
+
} else if (!active && this.frame !== null) {
|
|
814
|
+
cancelAnimationFrame(this.frame);
|
|
815
|
+
this.frame = null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
tick = (nowMs) => {
|
|
819
|
+
this.frame = null;
|
|
820
|
+
const deltaSeconds = autoOrbitDeltaSeconds(this.lastFrameMs, nowMs);
|
|
821
|
+
this.lastFrameMs = nowMs;
|
|
822
|
+
try {
|
|
823
|
+
this.step(deltaSeconds);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
this.dispose();
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
if (this.frame === null && this.status === "active") this.frame = requestAnimationFrame(this.tick);
|
|
829
|
+
};
|
|
830
|
+
armResumeTimer() {
|
|
831
|
+
this.clearResumeTimer();
|
|
832
|
+
this.resumeTimer = setTimeout(() => {
|
|
833
|
+
this.resumeTimer = null;
|
|
834
|
+
this.dispatch({ type: "idle" });
|
|
835
|
+
}, this.resumeAfterMs);
|
|
836
|
+
}
|
|
837
|
+
clearResumeTimer() {
|
|
838
|
+
if (this.resumeTimer === null) return;
|
|
839
|
+
clearTimeout(this.resumeTimer);
|
|
840
|
+
this.resumeTimer = null;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* A drag, pinch or wheel step pauses the orbit; `end` restarts the idle countdown. `start` arms
|
|
844
|
+
* it too, so a swallowed pointerup cannot pause the orbit forever. Resuming mid-drag is harmless:
|
|
845
|
+
* OrbitControls (r185) applies autoRotate in `update()` only while its state is NONE.
|
|
846
|
+
*/
|
|
847
|
+
handleInteractionStart = () => {
|
|
848
|
+
this.dispatch({ type: "interaction" });
|
|
849
|
+
this.armResumeTimer();
|
|
850
|
+
};
|
|
851
|
+
handleInteractionEnd = () => {
|
|
852
|
+
if (this.state.interacting) this.armResumeTimer();
|
|
853
|
+
};
|
|
854
|
+
handleVisibilityChange = () => {
|
|
855
|
+
this.dispatch({
|
|
856
|
+
type: "visibility",
|
|
857
|
+
hidden: this.element.ownerDocument.hidden
|
|
858
|
+
});
|
|
859
|
+
};
|
|
860
|
+
handleIntersection = (entries) => {
|
|
861
|
+
const entry = entries.at(-1);
|
|
862
|
+
if (entry) this.dispatch({
|
|
863
|
+
type: "intersection",
|
|
864
|
+
offscreen: !entry.isIntersecting
|
|
865
|
+
});
|
|
866
|
+
};
|
|
867
|
+
};
|
|
573
868
|
//#endregion
|
|
574
869
|
//#region src/camera-aspect.ts
|
|
575
870
|
/** Canvas aspect used by perspective cameras. Never 0. */
|
|
@@ -580,6 +875,170 @@ function containerAspect(width, height) {
|
|
|
580
875
|
function applyPerspectiveContainerAspect(camera, width, height) {
|
|
581
876
|
camera.aspect = containerAspect(width, height);
|
|
582
877
|
}
|
|
878
|
+
function resolveFlyToOptions(options) {
|
|
879
|
+
const durationMs = options.durationMs ?? 1200;
|
|
880
|
+
const distanceScale = options.distanceScale ?? 1;
|
|
881
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error(`flyTo durationMs must be a non-negative finite number of milliseconds; received ${durationMs}.`);
|
|
882
|
+
if (!Number.isFinite(distanceScale) || distanceScale <= 0) throw new Error(`flyTo distanceScale must be a finite number above 0; received ${distanceScale}.`);
|
|
883
|
+
return {
|
|
884
|
+
distanceScale,
|
|
885
|
+
durationMs
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
/** Cubic ease-in-out on `[0, 1]`: slow start, slow arrival, symmetric around 0.5. */
|
|
889
|
+
function easeInOutCubic(t) {
|
|
890
|
+
return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
891
|
+
}
|
|
892
|
+
/** Linear progress of a flight, clamped to `[0, 1]`; a zero duration is already done. */
|
|
893
|
+
function flightProgress(elapsedMs, durationMs) {
|
|
894
|
+
if (durationMs <= 0) return 1;
|
|
895
|
+
return Math.min(Math.max(elapsedMs / durationMs, 0), 1);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Interpolates a positive scale (camera distance, orthographic zoom) geometrically, so each
|
|
899
|
+
* step zooms by the same factor and a long zoom does not rush its last part. Exact at both ends.
|
|
900
|
+
*/
|
|
901
|
+
function interpolateScale(from, to, t) {
|
|
902
|
+
if (t <= 0) return from;
|
|
903
|
+
if (t >= 1) return to;
|
|
904
|
+
return from * (to / from) ** t;
|
|
905
|
+
}
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/theme.ts
|
|
908
|
+
/** Dark canvas: Ink Black, Turf Green, Porcelain. */
|
|
909
|
+
const graphraumThemeDark = Object.freeze({
|
|
910
|
+
background: "#040f0f",
|
|
911
|
+
dimmedEdge: "#315a51",
|
|
912
|
+
dimmedEdgeOpacity: .25,
|
|
913
|
+
dimmedNode: "#315a51",
|
|
914
|
+
edge: "#226f54",
|
|
915
|
+
edgeOpacity: .55,
|
|
916
|
+
edgeWidth: 1.5,
|
|
917
|
+
endpointAttach: "boundary",
|
|
918
|
+
focusedNode: "#73c7a5",
|
|
919
|
+
hoveredNode: "#e4a853",
|
|
920
|
+
node: "#226f54",
|
|
921
|
+
nodeStroke: "#fcfffc",
|
|
922
|
+
selectedEdge: "#fcfffc",
|
|
923
|
+
selectedNode: "#fcfffc"
|
|
924
|
+
});
|
|
925
|
+
/** Light canvas: Porcelain field with Turf marks and Ink selection. */
|
|
926
|
+
const graphraumThemeLight = Object.freeze({
|
|
927
|
+
background: "#fcfffc",
|
|
928
|
+
dimmedEdge: "#9bb5ac",
|
|
929
|
+
dimmedEdgeOpacity: .35,
|
|
930
|
+
dimmedNode: "#9bb5ac",
|
|
931
|
+
edge: "#226f54",
|
|
932
|
+
edgeOpacity: .65,
|
|
933
|
+
edgeWidth: 1.5,
|
|
934
|
+
endpointAttach: "boundary",
|
|
935
|
+
focusedNode: "#1a5c45",
|
|
936
|
+
hoveredNode: "#c47a1a",
|
|
937
|
+
node: "#226f54",
|
|
938
|
+
nodeStroke: "#040f0f",
|
|
939
|
+
selectedEdge: "#040f0f",
|
|
940
|
+
selectedNode: "#040f0f"
|
|
941
|
+
});
|
|
942
|
+
const graphraumThemes = Object.freeze({
|
|
943
|
+
dark: graphraumThemeDark,
|
|
944
|
+
light: graphraumThemeLight
|
|
945
|
+
});
|
|
946
|
+
/** Alias of {@link graphraumThemeDark} for backward compatibility. */
|
|
947
|
+
const graphraumTheme = graphraumThemeDark;
|
|
948
|
+
function resolveGraphraumTheme(input, base = graphraumThemeDark) {
|
|
949
|
+
if (input === void 0) return { ...base };
|
|
950
|
+
if (typeof input === "string") {
|
|
951
|
+
const preset = graphraumThemes[input];
|
|
952
|
+
if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
|
|
953
|
+
return { ...preset };
|
|
954
|
+
}
|
|
955
|
+
return assertGraphraumTheme({
|
|
956
|
+
...base,
|
|
957
|
+
...input
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
function assertGraphraumTheme(theme) {
|
|
961
|
+
const opacity = theme.dimmedEdgeOpacity;
|
|
962
|
+
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`Theme dimmedEdgeOpacity must be a finite number between 0 and 1, received ${opacity}.`);
|
|
963
|
+
return theme;
|
|
964
|
+
}
|
|
965
|
+
/** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
|
|
966
|
+
function normalizeGraphraumBackground(background) {
|
|
967
|
+
return background === "transparent" ? null : background;
|
|
968
|
+
}
|
|
969
|
+
function isTransparentGraphraumBackground(background) {
|
|
970
|
+
return normalizeGraphraumBackground(background) === null;
|
|
971
|
+
}
|
|
972
|
+
/** Halo radius as a multiple of the node's half-extent at `glow: 1`. */
|
|
973
|
+
const GLOW_RADIUS_SCALE = 3.4;
|
|
974
|
+
/** Halo opacity multiplier under normal blending, so a light background shows a soft tint. */
|
|
975
|
+
const GLOW_NORMAL_OPACITY_SCALE = .6;
|
|
976
|
+
function assertNodeGlow(nodeId, glow) {
|
|
977
|
+
if (glow !== void 0 && (!Number.isFinite(glow) || glow < 0 || glow > 1)) throw new Error(`Node "${nodeId}" glow must be a finite number between 0 and 1`);
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Halo radius in world units: `extent × (1 + (GLOW_RADIUS_SCALE − 1) × glow)`, where `extent` is
|
|
981
|
+
* the node's half-extent `max(width, height)`. Glow 0 collapses the halo under the node; glow 1
|
|
982
|
+
* reaches 3.4× the node extent.
|
|
983
|
+
*/
|
|
984
|
+
function glowHaloRadius(extent, glow) {
|
|
985
|
+
return extent * (1 + (GLOW_RADIUS_SCALE - 1) * glow);
|
|
986
|
+
}
|
|
987
|
+
/** Peak halo opacity at the node center; the shader fades it radially to 0 at the halo radius. */
|
|
988
|
+
function glowHaloOpacity(glow) {
|
|
989
|
+
return glow;
|
|
990
|
+
}
|
|
991
|
+
/** Keeps `current` while it fits, otherwise grows to the next power of two capped at `maximum`. */
|
|
992
|
+
function glowBufferCapacity(required, current, maximum) {
|
|
993
|
+
if (required <= current) return current;
|
|
994
|
+
let capacity = Math.max(current, 1);
|
|
995
|
+
while (capacity < required) capacity *= 2;
|
|
996
|
+
return Math.min(capacity, maximum);
|
|
997
|
+
}
|
|
998
|
+
function createGlowSelection() {
|
|
999
|
+
return {
|
|
1000
|
+
count: 0,
|
|
1001
|
+
indices: /* @__PURE__ */ new Uint32Array(0)
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Selects visible nodes with `glow > 0` into `selection`, highest glow first and ties by node
|
|
1006
|
+
* index, capped at {@link GLOW_MAX_INSTANCES}. Selects nothing in the density LOD tier. Reuses
|
|
1007
|
+
* the selection buffer and grows it geometrically; allocates no per-node objects.
|
|
1008
|
+
*/
|
|
1009
|
+
function selectGlowNodes(visibleNodeIndices, glowAt, densityLod, selection) {
|
|
1010
|
+
selection.count = 0;
|
|
1011
|
+
if (densityLod) return selection;
|
|
1012
|
+
let candidates = 0;
|
|
1013
|
+
for (const nodeIndex of visibleNodeIndices) {
|
|
1014
|
+
if (!(glowAt(nodeIndex) > 0)) continue;
|
|
1015
|
+
if (candidates === selection.indices.length) {
|
|
1016
|
+
const grown = new Uint32Array(glowBufferCapacity(candidates + 1, candidates, Number.POSITIVE_INFINITY));
|
|
1017
|
+
grown.set(selection.indices);
|
|
1018
|
+
selection.indices = grown;
|
|
1019
|
+
}
|
|
1020
|
+
selection.indices[candidates] = nodeIndex;
|
|
1021
|
+
candidates += 1;
|
|
1022
|
+
}
|
|
1023
|
+
selection.indices.subarray(0, candidates).sort((a, b) => glowAt(b) - glowAt(a) || a - b);
|
|
1024
|
+
selection.count = Math.min(candidates, 256);
|
|
1025
|
+
return selection;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Additive light reads on dark backgrounds but saturates to nothing on light ones. A solid
|
|
1029
|
+
* background whose relative luminance (linear sRGB, 0.2126 R + 0.7152 G + 0.0722 B) reaches
|
|
1030
|
+
* {@link GLOW_LIGHT_BACKGROUND_LUMINANCE} uses normal blending; transparent and pattern
|
|
1031
|
+
* backgrounds keep additive blending because their brightness is unknown.
|
|
1032
|
+
*/
|
|
1033
|
+
function glowBlendModeFor(background) {
|
|
1034
|
+
const normalized = normalizeGraphraumBackground(background);
|
|
1035
|
+
if (normalized === null || typeof normalized === "object") return "additive";
|
|
1036
|
+
const { b, g, r } = new Color(normalized);
|
|
1037
|
+
return .2126 * r + .7152 * g + .0722 * b >= .5 ? "normal" : "additive";
|
|
1038
|
+
}
|
|
1039
|
+
function glowOpacityScale(mode) {
|
|
1040
|
+
return mode === "normal" ? GLOW_NORMAL_OPACITY_SCALE : 1;
|
|
1041
|
+
}
|
|
583
1042
|
//#endregion
|
|
584
1043
|
//#region src/compile-graph.ts
|
|
585
1044
|
function assertFinitePosition(nodeId, axis, value) {
|
|
@@ -632,6 +1091,7 @@ function compileNodeVisual(id, visual) {
|
|
|
632
1091
|
assertPositiveFiniteVisual(id, "width", visual.width);
|
|
633
1092
|
assertPositiveFiniteVisual(id, "height", visual.height);
|
|
634
1093
|
assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
|
|
1094
|
+
assertNodeGlow(id, visual.glow);
|
|
635
1095
|
if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
|
|
636
1096
|
return Object.freeze({ ...visual });
|
|
637
1097
|
}
|
|
@@ -653,6 +1113,7 @@ function compileGraph(data, visuals) {
|
|
|
653
1113
|
const encoding = visuals?.node?.(node);
|
|
654
1114
|
const visual = compileNodeVisual(node.id, encoding?.visual ?? {
|
|
655
1115
|
color: node.color,
|
|
1116
|
+
glow: node.glow,
|
|
656
1117
|
height: node.height,
|
|
657
1118
|
shape: node.shape,
|
|
658
1119
|
size: node.size,
|
|
@@ -838,6 +1299,32 @@ function sampleEdgePath(input) {
|
|
|
838
1299
|
return points;
|
|
839
1300
|
}
|
|
840
1301
|
//#endregion
|
|
1302
|
+
//#region src/edge-state.ts
|
|
1303
|
+
/**
|
|
1304
|
+
* Single source of truth for an edge instance's color and opacity. Full packing and the
|
|
1305
|
+
* incremental state repaint both call it, so both paths write identical instance colors.
|
|
1306
|
+
*/
|
|
1307
|
+
function resolveEdgePaint(edgeIndex, visual, defaults, tier, edgeStates) {
|
|
1308
|
+
const opacity = tier === "overview" ? defaults.opacity : visual.opacity ?? defaults.opacity;
|
|
1309
|
+
const state = edgeStates?.stateOf(edgeIndex) ?? null;
|
|
1310
|
+
if (edgeStates && state === "selected") return {
|
|
1311
|
+
color: edgeStates.selectedColor,
|
|
1312
|
+
opacity
|
|
1313
|
+
};
|
|
1314
|
+
if (edgeStates && state === "dimmed") return {
|
|
1315
|
+
color: edgeStates.dimmedColor,
|
|
1316
|
+
opacity: Math.min(opacity, edgeStates.dimmedOpacity)
|
|
1317
|
+
};
|
|
1318
|
+
return {
|
|
1319
|
+
color: visual.color ?? defaults.color,
|
|
1320
|
+
opacity
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
/** IDs present in exactly one of the two sets. */
|
|
1324
|
+
function changedIds(current, next) {
|
|
1325
|
+
return [...[...current].filter((id) => !next.has(id)), ...[...next].filter((id) => !current.has(id))];
|
|
1326
|
+
}
|
|
1327
|
+
//#endregion
|
|
841
1328
|
//#region src/edge-materialize.ts
|
|
842
1329
|
function packEdgeInstances(input) {
|
|
843
1330
|
const segments = [];
|
|
@@ -854,37 +1341,43 @@ function packEdgeInstances(input) {
|
|
|
854
1341
|
let x2 = input.endpointPositions[base + 3] ?? 0;
|
|
855
1342
|
let y2 = input.endpointPositions[base + 4] ?? 0;
|
|
856
1343
|
let z2 = input.endpointPositions[base + 5] ?? 0;
|
|
1344
|
+
let trimSource = 0;
|
|
1345
|
+
let trimTarget = 0;
|
|
857
1346
|
if (attach === "boundary" && input.nodeOutlines && input.edgeNodeIndices) {
|
|
858
1347
|
const sourceIndex = input.edgeNodeIndices[edgeIndex * 2] ?? 0;
|
|
859
1348
|
const targetIndex = input.edgeNodeIndices[edgeIndex * 2 + 1] ?? 0;
|
|
860
1349
|
const sourceOutline = input.nodeOutlines[sourceIndex] ?? {};
|
|
861
1350
|
const targetOutline = input.nodeOutlines[targetIndex] ?? {};
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
clearance
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1351
|
+
if (input.mode === "3d") {
|
|
1352
|
+
trimSource = outlineReach(sourceOutline, clearance);
|
|
1353
|
+
trimTarget = outlineReach(targetOutline, clearance);
|
|
1354
|
+
} else {
|
|
1355
|
+
const trimmed = trimEdgeEndpoints({
|
|
1356
|
+
attach,
|
|
1357
|
+
clearance,
|
|
1358
|
+
source: {
|
|
1359
|
+
x: x1,
|
|
1360
|
+
y: y1,
|
|
1361
|
+
z: z1
|
|
1362
|
+
},
|
|
1363
|
+
target: {
|
|
1364
|
+
x: x2,
|
|
1365
|
+
y: y2,
|
|
1366
|
+
z: z2
|
|
1367
|
+
},
|
|
1368
|
+
sourceOutline,
|
|
1369
|
+
targetOutline
|
|
1370
|
+
});
|
|
1371
|
+
x1 = trimmed.source.x;
|
|
1372
|
+
y1 = trimmed.source.y;
|
|
1373
|
+
z1 = trimmed.source.z ?? 0;
|
|
1374
|
+
x2 = trimmed.target.x;
|
|
1375
|
+
y2 = trimmed.target.y;
|
|
1376
|
+
z2 = trimmed.target.z ?? 0;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
const { color, opacity } = resolveEdgePaint(edgeIndex, visual, input.defaults, input.tier, input.edgeStates);
|
|
886
1380
|
const width = input.tier === "overview" ? input.defaults.width : visual.width ?? input.defaults.width;
|
|
887
|
-
const opacity = input.tier === "overview" ? input.defaults.opacity : visual.opacity ?? input.defaults.opacity;
|
|
888
1381
|
const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
|
|
889
1382
|
const points = sampleEdgePath({
|
|
890
1383
|
path: visual.path,
|
|
@@ -917,7 +1410,9 @@ function packEdgeInstances(input) {
|
|
|
917
1410
|
color,
|
|
918
1411
|
width,
|
|
919
1412
|
opacity,
|
|
920
|
-
style
|
|
1413
|
+
style,
|
|
1414
|
+
trimStart: index === 0 ? trimSource : 0,
|
|
1415
|
+
trimEnd: index === segmentSpans - 1 ? trimTarget : 0
|
|
921
1416
|
});
|
|
922
1417
|
}
|
|
923
1418
|
if (input.tier === "overview") continue;
|
|
@@ -942,7 +1437,8 @@ function packEdgeInstances(input) {
|
|
|
942
1437
|
dz: fromSource ? (first.z ?? 0) - (second.z ?? 0) : (last.z ?? 0) - (previous.z ?? 0),
|
|
943
1438
|
color,
|
|
944
1439
|
size: markerSize,
|
|
945
|
-
opacity
|
|
1440
|
+
opacity,
|
|
1441
|
+
trim: fromSource ? trimSource : trimTarget
|
|
946
1442
|
});
|
|
947
1443
|
};
|
|
948
1444
|
if (ends === "source" || ends === "both") add("source");
|
|
@@ -996,14 +1492,26 @@ function pickClosestEdgeIndex(point, segments) {
|
|
|
996
1492
|
return nearestEdgeIndex;
|
|
997
1493
|
}
|
|
998
1494
|
//#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
|
|
999
1506
|
//#region src/edge-rendering.ts
|
|
1000
|
-
const vertexShader$
|
|
1507
|
+
const vertexShader$2 = `
|
|
1001
1508
|
attribute float instanceKind;
|
|
1002
1509
|
attribute vec3 instanceEndA;
|
|
1003
1510
|
attribute vec3 instanceEndB;
|
|
1004
1511
|
attribute vec4 instanceColor;
|
|
1005
1512
|
attribute float instanceWidth;
|
|
1006
1513
|
attribute float instanceStyle;
|
|
1514
|
+
attribute vec2 instanceTrim;
|
|
1007
1515
|
|
|
1008
1516
|
varying vec4 edgeColor;
|
|
1009
1517
|
varying float edgeKind;
|
|
@@ -1027,6 +1535,18 @@ void main() {
|
|
|
1027
1535
|
vec2 delta = viewB.xy - viewA.xy;
|
|
1028
1536
|
float len = length(delta);
|
|
1029
1537
|
vec2 dir = len > 0.0001 ? delta / len : vec2(1.0, 0.0);
|
|
1538
|
+
// 3D boundary ends (see trimViewSegment): stop on the camera-facing outline. A trimmed end
|
|
1539
|
+
// keeps its node's depth, where the billboard is, so it meets the rim from every view.
|
|
1540
|
+
if (instanceTrim.x + instanceTrim.y > 0.0) {
|
|
1541
|
+
if (len - instanceTrim.x - instanceTrim.y <= 0.0) {
|
|
1542
|
+
gl_Position = vec4(0.0, 0.0, 2.0, 1.0); // hidden behind its nodes: clip it away
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
viewA.xy += dir * instanceTrim.x;
|
|
1546
|
+
viewB.xy -= dir * instanceTrim.y;
|
|
1547
|
+
delta = viewB.xy - viewA.xy;
|
|
1548
|
+
len = length(delta);
|
|
1549
|
+
}
|
|
1030
1550
|
vec2 perp = vec2(-dir.y, dir.x);
|
|
1031
1551
|
vec2 mid = (viewA.xy + viewB.xy) * 0.5;
|
|
1032
1552
|
vec2 offset = dir * position.x * len + perp * position.y * instanceWidth;
|
|
@@ -1041,14 +1561,16 @@ void main() {
|
|
|
1041
1561
|
vec2 dir = dirLen > 0.0001 ? dirVec / dirLen : vec2(1.0, 0.0);
|
|
1042
1562
|
vec2 perp = vec2(-dir.y, dir.x);
|
|
1043
1563
|
float t = position.x + 0.5;
|
|
1564
|
+
// 3D boundary ends pull the apex back onto the camera-facing outline (see markerApexInView).
|
|
1565
|
+
vec2 apex = viewA.xy - dir * instanceTrim.x;
|
|
1044
1566
|
vec2 offset = dir * instanceWidth * (t - 1.0) + perp * position.y * instanceWidth;
|
|
1045
|
-
viewPosition = vec4(
|
|
1567
|
+
viewPosition = vec4(apex + offset, viewA.z, 1.0);
|
|
1046
1568
|
}
|
|
1047
1569
|
|
|
1048
1570
|
gl_Position = projectionMatrix * viewPosition;
|
|
1049
1571
|
}
|
|
1050
1572
|
`;
|
|
1051
|
-
const fragmentShader$
|
|
1573
|
+
const fragmentShader$2 = `
|
|
1052
1574
|
varying vec4 edgeColor;
|
|
1053
1575
|
varying float edgeKind;
|
|
1054
1576
|
varying float edgeStyle;
|
|
@@ -1072,8 +1594,19 @@ void main() {
|
|
|
1072
1594
|
}
|
|
1073
1595
|
if (edgeColor.a <= 0.0) discard;
|
|
1074
1596
|
gl_FragColor = edgeColor;
|
|
1597
|
+
${OUTPUT_COLOR_SPACE_FRAGMENT}
|
|
1075
1598
|
}
|
|
1076
1599
|
`;
|
|
1600
|
+
/** Per-instance attributes of the edge geometry; updates mark each of them dirty. */
|
|
1601
|
+
const EDGE_INSTANCE_ATTRIBUTES = [
|
|
1602
|
+
"instanceKind",
|
|
1603
|
+
"instanceEndA",
|
|
1604
|
+
"instanceEndB",
|
|
1605
|
+
"instanceColor",
|
|
1606
|
+
"instanceWidth",
|
|
1607
|
+
"instanceStyle",
|
|
1608
|
+
"instanceTrim"
|
|
1609
|
+
];
|
|
1077
1610
|
function createEdgeGeometry(capacity) {
|
|
1078
1611
|
const geometry = new PlaneGeometry(1, 1);
|
|
1079
1612
|
geometry.setAttribute("instanceKind", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
@@ -1082,15 +1615,16 @@ function createEdgeGeometry(capacity) {
|
|
|
1082
1615
|
geometry.setAttribute("instanceColor", new InstancedBufferAttribute(new Float32Array(capacity * 4), 4));
|
|
1083
1616
|
geometry.setAttribute("instanceWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1084
1617
|
geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1618
|
+
geometry.setAttribute("instanceTrim", new InstancedBufferAttribute(new Float32Array(capacity * 2), 2));
|
|
1085
1619
|
return geometry;
|
|
1086
1620
|
}
|
|
1087
|
-
function createEdgeMaterial(
|
|
1621
|
+
function createEdgeMaterial() {
|
|
1088
1622
|
return new ShaderMaterial({
|
|
1089
|
-
depthTest,
|
|
1090
|
-
depthWrite:
|
|
1091
|
-
fragmentShader: fragmentShader$
|
|
1623
|
+
depthTest: false,
|
|
1624
|
+
depthWrite: false,
|
|
1625
|
+
fragmentShader: fragmentShader$2,
|
|
1092
1626
|
transparent: true,
|
|
1093
|
-
vertexShader: vertexShader$
|
|
1627
|
+
vertexShader: vertexShader$2
|
|
1094
1628
|
});
|
|
1095
1629
|
}
|
|
1096
1630
|
function getInstancedAttribute(geometry, name) {
|
|
@@ -1108,10 +1642,10 @@ function writeEdgeSegmentInstance(geometry, slot, segment) {
|
|
|
1108
1642
|
kind.setX(slot, 0);
|
|
1109
1643
|
endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
|
|
1110
1644
|
endB.setXYZ(slot, segment.x2, segment.y2, segment.z2);
|
|
1111
|
-
|
|
1112
|
-
color.setXYZW(slot, rgb.r, rgb.g, rgb.b, segment.opacity);
|
|
1645
|
+
writeEdgeColorSlots(color, slot, 1, segment);
|
|
1113
1646
|
width.setX(slot, segment.width);
|
|
1114
1647
|
style.setX(slot, encodeEdgeStyle(segment.style));
|
|
1648
|
+
getInstancedAttribute(geometry, "instanceTrim").setXY(slot, segment.trimStart, segment.trimEnd);
|
|
1115
1649
|
}
|
|
1116
1650
|
function writeEdgeMarkerInstance(geometry, slot, marker) {
|
|
1117
1651
|
const kind = getInstancedAttribute(geometry, "instanceKind");
|
|
@@ -1123,21 +1657,18 @@ function writeEdgeMarkerInstance(geometry, slot, marker) {
|
|
|
1123
1657
|
kind.setX(slot, 1);
|
|
1124
1658
|
endA.setXYZ(slot, marker.x, marker.y, marker.z);
|
|
1125
1659
|
endB.setXYZ(slot, marker.x + marker.dx, marker.y + marker.dy, marker.z + marker.dz);
|
|
1126
|
-
|
|
1127
|
-
color.setXYZW(slot, rgb.r, rgb.g, rgb.b, marker.opacity);
|
|
1660
|
+
writeEdgeColorSlots(color, slot, 1, marker);
|
|
1128
1661
|
width.setX(slot, marker.size);
|
|
1129
1662
|
style.setX(slot, 0);
|
|
1663
|
+
getInstancedAttribute(geometry, "instanceTrim").setXY(slot, marker.trim, 0);
|
|
1664
|
+
}
|
|
1665
|
+
/** Writes one color and opacity into `count` consecutive instance slots starting at `start`. */
|
|
1666
|
+
function writeEdgeColorSlots(color, start, count, paint) {
|
|
1667
|
+
const rgb = new Color(paint.color);
|
|
1668
|
+
for (let slot = start; slot < start + count; slot += 1) color.setXYZW(slot, rgb.r, rgb.g, rgb.b, paint.opacity);
|
|
1130
1669
|
}
|
|
1131
1670
|
//#endregion
|
|
1132
1671
|
//#region src/edge-viewport-patch.ts
|
|
1133
|
-
const EDGE_INSTANCE_ATTRIBUTES$1 = [
|
|
1134
|
-
"instanceKind",
|
|
1135
|
-
"instanceEndA",
|
|
1136
|
-
"instanceEndB",
|
|
1137
|
-
"instanceColor",
|
|
1138
|
-
"instanceWidth",
|
|
1139
|
-
"instanceStyle"
|
|
1140
|
-
];
|
|
1141
1672
|
/** Records GPU slot ranges for each visible edge after a full viewport materialize. */
|
|
1142
1673
|
function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
|
|
1143
1674
|
const layouts = /* @__PURE__ */ new Map();
|
|
@@ -1188,6 +1719,8 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
|
1188
1719
|
tier: input.tier,
|
|
1189
1720
|
endpointAttach: input.endpointAttach,
|
|
1190
1721
|
edgeNodeIndices: input.edgeNodeIndices,
|
|
1722
|
+
edgeStates: input.edgeStates,
|
|
1723
|
+
mode: input.mode,
|
|
1191
1724
|
nodeOutlines: input.nodeOutlines
|
|
1192
1725
|
});
|
|
1193
1726
|
if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
|
|
@@ -1223,9 +1756,8 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
|
1223
1756
|
ok: true,
|
|
1224
1757
|
pickableSegments: nextPickable
|
|
1225
1758
|
};
|
|
1226
|
-
for (const name of EDGE_INSTANCE_ATTRIBUTES
|
|
1759
|
+
for (const name of EDGE_INSTANCE_ATTRIBUTES) {
|
|
1227
1760
|
const attribute = geometry.getAttribute(name);
|
|
1228
|
-
attribute.clearUpdateRanges();
|
|
1229
1761
|
attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
|
|
1230
1762
|
attribute.needsUpdate = true;
|
|
1231
1763
|
}
|
|
@@ -1236,14 +1768,146 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
|
1236
1768
|
pickableSegments: nextPickable
|
|
1237
1769
|
};
|
|
1238
1770
|
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Repaints the color and opacity of visible edges whose host state changed. Slot layout,
|
|
1773
|
+
* geometry, and picking stay untouched, so only the changed `instanceColor` ranges upload.
|
|
1774
|
+
* Edges without visible slots are skipped; the next materialize paints them from the same state.
|
|
1775
|
+
*/
|
|
1776
|
+
function patchVisibleEdgePaint(geometry, input) {
|
|
1777
|
+
const visible = [...input.changedEdgeIndices].flatMap((edgeIndex) => {
|
|
1778
|
+
const layout = input.layouts.get(edgeIndex);
|
|
1779
|
+
return layout ? [{
|
|
1780
|
+
edgeIndex,
|
|
1781
|
+
layout
|
|
1782
|
+
}] : [];
|
|
1783
|
+
});
|
|
1784
|
+
if (!visible.every(({ layout }) => layout.segmentStart + layout.segmentCount <= input.instanceCount && layout.markerStart + layout.markerCount <= input.instanceCount)) return {
|
|
1785
|
+
ok: false,
|
|
1786
|
+
patchedSlots: 0
|
|
1787
|
+
};
|
|
1788
|
+
const color = getInstancedAttribute(geometry, "instanceColor");
|
|
1789
|
+
let patchedSlots = 0;
|
|
1790
|
+
for (const { edgeIndex, layout } of visible) {
|
|
1791
|
+
const paint = resolveEdgePaint(edgeIndex, input.edgeVisuals[edgeIndex] ?? {}, input.defaults, input.tier, input.edgeStates);
|
|
1792
|
+
for (const [start, count] of [[layout.segmentStart, layout.segmentCount], [layout.markerStart, layout.markerCount]]) {
|
|
1793
|
+
if (count === 0) continue;
|
|
1794
|
+
writeEdgeColorSlots(color, start, count, paint);
|
|
1795
|
+
color.addUpdateRange(start * color.itemSize, count * color.itemSize);
|
|
1796
|
+
patchedSlots += count;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (patchedSlots > 0) color.needsUpdate = true;
|
|
1800
|
+
return {
|
|
1801
|
+
ok: true,
|
|
1802
|
+
patchedSlots
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
//#endregion
|
|
1806
|
+
//#region src/fit-3d.ts
|
|
1807
|
+
/** Newton steps on a piecewise-linear function before falling back to the plane-space centre. */
|
|
1808
|
+
const MAX_CENTERING_STEPS = 32;
|
|
1809
|
+
/**
|
|
1810
|
+
* The closest front-view camera that frames every node. Throws without nodes. `maxCenteringSteps`
|
|
1811
|
+
* exists so tests can exercise the fallback.
|
|
1812
|
+
*/
|
|
1813
|
+
function fitPerspective(frame, nodes, maxCenteringSteps = MAX_CENTERING_STEPS) {
|
|
1814
|
+
if (nodes.count === 0) throw new Error("fitPerspective needs at least one node");
|
|
1815
|
+
const tanY = Math.tan(frame.fovDegrees * Math.PI / 360) * frame.fill;
|
|
1816
|
+
const tanX = tanY * frame.aspect;
|
|
1817
|
+
const node = {
|
|
1818
|
+
halfHeight: 0,
|
|
1819
|
+
halfWidth: 0,
|
|
1820
|
+
x: 0,
|
|
1821
|
+
y: 0,
|
|
1822
|
+
z: 0
|
|
1823
|
+
};
|
|
1824
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
1825
|
+
let left = Number.NEGATIVE_INFINITY;
|
|
1826
|
+
let top = Number.NEGATIVE_INFINITY;
|
|
1827
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
1828
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
1829
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
1830
|
+
for (let index = 0; index < nodes.count; index += 1) {
|
|
1831
|
+
nodes.read(index, node);
|
|
1832
|
+
right = Math.max(right, node.x + node.halfWidth + tanX * node.z);
|
|
1833
|
+
left = Math.max(left, -node.x + node.halfWidth + tanX * node.z);
|
|
1834
|
+
top = Math.max(top, node.y + node.halfHeight + tanY * node.z);
|
|
1835
|
+
bottom = Math.max(bottom, -node.y + node.halfHeight + tanY * node.z);
|
|
1836
|
+
minZ = Math.min(minZ, node.z);
|
|
1837
|
+
maxZ = Math.max(maxZ, node.z);
|
|
1838
|
+
}
|
|
1839
|
+
const cameraZX = (right + left) / (2 * tanX);
|
|
1840
|
+
const cameraZY = (top + bottom) / (2 * tanY);
|
|
1841
|
+
const cameraZ = Math.max(cameraZX, cameraZY);
|
|
1842
|
+
let x = (right - left) / 2;
|
|
1843
|
+
let y = (top - bottom) / 2;
|
|
1844
|
+
if (cameraZX < cameraZY) x = centerOnScreen(nodes, node, "x", cameraZ, x, maxCenteringSteps);
|
|
1845
|
+
else if (cameraZY < cameraZX) y = centerOnScreen(nodes, node, "y", cameraZ, y, maxCenteringSteps);
|
|
1846
|
+
const target = {
|
|
1847
|
+
x,
|
|
1848
|
+
y,
|
|
1849
|
+
z: (minZ + maxZ) / 2
|
|
1850
|
+
};
|
|
1851
|
+
const distance = cameraZ - target.z;
|
|
1852
|
+
const nearestDepth = cameraZ - maxZ;
|
|
1853
|
+
return {
|
|
1854
|
+
cameraZ,
|
|
1855
|
+
near: Math.min(Math.max(distance / 1e4, .1), nearestDepth / 2),
|
|
1856
|
+
far: distance * 10,
|
|
1857
|
+
target
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Centre on the looser axis that makes the farthest screen reach equal on both sides.
|
|
1862
|
+
*
|
|
1863
|
+
* With depth `d = cameraZ - z`, a centre `c` keeps every node within screen reach `r` when
|
|
1864
|
+
* `max(p + h - r * d) <= c <= min(p - h + r * d)`. The gap between those bounds is convex and
|
|
1865
|
+
* decreasing in `r`, so Newton steps from `r = 0` rise monotonically to the smallest feasible
|
|
1866
|
+
* reach. `planeCenter` already fits, so it is the answer if the steps do not settle.
|
|
1867
|
+
*/
|
|
1868
|
+
function centerOnScreen(nodes, node, axis, cameraZ, planeCenter, maxSteps) {
|
|
1869
|
+
let reach = 0;
|
|
1870
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
1871
|
+
let low = Number.NEGATIVE_INFINITY;
|
|
1872
|
+
let high = Number.POSITIVE_INFINITY;
|
|
1873
|
+
let lowValue = 0;
|
|
1874
|
+
let lowDepth = 0;
|
|
1875
|
+
let highValue = 0;
|
|
1876
|
+
let highDepth = 0;
|
|
1877
|
+
for (let index = 0; index < nodes.count; index += 1) {
|
|
1878
|
+
nodes.read(index, node);
|
|
1879
|
+
const position = axis === "x" ? node.x : node.y;
|
|
1880
|
+
const half = axis === "x" ? node.halfWidth : node.halfHeight;
|
|
1881
|
+
const depth = cameraZ - node.z;
|
|
1882
|
+
const lowBound = position + half - reach * depth;
|
|
1883
|
+
const highBound = position - half + reach * depth;
|
|
1884
|
+
if (lowBound > low) {
|
|
1885
|
+
low = lowBound;
|
|
1886
|
+
lowValue = position + half;
|
|
1887
|
+
lowDepth = depth;
|
|
1888
|
+
}
|
|
1889
|
+
if (highBound < high) {
|
|
1890
|
+
high = highBound;
|
|
1891
|
+
highValue = position - half;
|
|
1892
|
+
highDepth = depth;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if (low <= high) return (low + high) / 2;
|
|
1896
|
+
const next = (lowValue - highValue) / (lowDepth + highDepth);
|
|
1897
|
+
if (next <= reach) return (low + high) / 2;
|
|
1898
|
+
reach = next;
|
|
1899
|
+
}
|
|
1900
|
+
return planeCenter;
|
|
1901
|
+
}
|
|
1239
1902
|
//#endregion
|
|
1240
1903
|
//#region src/instance-color-ranges.ts
|
|
1241
1904
|
/**
|
|
1242
1905
|
* Marks RGB update ranges for visible instance slots.
|
|
1906
|
+
* Keeps earlier ranges that have not been uploaded yet (a viewport materialize in the same frame);
|
|
1907
|
+
* three.js merges and clears ranges on upload.
|
|
1243
1908
|
* Returns how many slots were marked.
|
|
1244
1909
|
*/
|
|
1245
1910
|
function markInstanceColorSlots(attribute, slots) {
|
|
1246
|
-
attribute.clearUpdateRanges();
|
|
1247
1911
|
let count = 0;
|
|
1248
1912
|
for (const slot of slots) {
|
|
1249
1913
|
if (!Number.isSafeInteger(slot) || slot < 0) continue;
|
|
@@ -1275,6 +1939,7 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
|
|
|
1275
1939
|
for (const [axis, value] of Object.entries(position)) if (!Number.isFinite(value)) throw new Error(`Layout node "${id}" has a non-finite ${axis} position`);
|
|
1276
1940
|
return {
|
|
1277
1941
|
colorChanged: false,
|
|
1942
|
+
glowChanged: false,
|
|
1278
1943
|
index: nodeIndex,
|
|
1279
1944
|
next: {
|
|
1280
1945
|
...current,
|
|
@@ -1287,6 +1952,145 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
|
|
|
1287
1952
|
};
|
|
1288
1953
|
});
|
|
1289
1954
|
}
|
|
1955
|
+
const vertexShader$1 = `
|
|
1956
|
+
attribute float instanceGlowOpacity;
|
|
1957
|
+
varying vec3 glowColor;
|
|
1958
|
+
varying vec2 glowPoint;
|
|
1959
|
+
varying float glowOpacity;
|
|
1960
|
+
|
|
1961
|
+
void main() {
|
|
1962
|
+
glowColor = instanceColor;
|
|
1963
|
+
glowPoint = position.xy;
|
|
1964
|
+
glowOpacity = instanceGlowOpacity;
|
|
1965
|
+
vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
|
1966
|
+
center.xy += position.xy * length(instanceMatrix[0].xyz);
|
|
1967
|
+
gl_Position = projectionMatrix * center;
|
|
1968
|
+
}
|
|
1969
|
+
`;
|
|
1970
|
+
const fragmentShader$1 = `
|
|
1971
|
+
uniform float opacityScale;
|
|
1972
|
+
varying vec3 glowColor;
|
|
1973
|
+
varying vec2 glowPoint;
|
|
1974
|
+
varying float glowOpacity;
|
|
1975
|
+
|
|
1976
|
+
void main() {
|
|
1977
|
+
float distanceFromCenter = length(glowPoint);
|
|
1978
|
+
if (distanceFromCenter >= 1.0) discard;
|
|
1979
|
+
float falloff = 1.0 - distanceFromCenter;
|
|
1980
|
+
gl_FragColor = vec4(glowColor, falloff * falloff * glowOpacity * opacityScale);
|
|
1981
|
+
${OUTPUT_COLOR_SPACE_FRAGMENT}
|
|
1982
|
+
}
|
|
1983
|
+
`;
|
|
1984
|
+
function applyBlendMode(material, mode) {
|
|
1985
|
+
material.blending = mode === "normal" ? NormalBlending : AdditiveBlending;
|
|
1986
|
+
const opacityScale = material.uniforms.opacityScale;
|
|
1987
|
+
if (opacityScale) opacityScale.value = glowOpacityScale(mode);
|
|
1988
|
+
material.needsUpdate = true;
|
|
1989
|
+
}
|
|
1990
|
+
function createNodeGlowMaterial(mode) {
|
|
1991
|
+
const material = new ShaderMaterial({
|
|
1992
|
+
depthTest: false,
|
|
1993
|
+
depthWrite: false,
|
|
1994
|
+
fragmentShader: fragmentShader$1,
|
|
1995
|
+
transparent: true,
|
|
1996
|
+
uniforms: { opacityScale: { value: 1 } },
|
|
1997
|
+
vertexShader: vertexShader$1
|
|
1998
|
+
});
|
|
1999
|
+
applyBlendMode(material, mode);
|
|
2000
|
+
return material;
|
|
2001
|
+
}
|
|
2002
|
+
function createGlowMesh(capacity, mode) {
|
|
2003
|
+
const geometry = new PlaneGeometry(2, 2);
|
|
2004
|
+
geometry.setAttribute("instanceGlowOpacity", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
2005
|
+
const mesh = new InstancedMesh(geometry, createNodeGlowMaterial(mode), capacity);
|
|
2006
|
+
mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
|
2007
|
+
mesh.name = "graphraum-node-glow";
|
|
2008
|
+
mesh.frustumCulled = false;
|
|
2009
|
+
mesh.renderOrder = -2;
|
|
2010
|
+
return mesh;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Opt-in batched halo pass: one additive instanced mesh, created lazily on the first glowing
|
|
2014
|
+
* node and hidden while no halo is selected, so graphs without glow keep two draw calls.
|
|
2015
|
+
*/
|
|
2016
|
+
var NodeGlowLayer = class {
|
|
2017
|
+
scene;
|
|
2018
|
+
selection = createGlowSelection();
|
|
2019
|
+
target = {
|
|
2020
|
+
color: new Color(),
|
|
2021
|
+
extent: 0,
|
|
2022
|
+
glow: 0,
|
|
2023
|
+
x: 0,
|
|
2024
|
+
y: 0,
|
|
2025
|
+
z: 0
|
|
2026
|
+
};
|
|
2027
|
+
matrix = new Matrix4();
|
|
2028
|
+
mesh = null;
|
|
2029
|
+
blendMode = "additive";
|
|
2030
|
+
constructor(scene) {
|
|
2031
|
+
this.scene = scene;
|
|
2032
|
+
}
|
|
2033
|
+
/** Additive on dark or unknown backgrounds, normal on light solid ones; see `glowBlendModeFor`. */
|
|
2034
|
+
setBlending(mode) {
|
|
2035
|
+
if (mode === this.blendMode) return;
|
|
2036
|
+
this.blendMode = mode;
|
|
2037
|
+
if (this.mesh) applyBlendMode(this.mesh.material, mode);
|
|
2038
|
+
}
|
|
2039
|
+
/** Halo instances in the current frame. */
|
|
2040
|
+
get count() {
|
|
2041
|
+
return this.mesh?.visible ? this.mesh.count : 0;
|
|
2042
|
+
}
|
|
2043
|
+
/** Reselects glowing visible nodes and rewrites every halo instance. */
|
|
2044
|
+
sync(visibleNodeIndices, glowAt, densityLod, describe) {
|
|
2045
|
+
selectGlowNodes(visibleNodeIndices, glowAt, densityLod, this.selection);
|
|
2046
|
+
if (this.selection.count > 0) this.ensureCapacity(this.selection.count);
|
|
2047
|
+
this.rewrite(describe);
|
|
2048
|
+
}
|
|
2049
|
+
/** Rewrites positions, radii, opacities, and colors of the current selection in place. */
|
|
2050
|
+
rewrite(describe) {
|
|
2051
|
+
const mesh = this.mesh;
|
|
2052
|
+
if (!mesh) return;
|
|
2053
|
+
const opacity = mesh.geometry.getAttribute("instanceGlowOpacity");
|
|
2054
|
+
let slot = 0;
|
|
2055
|
+
for (let index = 0; index < this.selection.count; index += 1) {
|
|
2056
|
+
const nodeIndex = this.selection.indices[index];
|
|
2057
|
+
if (nodeIndex === void 0 || !describe(nodeIndex, this.target)) continue;
|
|
2058
|
+
const { color, extent, glow, x, y, z } = this.target;
|
|
2059
|
+
const radius = glowHaloRadius(extent, glow);
|
|
2060
|
+
this.matrix.makeScale(radius, radius, radius).setPosition(x, y, z);
|
|
2061
|
+
mesh.setMatrixAt(slot, this.matrix);
|
|
2062
|
+
mesh.setColorAt(slot, color);
|
|
2063
|
+
opacity.setX(slot, glowHaloOpacity(glow));
|
|
2064
|
+
slot += 1;
|
|
2065
|
+
}
|
|
2066
|
+
mesh.count = slot;
|
|
2067
|
+
mesh.visible = slot > 0;
|
|
2068
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
2069
|
+
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
|
2070
|
+
opacity.needsUpdate = true;
|
|
2071
|
+
}
|
|
2072
|
+
/** Releases the GPU mesh; the layer stays reusable and recreates it on the next glowing node. */
|
|
2073
|
+
dispose() {
|
|
2074
|
+
this.selection.count = 0;
|
|
2075
|
+
this.releaseMesh();
|
|
2076
|
+
}
|
|
2077
|
+
releaseMesh() {
|
|
2078
|
+
if (!this.mesh) return;
|
|
2079
|
+
this.scene.remove(this.mesh);
|
|
2080
|
+
this.mesh.geometry.dispose();
|
|
2081
|
+
this.mesh.material.dispose();
|
|
2082
|
+
this.mesh.dispose();
|
|
2083
|
+
this.mesh = null;
|
|
2084
|
+
}
|
|
2085
|
+
ensureCapacity(required) {
|
|
2086
|
+
const current = this.mesh?.instanceMatrix.count ?? 0;
|
|
2087
|
+
const capacity = glowBufferCapacity(required, current, 256);
|
|
2088
|
+
if (this.mesh && capacity === current) return;
|
|
2089
|
+
this.releaseMesh();
|
|
2090
|
+
this.mesh = createGlowMesh(capacity, this.blendMode);
|
|
2091
|
+
this.scene.add(this.mesh);
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
1290
2094
|
//#endregion
|
|
1291
2095
|
//#region src/node-rendering.ts
|
|
1292
2096
|
const vertexShader = `
|
|
@@ -1399,6 +2203,7 @@ void main() {
|
|
|
1399
2203
|
fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
|
|
1400
2204
|
}
|
|
1401
2205
|
gl_FragColor = vec4(fillColor, outerAlpha);
|
|
2206
|
+
${OUTPUT_COLOR_SPACE_FRAGMENT}
|
|
1402
2207
|
}
|
|
1403
2208
|
`;
|
|
1404
2209
|
function createNodeGeometry(capacity) {
|
|
@@ -1476,6 +2281,7 @@ function assertValidNode(node) {
|
|
|
1476
2281
|
});
|
|
1477
2282
|
if (node.strokeWidth !== void 0 && (!Number.isFinite(node.strokeWidth) || node.strokeWidth < 0)) throw new Error(`Node "${node.id}" must have a finite non-negative strokeWidth`);
|
|
1478
2283
|
if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
|
|
2284
|
+
assertNodeGlow(node.id, node.glow);
|
|
1479
2285
|
}
|
|
1480
2286
|
/** Validates a complete update batch before the renderer mutates CPU or GPU state. */
|
|
1481
2287
|
function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
@@ -1490,6 +2296,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
|
1490
2296
|
const next = {
|
|
1491
2297
|
...current,
|
|
1492
2298
|
...Object.hasOwn(update, "color") ? { color: update.color } : {},
|
|
2299
|
+
...Object.hasOwn(update, "glow") ? { glow: update.glow } : {},
|
|
1493
2300
|
...update.position ? { position: { ...update.position } } : {},
|
|
1494
2301
|
...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
|
|
1495
2302
|
...Object.hasOwn(update, "size") ? { size: update.size } : {},
|
|
@@ -1501,6 +2308,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
|
1501
2308
|
assertValidNode(next);
|
|
1502
2309
|
return {
|
|
1503
2310
|
colorChanged: Object.hasOwn(update, "color"),
|
|
2311
|
+
glowChanged: Object.hasOwn(update, "glow"),
|
|
1504
2312
|
index,
|
|
1505
2313
|
next,
|
|
1506
2314
|
positionChanged: update.position !== void 0,
|
|
@@ -1511,6 +2319,22 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
|
1511
2319
|
});
|
|
1512
2320
|
}
|
|
1513
2321
|
//#endregion
|
|
2322
|
+
//#region src/orbit-controls-mode.ts
|
|
2323
|
+
/**
|
|
2324
|
+
* Sets the controls for a 2D or 3D view. 2D turns rotation off, so one finger and the left
|
|
2325
|
+
* mouse button pan instead. One finger must not stay on ROTATE: when a pinch ends with one finger
|
|
2326
|
+
* still down, OrbitControls (r185) re-reads `touches.ONE`, returns early for a disabled rotate and
|
|
2327
|
+
* keeps the two-finger state, and the next move reads the lifted finger's position and throws.
|
|
2328
|
+
*/
|
|
2329
|
+
function configureControlsForMode(controls, mode) {
|
|
2330
|
+
controls.enableRotate = mode === "3d";
|
|
2331
|
+
if (mode === "2d") {
|
|
2332
|
+
controls.minZoom = .01;
|
|
2333
|
+
controls.mouseButtons.LEFT = MOUSE.PAN;
|
|
2334
|
+
controls.touches.ONE = TOUCH.PAN;
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
//#endregion
|
|
1514
2338
|
//#region src/label-budget.ts
|
|
1515
2339
|
/** Picks visible label ids by importance within a hard DOM budget. */
|
|
1516
2340
|
function selectBudgetedLabelIds({ candidates, maxLabels }) {
|
|
@@ -1895,63 +2719,6 @@ var SpatialGrid2D = class {
|
|
|
1895
2719
|
}
|
|
1896
2720
|
};
|
|
1897
2721
|
//#endregion
|
|
1898
|
-
//#region src/theme.ts
|
|
1899
|
-
/** Dark canvas: Ink Black, Turf Green, Porcelain. */
|
|
1900
|
-
const graphraumThemeDark = Object.freeze({
|
|
1901
|
-
background: "#040f0f",
|
|
1902
|
-
dimmedNode: "#315a51",
|
|
1903
|
-
edge: "#226f54",
|
|
1904
|
-
edgeOpacity: .55,
|
|
1905
|
-
edgeWidth: 1.5,
|
|
1906
|
-
endpointAttach: "boundary",
|
|
1907
|
-
focusedNode: "#73c7a5",
|
|
1908
|
-
hoveredNode: "#e4a853",
|
|
1909
|
-
node: "#226f54",
|
|
1910
|
-
nodeStroke: "#fcfffc",
|
|
1911
|
-
selectedEdge: "#fcfffc",
|
|
1912
|
-
selectedNode: "#fcfffc"
|
|
1913
|
-
});
|
|
1914
|
-
/** Light canvas: Porcelain field with Turf marks and Ink selection. */
|
|
1915
|
-
const graphraumThemeLight = Object.freeze({
|
|
1916
|
-
background: "#fcfffc",
|
|
1917
|
-
dimmedNode: "#9bb5ac",
|
|
1918
|
-
edge: "#226f54",
|
|
1919
|
-
edgeOpacity: .65,
|
|
1920
|
-
edgeWidth: 1.5,
|
|
1921
|
-
endpointAttach: "boundary",
|
|
1922
|
-
focusedNode: "#1a5c45",
|
|
1923
|
-
hoveredNode: "#c47a1a",
|
|
1924
|
-
node: "#226f54",
|
|
1925
|
-
nodeStroke: "#040f0f",
|
|
1926
|
-
selectedEdge: "#040f0f",
|
|
1927
|
-
selectedNode: "#040f0f"
|
|
1928
|
-
});
|
|
1929
|
-
const graphraumThemes = Object.freeze({
|
|
1930
|
-
dark: graphraumThemeDark,
|
|
1931
|
-
light: graphraumThemeLight
|
|
1932
|
-
});
|
|
1933
|
-
/** Alias of {@link graphraumThemeDark} for backward compatibility. */
|
|
1934
|
-
const graphraumTheme = graphraumThemeDark;
|
|
1935
|
-
function resolveGraphraumTheme(input, base = graphraumThemeDark) {
|
|
1936
|
-
if (input === void 0) return { ...base };
|
|
1937
|
-
if (typeof input === "string") {
|
|
1938
|
-
const preset = graphraumThemes[input];
|
|
1939
|
-
if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
|
|
1940
|
-
return { ...preset };
|
|
1941
|
-
}
|
|
1942
|
-
return {
|
|
1943
|
-
...base,
|
|
1944
|
-
...input
|
|
1945
|
-
};
|
|
1946
|
-
}
|
|
1947
|
-
/** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
|
|
1948
|
-
function normalizeGraphraumBackground(background) {
|
|
1949
|
-
return background === "transparent" ? null : background;
|
|
1950
|
-
}
|
|
1951
|
-
function isTransparentGraphraumBackground(background) {
|
|
1952
|
-
return normalizeGraphraumBackground(background) === null;
|
|
1953
|
-
}
|
|
1954
|
-
//#endregion
|
|
1955
2722
|
//#region src/viewport-lod.ts
|
|
1956
2723
|
/** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
|
|
1957
2724
|
function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
|
|
@@ -1981,16 +2748,19 @@ function shouldUseDensityLod(candidateCount, maximum, active) {
|
|
|
1981
2748
|
//#region src/graphraum.ts
|
|
1982
2749
|
/** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
|
|
1983
2750
|
const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
|
|
2751
|
+
/** The 3D fit leaves 4% of the tighter canvas axis free on each side, e.g. for edge labels. */
|
|
2752
|
+
const PERSPECTIVE_FIT_FILL = .92;
|
|
2753
|
+
function prefersReducedMotion() {
|
|
2754
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2755
|
+
}
|
|
1984
2756
|
function disposeMaterial(material) {
|
|
1985
2757
|
for (const item of Array.isArray(material) ? material : [material]) item.dispose();
|
|
1986
2758
|
}
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
"
|
|
1990
|
-
"
|
|
1991
|
-
"
|
|
1992
|
-
"instanceWidth",
|
|
1993
|
-
"instanceStyle"
|
|
2759
|
+
/** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
|
|
2760
|
+
const FLIGHT_CANCEL_EVENTS = [
|
|
2761
|
+
"keydown",
|
|
2762
|
+
"pointerdown",
|
|
2763
|
+
"wheel"
|
|
1994
2764
|
];
|
|
1995
2765
|
/** A small, explicit WebGL graph renderer: one instanced node mesh and one batched edge geometry. */
|
|
1996
2766
|
var Graphraum = class {
|
|
@@ -2027,12 +2797,19 @@ var Graphraum = class {
|
|
|
2027
2797
|
nodeMesh = null;
|
|
2028
2798
|
nodePickMesh = null;
|
|
2029
2799
|
edgeMesh = null;
|
|
2800
|
+
glowLayer = new NodeGlowLayer(this.scene);
|
|
2801
|
+
hasGlowNodes = false;
|
|
2030
2802
|
nodeCapacity = 0;
|
|
2031
2803
|
edgeCapacity = 0;
|
|
2032
2804
|
markerCapacity = 0;
|
|
2033
2805
|
edgeInstanceCapacity = 0;
|
|
2034
2806
|
selectedNodeIds = /* @__PURE__ */ new Set();
|
|
2035
2807
|
selectedEdgeIds = /* @__PURE__ */ new Set();
|
|
2808
|
+
dimmedEdgeIds = /* @__PURE__ */ new Set();
|
|
2809
|
+
edgeIndexCache = {
|
|
2810
|
+
edges: [],
|
|
2811
|
+
indices: /* @__PURE__ */ new Map()
|
|
2812
|
+
};
|
|
2036
2813
|
hoveredNodeIds = /* @__PURE__ */ new Set();
|
|
2037
2814
|
focusedNodeIds = /* @__PURE__ */ new Set();
|
|
2038
2815
|
dimmedNodeIds = /* @__PURE__ */ new Set();
|
|
@@ -2053,6 +2830,12 @@ var Graphraum = class {
|
|
|
2053
2830
|
edgeSegmentCapacity = 0;
|
|
2054
2831
|
mode;
|
|
2055
2832
|
frameRequest = null;
|
|
2833
|
+
autoOrbit = null;
|
|
2834
|
+
flight = null;
|
|
2835
|
+
flightDirection = new Vector3();
|
|
2836
|
+
/** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
|
|
2837
|
+
orbitUpdating = false;
|
|
2838
|
+
destroyed = false;
|
|
2056
2839
|
cpuFrameMilliseconds = 0;
|
|
2057
2840
|
gpuFrameMilliseconds = null;
|
|
2058
2841
|
gpuTimer = null;
|
|
@@ -2092,6 +2875,7 @@ var Graphraum = class {
|
|
|
2092
2875
|
this.container.append(this.renderer.domElement);
|
|
2093
2876
|
this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
|
|
2094
2877
|
this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
|
|
2878
|
+
for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
|
|
2095
2879
|
this.camera = this.createCamera();
|
|
2096
2880
|
this.controls = this.createControls();
|
|
2097
2881
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
@@ -2148,6 +2932,7 @@ var Graphraum = class {
|
|
|
2148
2932
|
}))
|
|
2149
2933
|
};
|
|
2150
2934
|
this.nodeIds = compiled.nodeIds;
|
|
2935
|
+
this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
|
|
2151
2936
|
this.nodeIndices = new Map(compiled.nodeIndices);
|
|
2152
2937
|
this.edgeNodeIndices = compiled.edgeNodeIndices;
|
|
2153
2938
|
this.canonicalEdgePositions = compiled.edgePositions;
|
|
@@ -2157,12 +2942,11 @@ var Graphraum = class {
|
|
|
2157
2942
|
this.spatialGrid2d = new SpatialGrid2D();
|
|
2158
2943
|
for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
|
|
2159
2944
|
const liveNodeIds = new Set(this.nodeIds);
|
|
2160
|
-
const liveEdgeIds = new Set(this.data.edges.map((edge) => edge.id));
|
|
2161
2945
|
this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2162
2946
|
this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2163
2947
|
this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2164
2948
|
this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2165
|
-
this.
|
|
2949
|
+
this.pruneEdgeStates();
|
|
2166
2950
|
this.materializeViewport();
|
|
2167
2951
|
this.requestRender();
|
|
2168
2952
|
}
|
|
@@ -2226,6 +3010,7 @@ var Graphraum = class {
|
|
|
2226
3010
|
edges: nextEdges
|
|
2227
3011
|
};
|
|
2228
3012
|
this.nodeIds = nextNodes.map((node) => node.id);
|
|
3013
|
+
this.hasGlowNodes = nextNodes.some(hasNodeGlow);
|
|
2229
3014
|
this.nodeIndices = nextNodeIndices;
|
|
2230
3015
|
this.edgeNodeIndices = nextEdgeIndices;
|
|
2231
3016
|
this.canonicalEdgePositions = nextEdgePositions;
|
|
@@ -2255,6 +3040,7 @@ var Graphraum = class {
|
|
|
2255
3040
|
}))
|
|
2256
3041
|
};
|
|
2257
3042
|
this.nodeIds = compiled.nodeIds;
|
|
3043
|
+
this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
|
|
2258
3044
|
this.nodeIndices = new Map(compiled.nodeIndices);
|
|
2259
3045
|
this.edgeNodeIndices = compiled.edgeNodeIndices;
|
|
2260
3046
|
this.canonicalEdgePositions = compiled.edgePositions;
|
|
@@ -2263,11 +3049,12 @@ var Graphraum = class {
|
|
|
2263
3049
|
this.edgePresentations = new Map(compiled.edgePresentations);
|
|
2264
3050
|
this.spatialGrid2d = new SpatialGrid2D();
|
|
2265
3051
|
for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
|
|
3052
|
+
this.pruneEdgeStates();
|
|
2266
3053
|
this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
|
|
2267
3054
|
const nodeCapacity = this.nodeCapacity;
|
|
2268
3055
|
const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
|
|
2269
3056
|
allocateNodeInstanceColors(nodeMesh, nodeCapacity);
|
|
2270
|
-
nodeMesh.renderOrder =
|
|
3057
|
+
nodeMesh.renderOrder = 1;
|
|
2271
3058
|
this.nodeMesh = nodeMesh;
|
|
2272
3059
|
this.scene.add(nodeMesh);
|
|
2273
3060
|
const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
|
|
@@ -2277,9 +3064,9 @@ var Graphraum = class {
|
|
|
2277
3064
|
this.edgeSegmentCapacity = capacities.segmentCapacity;
|
|
2278
3065
|
this.markerCapacity = capacities.markerCapacity;
|
|
2279
3066
|
this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
|
|
2280
|
-
const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(
|
|
3067
|
+
const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(), this.edgeInstanceCapacity);
|
|
2281
3068
|
edgeMesh.frustumCulled = false;
|
|
2282
|
-
edgeMesh.renderOrder =
|
|
3069
|
+
edgeMesh.renderOrder = -1;
|
|
2283
3070
|
this.edgeMesh = edgeMesh;
|
|
2284
3071
|
this.scene.add(edgeMesh);
|
|
2285
3072
|
if (fitView) this.fitView();
|
|
@@ -2376,6 +3163,8 @@ var Graphraum = class {
|
|
|
2376
3163
|
}
|
|
2377
3164
|
}
|
|
2378
3165
|
if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
|
|
3166
|
+
const glowChanged = prepared.some((update) => update.glowChanged);
|
|
3167
|
+
if (glowChanged) this.hasGlowNodes = nodes.some(hasNodeGlow);
|
|
2379
3168
|
this.data = {
|
|
2380
3169
|
...this.data,
|
|
2381
3170
|
nodes
|
|
@@ -2394,6 +3183,8 @@ var Graphraum = class {
|
|
|
2394
3183
|
}
|
|
2395
3184
|
if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
|
|
2396
3185
|
if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
|
|
3186
|
+
if (glowChanged) this.syncGlow();
|
|
3187
|
+
else if (prepared.some((update) => update.positionChanged || update.sizeChanged || update.colorChanged)) this.glowLayer.rewrite(this.describeGlowNode);
|
|
2397
3188
|
if (prepared.some((update) => update.strokeChanged)) {
|
|
2398
3189
|
nodeStrokeWidth.needsUpdate = true;
|
|
2399
3190
|
nodeStrokeColor.needsUpdate = true;
|
|
@@ -2405,10 +3196,6 @@ var Graphraum = class {
|
|
|
2405
3196
|
if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
|
|
2406
3197
|
const worldPerPixel = this.worldUnitsPerPixel();
|
|
2407
3198
|
const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
|
|
2408
|
-
const edgeVisuals = this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
|
|
2409
|
-
...edge,
|
|
2410
|
-
color: this.theme.selectedEdge
|
|
2411
|
-
} : edge);
|
|
2412
3199
|
const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
|
|
2413
3200
|
changedEdgeIndices,
|
|
2414
3201
|
defaults: {
|
|
@@ -2417,11 +3204,13 @@ var Graphraum = class {
|
|
|
2417
3204
|
width: this.theme.edgeWidth
|
|
2418
3205
|
},
|
|
2419
3206
|
edgeNodeIndices: this.edgeNodeIndices,
|
|
2420
|
-
|
|
3207
|
+
edgeStates: this.edgeStateStyling(),
|
|
3208
|
+
edgeVisuals: this.data.edges,
|
|
2421
3209
|
endpointAttach: this.theme.endpointAttach,
|
|
2422
3210
|
endpointPositions: this.canonicalEdgePositions,
|
|
2423
3211
|
layouts: this.visibleEdgeLayouts,
|
|
2424
3212
|
minHitSlop,
|
|
3213
|
+
mode: this.mode,
|
|
2425
3214
|
nodeOutlines: this.data.nodes.map((node) => ({
|
|
2426
3215
|
height: node.height,
|
|
2427
3216
|
shape: node.shape,
|
|
@@ -2439,12 +3228,80 @@ var Graphraum = class {
|
|
|
2439
3228
|
setSelection(nodeIds) {
|
|
2440
3229
|
this.setNodeState("selected", nodeIds);
|
|
2441
3230
|
}
|
|
2442
|
-
/**
|
|
3231
|
+
/**
|
|
3232
|
+
* Highlights edges without changing node selection or source graph data. Unknown IDs are ignored.
|
|
3233
|
+
* Only the instance colors of edges whose selection changed are rewritten and uploaded.
|
|
3234
|
+
*/
|
|
2443
3235
|
setEdgeSelection(edgeIds) {
|
|
2444
|
-
|
|
2445
|
-
this.
|
|
3236
|
+
const next = this.knownEdgeIds(edgeIds);
|
|
3237
|
+
const changed = changedIds(this.selectedEdgeIds, next);
|
|
3238
|
+
this.selectedEdgeIds = next;
|
|
3239
|
+
this.repaintEdgeStates(changed);
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* Applies an application-owned edge state without changing source graph data. `dimmed` uses
|
|
3243
|
+
* `theme.dimmedEdge` / `theme.dimmedEdgeOpacity`; selected edges stay selected. Unknown IDs are ignored.
|
|
3244
|
+
*/
|
|
3245
|
+
setEdgeState(state, edgeIds) {
|
|
3246
|
+
if (state !== "dimmed") throw new Error(`Unknown edge state "${String(state)}". Use "dimmed".`);
|
|
3247
|
+
const next = this.knownEdgeIds(edgeIds);
|
|
3248
|
+
const changed = changedIds(this.dimmedEdgeIds, next);
|
|
3249
|
+
this.dimmedEdgeIds = next;
|
|
3250
|
+
this.repaintEdgeStates(changed);
|
|
3251
|
+
}
|
|
3252
|
+
knownEdgeIds(edgeIds) {
|
|
3253
|
+
const indices = this.edgeIndicesById();
|
|
3254
|
+
return new Set([...edgeIds].filter((id) => indices.has(id)));
|
|
3255
|
+
}
|
|
3256
|
+
/**
|
|
3257
|
+
* Visible slots, LOD tier, and geometry only change through materialize or endpoint patches,
|
|
3258
|
+
* which keep `visibleEdgeLayouts` current, so a state change repaints in place. If a layout no
|
|
3259
|
+
* longer fits the drawn instances, that invariant broke: fall back to a full materialize.
|
|
3260
|
+
*/
|
|
3261
|
+
repaintEdgeStates(changedEdgeIds) {
|
|
3262
|
+
if (!this.edgeMesh || changedEdgeIds.length === 0) return;
|
|
3263
|
+
const indices = this.edgeIndicesById();
|
|
3264
|
+
if (!patchVisibleEdgePaint(this.edgeMesh.geometry, {
|
|
3265
|
+
changedEdgeIndices: changedEdgeIds.flatMap((id) => indices.get(id) ?? []),
|
|
3266
|
+
defaults: {
|
|
3267
|
+
color: this.theme.edge,
|
|
3268
|
+
opacity: this.theme.edgeOpacity
|
|
3269
|
+
},
|
|
3270
|
+
edgeStates: this.edgeStateStyling(),
|
|
3271
|
+
edgeVisuals: this.data.edges,
|
|
3272
|
+
instanceCount: this.edgeMesh.count,
|
|
3273
|
+
layouts: this.visibleEdgeLayouts,
|
|
3274
|
+
tier: this.lastEdgeLodTier
|
|
3275
|
+
}).ok) this.materializeViewport();
|
|
2446
3276
|
this.requestRender();
|
|
2447
3277
|
}
|
|
3278
|
+
edgeStateStyling() {
|
|
3279
|
+
return {
|
|
3280
|
+
dimmedColor: this.theme.dimmedEdge,
|
|
3281
|
+
dimmedOpacity: this.theme.dimmedEdgeOpacity,
|
|
3282
|
+
selectedColor: this.theme.selectedEdge,
|
|
3283
|
+
stateOf: (edgeIndex) => {
|
|
3284
|
+
const id = this.data.edges[edgeIndex]?.id;
|
|
3285
|
+
if (id === void 0) return null;
|
|
3286
|
+
if (this.selectedEdgeIds.has(id)) return "selected";
|
|
3287
|
+
return this.dimmedEdgeIds.has(id) ? "dimmed" : null;
|
|
3288
|
+
}
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
3291
|
+
/** Edge ID to compiled index, rebuilt only when the edge list is replaced. */
|
|
3292
|
+
edgeIndicesById() {
|
|
3293
|
+
if (this.edgeIndexCache.edges !== this.data.edges) this.edgeIndexCache = {
|
|
3294
|
+
edges: this.data.edges,
|
|
3295
|
+
indices: new Map(this.data.edges.map((edge, index) => [edge.id, index]))
|
|
3296
|
+
};
|
|
3297
|
+
return this.edgeIndexCache.indices;
|
|
3298
|
+
}
|
|
3299
|
+
/** Drops edge states whose edges left the graph. */
|
|
3300
|
+
pruneEdgeStates() {
|
|
3301
|
+
const indices = this.edgeIndicesById();
|
|
3302
|
+
this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => indices.has(id)));
|
|
3303
|
+
this.dimmedEdgeIds = new Set([...this.dimmedEdgeIds].filter((id) => indices.has(id)));
|
|
3304
|
+
}
|
|
2448
3305
|
/** Applies an application-owned visual state without changing source graph data. */
|
|
2449
3306
|
setNodeState(state, nodeIds) {
|
|
2450
3307
|
const nextSelection = new Set(nodeIds);
|
|
@@ -2481,6 +3338,8 @@ var Graphraum = class {
|
|
|
2481
3338
|
}
|
|
2482
3339
|
setMode(mode) {
|
|
2483
3340
|
if (mode === this.mode) return;
|
|
3341
|
+
this.cancelFlight();
|
|
3342
|
+
this.stopAutoOrbit();
|
|
2484
3343
|
this.mode = mode;
|
|
2485
3344
|
this.controls.removeEventListener("change", this.handleViewChange);
|
|
2486
3345
|
this.controls.dispose();
|
|
@@ -2490,19 +3349,30 @@ var Graphraum = class {
|
|
|
2490
3349
|
this.nodeMesh.material.depthTest = mode === "3d";
|
|
2491
3350
|
this.nodeMesh.material.depthWrite = mode === "3d";
|
|
2492
3351
|
this.nodeMesh.material.needsUpdate = true;
|
|
2493
|
-
this.nodeMesh.renderOrder = mode === "2d" ? 1 : 0;
|
|
2494
|
-
}
|
|
2495
|
-
if (this.edgeMesh && !Array.isArray(this.edgeMesh.material)) {
|
|
2496
|
-
this.edgeMesh.material.depthTest = mode === "3d";
|
|
2497
|
-
this.edgeMesh.material.depthWrite = mode === "3d";
|
|
2498
|
-
this.edgeMesh.material.needsUpdate = true;
|
|
2499
|
-
this.edgeMesh.renderOrder = mode === "2d" ? -1 : 0;
|
|
2500
3352
|
}
|
|
2501
3353
|
this.fitView();
|
|
2502
3354
|
}
|
|
2503
3355
|
getMode() {
|
|
2504
3356
|
return this.mode;
|
|
2505
3357
|
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Slowly orbits the 3D camera around its target until called with `false`. The orbit pauses while
|
|
3360
|
+
* the camera controls handle a drag, pinch or wheel step and resumes after `resumeAfterMs` of idle;
|
|
3361
|
+
* it also stops while the document is hidden or the canvas is off screen. While it is active a
|
|
3362
|
+
* frame loop renders every frame (no viewport rematerialization), so check `prefers-reduced-motion`
|
|
3363
|
+
* before enabling it. Throws in 2D mode; `setMode("2d")` turns it off. Does nothing after `destroy()`.
|
|
3364
|
+
*/
|
|
3365
|
+
setAutoOrbit(options) {
|
|
3366
|
+
if (options === false || this.destroyed) {
|
|
3367
|
+
this.stopAutoOrbit();
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
if (this.mode !== "3d") throw new Error("Auto-orbit requires 3D mode. Call setMode(\"3d\") before setAutoOrbit(options).");
|
|
3371
|
+
const { resumeAfterMs, speed } = resolveAutoOrbitOptions(options);
|
|
3372
|
+
this.controls.autoRotateSpeed = autoRotateSpeedFor(speed);
|
|
3373
|
+
if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
|
|
3374
|
+
else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
|
|
3375
|
+
}
|
|
2506
3376
|
getNodePresentation(nodeId) {
|
|
2507
3377
|
return this.nodePresentations.get(nodeId) ?? null;
|
|
2508
3378
|
}
|
|
@@ -2595,6 +3465,7 @@ var Graphraum = class {
|
|
|
2595
3465
|
getDiagnostics() {
|
|
2596
3466
|
return {
|
|
2597
3467
|
aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
|
|
3468
|
+
autoOrbit: this.autoOrbit?.status ?? "off",
|
|
2598
3469
|
cpuFrameMilliseconds: this.cpuFrameMilliseconds,
|
|
2599
3470
|
gpuFrameMilliseconds: this.gpuFrameMilliseconds,
|
|
2600
3471
|
gpuDrawCalls: this.renderer.info.render.calls,
|
|
@@ -2602,6 +3473,7 @@ var Graphraum = class {
|
|
|
2602
3473
|
gpuTextures: this.renderer.info.memory.textures,
|
|
2603
3474
|
lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
|
|
2604
3475
|
pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
|
|
3476
|
+
dimmedEdges: this.dimmedEdgeIds.size,
|
|
2605
3477
|
selectedEdges: this.selectedEdgeIds.size,
|
|
2606
3478
|
selectedNodes: this.selectedNodeIds.size,
|
|
2607
3479
|
totalEdges: this.data.edges.length,
|
|
@@ -2610,6 +3482,7 @@ var Graphraum = class {
|
|
|
2610
3482
|
visibleEdgeMarkers: this.visibleEdgeMarkerCount,
|
|
2611
3483
|
visibleEdgeSegments: this.visibleEdgeSegmentCount,
|
|
2612
3484
|
visibleEdges: this.visibleEdgeCount,
|
|
3485
|
+
visibleGlowNodes: this.glowLayer.count,
|
|
2613
3486
|
visibleNodes: this.visibleNodeCount,
|
|
2614
3487
|
visibleNodeCandidates: this.visibleNodeCandidateCount
|
|
2615
3488
|
};
|
|
@@ -2636,52 +3509,207 @@ var Graphraum = class {
|
|
|
2636
3509
|
id: edgeId
|
|
2637
3510
|
} : null;
|
|
2638
3511
|
}
|
|
2639
|
-
|
|
3512
|
+
/** Frames every node, or only `nodeIds`; unknown ids are ignored and no match frames all nodes. Cancels a running `flyTo`. */
|
|
3513
|
+
fitView(options) {
|
|
3514
|
+
this.cancelFlight();
|
|
2640
3515
|
if (!this.nodeMesh || this.data.nodes.length === 0) {
|
|
2641
3516
|
this.requestRender();
|
|
2642
3517
|
return;
|
|
2643
3518
|
}
|
|
2644
|
-
const
|
|
2645
|
-
const
|
|
2646
|
-
const maximum = new Vector3();
|
|
2647
|
-
for (const node of this.data.nodes) {
|
|
2648
|
-
const { height, width } = resolveNodeAxes({
|
|
2649
|
-
height: node.height,
|
|
2650
|
-
nodeId: node.id,
|
|
2651
|
-
size: node.size,
|
|
2652
|
-
width: node.width
|
|
2653
|
-
});
|
|
2654
|
-
const radius = Math.max(width, height);
|
|
2655
|
-
const z = node.position.z ?? 0;
|
|
2656
|
-
bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
|
|
2657
|
-
bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
|
|
2658
|
-
}
|
|
2659
|
-
const center = bounds.getCenter(new Vector3());
|
|
2660
|
-
const size = bounds.getSize(new Vector3());
|
|
3519
|
+
const subset = this.nodesWithIds(options?.nodeIds);
|
|
3520
|
+
const nodes = subset.length > 0 ? subset : this.data.nodes;
|
|
2661
3521
|
const width = Math.max(this.container.clientWidth, 1);
|
|
2662
3522
|
const height = Math.max(this.container.clientHeight, 1);
|
|
2663
|
-
const aspect = width / height;
|
|
2664
3523
|
if (this.camera instanceof OrthographicCamera) {
|
|
2665
|
-
const
|
|
2666
|
-
|
|
2667
|
-
this.camera
|
|
2668
|
-
this.camera.
|
|
2669
|
-
this.camera.
|
|
2670
|
-
this.
|
|
3524
|
+
const aspect = width / height;
|
|
3525
|
+
const fit = orthographicFit(aspect, nodes);
|
|
3526
|
+
setOrthographicFrustum(this.camera, aspect, fit.visibleHeight);
|
|
3527
|
+
this.camera.position.set(fit.center.x, fit.center.y, fit.cameraZ);
|
|
3528
|
+
this.camera.lookAt(fit.center);
|
|
3529
|
+
this.controls.target.copy(fit.center);
|
|
2671
3530
|
} else {
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
this.camera.
|
|
2675
|
-
this.camera.
|
|
2676
|
-
this.
|
|
3531
|
+
const fit = this.perspectiveFit(this.camera, width, height, nodes);
|
|
3532
|
+
this.camera.near = fit.near;
|
|
3533
|
+
this.camera.far = fit.far;
|
|
3534
|
+
this.camera.position.set(fit.target.x, fit.target.y, fit.cameraZ);
|
|
3535
|
+
this.controls.target.set(fit.target.x, fit.target.y, fit.target.z);
|
|
3536
|
+
this.camera.lookAt(this.controls.target);
|
|
2677
3537
|
}
|
|
2678
|
-
this.camera.lookAt(center);
|
|
2679
3538
|
this.camera.updateProjectionMatrix();
|
|
2680
|
-
this.controls.target.copy(center);
|
|
2681
3539
|
this.controls.update();
|
|
2682
3540
|
this.materializeViewport();
|
|
2683
3541
|
this.requestRender();
|
|
2684
3542
|
}
|
|
3543
|
+
/**
|
|
3544
|
+
* Animates the camera to frame `nodeIds` as `fitView({ nodeIds })` would, with an ease-in-out
|
|
3545
|
+
* over `durationMs`. In 3D the camera and the orbit target move together and keep the current
|
|
3546
|
+
* view direction, so a running auto-orbit carries on around the new target, at the fitted
|
|
3547
|
+
* distance times `distanceScale`. In 2D it pans and zooms. Resolves `true` on arrival and
|
|
3548
|
+
* `false` when cancelled by canvas pointer, wheel or key input, another `flyTo`, `fitView`,
|
|
3549
|
+
* `setData`, `setMode`, a 2D resize or `destroy()`. Unknown IDs are ignored; when none match,
|
|
3550
|
+
* or after `destroy()`, it resolves `false` without moving. With `prefers-reduced-motion:
|
|
3551
|
+
* reduce` or a zero duration it jumps. Throws for an invalid duration or distance scale.
|
|
3552
|
+
*/
|
|
3553
|
+
flyTo(options) {
|
|
3554
|
+
const { distanceScale, durationMs } = resolveFlyToOptions(options);
|
|
3555
|
+
this.cancelFlight();
|
|
3556
|
+
if (this.destroyed || !this.nodeMesh) return Promise.resolve(false);
|
|
3557
|
+
const nodes = this.nodesWithIds(options.nodeIds);
|
|
3558
|
+
if (nodes.length === 0) return Promise.resolve(false);
|
|
3559
|
+
const width = Math.max(this.container.clientWidth, 1);
|
|
3560
|
+
const height = Math.max(this.container.clientHeight, 1);
|
|
3561
|
+
const flight = this.camera instanceof OrthographicCamera ? this.planOrthographicFlight(this.camera, width / height, nodes, distanceScale) : this.planPerspectiveFlight(this.camera, width, height, nodes, distanceScale);
|
|
3562
|
+
if (durationMs === 0 || prefersReducedMotion()) {
|
|
3563
|
+
flight.step(1);
|
|
3564
|
+
flight.arrive();
|
|
3565
|
+
this.requestRender();
|
|
3566
|
+
return Promise.resolve(true);
|
|
3567
|
+
}
|
|
3568
|
+
return new Promise((resolve) => {
|
|
3569
|
+
this.flight = {
|
|
3570
|
+
...flight,
|
|
3571
|
+
durationMs,
|
|
3572
|
+
resolve,
|
|
3573
|
+
startMs: null
|
|
3574
|
+
};
|
|
3575
|
+
this.requestRender();
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
/** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
|
|
3579
|
+
nodesWithIds(nodeIds) {
|
|
3580
|
+
if (!nodeIds) return this.data.nodes;
|
|
3581
|
+
const wanted = new Set(nodeIds);
|
|
3582
|
+
return this.data.nodes.filter((node) => wanted.has(node.id));
|
|
3583
|
+
}
|
|
3584
|
+
/** The closest front-view camera that frames `nodes` (issue #124); applies the container aspect. */
|
|
3585
|
+
perspectiveFit(camera, width, height, nodes) {
|
|
3586
|
+
applyPerspectiveContainerAspect(camera, width, height);
|
|
3587
|
+
return fitPerspective({
|
|
3588
|
+
aspect: camera.aspect,
|
|
3589
|
+
fill: PERSPECTIVE_FIT_FILL,
|
|
3590
|
+
fovDegrees: camera.fov
|
|
3591
|
+
}, {
|
|
3592
|
+
count: nodes.length,
|
|
3593
|
+
read(index, out) {
|
|
3594
|
+
const node = nodes[index];
|
|
3595
|
+
const axes = resolveNodeAxes({
|
|
3596
|
+
height: node.height,
|
|
3597
|
+
nodeId: node.id,
|
|
3598
|
+
size: node.size,
|
|
3599
|
+
width: node.width
|
|
3600
|
+
});
|
|
3601
|
+
out.x = node.position.x;
|
|
3602
|
+
out.y = node.position.y;
|
|
3603
|
+
out.z = node.position.z ?? 0;
|
|
3604
|
+
const extent = Math.max(axes.width, axes.height);
|
|
3605
|
+
const glow = node.glow ?? 0;
|
|
3606
|
+
const halo = glow > 0 ? glowHaloRadius(extent, glow) : 0;
|
|
3607
|
+
out.halfWidth = Math.max(axes.width, halo);
|
|
3608
|
+
out.halfHeight = Math.max(axes.height, halo);
|
|
3609
|
+
}
|
|
3610
|
+
});
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
3613
|
+
* A 3D flight keeps the view direction it reads from the camera every frame, so an orbit step
|
|
3614
|
+
* between frames turns the flight with it. The destination distance is the front-view fit
|
|
3615
|
+
* distance; near and far span both ends while flying, and the far plane at arrival still holds
|
|
3616
|
+
* the whole graph so the rest of it stays visible around the framed nodes.
|
|
3617
|
+
*/
|
|
3618
|
+
planPerspectiveFlight(camera, width, height, nodes, distanceScale) {
|
|
3619
|
+
const fit = this.perspectiveFit(camera, width, height, nodes);
|
|
3620
|
+
const fromTarget = this.controls.target.clone();
|
|
3621
|
+
const toTarget = new Vector3(fit.target.x, fit.target.y, fit.target.z);
|
|
3622
|
+
const fromDistance = Math.max(camera.position.distanceTo(fromTarget), Number.EPSILON);
|
|
3623
|
+
const toDistance = (fit.cameraZ - fit.target.z) * distanceScale;
|
|
3624
|
+
const near = fit.near * Math.min(distanceScale, 1);
|
|
3625
|
+
const far = Math.max(fit.far * distanceScale, (toDistance + this.graphRadiusAround(toTarget)) * 1.1);
|
|
3626
|
+
camera.near = Math.min(camera.near, near);
|
|
3627
|
+
camera.far = Math.max(camera.far, far);
|
|
3628
|
+
camera.updateProjectionMatrix();
|
|
3629
|
+
return {
|
|
3630
|
+
arrive: () => {
|
|
3631
|
+
camera.near = near;
|
|
3632
|
+
camera.far = far;
|
|
3633
|
+
camera.updateProjectionMatrix();
|
|
3634
|
+
this.materializeViewport();
|
|
3635
|
+
},
|
|
3636
|
+
step: (eased) => {
|
|
3637
|
+
const direction = this.flightDirection.subVectors(camera.position, this.controls.target);
|
|
3638
|
+
if (direction.lengthSq() === 0) direction.set(0, 0, 1);
|
|
3639
|
+
direction.normalize();
|
|
3640
|
+
this.controls.target.lerpVectors(fromTarget, toTarget, eased);
|
|
3641
|
+
camera.position.copy(this.controls.target).addScaledVector(direction, interpolateScale(fromDistance, toDistance, eased));
|
|
3642
|
+
camera.lookAt(this.controls.target);
|
|
3643
|
+
}
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
/**
|
|
3647
|
+
* A 2D flight switches to the destination frustum up front with a zoom that keeps the current
|
|
3648
|
+
* view, then pans and eases the zoom back to 1, so it lands exactly on the fitted frustum.
|
|
3649
|
+
*/
|
|
3650
|
+
planOrthographicFlight(camera, aspect, nodes, distanceScale) {
|
|
3651
|
+
const fit = orthographicFit(aspect, nodes);
|
|
3652
|
+
const fromVisibleHeight = (camera.top - camera.bottom) / camera.zoom;
|
|
3653
|
+
const toVisibleHeight = fit.visibleHeight * distanceScale;
|
|
3654
|
+
setOrthographicFrustum(camera, aspect, toVisibleHeight);
|
|
3655
|
+
const fromZoom = toVisibleHeight / fromVisibleHeight;
|
|
3656
|
+
camera.zoom = fromZoom;
|
|
3657
|
+
camera.updateProjectionMatrix();
|
|
3658
|
+
const fromPosition = camera.position.clone();
|
|
3659
|
+
const toPosition = new Vector3(fit.center.x, fit.center.y, fit.cameraZ);
|
|
3660
|
+
const fromTarget = this.controls.target.clone();
|
|
3661
|
+
return {
|
|
3662
|
+
arrive: () => this.materializeViewport(),
|
|
3663
|
+
step: (eased) => {
|
|
3664
|
+
camera.zoom = interpolateScale(fromZoom, 1, eased);
|
|
3665
|
+
camera.position.lerpVectors(fromPosition, toPosition, eased);
|
|
3666
|
+
this.controls.target.lerpVectors(fromTarget, fit.center, eased);
|
|
3667
|
+
camera.updateProjectionMatrix();
|
|
3668
|
+
}
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
/** Largest distance from `center` to any node's far edge. */
|
|
3672
|
+
graphRadiusAround(center) {
|
|
3673
|
+
let radius = 0;
|
|
3674
|
+
for (const node of this.data.nodes) {
|
|
3675
|
+
const axes = resolveNodeAxes({
|
|
3676
|
+
height: node.height,
|
|
3677
|
+
nodeId: node.id,
|
|
3678
|
+
size: node.size,
|
|
3679
|
+
width: node.width
|
|
3680
|
+
});
|
|
3681
|
+
const distance = Math.hypot(node.position.x - center.x, node.position.y - center.y, (node.position.z ?? 0) - center.z);
|
|
3682
|
+
radius = Math.max(radius, distance + Math.max(axes.width, axes.height));
|
|
3683
|
+
}
|
|
3684
|
+
return radius;
|
|
3685
|
+
}
|
|
3686
|
+
/** Steps a running flight to the frame time; settles and resolves it at the end. */
|
|
3687
|
+
advanceFlight(nowMs) {
|
|
3688
|
+
const flight = this.flight;
|
|
3689
|
+
if (!flight) return;
|
|
3690
|
+
flight.startMs ??= nowMs;
|
|
3691
|
+
const progress = flightProgress(nowMs - flight.startMs, flight.durationMs);
|
|
3692
|
+
try {
|
|
3693
|
+
flight.step(easeInOutCubic(progress));
|
|
3694
|
+
if (progress < 1) {
|
|
3695
|
+
if (this.mode === "2d") this.materializeViewport();
|
|
3696
|
+
this.requestRender();
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
this.flight = null;
|
|
3700
|
+
flight.arrive();
|
|
3701
|
+
} catch (error) {
|
|
3702
|
+
this.cancelFlight();
|
|
3703
|
+
throw error;
|
|
3704
|
+
}
|
|
3705
|
+
flight.resolve(true);
|
|
3706
|
+
}
|
|
3707
|
+
cancelFlight = () => {
|
|
3708
|
+
const flight = this.flight;
|
|
3709
|
+
if (!flight) return;
|
|
3710
|
+
this.flight = null;
|
|
3711
|
+
flight.resolve(false);
|
|
3712
|
+
};
|
|
2685
3713
|
setTheme(theme) {
|
|
2686
3714
|
this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
|
|
2687
3715
|
this.applyBackground();
|
|
@@ -2706,9 +3734,13 @@ var Graphraum = class {
|
|
|
2706
3734
|
this.requestRender();
|
|
2707
3735
|
}
|
|
2708
3736
|
destroy() {
|
|
3737
|
+
this.destroyed = true;
|
|
3738
|
+
this.cancelFlight();
|
|
3739
|
+
this.stopAutoOrbit();
|
|
2709
3740
|
this.resizeObserver.disconnect();
|
|
2710
3741
|
this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
|
|
2711
3742
|
this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
|
|
3743
|
+
for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.removeEventListener(type, this.cancelFlight);
|
|
2712
3744
|
this.controls.removeEventListener("change", this.handleViewChange);
|
|
2713
3745
|
this.controls.dispose();
|
|
2714
3746
|
if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
|
|
@@ -2720,22 +3752,63 @@ var Graphraum = class {
|
|
|
2720
3752
|
}
|
|
2721
3753
|
requestRender = () => {
|
|
2722
3754
|
if (this.frameRequest !== null) return;
|
|
2723
|
-
this.frameRequest = requestAnimationFrame(() => {
|
|
3755
|
+
this.frameRequest = requestAnimationFrame((nowMs) => {
|
|
2724
3756
|
this.frameRequest = null;
|
|
2725
|
-
this.
|
|
2726
|
-
|
|
2727
|
-
const query = timer?.context.createQuery() ?? null;
|
|
2728
|
-
if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
|
|
2729
|
-
const startedAt = performance.now();
|
|
2730
|
-
this.renderer.render(this.scene, this.camera);
|
|
2731
|
-
this.cpuFrameMilliseconds = performance.now() - startedAt;
|
|
2732
|
-
if (timer && query) {
|
|
2733
|
-
timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
|
|
2734
|
-
timer.pending.push(query);
|
|
2735
|
-
}
|
|
2736
|
-
for (const listener of this.viewListeners) listener();
|
|
3757
|
+
this.advanceFlight(nowMs);
|
|
3758
|
+
this.renderFrame();
|
|
2737
3759
|
});
|
|
2738
3760
|
};
|
|
3761
|
+
/** Renders a pending frame now instead of on the next animation frame. */
|
|
3762
|
+
flushRender() {
|
|
3763
|
+
if (this.frameRequest === null) return;
|
|
3764
|
+
cancelAnimationFrame(this.frameRequest);
|
|
3765
|
+
this.frameRequest = null;
|
|
3766
|
+
this.renderFrame();
|
|
3767
|
+
}
|
|
3768
|
+
renderFrame() {
|
|
3769
|
+
this.readGpuTimers();
|
|
3770
|
+
const timer = this.gpuTimer;
|
|
3771
|
+
const query = timer?.context.createQuery() ?? null;
|
|
3772
|
+
if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
|
|
3773
|
+
const startedAt = performance.now();
|
|
3774
|
+
this.renderer.render(this.scene, this.camera);
|
|
3775
|
+
this.cpuFrameMilliseconds = performance.now() - startedAt;
|
|
3776
|
+
if (timer && query) {
|
|
3777
|
+
timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
|
|
3778
|
+
timer.pending.push(query);
|
|
3779
|
+
}
|
|
3780
|
+
for (const listener of this.viewListeners) listener();
|
|
3781
|
+
}
|
|
3782
|
+
stopAutoOrbit() {
|
|
3783
|
+
this.autoOrbit?.dispose();
|
|
3784
|
+
this.autoOrbit = null;
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* One orbit frame. `autoRotate` is on only for this `update(dt)` call: OrbitControls also calls
|
|
3788
|
+
* `update()` without a delta from wheel and key handlers and `fitView`, which would otherwise add
|
|
3789
|
+
* a fixed per-call rotation. A moved camera emits `change` (a render request only, see
|
|
3790
|
+
* `handleViewChange`); flushing that request renders this frame instead of the next one. During
|
|
3791
|
+
* a `flyTo` the orbit still turns the camera but leaves the pending frame to the flight, which
|
|
3792
|
+
* steps from the turned direction before it renders. A
|
|
3793
|
+
* throwing update, render or view listener turns the orbit off rather than leaving it enabled
|
|
3794
|
+
* without a loop.
|
|
3795
|
+
*/
|
|
3796
|
+
stepAutoOrbit = (deltaSeconds) => {
|
|
3797
|
+
try {
|
|
3798
|
+
this.controls.autoRotate = true;
|
|
3799
|
+
this.orbitUpdating = true;
|
|
3800
|
+
try {
|
|
3801
|
+
this.controls.update(deltaSeconds);
|
|
3802
|
+
} finally {
|
|
3803
|
+
this.controls.autoRotate = false;
|
|
3804
|
+
this.orbitUpdating = false;
|
|
3805
|
+
}
|
|
3806
|
+
if (this.flight === null) this.flushRender();
|
|
3807
|
+
} catch (error) {
|
|
3808
|
+
this.stopAutoOrbit();
|
|
3809
|
+
throw error;
|
|
3810
|
+
}
|
|
3811
|
+
};
|
|
2739
3812
|
readGpuTimers() {
|
|
2740
3813
|
const timer = this.gpuTimer;
|
|
2741
3814
|
if (!timer) return;
|
|
@@ -2747,8 +3820,15 @@ var Graphraum = class {
|
|
|
2747
3820
|
timer.context.deleteQuery(query);
|
|
2748
3821
|
}
|
|
2749
3822
|
}
|
|
3823
|
+
/**
|
|
3824
|
+
* Orbit frames skip materialization. In 3D the packed instances do not depend on camera
|
|
3825
|
+
* orientation: there is no viewport culling, density LOD and edge tiers follow counts only,
|
|
3826
|
+
* and the one orientation-dependent value (`worldUnitsPerPixel`, for edge hit slop) is used
|
|
3827
|
+
* only by 2D picking. 3D picking raycasts the instances and screen positions are projected per
|
|
3828
|
+
* call, so an orbit frame needs only a render, whose view listeners still run every frame.
|
|
3829
|
+
*/
|
|
2750
3830
|
handleViewChange = () => {
|
|
2751
|
-
this.materializeViewport();
|
|
3831
|
+
if (!this.orbitUpdating) this.materializeViewport();
|
|
2752
3832
|
this.requestRender();
|
|
2753
3833
|
};
|
|
2754
3834
|
createCamera() {
|
|
@@ -2758,12 +3838,8 @@ var Graphraum = class {
|
|
|
2758
3838
|
createControls() {
|
|
2759
3839
|
const controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
2760
3840
|
controls.enableDamping = false;
|
|
2761
|
-
controls.enableRotate = this.mode === "3d";
|
|
2762
3841
|
controls.screenSpacePanning = true;
|
|
2763
|
-
|
|
2764
|
-
controls.minZoom = .01;
|
|
2765
|
-
controls.mouseButtons.LEFT = MOUSE.PAN;
|
|
2766
|
-
}
|
|
3842
|
+
configureControlsForMode(controls, this.mode);
|
|
2767
3843
|
controls.addEventListener("change", this.handleViewChange);
|
|
2768
3844
|
return controls;
|
|
2769
3845
|
}
|
|
@@ -2854,7 +3930,35 @@ var Graphraum = class {
|
|
|
2854
3930
|
slots.push(slot);
|
|
2855
3931
|
}
|
|
2856
3932
|
markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
|
|
3933
|
+
this.glowLayer.rewrite(this.describeGlowNode);
|
|
3934
|
+
}
|
|
3935
|
+
/** Rebuilds the opt-in halo pass; graphs without glow release it and skip the visible scan. */
|
|
3936
|
+
syncGlow() {
|
|
3937
|
+
if (!this.hasGlowNodes) {
|
|
3938
|
+
this.glowLayer.dispose();
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
this.glowLayer.setBlending(glowBlendModeFor(this.theme.background));
|
|
3942
|
+
this.glowLayer.sync(this.visibleNodeIndices, this.glowAt, this.densityLodActive, this.describeGlowNode);
|
|
2857
3943
|
}
|
|
3944
|
+
glowAt = (nodeIndex) => this.data.nodes[nodeIndex]?.glow ?? 0;
|
|
3945
|
+
describeGlowNode = (nodeIndex, target) => {
|
|
3946
|
+
const node = this.data.nodes[nodeIndex];
|
|
3947
|
+
if (!node) return false;
|
|
3948
|
+
const { height, width } = resolveNodeAxes({
|
|
3949
|
+
height: node.height,
|
|
3950
|
+
nodeId: node.id,
|
|
3951
|
+
size: node.size,
|
|
3952
|
+
width: node.width
|
|
3953
|
+
});
|
|
3954
|
+
target.color.copy(this.getNodeColor(node));
|
|
3955
|
+
target.extent = Math.max(width, height);
|
|
3956
|
+
target.glow = node.glow ?? 0;
|
|
3957
|
+
target.x = node.position.x;
|
|
3958
|
+
target.y = node.position.y;
|
|
3959
|
+
target.z = node.position.z ?? 0;
|
|
3960
|
+
return true;
|
|
3961
|
+
};
|
|
2858
3962
|
getNodeStateIds(state) {
|
|
2859
3963
|
switch (state) {
|
|
2860
3964
|
case "dimmed": return this.dimmedNodeIds;
|
|
@@ -3000,10 +4104,8 @@ var Graphraum = class {
|
|
|
3000
4104
|
const worldPerPixel = this.worldUnitsPerPixel();
|
|
3001
4105
|
const packed = packEdgeInstances({
|
|
3002
4106
|
edgeIndices: visibleEdgeIndices,
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
color: this.theme.selectedEdge
|
|
3006
|
-
} : edge),
|
|
4107
|
+
edgeStates: this.edgeStateStyling(),
|
|
4108
|
+
edgeVisuals: this.data.edges,
|
|
3007
4109
|
endpointPositions: this.canonicalEdgePositions,
|
|
3008
4110
|
defaults: {
|
|
3009
4111
|
color: this.theme.edge,
|
|
@@ -3014,6 +4116,7 @@ var Graphraum = class {
|
|
|
3014
4116
|
maxSegments: this.edgeSegmentCapacity,
|
|
3015
4117
|
endpointAttach: this.theme.endpointAttach,
|
|
3016
4118
|
edgeNodeIndices: this.edgeNodeIndices,
|
|
4119
|
+
mode: this.mode,
|
|
3017
4120
|
nodeOutlines: this.data.nodes.map((node) => ({
|
|
3018
4121
|
height: node.height,
|
|
3019
4122
|
shape: node.shape,
|
|
@@ -3063,6 +4166,7 @@ var Graphraum = class {
|
|
|
3063
4166
|
this.visibleEdgeSegmentCount = segmentCount;
|
|
3064
4167
|
this.visibleEdgeCount = distinctEdges.size;
|
|
3065
4168
|
this.visibleEdgeMarkerCount = packed.markers.length;
|
|
4169
|
+
this.syncGlow();
|
|
3066
4170
|
}
|
|
3067
4171
|
materializeNodes(indices, bounds) {
|
|
3068
4172
|
if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
|
|
@@ -3124,6 +4228,7 @@ var Graphraum = class {
|
|
|
3124
4228
|
return [...clusters.values()].slice(0, this.maxVisibleNodes);
|
|
3125
4229
|
}
|
|
3126
4230
|
disposeGraphObjects() {
|
|
4231
|
+
this.glowLayer.dispose();
|
|
3127
4232
|
this.pickableEdgeSegments = [];
|
|
3128
4233
|
this.visibleEdgeSegmentEdgeIndices = [];
|
|
3129
4234
|
this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
|
|
@@ -3146,6 +4251,42 @@ var Graphraum = class {
|
|
|
3146
4251
|
}
|
|
3147
4252
|
}
|
|
3148
4253
|
};
|
|
4254
|
+
/** The 2D fit: the nodes' padded bounds, framed with 15% slack on the tighter axis. */
|
|
4255
|
+
function orthographicFit(aspect, nodes) {
|
|
4256
|
+
const bounds = new Box3();
|
|
4257
|
+
const minimum = new Vector3();
|
|
4258
|
+
const maximum = new Vector3();
|
|
4259
|
+
for (const node of nodes) {
|
|
4260
|
+
const { height, width } = resolveNodeAxes({
|
|
4261
|
+
height: node.height,
|
|
4262
|
+
nodeId: node.id,
|
|
4263
|
+
size: node.size,
|
|
4264
|
+
width: node.width
|
|
4265
|
+
});
|
|
4266
|
+
const radius = Math.max(width, height);
|
|
4267
|
+
const z = node.position.z ?? 0;
|
|
4268
|
+
bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
|
|
4269
|
+
bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
|
|
4270
|
+
}
|
|
4271
|
+
const center = bounds.getCenter(new Vector3());
|
|
4272
|
+
const size = bounds.getSize(new Vector3());
|
|
4273
|
+
return {
|
|
4274
|
+
cameraZ: center.z + Math.max(size.z, 1e3),
|
|
4275
|
+
center,
|
|
4276
|
+
visibleHeight: Math.max(size.y, size.x / aspect, 1) * 1.15
|
|
4277
|
+
};
|
|
4278
|
+
}
|
|
4279
|
+
/** Sets a frustum that shows `visibleHeight` world units at zoom 1 and resets the zoom. */
|
|
4280
|
+
function setOrthographicFrustum(camera, aspect, visibleHeight) {
|
|
4281
|
+
camera.left = -visibleHeight * aspect / 2;
|
|
4282
|
+
camera.right = visibleHeight * aspect / 2;
|
|
4283
|
+
camera.top = visibleHeight / 2;
|
|
4284
|
+
camera.bottom = -visibleHeight / 2;
|
|
4285
|
+
camera.zoom = 1;
|
|
4286
|
+
}
|
|
4287
|
+
function hasNodeGlow(node) {
|
|
4288
|
+
return (node.glow ?? 0) > 0;
|
|
4289
|
+
}
|
|
3149
4290
|
function nextCapacity(length) {
|
|
3150
4291
|
let capacity = 1;
|
|
3151
4292
|
while (capacity < length) capacity *= 2;
|