@yggy-tech/graphraum 0.28.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +184 -7
- package/dist/index.js +1312 -200
- package/package.json +1 -1
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,28 @@ 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;
|
|
114
125
|
}
|
|
115
126
|
/**
|
|
116
127
|
* Walk from the node center toward the far point and stop on the outline,
|
|
117
128
|
* then pull back by `clearance` so the stroke is not stabbed.
|
|
129
|
+
*
|
|
130
|
+
* In `2d` the outline lies in the xy plane, so only the xy part of the chord counts. In `3d` the
|
|
131
|
+
* billboard faces the camera, so the edge stops one outline radius from the center along the 3D
|
|
132
|
+
* segment. The radius is measured along the chord's world xy heading (or +x when the chord is
|
|
133
|
+
* parallel to z), not along its on-screen heading. For circles the radius is the same in every
|
|
134
|
+
* direction, so the projected end lies on the outline or inside it (where the node covers the
|
|
135
|
+
* edge) from every view. Other shapes are approximate in 3D: an elongated or angular node can end
|
|
136
|
+
* inside its outline or short of it, depending on the camera.
|
|
118
137
|
*/
|
|
119
|
-
function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
|
|
138
|
+
function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE, mode = "2d") {
|
|
120
139
|
const dx = far.x - center.x;
|
|
121
140
|
const dy = far.y - center.y;
|
|
122
141
|
const dz = (far.z ?? 0) - (center.z ?? 0);
|
|
@@ -126,15 +145,12 @@ function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT
|
|
|
126
145
|
y: center.y,
|
|
127
146
|
z: center.z ?? 0
|
|
128
147
|
};
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (isInsideOutline(outline, center.x + dx * mid, center.y + dy * mid, center)) low = mid;
|
|
134
|
-
else high = mid;
|
|
135
|
-
}
|
|
148
|
+
const planar = Math.hypot(dx, dy);
|
|
149
|
+
const reach = mode === "3d" ? length : planar;
|
|
150
|
+
const radius = planar < 1e-6 ? outlineRadius(outline, 1, 0) : outlineRadius(outline, dx / planar, dy / planar);
|
|
151
|
+
const outlineT = reach < 1e-6 ? 1 : Math.min(1, radius / reach);
|
|
136
152
|
const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
|
|
137
|
-
const t = Math.max(0,
|
|
153
|
+
const t = Math.max(0, outlineT - clearanceT);
|
|
138
154
|
return {
|
|
139
155
|
x: center.x + dx * t,
|
|
140
156
|
y: center.y + dy * t,
|
|
@@ -156,8 +172,8 @@ function trimEdgeEndpoints(input) {
|
|
|
156
172
|
}
|
|
157
173
|
};
|
|
158
174
|
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);
|
|
175
|
+
const source = attachPointOnOutline(input.source, input.target, input.sourceOutline, clearance, input.mode);
|
|
176
|
+
const target = attachPointOnOutline(input.target, input.source, input.targetOutline, clearance, input.mode);
|
|
161
177
|
const ox = input.target.x - input.source.x;
|
|
162
178
|
const oy = input.target.y - input.source.y;
|
|
163
179
|
const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
|
|
@@ -245,6 +261,7 @@ function encodeEdgeStyle(style) {
|
|
|
245
261
|
//#region src/force-layout.ts
|
|
246
262
|
const DEFAULT_FORCE_SETTINGS = {
|
|
247
263
|
centerAttraction: .002,
|
|
264
|
+
clusterStrength: .02,
|
|
248
265
|
damping: .82,
|
|
249
266
|
linkDistance: 24,
|
|
250
267
|
repulsion: 400,
|
|
@@ -253,6 +270,7 @@ const DEFAULT_FORCE_SETTINGS = {
|
|
|
253
270
|
function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
|
|
254
271
|
return {
|
|
255
272
|
centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
|
|
273
|
+
clusterStrength: Math.max(0, Math.min(.1, settings.clusterStrength ?? DEFAULT_FORCE_SETTINGS.clusterStrength)),
|
|
256
274
|
damping: Math.max(.1, Math.min(.99, settings.damping)),
|
|
257
275
|
linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
|
|
258
276
|
repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
|
|
@@ -311,6 +329,60 @@ function random(seed) {
|
|
|
311
329
|
value = Math.imul(value ^ value >>> 13, 3266489909);
|
|
312
330
|
return ((value ^ value >>> 16) >>> 0) / 4294967296;
|
|
313
331
|
}
|
|
332
|
+
function assertClusterCount(clusters, expected, subject) {
|
|
333
|
+
if (clusters.length !== expected) throw new Error(`Cluster index count (${clusters.length}) must match ${subject} (${expected}).`);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Live simulations index per-cluster scratch arrays by id, so ids must be dense: below the node count.
|
|
337
|
+
* Validate before allocating anything.
|
|
338
|
+
*/
|
|
339
|
+
function assertDenseClusterIds(clusters, totalNodeCount) {
|
|
340
|
+
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.`);
|
|
341
|
+
}
|
|
342
|
+
function clusterCountOf(clusters) {
|
|
343
|
+
let clusterCount = 0;
|
|
344
|
+
for (const cluster of clusters) clusterCount = Math.max(clusterCount, cluster + 1);
|
|
345
|
+
return clusterCount;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Seeds nodes around cluster centres: centres come from a force layout of the inter-cluster graph
|
|
349
|
+
* and each node is scattered around its centre. Shared by the one-shot and live clustered layouts.
|
|
350
|
+
*/
|
|
351
|
+
function clusteredInitialPositions(request) {
|
|
352
|
+
const clusterCount = clusterCountOf(request.clusters);
|
|
353
|
+
const clusterEdges = new Uint32Array(request.edges.length);
|
|
354
|
+
let edgeCursor = 0;
|
|
355
|
+
for (let index = 0; index < request.edges.length; index += 2) {
|
|
356
|
+
const source = request.clusters[request.edges[index] ?? 0];
|
|
357
|
+
const target = request.clusters[request.edges[index + 1] ?? 0];
|
|
358
|
+
if (source === void 0 || target === void 0 || source === target) continue;
|
|
359
|
+
clusterEdges[edgeCursor++] = source;
|
|
360
|
+
clusterEdges[edgeCursor++] = target;
|
|
361
|
+
}
|
|
362
|
+
const centers = computeForcePositions({
|
|
363
|
+
dimensions: request.dimensions,
|
|
364
|
+
edges: clusterEdges.subarray(0, edgeCursor),
|
|
365
|
+
nodeCount: clusterCount,
|
|
366
|
+
settings: request.settings
|
|
367
|
+
});
|
|
368
|
+
const clusterSizes = new Uint32Array(clusterCount);
|
|
369
|
+
for (const cluster of request.clusters) clusterSizes[cluster] += 1;
|
|
370
|
+
const positions = new Float32Array(request.nodeCount * 3);
|
|
371
|
+
for (let index = 0; index < request.nodeCount; index += 1) {
|
|
372
|
+
const offset = index * 3;
|
|
373
|
+
const cluster = request.clusters[index] ?? 0;
|
|
374
|
+
const center = cluster * 3;
|
|
375
|
+
const radius = Math.sqrt(clusterSizes[cluster] ?? 1) * Math.sqrt(random(index * 4 + 2)) * 2;
|
|
376
|
+
const angle = random(index * 4 + 1) * Math.PI * 2;
|
|
377
|
+
positions[offset] = (centers[center] ?? 0) * 12 + Math.cos(angle) * radius;
|
|
378
|
+
positions[offset + 1] = (centers[center + 1] ?? 0) * 12 + Math.sin(angle) * radius;
|
|
379
|
+
if (request.dimensions === 3) {
|
|
380
|
+
const zDirection = random(index * 4 + 3) * 2 - 1;
|
|
381
|
+
positions[offset + 2] = (centers[center + 2] ?? 0) * 12 + zDirection * radius;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return positions;
|
|
385
|
+
}
|
|
314
386
|
function createForceSimulation(request) {
|
|
315
387
|
const settings = normalizeForceSettings(request.settings);
|
|
316
388
|
let nodeCount = request.nodeCount;
|
|
@@ -319,10 +391,41 @@ function createForceSimulation(request) {
|
|
|
319
391
|
const edgeCount = edges.length / 2;
|
|
320
392
|
let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
|
|
321
393
|
let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
|
|
322
|
-
let
|
|
394
|
+
let clusters = null;
|
|
395
|
+
if (request.clusters) {
|
|
396
|
+
assertClusterCount(request.clusters, request.nodeCount, "node count");
|
|
397
|
+
assertDenseClusterIds(request.clusters, request.nodeCount);
|
|
398
|
+
clusters = new Uint32Array(request.clusters);
|
|
399
|
+
}
|
|
400
|
+
let positions = clusters ? clusteredInitialPositions({
|
|
401
|
+
...request,
|
|
402
|
+
clusters
|
|
403
|
+
}) : initialPositions(request);
|
|
323
404
|
let velocities = new Float32Array(positions.length);
|
|
324
405
|
let forces = new Float32Array(positions.length);
|
|
325
406
|
let iteration = 0;
|
|
407
|
+
let clusterCount = clusters ? clusterCountOf(clusters) : 0;
|
|
408
|
+
let clusterCentroids = new Float64Array(clusterCount * 3);
|
|
409
|
+
let clusterSizes = new Uint32Array(clusterCount);
|
|
410
|
+
/** Mean position per cluster in O(N): no per-node allocation. */
|
|
411
|
+
function updateClusterCentroids(activeClusters) {
|
|
412
|
+
clusterCentroids.fill(0);
|
|
413
|
+
clusterSizes.fill(0);
|
|
414
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
415
|
+
const cluster = activeClusters[index] ?? 0;
|
|
416
|
+
const offset = index * 3;
|
|
417
|
+
const centroid = cluster * 3;
|
|
418
|
+
clusterSizes[cluster] += 1;
|
|
419
|
+
clusterCentroids[centroid] += positions[offset] ?? 0;
|
|
420
|
+
clusterCentroids[centroid + 1] += positions[offset + 1] ?? 0;
|
|
421
|
+
if (request.dimensions === 3) clusterCentroids[centroid + 2] += positions[offset + 2] ?? 0;
|
|
422
|
+
}
|
|
423
|
+
for (let cluster = 0; cluster < clusterCount; cluster += 1) {
|
|
424
|
+
const size = clusterSizes[cluster] ?? 0;
|
|
425
|
+
if (size === 0) continue;
|
|
426
|
+
for (let axis = 0; axis < request.dimensions; axis += 1) clusterCentroids[cluster * 3 + axis] /= size;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
326
429
|
return {
|
|
327
430
|
get nodeCount() {
|
|
328
431
|
return nodeCount;
|
|
@@ -333,10 +436,20 @@ function createForceSimulation(request) {
|
|
|
333
436
|
get positions() {
|
|
334
437
|
return positions;
|
|
335
438
|
},
|
|
336
|
-
/**
|
|
337
|
-
|
|
439
|
+
/**
|
|
440
|
+
* Add nodes while preserving every existing position and velocity. A simulation created with
|
|
441
|
+
* `clusters` requires one cluster index per added node (a new id starts a new cluster); a
|
|
442
|
+
* simulation created without clusters rejects them.
|
|
443
|
+
*/
|
|
444
|
+
addNodes(count, initial, addedClusters) {
|
|
338
445
|
if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
|
|
339
446
|
if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
|
|
447
|
+
if (clusters && !addedClusters) throw new Error("This simulation was created with clusters: addNodes() requires a clusters array.");
|
|
448
|
+
if (!clusters && addedClusters) throw new Error("This simulation was created without clusters: addNodes() cannot take a clusters array.");
|
|
449
|
+
if (addedClusters) {
|
|
450
|
+
assertClusterCount(addedClusters, count, "added node count");
|
|
451
|
+
assertDenseClusterIds(addedClusters, nodeCount + count);
|
|
452
|
+
}
|
|
340
453
|
const start = nodeCount;
|
|
341
454
|
const nextPositions = new Float32Array((nodeCount + count) * 3);
|
|
342
455
|
nextPositions.set(positions);
|
|
@@ -352,6 +465,24 @@ function createForceSimulation(request) {
|
|
|
352
465
|
});
|
|
353
466
|
nextPositions.set(added, positions.length);
|
|
354
467
|
}
|
|
468
|
+
let nextClusters = clusters;
|
|
469
|
+
let nextClusterCount = clusterCount;
|
|
470
|
+
let nextCentroids = clusterCentroids;
|
|
471
|
+
let nextSizes = clusterSizes;
|
|
472
|
+
if (clusters && addedClusters) {
|
|
473
|
+
nextClusters = new Uint32Array(nodeCount + count);
|
|
474
|
+
nextClusters.set(clusters);
|
|
475
|
+
nextClusters.set(addedClusters, nodeCount);
|
|
476
|
+
nextClusterCount = Math.max(clusterCount, clusterCountOf(addedClusters));
|
|
477
|
+
if (nextClusterCount !== clusterCount) {
|
|
478
|
+
nextCentroids = new Float64Array(nextClusterCount * 3);
|
|
479
|
+
nextSizes = new Uint32Array(nextClusterCount);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
clusters = nextClusters;
|
|
483
|
+
clusterCount = nextClusterCount;
|
|
484
|
+
clusterCentroids = nextCentroids;
|
|
485
|
+
clusterSizes = nextSizes;
|
|
355
486
|
positions = nextPositions;
|
|
356
487
|
velocities = nextVelocities;
|
|
357
488
|
forces = nextForces;
|
|
@@ -383,11 +514,18 @@ function createForceSimulation(request) {
|
|
|
383
514
|
step(alpha) {
|
|
384
515
|
if (nodeCount === 0) return;
|
|
385
516
|
forces.fill(0);
|
|
517
|
+
const pullClusters = clusters && settings.clusterStrength > 0 ? clusters : null;
|
|
518
|
+
const clusterPull = settings.clusterStrength * alpha;
|
|
519
|
+
if (pullClusters) updateClusterCentroids(pullClusters);
|
|
386
520
|
for (let index = 0; index < nodeCount; index += 1) {
|
|
387
521
|
const offset = index * 3;
|
|
388
522
|
forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
|
|
389
523
|
forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
|
|
390
524
|
forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
|
|
525
|
+
if (pullClusters) {
|
|
526
|
+
const centroid = (pullClusters[index] ?? 0) * 3;
|
|
527
|
+
for (let axis = 0; axis < request.dimensions; axis += 1) forces[offset + axis] += ((clusterCentroids[centroid + axis] ?? 0) - (positions[offset + axis] ?? 0)) * clusterPull;
|
|
528
|
+
}
|
|
391
529
|
for (let sample = 0; sample < 8; sample += 1) {
|
|
392
530
|
const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
|
|
393
531
|
if (targetIndex === index) continue;
|
|
@@ -469,41 +607,8 @@ function computeForcePositions(request) {
|
|
|
469
607
|
return simulation.positions;
|
|
470
608
|
}
|
|
471
609
|
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;
|
|
610
|
+
assertClusterCount(request.clusters, request.nodeCount, "node count");
|
|
611
|
+
return clusteredInitialPositions(request);
|
|
507
612
|
}
|
|
508
613
|
//#endregion
|
|
509
614
|
//#region src/graphology.ts
|
|
@@ -570,6 +675,188 @@ function bindGraphology(renderer, graph) {
|
|
|
570
675
|
graph.off("edgesCleared", schedule);
|
|
571
676
|
};
|
|
572
677
|
}
|
|
678
|
+
/** Longest frame step the orbit applies, so a throttled or backgrounded tab does not jump on return. */
|
|
679
|
+
const AUTO_ORBIT_MAX_DELTA_SECONDS = .1;
|
|
680
|
+
const AUTO_ORBIT_OFF = {
|
|
681
|
+
enabled: false,
|
|
682
|
+
hidden: false,
|
|
683
|
+
interacting: false,
|
|
684
|
+
offscreen: false
|
|
685
|
+
};
|
|
686
|
+
function resolveAutoOrbitOptions(options) {
|
|
687
|
+
const speed = options.speed ?? .07;
|
|
688
|
+
const resumeAfterMs = options.resumeAfterMs ?? 3e3;
|
|
689
|
+
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}.`);
|
|
690
|
+
if (!Number.isFinite(resumeAfterMs) || resumeAfterMs < 0) throw new Error(`Auto-orbit resumeAfterMs must be a non-negative finite number of milliseconds; received ${resumeAfterMs}.`);
|
|
691
|
+
return {
|
|
692
|
+
speed,
|
|
693
|
+
resumeAfterMs
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function nextAutoOrbitState(state, event) {
|
|
697
|
+
if (event.type === "enable") return state.enabled ? {
|
|
698
|
+
...state,
|
|
699
|
+
hidden: event.hidden
|
|
700
|
+
} : {
|
|
701
|
+
...AUTO_ORBIT_OFF,
|
|
702
|
+
enabled: true,
|
|
703
|
+
hidden: event.hidden
|
|
704
|
+
};
|
|
705
|
+
if (!state.enabled) return state;
|
|
706
|
+
switch (event.type) {
|
|
707
|
+
case "disable": return AUTO_ORBIT_OFF;
|
|
708
|
+
case "interaction": return {
|
|
709
|
+
...state,
|
|
710
|
+
interacting: true
|
|
711
|
+
};
|
|
712
|
+
case "idle": return {
|
|
713
|
+
...state,
|
|
714
|
+
interacting: false
|
|
715
|
+
};
|
|
716
|
+
case "visibility": return {
|
|
717
|
+
...state,
|
|
718
|
+
hidden: event.hidden
|
|
719
|
+
};
|
|
720
|
+
case "intersection": return {
|
|
721
|
+
...state,
|
|
722
|
+
offscreen: event.offscreen
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function autoOrbitStatus(state) {
|
|
727
|
+
if (!state.enabled) return "off";
|
|
728
|
+
return state.hidden || state.interacting || state.offscreen ? "paused" : "active";
|
|
729
|
+
}
|
|
730
|
+
/** Seconds between two animation-frame timestamps, zero on the first frame, clamped to `[0, AUTO_ORBIT_MAX_DELTA_SECONDS]`. */
|
|
731
|
+
function autoOrbitDeltaSeconds(previousMs, nowMs) {
|
|
732
|
+
if (previousMs === null) return 0;
|
|
733
|
+
return Math.min(Math.max((nowMs - previousMs) / 1e3, 0), AUTO_ORBIT_MAX_DELTA_SECONDS);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* OrbitControls (three r185) rotates `(2π / 60) * autoRotateSpeed * deltaTime` radians when
|
|
737
|
+
* `update(deltaTime)` receives seconds (and a fixed per-call step without it). Inverting that
|
|
738
|
+
* gives an `autoRotateSpeed` for which `update(dtSeconds)` turns exactly `radiansPerSecond`,
|
|
739
|
+
* independent of frame rate.
|
|
740
|
+
*/
|
|
741
|
+
function autoRotateSpeedFor(radiansPerSecond) {
|
|
742
|
+
return radiansPerSecond * 60 / (2 * Math.PI);
|
|
743
|
+
}
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/auto-orbit-loop.ts
|
|
746
|
+
/**
|
|
747
|
+
* Owns the auto-orbit frame loop and the browser signals that stop it. The loop exists only
|
|
748
|
+
* while the orbit is active: pointer, wheel and key input pause it until `resumeAfterMs` of
|
|
749
|
+
* idle, and a hidden document or an off-screen element stop it until they change back.
|
|
750
|
+
* Decisions come from the pure state in `auto-orbit.ts`; this class only wires DOM events,
|
|
751
|
+
* the resume timer and `requestAnimationFrame`. Interaction comes from the controls' `start`
|
|
752
|
+
* and `end` events, so multi-touch and lost pointer capture end the pause only once the last
|
|
753
|
+
* pointer lifts. DOM overlays above the canvas do not reach the controls and do not pause it.
|
|
754
|
+
*/
|
|
755
|
+
var AutoOrbitLoop = class {
|
|
756
|
+
element;
|
|
757
|
+
interactions;
|
|
758
|
+
resumeAfterMs;
|
|
759
|
+
step;
|
|
760
|
+
state;
|
|
761
|
+
frame = null;
|
|
762
|
+
lastFrameMs = null;
|
|
763
|
+
resumeTimer = null;
|
|
764
|
+
observer;
|
|
765
|
+
constructor(element, interactions, resumeAfterMs, step) {
|
|
766
|
+
this.element = element;
|
|
767
|
+
this.interactions = interactions;
|
|
768
|
+
this.resumeAfterMs = resumeAfterMs;
|
|
769
|
+
this.step = step;
|
|
770
|
+
this.state = nextAutoOrbitState(AUTO_ORBIT_OFF, {
|
|
771
|
+
type: "enable",
|
|
772
|
+
hidden: element.ownerDocument.hidden
|
|
773
|
+
});
|
|
774
|
+
interactions.addEventListener("start", this.handleInteractionStart);
|
|
775
|
+
interactions.addEventListener("end", this.handleInteractionEnd);
|
|
776
|
+
element.ownerDocument.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
777
|
+
this.observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(this.handleIntersection);
|
|
778
|
+
this.observer?.observe(element);
|
|
779
|
+
this.sync();
|
|
780
|
+
}
|
|
781
|
+
get status() {
|
|
782
|
+
return autoOrbitStatus(this.state);
|
|
783
|
+
}
|
|
784
|
+
setResumeAfterMs(resumeAfterMs) {
|
|
785
|
+
this.resumeAfterMs = resumeAfterMs;
|
|
786
|
+
}
|
|
787
|
+
dispose() {
|
|
788
|
+
this.interactions.removeEventListener("start", this.handleInteractionStart);
|
|
789
|
+
this.interactions.removeEventListener("end", this.handleInteractionEnd);
|
|
790
|
+
this.element.ownerDocument.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
791
|
+
this.observer?.disconnect();
|
|
792
|
+
this.clearResumeTimer();
|
|
793
|
+
this.dispatch({ type: "disable" });
|
|
794
|
+
}
|
|
795
|
+
dispatch(event) {
|
|
796
|
+
this.state = nextAutoOrbitState(this.state, event);
|
|
797
|
+
this.sync();
|
|
798
|
+
}
|
|
799
|
+
/** Starts the frame loop when the orbit becomes active and cancels it otherwise. */
|
|
800
|
+
sync() {
|
|
801
|
+
const active = this.status === "active";
|
|
802
|
+
if (active && this.frame === null) {
|
|
803
|
+
this.lastFrameMs = null;
|
|
804
|
+
this.frame = requestAnimationFrame(this.tick);
|
|
805
|
+
} else if (!active && this.frame !== null) {
|
|
806
|
+
cancelAnimationFrame(this.frame);
|
|
807
|
+
this.frame = null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
tick = (nowMs) => {
|
|
811
|
+
this.frame = null;
|
|
812
|
+
const deltaSeconds = autoOrbitDeltaSeconds(this.lastFrameMs, nowMs);
|
|
813
|
+
this.lastFrameMs = nowMs;
|
|
814
|
+
try {
|
|
815
|
+
this.step(deltaSeconds);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
this.dispose();
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
if (this.frame === null && this.status === "active") this.frame = requestAnimationFrame(this.tick);
|
|
821
|
+
};
|
|
822
|
+
armResumeTimer() {
|
|
823
|
+
this.clearResumeTimer();
|
|
824
|
+
this.resumeTimer = setTimeout(() => {
|
|
825
|
+
this.resumeTimer = null;
|
|
826
|
+
this.dispatch({ type: "idle" });
|
|
827
|
+
}, this.resumeAfterMs);
|
|
828
|
+
}
|
|
829
|
+
clearResumeTimer() {
|
|
830
|
+
if (this.resumeTimer === null) return;
|
|
831
|
+
clearTimeout(this.resumeTimer);
|
|
832
|
+
this.resumeTimer = null;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* A drag, pinch or wheel step pauses the orbit; `end` restarts the idle countdown. `start` arms
|
|
836
|
+
* it too, so a swallowed pointerup cannot pause the orbit forever. Resuming mid-drag is harmless:
|
|
837
|
+
* OrbitControls (r185) applies autoRotate in `update()` only while its state is NONE.
|
|
838
|
+
*/
|
|
839
|
+
handleInteractionStart = () => {
|
|
840
|
+
this.dispatch({ type: "interaction" });
|
|
841
|
+
this.armResumeTimer();
|
|
842
|
+
};
|
|
843
|
+
handleInteractionEnd = () => {
|
|
844
|
+
if (this.state.interacting) this.armResumeTimer();
|
|
845
|
+
};
|
|
846
|
+
handleVisibilityChange = () => {
|
|
847
|
+
this.dispatch({
|
|
848
|
+
type: "visibility",
|
|
849
|
+
hidden: this.element.ownerDocument.hidden
|
|
850
|
+
});
|
|
851
|
+
};
|
|
852
|
+
handleIntersection = (entries) => {
|
|
853
|
+
const entry = entries.at(-1);
|
|
854
|
+
if (entry) this.dispatch({
|
|
855
|
+
type: "intersection",
|
|
856
|
+
offscreen: !entry.isIntersecting
|
|
857
|
+
});
|
|
858
|
+
};
|
|
859
|
+
};
|
|
573
860
|
//#endregion
|
|
574
861
|
//#region src/camera-aspect.ts
|
|
575
862
|
/** Canvas aspect used by perspective cameras. Never 0. */
|
|
@@ -580,6 +867,170 @@ function containerAspect(width, height) {
|
|
|
580
867
|
function applyPerspectiveContainerAspect(camera, width, height) {
|
|
581
868
|
camera.aspect = containerAspect(width, height);
|
|
582
869
|
}
|
|
870
|
+
function resolveFlyToOptions(options) {
|
|
871
|
+
const durationMs = options.durationMs ?? 1200;
|
|
872
|
+
const distanceScale = options.distanceScale ?? 1;
|
|
873
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) throw new Error(`flyTo durationMs must be a non-negative finite number of milliseconds; received ${durationMs}.`);
|
|
874
|
+
if (!Number.isFinite(distanceScale) || distanceScale <= 0) throw new Error(`flyTo distanceScale must be a finite number above 0; received ${distanceScale}.`);
|
|
875
|
+
return {
|
|
876
|
+
distanceScale,
|
|
877
|
+
durationMs
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/** Cubic ease-in-out on `[0, 1]`: slow start, slow arrival, symmetric around 0.5. */
|
|
881
|
+
function easeInOutCubic(t) {
|
|
882
|
+
return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
883
|
+
}
|
|
884
|
+
/** Linear progress of a flight, clamped to `[0, 1]`; a zero duration is already done. */
|
|
885
|
+
function flightProgress(elapsedMs, durationMs) {
|
|
886
|
+
if (durationMs <= 0) return 1;
|
|
887
|
+
return Math.min(Math.max(elapsedMs / durationMs, 0), 1);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Interpolates a positive scale (camera distance, orthographic zoom) geometrically, so each
|
|
891
|
+
* step zooms by the same factor and a long zoom does not rush its last part. Exact at both ends.
|
|
892
|
+
*/
|
|
893
|
+
function interpolateScale(from, to, t) {
|
|
894
|
+
if (t <= 0) return from;
|
|
895
|
+
if (t >= 1) return to;
|
|
896
|
+
return from * (to / from) ** t;
|
|
897
|
+
}
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/theme.ts
|
|
900
|
+
/** Dark canvas: Ink Black, Turf Green, Porcelain. */
|
|
901
|
+
const graphraumThemeDark = Object.freeze({
|
|
902
|
+
background: "#040f0f",
|
|
903
|
+
dimmedEdge: "#315a51",
|
|
904
|
+
dimmedEdgeOpacity: .25,
|
|
905
|
+
dimmedNode: "#315a51",
|
|
906
|
+
edge: "#226f54",
|
|
907
|
+
edgeOpacity: .55,
|
|
908
|
+
edgeWidth: 1.5,
|
|
909
|
+
endpointAttach: "boundary",
|
|
910
|
+
focusedNode: "#73c7a5",
|
|
911
|
+
hoveredNode: "#e4a853",
|
|
912
|
+
node: "#226f54",
|
|
913
|
+
nodeStroke: "#fcfffc",
|
|
914
|
+
selectedEdge: "#fcfffc",
|
|
915
|
+
selectedNode: "#fcfffc"
|
|
916
|
+
});
|
|
917
|
+
/** Light canvas: Porcelain field with Turf marks and Ink selection. */
|
|
918
|
+
const graphraumThemeLight = Object.freeze({
|
|
919
|
+
background: "#fcfffc",
|
|
920
|
+
dimmedEdge: "#9bb5ac",
|
|
921
|
+
dimmedEdgeOpacity: .35,
|
|
922
|
+
dimmedNode: "#9bb5ac",
|
|
923
|
+
edge: "#226f54",
|
|
924
|
+
edgeOpacity: .65,
|
|
925
|
+
edgeWidth: 1.5,
|
|
926
|
+
endpointAttach: "boundary",
|
|
927
|
+
focusedNode: "#1a5c45",
|
|
928
|
+
hoveredNode: "#c47a1a",
|
|
929
|
+
node: "#226f54",
|
|
930
|
+
nodeStroke: "#040f0f",
|
|
931
|
+
selectedEdge: "#040f0f",
|
|
932
|
+
selectedNode: "#040f0f"
|
|
933
|
+
});
|
|
934
|
+
const graphraumThemes = Object.freeze({
|
|
935
|
+
dark: graphraumThemeDark,
|
|
936
|
+
light: graphraumThemeLight
|
|
937
|
+
});
|
|
938
|
+
/** Alias of {@link graphraumThemeDark} for backward compatibility. */
|
|
939
|
+
const graphraumTheme = graphraumThemeDark;
|
|
940
|
+
function resolveGraphraumTheme(input, base = graphraumThemeDark) {
|
|
941
|
+
if (input === void 0) return { ...base };
|
|
942
|
+
if (typeof input === "string") {
|
|
943
|
+
const preset = graphraumThemes[input];
|
|
944
|
+
if (!preset) throw new Error(`Unknown graphraum theme "${input}". Use "dark" or "light".`);
|
|
945
|
+
return { ...preset };
|
|
946
|
+
}
|
|
947
|
+
return assertGraphraumTheme({
|
|
948
|
+
...base,
|
|
949
|
+
...input
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
function assertGraphraumTheme(theme) {
|
|
953
|
+
const opacity = theme.dimmedEdgeOpacity;
|
|
954
|
+
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`Theme dimmedEdgeOpacity must be a finite number between 0 and 1, received ${opacity}.`);
|
|
955
|
+
return theme;
|
|
956
|
+
}
|
|
957
|
+
/** Collapse `"transparent"` onto `null` so callers have one transparent sentinel. */
|
|
958
|
+
function normalizeGraphraumBackground(background) {
|
|
959
|
+
return background === "transparent" ? null : background;
|
|
960
|
+
}
|
|
961
|
+
function isTransparentGraphraumBackground(background) {
|
|
962
|
+
return normalizeGraphraumBackground(background) === null;
|
|
963
|
+
}
|
|
964
|
+
/** Halo radius as a multiple of the node's half-extent at `glow: 1`. */
|
|
965
|
+
const GLOW_RADIUS_SCALE = 3.4;
|
|
966
|
+
/** Halo opacity multiplier under normal blending, so a light background shows a soft tint. */
|
|
967
|
+
const GLOW_NORMAL_OPACITY_SCALE = .6;
|
|
968
|
+
function assertNodeGlow(nodeId, glow) {
|
|
969
|
+
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`);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Halo radius in world units: `extent × (1 + (GLOW_RADIUS_SCALE − 1) × glow)`, where `extent` is
|
|
973
|
+
* the node's half-extent `max(width, height)`. Glow 0 collapses the halo under the node; glow 1
|
|
974
|
+
* reaches 3.4× the node extent.
|
|
975
|
+
*/
|
|
976
|
+
function glowHaloRadius(extent, glow) {
|
|
977
|
+
return extent * (1 + (GLOW_RADIUS_SCALE - 1) * glow);
|
|
978
|
+
}
|
|
979
|
+
/** Peak halo opacity at the node center; the shader fades it radially to 0 at the halo radius. */
|
|
980
|
+
function glowHaloOpacity(glow) {
|
|
981
|
+
return glow;
|
|
982
|
+
}
|
|
983
|
+
/** Keeps `current` while it fits, otherwise grows to the next power of two capped at `maximum`. */
|
|
984
|
+
function glowBufferCapacity(required, current, maximum) {
|
|
985
|
+
if (required <= current) return current;
|
|
986
|
+
let capacity = Math.max(current, 1);
|
|
987
|
+
while (capacity < required) capacity *= 2;
|
|
988
|
+
return Math.min(capacity, maximum);
|
|
989
|
+
}
|
|
990
|
+
function createGlowSelection() {
|
|
991
|
+
return {
|
|
992
|
+
count: 0,
|
|
993
|
+
indices: /* @__PURE__ */ new Uint32Array(0)
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Selects visible nodes with `glow > 0` into `selection`, highest glow first and ties by node
|
|
998
|
+
* index, capped at {@link GLOW_MAX_INSTANCES}. Selects nothing in the density LOD tier. Reuses
|
|
999
|
+
* the selection buffer and grows it geometrically; allocates no per-node objects.
|
|
1000
|
+
*/
|
|
1001
|
+
function selectGlowNodes(visibleNodeIndices, glowAt, densityLod, selection) {
|
|
1002
|
+
selection.count = 0;
|
|
1003
|
+
if (densityLod) return selection;
|
|
1004
|
+
let candidates = 0;
|
|
1005
|
+
for (const nodeIndex of visibleNodeIndices) {
|
|
1006
|
+
if (!(glowAt(nodeIndex) > 0)) continue;
|
|
1007
|
+
if (candidates === selection.indices.length) {
|
|
1008
|
+
const grown = new Uint32Array(glowBufferCapacity(candidates + 1, candidates, Number.POSITIVE_INFINITY));
|
|
1009
|
+
grown.set(selection.indices);
|
|
1010
|
+
selection.indices = grown;
|
|
1011
|
+
}
|
|
1012
|
+
selection.indices[candidates] = nodeIndex;
|
|
1013
|
+
candidates += 1;
|
|
1014
|
+
}
|
|
1015
|
+
selection.indices.subarray(0, candidates).sort((a, b) => glowAt(b) - glowAt(a) || a - b);
|
|
1016
|
+
selection.count = Math.min(candidates, 256);
|
|
1017
|
+
return selection;
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Additive light reads on dark backgrounds but saturates to nothing on light ones. A solid
|
|
1021
|
+
* background whose relative luminance (linear sRGB, 0.2126 R + 0.7152 G + 0.0722 B) reaches
|
|
1022
|
+
* {@link GLOW_LIGHT_BACKGROUND_LUMINANCE} uses normal blending; transparent and pattern
|
|
1023
|
+
* backgrounds keep additive blending because their brightness is unknown.
|
|
1024
|
+
*/
|
|
1025
|
+
function glowBlendModeFor(background) {
|
|
1026
|
+
const normalized = normalizeGraphraumBackground(background);
|
|
1027
|
+
if (normalized === null || typeof normalized === "object") return "additive";
|
|
1028
|
+
const { b, g, r } = new Color(normalized);
|
|
1029
|
+
return .2126 * r + .7152 * g + .0722 * b >= .5 ? "normal" : "additive";
|
|
1030
|
+
}
|
|
1031
|
+
function glowOpacityScale(mode) {
|
|
1032
|
+
return mode === "normal" ? GLOW_NORMAL_OPACITY_SCALE : 1;
|
|
1033
|
+
}
|
|
583
1034
|
//#endregion
|
|
584
1035
|
//#region src/compile-graph.ts
|
|
585
1036
|
function assertFinitePosition(nodeId, axis, value) {
|
|
@@ -632,6 +1083,7 @@ function compileNodeVisual(id, visual) {
|
|
|
632
1083
|
assertPositiveFiniteVisual(id, "width", visual.width);
|
|
633
1084
|
assertPositiveFiniteVisual(id, "height", visual.height);
|
|
634
1085
|
assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
|
|
1086
|
+
assertNodeGlow(id, visual.glow);
|
|
635
1087
|
if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
|
|
636
1088
|
return Object.freeze({ ...visual });
|
|
637
1089
|
}
|
|
@@ -653,6 +1105,7 @@ function compileGraph(data, visuals) {
|
|
|
653
1105
|
const encoding = visuals?.node?.(node);
|
|
654
1106
|
const visual = compileNodeVisual(node.id, encoding?.visual ?? {
|
|
655
1107
|
color: node.color,
|
|
1108
|
+
glow: node.glow,
|
|
656
1109
|
height: node.height,
|
|
657
1110
|
shape: node.shape,
|
|
658
1111
|
size: node.size,
|
|
@@ -838,6 +1291,32 @@ function sampleEdgePath(input) {
|
|
|
838
1291
|
return points;
|
|
839
1292
|
}
|
|
840
1293
|
//#endregion
|
|
1294
|
+
//#region src/edge-state.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* Single source of truth for an edge instance's color and opacity. Full packing and the
|
|
1297
|
+
* incremental state repaint both call it, so both paths write identical instance colors.
|
|
1298
|
+
*/
|
|
1299
|
+
function resolveEdgePaint(edgeIndex, visual, defaults, tier, edgeStates) {
|
|
1300
|
+
const opacity = tier === "overview" ? defaults.opacity : visual.opacity ?? defaults.opacity;
|
|
1301
|
+
const state = edgeStates?.stateOf(edgeIndex) ?? null;
|
|
1302
|
+
if (edgeStates && state === "selected") return {
|
|
1303
|
+
color: edgeStates.selectedColor,
|
|
1304
|
+
opacity
|
|
1305
|
+
};
|
|
1306
|
+
if (edgeStates && state === "dimmed") return {
|
|
1307
|
+
color: edgeStates.dimmedColor,
|
|
1308
|
+
opacity: Math.min(opacity, edgeStates.dimmedOpacity)
|
|
1309
|
+
};
|
|
1310
|
+
return {
|
|
1311
|
+
color: visual.color ?? defaults.color,
|
|
1312
|
+
opacity
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
/** IDs present in exactly one of the two sets. */
|
|
1316
|
+
function changedIds(current, next) {
|
|
1317
|
+
return [...[...current].filter((id) => !next.has(id)), ...[...next].filter((id) => !current.has(id))];
|
|
1318
|
+
}
|
|
1319
|
+
//#endregion
|
|
841
1320
|
//#region src/edge-materialize.ts
|
|
842
1321
|
function packEdgeInstances(input) {
|
|
843
1322
|
const segments = [];
|
|
@@ -862,6 +1341,7 @@ function packEdgeInstances(input) {
|
|
|
862
1341
|
const trimmed = trimEdgeEndpoints({
|
|
863
1342
|
attach,
|
|
864
1343
|
clearance,
|
|
1344
|
+
mode: input.mode,
|
|
865
1345
|
source: {
|
|
866
1346
|
x: x1,
|
|
867
1347
|
y: y1,
|
|
@@ -882,9 +1362,8 @@ function packEdgeInstances(input) {
|
|
|
882
1362
|
y2 = trimmed.target.y;
|
|
883
1363
|
z2 = trimmed.target.z ?? 0;
|
|
884
1364
|
}
|
|
885
|
-
const color = visual.
|
|
1365
|
+
const { color, opacity } = resolveEdgePaint(edgeIndex, visual, input.defaults, input.tier, input.edgeStates);
|
|
886
1366
|
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
1367
|
const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
|
|
889
1368
|
const points = sampleEdgePath({
|
|
890
1369
|
path: visual.path,
|
|
@@ -996,8 +1475,19 @@ function pickClosestEdgeIndex(point, segments) {
|
|
|
996
1475
|
return nearestEdgeIndex;
|
|
997
1476
|
}
|
|
998
1477
|
//#endregion
|
|
1478
|
+
//#region src/shader-output.ts
|
|
1479
|
+
/**
|
|
1480
|
+
* Encodes `gl_FragColor.rgb` from the linear working space into the renderer's output color space:
|
|
1481
|
+
* sRGB on the canvas (so a `#8a9096` theme color draws as `#8a9096`), unchanged on linear render
|
|
1482
|
+
* targets. Alpha is left untouched, so anti-aliased coverage and blending are unaffected.
|
|
1483
|
+
*
|
|
1484
|
+
* Append once to every display fragment shader, after its final `gl_FragColor` assignment.
|
|
1485
|
+
* Requires a `ShaderMaterial` (not `RawShaderMaterial`), whose prefix defines `linearToOutputTexel`.
|
|
1486
|
+
*/
|
|
1487
|
+
const OUTPUT_COLOR_SPACE_FRAGMENT = "#include <colorspace_fragment>";
|
|
1488
|
+
//#endregion
|
|
999
1489
|
//#region src/edge-rendering.ts
|
|
1000
|
-
const vertexShader$
|
|
1490
|
+
const vertexShader$2 = `
|
|
1001
1491
|
attribute float instanceKind;
|
|
1002
1492
|
attribute vec3 instanceEndA;
|
|
1003
1493
|
attribute vec3 instanceEndB;
|
|
@@ -1048,7 +1538,7 @@ void main() {
|
|
|
1048
1538
|
gl_Position = projectionMatrix * viewPosition;
|
|
1049
1539
|
}
|
|
1050
1540
|
`;
|
|
1051
|
-
const fragmentShader$
|
|
1541
|
+
const fragmentShader$2 = `
|
|
1052
1542
|
varying vec4 edgeColor;
|
|
1053
1543
|
varying float edgeKind;
|
|
1054
1544
|
varying float edgeStyle;
|
|
@@ -1072,6 +1562,7 @@ void main() {
|
|
|
1072
1562
|
}
|
|
1073
1563
|
if (edgeColor.a <= 0.0) discard;
|
|
1074
1564
|
gl_FragColor = edgeColor;
|
|
1565
|
+
${OUTPUT_COLOR_SPACE_FRAGMENT}
|
|
1075
1566
|
}
|
|
1076
1567
|
`;
|
|
1077
1568
|
function createEdgeGeometry(capacity) {
|
|
@@ -1084,13 +1575,13 @@ function createEdgeGeometry(capacity) {
|
|
|
1084
1575
|
geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1085
1576
|
return geometry;
|
|
1086
1577
|
}
|
|
1087
|
-
function createEdgeMaterial(
|
|
1578
|
+
function createEdgeMaterial() {
|
|
1088
1579
|
return new ShaderMaterial({
|
|
1089
|
-
depthTest,
|
|
1090
|
-
depthWrite:
|
|
1091
|
-
fragmentShader: fragmentShader$
|
|
1580
|
+
depthTest: false,
|
|
1581
|
+
depthWrite: false,
|
|
1582
|
+
fragmentShader: fragmentShader$2,
|
|
1092
1583
|
transparent: true,
|
|
1093
|
-
vertexShader: vertexShader$
|
|
1584
|
+
vertexShader: vertexShader$2
|
|
1094
1585
|
});
|
|
1095
1586
|
}
|
|
1096
1587
|
function getInstancedAttribute(geometry, name) {
|
|
@@ -1108,8 +1599,7 @@ function writeEdgeSegmentInstance(geometry, slot, segment) {
|
|
|
1108
1599
|
kind.setX(slot, 0);
|
|
1109
1600
|
endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
|
|
1110
1601
|
endB.setXYZ(slot, segment.x2, segment.y2, segment.z2);
|
|
1111
|
-
|
|
1112
|
-
color.setXYZW(slot, rgb.r, rgb.g, rgb.b, segment.opacity);
|
|
1602
|
+
writeEdgeColorSlots(color, slot, 1, segment);
|
|
1113
1603
|
width.setX(slot, segment.width);
|
|
1114
1604
|
style.setX(slot, encodeEdgeStyle(segment.style));
|
|
1115
1605
|
}
|
|
@@ -1123,11 +1613,15 @@ function writeEdgeMarkerInstance(geometry, slot, marker) {
|
|
|
1123
1613
|
kind.setX(slot, 1);
|
|
1124
1614
|
endA.setXYZ(slot, marker.x, marker.y, marker.z);
|
|
1125
1615
|
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);
|
|
1616
|
+
writeEdgeColorSlots(color, slot, 1, marker);
|
|
1128
1617
|
width.setX(slot, marker.size);
|
|
1129
1618
|
style.setX(slot, 0);
|
|
1130
1619
|
}
|
|
1620
|
+
/** Writes one color and opacity into `count` consecutive instance slots starting at `start`. */
|
|
1621
|
+
function writeEdgeColorSlots(color, start, count, paint) {
|
|
1622
|
+
const rgb = new Color(paint.color);
|
|
1623
|
+
for (let slot = start; slot < start + count; slot += 1) color.setXYZW(slot, rgb.r, rgb.g, rgb.b, paint.opacity);
|
|
1624
|
+
}
|
|
1131
1625
|
//#endregion
|
|
1132
1626
|
//#region src/edge-viewport-patch.ts
|
|
1133
1627
|
const EDGE_INSTANCE_ATTRIBUTES$1 = [
|
|
@@ -1188,6 +1682,8 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
|
1188
1682
|
tier: input.tier,
|
|
1189
1683
|
endpointAttach: input.endpointAttach,
|
|
1190
1684
|
edgeNodeIndices: input.edgeNodeIndices,
|
|
1685
|
+
edgeStates: input.edgeStates,
|
|
1686
|
+
mode: input.mode,
|
|
1191
1687
|
nodeOutlines: input.nodeOutlines
|
|
1192
1688
|
});
|
|
1193
1689
|
if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
|
|
@@ -1225,7 +1721,6 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
|
1225
1721
|
};
|
|
1226
1722
|
for (const name of EDGE_INSTANCE_ATTRIBUTES$1) {
|
|
1227
1723
|
const attribute = geometry.getAttribute(name);
|
|
1228
|
-
attribute.clearUpdateRanges();
|
|
1229
1724
|
attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
|
|
1230
1725
|
attribute.needsUpdate = true;
|
|
1231
1726
|
}
|
|
@@ -1236,14 +1731,146 @@ function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
|
1236
1731
|
pickableSegments: nextPickable
|
|
1237
1732
|
};
|
|
1238
1733
|
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Repaints the color and opacity of visible edges whose host state changed. Slot layout,
|
|
1736
|
+
* geometry, and picking stay untouched, so only the changed `instanceColor` ranges upload.
|
|
1737
|
+
* Edges without visible slots are skipped; the next materialize paints them from the same state.
|
|
1738
|
+
*/
|
|
1739
|
+
function patchVisibleEdgePaint(geometry, input) {
|
|
1740
|
+
const visible = [...input.changedEdgeIndices].flatMap((edgeIndex) => {
|
|
1741
|
+
const layout = input.layouts.get(edgeIndex);
|
|
1742
|
+
return layout ? [{
|
|
1743
|
+
edgeIndex,
|
|
1744
|
+
layout
|
|
1745
|
+
}] : [];
|
|
1746
|
+
});
|
|
1747
|
+
if (!visible.every(({ layout }) => layout.segmentStart + layout.segmentCount <= input.instanceCount && layout.markerStart + layout.markerCount <= input.instanceCount)) return {
|
|
1748
|
+
ok: false,
|
|
1749
|
+
patchedSlots: 0
|
|
1750
|
+
};
|
|
1751
|
+
const color = getInstancedAttribute(geometry, "instanceColor");
|
|
1752
|
+
let patchedSlots = 0;
|
|
1753
|
+
for (const { edgeIndex, layout } of visible) {
|
|
1754
|
+
const paint = resolveEdgePaint(edgeIndex, input.edgeVisuals[edgeIndex] ?? {}, input.defaults, input.tier, input.edgeStates);
|
|
1755
|
+
for (const [start, count] of [[layout.segmentStart, layout.segmentCount], [layout.markerStart, layout.markerCount]]) {
|
|
1756
|
+
if (count === 0) continue;
|
|
1757
|
+
writeEdgeColorSlots(color, start, count, paint);
|
|
1758
|
+
color.addUpdateRange(start * color.itemSize, count * color.itemSize);
|
|
1759
|
+
patchedSlots += count;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
if (patchedSlots > 0) color.needsUpdate = true;
|
|
1763
|
+
return {
|
|
1764
|
+
ok: true,
|
|
1765
|
+
patchedSlots
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
//#endregion
|
|
1769
|
+
//#region src/fit-3d.ts
|
|
1770
|
+
/** Newton steps on a piecewise-linear function before falling back to the plane-space centre. */
|
|
1771
|
+
const MAX_CENTERING_STEPS = 32;
|
|
1772
|
+
/**
|
|
1773
|
+
* The closest front-view camera that frames every node. Throws without nodes. `maxCenteringSteps`
|
|
1774
|
+
* exists so tests can exercise the fallback.
|
|
1775
|
+
*/
|
|
1776
|
+
function fitPerspective(frame, nodes, maxCenteringSteps = MAX_CENTERING_STEPS) {
|
|
1777
|
+
if (nodes.count === 0) throw new Error("fitPerspective needs at least one node");
|
|
1778
|
+
const tanY = Math.tan(frame.fovDegrees * Math.PI / 360) * frame.fill;
|
|
1779
|
+
const tanX = tanY * frame.aspect;
|
|
1780
|
+
const node = {
|
|
1781
|
+
halfHeight: 0,
|
|
1782
|
+
halfWidth: 0,
|
|
1783
|
+
x: 0,
|
|
1784
|
+
y: 0,
|
|
1785
|
+
z: 0
|
|
1786
|
+
};
|
|
1787
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
1788
|
+
let left = Number.NEGATIVE_INFINITY;
|
|
1789
|
+
let top = Number.NEGATIVE_INFINITY;
|
|
1790
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
1791
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
1792
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
1793
|
+
for (let index = 0; index < nodes.count; index += 1) {
|
|
1794
|
+
nodes.read(index, node);
|
|
1795
|
+
right = Math.max(right, node.x + node.halfWidth + tanX * node.z);
|
|
1796
|
+
left = Math.max(left, -node.x + node.halfWidth + tanX * node.z);
|
|
1797
|
+
top = Math.max(top, node.y + node.halfHeight + tanY * node.z);
|
|
1798
|
+
bottom = Math.max(bottom, -node.y + node.halfHeight + tanY * node.z);
|
|
1799
|
+
minZ = Math.min(minZ, node.z);
|
|
1800
|
+
maxZ = Math.max(maxZ, node.z);
|
|
1801
|
+
}
|
|
1802
|
+
const cameraZX = (right + left) / (2 * tanX);
|
|
1803
|
+
const cameraZY = (top + bottom) / (2 * tanY);
|
|
1804
|
+
const cameraZ = Math.max(cameraZX, cameraZY);
|
|
1805
|
+
let x = (right - left) / 2;
|
|
1806
|
+
let y = (top - bottom) / 2;
|
|
1807
|
+
if (cameraZX < cameraZY) x = centerOnScreen(nodes, node, "x", cameraZ, x, maxCenteringSteps);
|
|
1808
|
+
else if (cameraZY < cameraZX) y = centerOnScreen(nodes, node, "y", cameraZ, y, maxCenteringSteps);
|
|
1809
|
+
const target = {
|
|
1810
|
+
x,
|
|
1811
|
+
y,
|
|
1812
|
+
z: (minZ + maxZ) / 2
|
|
1813
|
+
};
|
|
1814
|
+
const distance = cameraZ - target.z;
|
|
1815
|
+
const nearestDepth = cameraZ - maxZ;
|
|
1816
|
+
return {
|
|
1817
|
+
cameraZ,
|
|
1818
|
+
near: Math.min(Math.max(distance / 1e4, .1), nearestDepth / 2),
|
|
1819
|
+
far: distance * 10,
|
|
1820
|
+
target
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Centre on the looser axis that makes the farthest screen reach equal on both sides.
|
|
1825
|
+
*
|
|
1826
|
+
* With depth `d = cameraZ - z`, a centre `c` keeps every node within screen reach `r` when
|
|
1827
|
+
* `max(p + h - r * d) <= c <= min(p - h + r * d)`. The gap between those bounds is convex and
|
|
1828
|
+
* decreasing in `r`, so Newton steps from `r = 0` rise monotonically to the smallest feasible
|
|
1829
|
+
* reach. `planeCenter` already fits, so it is the answer if the steps do not settle.
|
|
1830
|
+
*/
|
|
1831
|
+
function centerOnScreen(nodes, node, axis, cameraZ, planeCenter, maxSteps) {
|
|
1832
|
+
let reach = 0;
|
|
1833
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
1834
|
+
let low = Number.NEGATIVE_INFINITY;
|
|
1835
|
+
let high = Number.POSITIVE_INFINITY;
|
|
1836
|
+
let lowValue = 0;
|
|
1837
|
+
let lowDepth = 0;
|
|
1838
|
+
let highValue = 0;
|
|
1839
|
+
let highDepth = 0;
|
|
1840
|
+
for (let index = 0; index < nodes.count; index += 1) {
|
|
1841
|
+
nodes.read(index, node);
|
|
1842
|
+
const position = axis === "x" ? node.x : node.y;
|
|
1843
|
+
const half = axis === "x" ? node.halfWidth : node.halfHeight;
|
|
1844
|
+
const depth = cameraZ - node.z;
|
|
1845
|
+
const lowBound = position + half - reach * depth;
|
|
1846
|
+
const highBound = position - half + reach * depth;
|
|
1847
|
+
if (lowBound > low) {
|
|
1848
|
+
low = lowBound;
|
|
1849
|
+
lowValue = position + half;
|
|
1850
|
+
lowDepth = depth;
|
|
1851
|
+
}
|
|
1852
|
+
if (highBound < high) {
|
|
1853
|
+
high = highBound;
|
|
1854
|
+
highValue = position - half;
|
|
1855
|
+
highDepth = depth;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (low <= high) return (low + high) / 2;
|
|
1859
|
+
const next = (lowValue - highValue) / (lowDepth + highDepth);
|
|
1860
|
+
if (next <= reach) return (low + high) / 2;
|
|
1861
|
+
reach = next;
|
|
1862
|
+
}
|
|
1863
|
+
return planeCenter;
|
|
1864
|
+
}
|
|
1239
1865
|
//#endregion
|
|
1240
1866
|
//#region src/instance-color-ranges.ts
|
|
1241
1867
|
/**
|
|
1242
1868
|
* Marks RGB update ranges for visible instance slots.
|
|
1869
|
+
* Keeps earlier ranges that have not been uploaded yet (a viewport materialize in the same frame);
|
|
1870
|
+
* three.js merges and clears ranges on upload.
|
|
1243
1871
|
* Returns how many slots were marked.
|
|
1244
1872
|
*/
|
|
1245
1873
|
function markInstanceColorSlots(attribute, slots) {
|
|
1246
|
-
attribute.clearUpdateRanges();
|
|
1247
1874
|
let count = 0;
|
|
1248
1875
|
for (const slot of slots) {
|
|
1249
1876
|
if (!Number.isSafeInteger(slot) || slot < 0) continue;
|
|
@@ -1275,6 +1902,7 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
|
|
|
1275
1902
|
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
1903
|
return {
|
|
1277
1904
|
colorChanged: false,
|
|
1905
|
+
glowChanged: false,
|
|
1278
1906
|
index: nodeIndex,
|
|
1279
1907
|
next: {
|
|
1280
1908
|
...current,
|
|
@@ -1287,6 +1915,145 @@ function prepareLayoutPositions(nodes, nodeIndices, layout) {
|
|
|
1287
1915
|
};
|
|
1288
1916
|
});
|
|
1289
1917
|
}
|
|
1918
|
+
const vertexShader$1 = `
|
|
1919
|
+
attribute float instanceGlowOpacity;
|
|
1920
|
+
varying vec3 glowColor;
|
|
1921
|
+
varying vec2 glowPoint;
|
|
1922
|
+
varying float glowOpacity;
|
|
1923
|
+
|
|
1924
|
+
void main() {
|
|
1925
|
+
glowColor = instanceColor;
|
|
1926
|
+
glowPoint = position.xy;
|
|
1927
|
+
glowOpacity = instanceGlowOpacity;
|
|
1928
|
+
vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
|
1929
|
+
center.xy += position.xy * length(instanceMatrix[0].xyz);
|
|
1930
|
+
gl_Position = projectionMatrix * center;
|
|
1931
|
+
}
|
|
1932
|
+
`;
|
|
1933
|
+
const fragmentShader$1 = `
|
|
1934
|
+
uniform float opacityScale;
|
|
1935
|
+
varying vec3 glowColor;
|
|
1936
|
+
varying vec2 glowPoint;
|
|
1937
|
+
varying float glowOpacity;
|
|
1938
|
+
|
|
1939
|
+
void main() {
|
|
1940
|
+
float distanceFromCenter = length(glowPoint);
|
|
1941
|
+
if (distanceFromCenter >= 1.0) discard;
|
|
1942
|
+
float falloff = 1.0 - distanceFromCenter;
|
|
1943
|
+
gl_FragColor = vec4(glowColor, falloff * falloff * glowOpacity * opacityScale);
|
|
1944
|
+
${OUTPUT_COLOR_SPACE_FRAGMENT}
|
|
1945
|
+
}
|
|
1946
|
+
`;
|
|
1947
|
+
function applyBlendMode(material, mode) {
|
|
1948
|
+
material.blending = mode === "normal" ? NormalBlending : AdditiveBlending;
|
|
1949
|
+
const opacityScale = material.uniforms.opacityScale;
|
|
1950
|
+
if (opacityScale) opacityScale.value = glowOpacityScale(mode);
|
|
1951
|
+
material.needsUpdate = true;
|
|
1952
|
+
}
|
|
1953
|
+
function createNodeGlowMaterial(mode) {
|
|
1954
|
+
const material = new ShaderMaterial({
|
|
1955
|
+
depthTest: false,
|
|
1956
|
+
depthWrite: false,
|
|
1957
|
+
fragmentShader: fragmentShader$1,
|
|
1958
|
+
transparent: true,
|
|
1959
|
+
uniforms: { opacityScale: { value: 1 } },
|
|
1960
|
+
vertexShader: vertexShader$1
|
|
1961
|
+
});
|
|
1962
|
+
applyBlendMode(material, mode);
|
|
1963
|
+
return material;
|
|
1964
|
+
}
|
|
1965
|
+
function createGlowMesh(capacity, mode) {
|
|
1966
|
+
const geometry = new PlaneGeometry(2, 2);
|
|
1967
|
+
geometry.setAttribute("instanceGlowOpacity", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1968
|
+
const mesh = new InstancedMesh(geometry, createNodeGlowMaterial(mode), capacity);
|
|
1969
|
+
mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
|
|
1970
|
+
mesh.name = "graphraum-node-glow";
|
|
1971
|
+
mesh.frustumCulled = false;
|
|
1972
|
+
mesh.renderOrder = -2;
|
|
1973
|
+
return mesh;
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Opt-in batched halo pass: one additive instanced mesh, created lazily on the first glowing
|
|
1977
|
+
* node and hidden while no halo is selected, so graphs without glow keep two draw calls.
|
|
1978
|
+
*/
|
|
1979
|
+
var NodeGlowLayer = class {
|
|
1980
|
+
scene;
|
|
1981
|
+
selection = createGlowSelection();
|
|
1982
|
+
target = {
|
|
1983
|
+
color: new Color(),
|
|
1984
|
+
extent: 0,
|
|
1985
|
+
glow: 0,
|
|
1986
|
+
x: 0,
|
|
1987
|
+
y: 0,
|
|
1988
|
+
z: 0
|
|
1989
|
+
};
|
|
1990
|
+
matrix = new Matrix4();
|
|
1991
|
+
mesh = null;
|
|
1992
|
+
blendMode = "additive";
|
|
1993
|
+
constructor(scene) {
|
|
1994
|
+
this.scene = scene;
|
|
1995
|
+
}
|
|
1996
|
+
/** Additive on dark or unknown backgrounds, normal on light solid ones; see `glowBlendModeFor`. */
|
|
1997
|
+
setBlending(mode) {
|
|
1998
|
+
if (mode === this.blendMode) return;
|
|
1999
|
+
this.blendMode = mode;
|
|
2000
|
+
if (this.mesh) applyBlendMode(this.mesh.material, mode);
|
|
2001
|
+
}
|
|
2002
|
+
/** Halo instances in the current frame. */
|
|
2003
|
+
get count() {
|
|
2004
|
+
return this.mesh?.visible ? this.mesh.count : 0;
|
|
2005
|
+
}
|
|
2006
|
+
/** Reselects glowing visible nodes and rewrites every halo instance. */
|
|
2007
|
+
sync(visibleNodeIndices, glowAt, densityLod, describe) {
|
|
2008
|
+
selectGlowNodes(visibleNodeIndices, glowAt, densityLod, this.selection);
|
|
2009
|
+
if (this.selection.count > 0) this.ensureCapacity(this.selection.count);
|
|
2010
|
+
this.rewrite(describe);
|
|
2011
|
+
}
|
|
2012
|
+
/** Rewrites positions, radii, opacities, and colors of the current selection in place. */
|
|
2013
|
+
rewrite(describe) {
|
|
2014
|
+
const mesh = this.mesh;
|
|
2015
|
+
if (!mesh) return;
|
|
2016
|
+
const opacity = mesh.geometry.getAttribute("instanceGlowOpacity");
|
|
2017
|
+
let slot = 0;
|
|
2018
|
+
for (let index = 0; index < this.selection.count; index += 1) {
|
|
2019
|
+
const nodeIndex = this.selection.indices[index];
|
|
2020
|
+
if (nodeIndex === void 0 || !describe(nodeIndex, this.target)) continue;
|
|
2021
|
+
const { color, extent, glow, x, y, z } = this.target;
|
|
2022
|
+
const radius = glowHaloRadius(extent, glow);
|
|
2023
|
+
this.matrix.makeScale(radius, radius, radius).setPosition(x, y, z);
|
|
2024
|
+
mesh.setMatrixAt(slot, this.matrix);
|
|
2025
|
+
mesh.setColorAt(slot, color);
|
|
2026
|
+
opacity.setX(slot, glowHaloOpacity(glow));
|
|
2027
|
+
slot += 1;
|
|
2028
|
+
}
|
|
2029
|
+
mesh.count = slot;
|
|
2030
|
+
mesh.visible = slot > 0;
|
|
2031
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
2032
|
+
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
|
2033
|
+
opacity.needsUpdate = true;
|
|
2034
|
+
}
|
|
2035
|
+
/** Releases the GPU mesh; the layer stays reusable and recreates it on the next glowing node. */
|
|
2036
|
+
dispose() {
|
|
2037
|
+
this.selection.count = 0;
|
|
2038
|
+
this.releaseMesh();
|
|
2039
|
+
}
|
|
2040
|
+
releaseMesh() {
|
|
2041
|
+
if (!this.mesh) return;
|
|
2042
|
+
this.scene.remove(this.mesh);
|
|
2043
|
+
this.mesh.geometry.dispose();
|
|
2044
|
+
this.mesh.material.dispose();
|
|
2045
|
+
this.mesh.dispose();
|
|
2046
|
+
this.mesh = null;
|
|
2047
|
+
}
|
|
2048
|
+
ensureCapacity(required) {
|
|
2049
|
+
const current = this.mesh?.instanceMatrix.count ?? 0;
|
|
2050
|
+
const capacity = glowBufferCapacity(required, current, 256);
|
|
2051
|
+
if (this.mesh && capacity === current) return;
|
|
2052
|
+
this.releaseMesh();
|
|
2053
|
+
this.mesh = createGlowMesh(capacity, this.blendMode);
|
|
2054
|
+
this.scene.add(this.mesh);
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
1290
2057
|
//#endregion
|
|
1291
2058
|
//#region src/node-rendering.ts
|
|
1292
2059
|
const vertexShader = `
|
|
@@ -1399,6 +2166,7 @@ void main() {
|
|
|
1399
2166
|
fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
|
|
1400
2167
|
}
|
|
1401
2168
|
gl_FragColor = vec4(fillColor, outerAlpha);
|
|
2169
|
+
${OUTPUT_COLOR_SPACE_FRAGMENT}
|
|
1402
2170
|
}
|
|
1403
2171
|
`;
|
|
1404
2172
|
function createNodeGeometry(capacity) {
|
|
@@ -1476,6 +2244,7 @@ function assertValidNode(node) {
|
|
|
1476
2244
|
});
|
|
1477
2245
|
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
2246
|
if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
|
|
2247
|
+
assertNodeGlow(node.id, node.glow);
|
|
1479
2248
|
}
|
|
1480
2249
|
/** Validates a complete update batch before the renderer mutates CPU or GPU state. */
|
|
1481
2250
|
function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
@@ -1490,6 +2259,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
|
1490
2259
|
const next = {
|
|
1491
2260
|
...current,
|
|
1492
2261
|
...Object.hasOwn(update, "color") ? { color: update.color } : {},
|
|
2262
|
+
...Object.hasOwn(update, "glow") ? { glow: update.glow } : {},
|
|
1493
2263
|
...update.position ? { position: { ...update.position } } : {},
|
|
1494
2264
|
...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
|
|
1495
2265
|
...Object.hasOwn(update, "size") ? { size: update.size } : {},
|
|
@@ -1501,6 +2271,7 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
|
1501
2271
|
assertValidNode(next);
|
|
1502
2272
|
return {
|
|
1503
2273
|
colorChanged: Object.hasOwn(update, "color"),
|
|
2274
|
+
glowChanged: Object.hasOwn(update, "glow"),
|
|
1504
2275
|
index,
|
|
1505
2276
|
next,
|
|
1506
2277
|
positionChanged: update.position !== void 0,
|
|
@@ -1511,6 +2282,22 @@ function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
|
1511
2282
|
});
|
|
1512
2283
|
}
|
|
1513
2284
|
//#endregion
|
|
2285
|
+
//#region src/orbit-controls-mode.ts
|
|
2286
|
+
/**
|
|
2287
|
+
* Sets the controls for a 2D or 3D view. 2D turns rotation off, so one finger and the left
|
|
2288
|
+
* mouse button pan instead. One finger must not stay on ROTATE: when a pinch ends with one finger
|
|
2289
|
+
* still down, OrbitControls (r185) re-reads `touches.ONE`, returns early for a disabled rotate and
|
|
2290
|
+
* keeps the two-finger state, and the next move reads the lifted finger's position and throws.
|
|
2291
|
+
*/
|
|
2292
|
+
function configureControlsForMode(controls, mode) {
|
|
2293
|
+
controls.enableRotate = mode === "3d";
|
|
2294
|
+
if (mode === "2d") {
|
|
2295
|
+
controls.minZoom = .01;
|
|
2296
|
+
controls.mouseButtons.LEFT = MOUSE.PAN;
|
|
2297
|
+
controls.touches.ONE = TOUCH.PAN;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
//#endregion
|
|
1514
2301
|
//#region src/label-budget.ts
|
|
1515
2302
|
/** Picks visible label ids by importance within a hard DOM budget. */
|
|
1516
2303
|
function selectBudgetedLabelIds({ candidates, maxLabels }) {
|
|
@@ -1895,63 +2682,6 @@ var SpatialGrid2D = class {
|
|
|
1895
2682
|
}
|
|
1896
2683
|
};
|
|
1897
2684
|
//#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
2685
|
//#region src/viewport-lod.ts
|
|
1956
2686
|
/** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
|
|
1957
2687
|
function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
|
|
@@ -1981,9 +2711,20 @@ function shouldUseDensityLod(candidateCount, maximum, active) {
|
|
|
1981
2711
|
//#region src/graphraum.ts
|
|
1982
2712
|
/** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
|
|
1983
2713
|
const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
|
|
2714
|
+
/** The 3D fit leaves 4% of the tighter canvas axis free on each side, e.g. for edge labels. */
|
|
2715
|
+
const PERSPECTIVE_FIT_FILL = .92;
|
|
2716
|
+
function prefersReducedMotion() {
|
|
2717
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2718
|
+
}
|
|
1984
2719
|
function disposeMaterial(material) {
|
|
1985
2720
|
for (const item of Array.isArray(material) ? material : [material]) item.dispose();
|
|
1986
2721
|
}
|
|
2722
|
+
/** Canvas input that cancels a running `flyTo`, so people take over the camera at once. */
|
|
2723
|
+
const FLIGHT_CANCEL_EVENTS = [
|
|
2724
|
+
"keydown",
|
|
2725
|
+
"pointerdown",
|
|
2726
|
+
"wheel"
|
|
2727
|
+
];
|
|
1987
2728
|
const EDGE_INSTANCE_ATTRIBUTES = [
|
|
1988
2729
|
"instanceKind",
|
|
1989
2730
|
"instanceEndA",
|
|
@@ -2027,12 +2768,19 @@ var Graphraum = class {
|
|
|
2027
2768
|
nodeMesh = null;
|
|
2028
2769
|
nodePickMesh = null;
|
|
2029
2770
|
edgeMesh = null;
|
|
2771
|
+
glowLayer = new NodeGlowLayer(this.scene);
|
|
2772
|
+
hasGlowNodes = false;
|
|
2030
2773
|
nodeCapacity = 0;
|
|
2031
2774
|
edgeCapacity = 0;
|
|
2032
2775
|
markerCapacity = 0;
|
|
2033
2776
|
edgeInstanceCapacity = 0;
|
|
2034
2777
|
selectedNodeIds = /* @__PURE__ */ new Set();
|
|
2035
2778
|
selectedEdgeIds = /* @__PURE__ */ new Set();
|
|
2779
|
+
dimmedEdgeIds = /* @__PURE__ */ new Set();
|
|
2780
|
+
edgeIndexCache = {
|
|
2781
|
+
edges: [],
|
|
2782
|
+
indices: /* @__PURE__ */ new Map()
|
|
2783
|
+
};
|
|
2036
2784
|
hoveredNodeIds = /* @__PURE__ */ new Set();
|
|
2037
2785
|
focusedNodeIds = /* @__PURE__ */ new Set();
|
|
2038
2786
|
dimmedNodeIds = /* @__PURE__ */ new Set();
|
|
@@ -2053,6 +2801,12 @@ var Graphraum = class {
|
|
|
2053
2801
|
edgeSegmentCapacity = 0;
|
|
2054
2802
|
mode;
|
|
2055
2803
|
frameRequest = null;
|
|
2804
|
+
autoOrbit = null;
|
|
2805
|
+
flight = null;
|
|
2806
|
+
flightDirection = new Vector3();
|
|
2807
|
+
/** True only during the orbit's own `controls.update(dt)`; see `handleViewChange`. */
|
|
2808
|
+
orbitUpdating = false;
|
|
2809
|
+
destroyed = false;
|
|
2056
2810
|
cpuFrameMilliseconds = 0;
|
|
2057
2811
|
gpuFrameMilliseconds = null;
|
|
2058
2812
|
gpuTimer = null;
|
|
@@ -2092,6 +2846,7 @@ var Graphraum = class {
|
|
|
2092
2846
|
this.container.append(this.renderer.domElement);
|
|
2093
2847
|
this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
|
|
2094
2848
|
this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
|
|
2849
|
+
for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.addEventListener(type, this.cancelFlight);
|
|
2095
2850
|
this.camera = this.createCamera();
|
|
2096
2851
|
this.controls = this.createControls();
|
|
2097
2852
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
@@ -2148,6 +2903,7 @@ var Graphraum = class {
|
|
|
2148
2903
|
}))
|
|
2149
2904
|
};
|
|
2150
2905
|
this.nodeIds = compiled.nodeIds;
|
|
2906
|
+
this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
|
|
2151
2907
|
this.nodeIndices = new Map(compiled.nodeIndices);
|
|
2152
2908
|
this.edgeNodeIndices = compiled.edgeNodeIndices;
|
|
2153
2909
|
this.canonicalEdgePositions = compiled.edgePositions;
|
|
@@ -2157,12 +2913,11 @@ var Graphraum = class {
|
|
|
2157
2913
|
this.spatialGrid2d = new SpatialGrid2D();
|
|
2158
2914
|
for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
|
|
2159
2915
|
const liveNodeIds = new Set(this.nodeIds);
|
|
2160
|
-
const liveEdgeIds = new Set(this.data.edges.map((edge) => edge.id));
|
|
2161
2916
|
this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2162
2917
|
this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2163
2918
|
this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2164
2919
|
this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2165
|
-
this.
|
|
2920
|
+
this.pruneEdgeStates();
|
|
2166
2921
|
this.materializeViewport();
|
|
2167
2922
|
this.requestRender();
|
|
2168
2923
|
}
|
|
@@ -2226,6 +2981,7 @@ var Graphraum = class {
|
|
|
2226
2981
|
edges: nextEdges
|
|
2227
2982
|
};
|
|
2228
2983
|
this.nodeIds = nextNodes.map((node) => node.id);
|
|
2984
|
+
this.hasGlowNodes = nextNodes.some(hasNodeGlow);
|
|
2229
2985
|
this.nodeIndices = nextNodeIndices;
|
|
2230
2986
|
this.edgeNodeIndices = nextEdgeIndices;
|
|
2231
2987
|
this.canonicalEdgePositions = nextEdgePositions;
|
|
@@ -2255,6 +3011,7 @@ var Graphraum = class {
|
|
|
2255
3011
|
}))
|
|
2256
3012
|
};
|
|
2257
3013
|
this.nodeIds = compiled.nodeIds;
|
|
3014
|
+
this.hasGlowNodes = this.data.nodes.some(hasNodeGlow);
|
|
2258
3015
|
this.nodeIndices = new Map(compiled.nodeIndices);
|
|
2259
3016
|
this.edgeNodeIndices = compiled.edgeNodeIndices;
|
|
2260
3017
|
this.canonicalEdgePositions = compiled.edgePositions;
|
|
@@ -2263,11 +3020,12 @@ var Graphraum = class {
|
|
|
2263
3020
|
this.edgePresentations = new Map(compiled.edgePresentations);
|
|
2264
3021
|
this.spatialGrid2d = new SpatialGrid2D();
|
|
2265
3022
|
for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
|
|
3023
|
+
this.pruneEdgeStates();
|
|
2266
3024
|
this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
|
|
2267
3025
|
const nodeCapacity = this.nodeCapacity;
|
|
2268
3026
|
const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
|
|
2269
3027
|
allocateNodeInstanceColors(nodeMesh, nodeCapacity);
|
|
2270
|
-
nodeMesh.renderOrder =
|
|
3028
|
+
nodeMesh.renderOrder = 1;
|
|
2271
3029
|
this.nodeMesh = nodeMesh;
|
|
2272
3030
|
this.scene.add(nodeMesh);
|
|
2273
3031
|
const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
|
|
@@ -2277,9 +3035,9 @@ var Graphraum = class {
|
|
|
2277
3035
|
this.edgeSegmentCapacity = capacities.segmentCapacity;
|
|
2278
3036
|
this.markerCapacity = capacities.markerCapacity;
|
|
2279
3037
|
this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
|
|
2280
|
-
const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(
|
|
3038
|
+
const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(), this.edgeInstanceCapacity);
|
|
2281
3039
|
edgeMesh.frustumCulled = false;
|
|
2282
|
-
edgeMesh.renderOrder =
|
|
3040
|
+
edgeMesh.renderOrder = -1;
|
|
2283
3041
|
this.edgeMesh = edgeMesh;
|
|
2284
3042
|
this.scene.add(edgeMesh);
|
|
2285
3043
|
if (fitView) this.fitView();
|
|
@@ -2376,6 +3134,8 @@ var Graphraum = class {
|
|
|
2376
3134
|
}
|
|
2377
3135
|
}
|
|
2378
3136
|
if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
|
|
3137
|
+
const glowChanged = prepared.some((update) => update.glowChanged);
|
|
3138
|
+
if (glowChanged) this.hasGlowNodes = nodes.some(hasNodeGlow);
|
|
2379
3139
|
this.data = {
|
|
2380
3140
|
...this.data,
|
|
2381
3141
|
nodes
|
|
@@ -2394,6 +3154,8 @@ var Graphraum = class {
|
|
|
2394
3154
|
}
|
|
2395
3155
|
if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
|
|
2396
3156
|
if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
|
|
3157
|
+
if (glowChanged) this.syncGlow();
|
|
3158
|
+
else if (prepared.some((update) => update.positionChanged || update.sizeChanged || update.colorChanged)) this.glowLayer.rewrite(this.describeGlowNode);
|
|
2397
3159
|
if (prepared.some((update) => update.strokeChanged)) {
|
|
2398
3160
|
nodeStrokeWidth.needsUpdate = true;
|
|
2399
3161
|
nodeStrokeColor.needsUpdate = true;
|
|
@@ -2405,10 +3167,6 @@ var Graphraum = class {
|
|
|
2405
3167
|
if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
|
|
2406
3168
|
const worldPerPixel = this.worldUnitsPerPixel();
|
|
2407
3169
|
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
3170
|
const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
|
|
2413
3171
|
changedEdgeIndices,
|
|
2414
3172
|
defaults: {
|
|
@@ -2417,11 +3175,13 @@ var Graphraum = class {
|
|
|
2417
3175
|
width: this.theme.edgeWidth
|
|
2418
3176
|
},
|
|
2419
3177
|
edgeNodeIndices: this.edgeNodeIndices,
|
|
2420
|
-
|
|
3178
|
+
edgeStates: this.edgeStateStyling(),
|
|
3179
|
+
edgeVisuals: this.data.edges,
|
|
2421
3180
|
endpointAttach: this.theme.endpointAttach,
|
|
2422
3181
|
endpointPositions: this.canonicalEdgePositions,
|
|
2423
3182
|
layouts: this.visibleEdgeLayouts,
|
|
2424
3183
|
minHitSlop,
|
|
3184
|
+
mode: this.mode,
|
|
2425
3185
|
nodeOutlines: this.data.nodes.map((node) => ({
|
|
2426
3186
|
height: node.height,
|
|
2427
3187
|
shape: node.shape,
|
|
@@ -2439,12 +3199,80 @@ var Graphraum = class {
|
|
|
2439
3199
|
setSelection(nodeIds) {
|
|
2440
3200
|
this.setNodeState("selected", nodeIds);
|
|
2441
3201
|
}
|
|
2442
|
-
/**
|
|
3202
|
+
/**
|
|
3203
|
+
* Highlights edges without changing node selection or source graph data. Unknown IDs are ignored.
|
|
3204
|
+
* Only the instance colors of edges whose selection changed are rewritten and uploaded.
|
|
3205
|
+
*/
|
|
2443
3206
|
setEdgeSelection(edgeIds) {
|
|
2444
|
-
|
|
2445
|
-
this.
|
|
3207
|
+
const next = this.knownEdgeIds(edgeIds);
|
|
3208
|
+
const changed = changedIds(this.selectedEdgeIds, next);
|
|
3209
|
+
this.selectedEdgeIds = next;
|
|
3210
|
+
this.repaintEdgeStates(changed);
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Applies an application-owned edge state without changing source graph data. `dimmed` uses
|
|
3214
|
+
* `theme.dimmedEdge` / `theme.dimmedEdgeOpacity`; selected edges stay selected. Unknown IDs are ignored.
|
|
3215
|
+
*/
|
|
3216
|
+
setEdgeState(state, edgeIds) {
|
|
3217
|
+
if (state !== "dimmed") throw new Error(`Unknown edge state "${String(state)}". Use "dimmed".`);
|
|
3218
|
+
const next = this.knownEdgeIds(edgeIds);
|
|
3219
|
+
const changed = changedIds(this.dimmedEdgeIds, next);
|
|
3220
|
+
this.dimmedEdgeIds = next;
|
|
3221
|
+
this.repaintEdgeStates(changed);
|
|
3222
|
+
}
|
|
3223
|
+
knownEdgeIds(edgeIds) {
|
|
3224
|
+
const indices = this.edgeIndicesById();
|
|
3225
|
+
return new Set([...edgeIds].filter((id) => indices.has(id)));
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* Visible slots, LOD tier, and geometry only change through materialize or endpoint patches,
|
|
3229
|
+
* which keep `visibleEdgeLayouts` current, so a state change repaints in place. If a layout no
|
|
3230
|
+
* longer fits the drawn instances, that invariant broke: fall back to a full materialize.
|
|
3231
|
+
*/
|
|
3232
|
+
repaintEdgeStates(changedEdgeIds) {
|
|
3233
|
+
if (!this.edgeMesh || changedEdgeIds.length === 0) return;
|
|
3234
|
+
const indices = this.edgeIndicesById();
|
|
3235
|
+
if (!patchVisibleEdgePaint(this.edgeMesh.geometry, {
|
|
3236
|
+
changedEdgeIndices: changedEdgeIds.flatMap((id) => indices.get(id) ?? []),
|
|
3237
|
+
defaults: {
|
|
3238
|
+
color: this.theme.edge,
|
|
3239
|
+
opacity: this.theme.edgeOpacity
|
|
3240
|
+
},
|
|
3241
|
+
edgeStates: this.edgeStateStyling(),
|
|
3242
|
+
edgeVisuals: this.data.edges,
|
|
3243
|
+
instanceCount: this.edgeMesh.count,
|
|
3244
|
+
layouts: this.visibleEdgeLayouts,
|
|
3245
|
+
tier: this.lastEdgeLodTier
|
|
3246
|
+
}).ok) this.materializeViewport();
|
|
2446
3247
|
this.requestRender();
|
|
2447
3248
|
}
|
|
3249
|
+
edgeStateStyling() {
|
|
3250
|
+
return {
|
|
3251
|
+
dimmedColor: this.theme.dimmedEdge,
|
|
3252
|
+
dimmedOpacity: this.theme.dimmedEdgeOpacity,
|
|
3253
|
+
selectedColor: this.theme.selectedEdge,
|
|
3254
|
+
stateOf: (edgeIndex) => {
|
|
3255
|
+
const id = this.data.edges[edgeIndex]?.id;
|
|
3256
|
+
if (id === void 0) return null;
|
|
3257
|
+
if (this.selectedEdgeIds.has(id)) return "selected";
|
|
3258
|
+
return this.dimmedEdgeIds.has(id) ? "dimmed" : null;
|
|
3259
|
+
}
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
/** Edge ID to compiled index, rebuilt only when the edge list is replaced. */
|
|
3263
|
+
edgeIndicesById() {
|
|
3264
|
+
if (this.edgeIndexCache.edges !== this.data.edges) this.edgeIndexCache = {
|
|
3265
|
+
edges: this.data.edges,
|
|
3266
|
+
indices: new Map(this.data.edges.map((edge, index) => [edge.id, index]))
|
|
3267
|
+
};
|
|
3268
|
+
return this.edgeIndexCache.indices;
|
|
3269
|
+
}
|
|
3270
|
+
/** Drops edge states whose edges left the graph. */
|
|
3271
|
+
pruneEdgeStates() {
|
|
3272
|
+
const indices = this.edgeIndicesById();
|
|
3273
|
+
this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => indices.has(id)));
|
|
3274
|
+
this.dimmedEdgeIds = new Set([...this.dimmedEdgeIds].filter((id) => indices.has(id)));
|
|
3275
|
+
}
|
|
2448
3276
|
/** Applies an application-owned visual state without changing source graph data. */
|
|
2449
3277
|
setNodeState(state, nodeIds) {
|
|
2450
3278
|
const nextSelection = new Set(nodeIds);
|
|
@@ -2481,6 +3309,8 @@ var Graphraum = class {
|
|
|
2481
3309
|
}
|
|
2482
3310
|
setMode(mode) {
|
|
2483
3311
|
if (mode === this.mode) return;
|
|
3312
|
+
this.cancelFlight();
|
|
3313
|
+
this.stopAutoOrbit();
|
|
2484
3314
|
this.mode = mode;
|
|
2485
3315
|
this.controls.removeEventListener("change", this.handleViewChange);
|
|
2486
3316
|
this.controls.dispose();
|
|
@@ -2490,19 +3320,30 @@ var Graphraum = class {
|
|
|
2490
3320
|
this.nodeMesh.material.depthTest = mode === "3d";
|
|
2491
3321
|
this.nodeMesh.material.depthWrite = mode === "3d";
|
|
2492
3322
|
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
3323
|
}
|
|
2501
3324
|
this.fitView();
|
|
2502
3325
|
}
|
|
2503
3326
|
getMode() {
|
|
2504
3327
|
return this.mode;
|
|
2505
3328
|
}
|
|
3329
|
+
/**
|
|
3330
|
+
* Slowly orbits the 3D camera around its target until called with `false`. The orbit pauses while
|
|
3331
|
+
* the camera controls handle a drag, pinch or wheel step and resumes after `resumeAfterMs` of idle;
|
|
3332
|
+
* it also stops while the document is hidden or the canvas is off screen. While it is active a
|
|
3333
|
+
* frame loop renders every frame (no viewport rematerialization), so check `prefers-reduced-motion`
|
|
3334
|
+
* before enabling it. Throws in 2D mode; `setMode("2d")` turns it off. Does nothing after `destroy()`.
|
|
3335
|
+
*/
|
|
3336
|
+
setAutoOrbit(options) {
|
|
3337
|
+
if (options === false || this.destroyed) {
|
|
3338
|
+
this.stopAutoOrbit();
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
if (this.mode !== "3d") throw new Error("Auto-orbit requires 3D mode. Call setMode(\"3d\") before setAutoOrbit(options).");
|
|
3342
|
+
const { resumeAfterMs, speed } = resolveAutoOrbitOptions(options);
|
|
3343
|
+
this.controls.autoRotateSpeed = autoRotateSpeedFor(speed);
|
|
3344
|
+
if (this.autoOrbit && this.autoOrbit.status !== "off") this.autoOrbit.setResumeAfterMs(resumeAfterMs);
|
|
3345
|
+
else this.autoOrbit = new AutoOrbitLoop(this.renderer.domElement, this.controls, resumeAfterMs, this.stepAutoOrbit);
|
|
3346
|
+
}
|
|
2506
3347
|
getNodePresentation(nodeId) {
|
|
2507
3348
|
return this.nodePresentations.get(nodeId) ?? null;
|
|
2508
3349
|
}
|
|
@@ -2595,6 +3436,7 @@ var Graphraum = class {
|
|
|
2595
3436
|
getDiagnostics() {
|
|
2596
3437
|
return {
|
|
2597
3438
|
aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
|
|
3439
|
+
autoOrbit: this.autoOrbit?.status ?? "off",
|
|
2598
3440
|
cpuFrameMilliseconds: this.cpuFrameMilliseconds,
|
|
2599
3441
|
gpuFrameMilliseconds: this.gpuFrameMilliseconds,
|
|
2600
3442
|
gpuDrawCalls: this.renderer.info.render.calls,
|
|
@@ -2602,6 +3444,7 @@ var Graphraum = class {
|
|
|
2602
3444
|
gpuTextures: this.renderer.info.memory.textures,
|
|
2603
3445
|
lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
|
|
2604
3446
|
pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
|
|
3447
|
+
dimmedEdges: this.dimmedEdgeIds.size,
|
|
2605
3448
|
selectedEdges: this.selectedEdgeIds.size,
|
|
2606
3449
|
selectedNodes: this.selectedNodeIds.size,
|
|
2607
3450
|
totalEdges: this.data.edges.length,
|
|
@@ -2610,6 +3453,7 @@ var Graphraum = class {
|
|
|
2610
3453
|
visibleEdgeMarkers: this.visibleEdgeMarkerCount,
|
|
2611
3454
|
visibleEdgeSegments: this.visibleEdgeSegmentCount,
|
|
2612
3455
|
visibleEdges: this.visibleEdgeCount,
|
|
3456
|
+
visibleGlowNodes: this.glowLayer.count,
|
|
2613
3457
|
visibleNodes: this.visibleNodeCount,
|
|
2614
3458
|
visibleNodeCandidates: this.visibleNodeCandidateCount
|
|
2615
3459
|
};
|
|
@@ -2636,52 +3480,207 @@ var Graphraum = class {
|
|
|
2636
3480
|
id: edgeId
|
|
2637
3481
|
} : null;
|
|
2638
3482
|
}
|
|
2639
|
-
|
|
3483
|
+
/** Frames every node, or only `nodeIds`; unknown ids are ignored and no match frames all nodes. Cancels a running `flyTo`. */
|
|
3484
|
+
fitView(options) {
|
|
3485
|
+
this.cancelFlight();
|
|
2640
3486
|
if (!this.nodeMesh || this.data.nodes.length === 0) {
|
|
2641
3487
|
this.requestRender();
|
|
2642
3488
|
return;
|
|
2643
3489
|
}
|
|
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());
|
|
3490
|
+
const subset = this.nodesWithIds(options?.nodeIds);
|
|
3491
|
+
const nodes = subset.length > 0 ? subset : this.data.nodes;
|
|
2661
3492
|
const width = Math.max(this.container.clientWidth, 1);
|
|
2662
3493
|
const height = Math.max(this.container.clientHeight, 1);
|
|
2663
|
-
const aspect = width / height;
|
|
2664
3494
|
if (this.camera instanceof OrthographicCamera) {
|
|
2665
|
-
const
|
|
2666
|
-
|
|
2667
|
-
this.camera
|
|
2668
|
-
this.camera.
|
|
2669
|
-
this.camera.
|
|
2670
|
-
this.
|
|
3495
|
+
const aspect = width / height;
|
|
3496
|
+
const fit = orthographicFit(aspect, nodes);
|
|
3497
|
+
setOrthographicFrustum(this.camera, aspect, fit.visibleHeight);
|
|
3498
|
+
this.camera.position.set(fit.center.x, fit.center.y, fit.cameraZ);
|
|
3499
|
+
this.camera.lookAt(fit.center);
|
|
3500
|
+
this.controls.target.copy(fit.center);
|
|
2671
3501
|
} else {
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
this.camera.
|
|
2675
|
-
this.camera.
|
|
2676
|
-
this.
|
|
3502
|
+
const fit = this.perspectiveFit(this.camera, width, height, nodes);
|
|
3503
|
+
this.camera.near = fit.near;
|
|
3504
|
+
this.camera.far = fit.far;
|
|
3505
|
+
this.camera.position.set(fit.target.x, fit.target.y, fit.cameraZ);
|
|
3506
|
+
this.controls.target.set(fit.target.x, fit.target.y, fit.target.z);
|
|
3507
|
+
this.camera.lookAt(this.controls.target);
|
|
2677
3508
|
}
|
|
2678
|
-
this.camera.lookAt(center);
|
|
2679
3509
|
this.camera.updateProjectionMatrix();
|
|
2680
|
-
this.controls.target.copy(center);
|
|
2681
3510
|
this.controls.update();
|
|
2682
3511
|
this.materializeViewport();
|
|
2683
3512
|
this.requestRender();
|
|
2684
3513
|
}
|
|
3514
|
+
/**
|
|
3515
|
+
* Animates the camera to frame `nodeIds` as `fitView({ nodeIds })` would, with an ease-in-out
|
|
3516
|
+
* over `durationMs`. In 3D the camera and the orbit target move together and keep the current
|
|
3517
|
+
* view direction, so a running auto-orbit carries on around the new target, at the fitted
|
|
3518
|
+
* distance times `distanceScale`. In 2D it pans and zooms. Resolves `true` on arrival and
|
|
3519
|
+
* `false` when cancelled by canvas pointer, wheel or key input, another `flyTo`, `fitView`,
|
|
3520
|
+
* `setData`, `setMode`, a 2D resize or `destroy()`. Unknown IDs are ignored; when none match,
|
|
3521
|
+
* or after `destroy()`, it resolves `false` without moving. With `prefers-reduced-motion:
|
|
3522
|
+
* reduce` or a zero duration it jumps. Throws for an invalid duration or distance scale.
|
|
3523
|
+
*/
|
|
3524
|
+
flyTo(options) {
|
|
3525
|
+
const { distanceScale, durationMs } = resolveFlyToOptions(options);
|
|
3526
|
+
this.cancelFlight();
|
|
3527
|
+
if (this.destroyed || !this.nodeMesh) return Promise.resolve(false);
|
|
3528
|
+
const nodes = this.nodesWithIds(options.nodeIds);
|
|
3529
|
+
if (nodes.length === 0) return Promise.resolve(false);
|
|
3530
|
+
const width = Math.max(this.container.clientWidth, 1);
|
|
3531
|
+
const height = Math.max(this.container.clientHeight, 1);
|
|
3532
|
+
const flight = this.camera instanceof OrthographicCamera ? this.planOrthographicFlight(this.camera, width / height, nodes, distanceScale) : this.planPerspectiveFlight(this.camera, width, height, nodes, distanceScale);
|
|
3533
|
+
if (durationMs === 0 || prefersReducedMotion()) {
|
|
3534
|
+
flight.step(1);
|
|
3535
|
+
flight.arrive();
|
|
3536
|
+
this.requestRender();
|
|
3537
|
+
return Promise.resolve(true);
|
|
3538
|
+
}
|
|
3539
|
+
return new Promise((resolve) => {
|
|
3540
|
+
this.flight = {
|
|
3541
|
+
...flight,
|
|
3542
|
+
durationMs,
|
|
3543
|
+
resolve,
|
|
3544
|
+
startMs: null
|
|
3545
|
+
};
|
|
3546
|
+
this.requestRender();
|
|
3547
|
+
});
|
|
3548
|
+
}
|
|
3549
|
+
/** Nodes whose id is in `nodeIds`, in graph order; every node when `nodeIds` is omitted. */
|
|
3550
|
+
nodesWithIds(nodeIds) {
|
|
3551
|
+
if (!nodeIds) return this.data.nodes;
|
|
3552
|
+
const wanted = new Set(nodeIds);
|
|
3553
|
+
return this.data.nodes.filter((node) => wanted.has(node.id));
|
|
3554
|
+
}
|
|
3555
|
+
/** The closest front-view camera that frames `nodes` (issue #124); applies the container aspect. */
|
|
3556
|
+
perspectiveFit(camera, width, height, nodes) {
|
|
3557
|
+
applyPerspectiveContainerAspect(camera, width, height);
|
|
3558
|
+
return fitPerspective({
|
|
3559
|
+
aspect: camera.aspect,
|
|
3560
|
+
fill: PERSPECTIVE_FIT_FILL,
|
|
3561
|
+
fovDegrees: camera.fov
|
|
3562
|
+
}, {
|
|
3563
|
+
count: nodes.length,
|
|
3564
|
+
read(index, out) {
|
|
3565
|
+
const node = nodes[index];
|
|
3566
|
+
const axes = resolveNodeAxes({
|
|
3567
|
+
height: node.height,
|
|
3568
|
+
nodeId: node.id,
|
|
3569
|
+
size: node.size,
|
|
3570
|
+
width: node.width
|
|
3571
|
+
});
|
|
3572
|
+
out.x = node.position.x;
|
|
3573
|
+
out.y = node.position.y;
|
|
3574
|
+
out.z = node.position.z ?? 0;
|
|
3575
|
+
const extent = Math.max(axes.width, axes.height);
|
|
3576
|
+
const glow = node.glow ?? 0;
|
|
3577
|
+
const halo = glow > 0 ? glowHaloRadius(extent, glow) : 0;
|
|
3578
|
+
out.halfWidth = Math.max(axes.width, halo);
|
|
3579
|
+
out.halfHeight = Math.max(axes.height, halo);
|
|
3580
|
+
}
|
|
3581
|
+
});
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* A 3D flight keeps the view direction it reads from the camera every frame, so an orbit step
|
|
3585
|
+
* between frames turns the flight with it. The destination distance is the front-view fit
|
|
3586
|
+
* distance; near and far span both ends while flying, and the far plane at arrival still holds
|
|
3587
|
+
* the whole graph so the rest of it stays visible around the framed nodes.
|
|
3588
|
+
*/
|
|
3589
|
+
planPerspectiveFlight(camera, width, height, nodes, distanceScale) {
|
|
3590
|
+
const fit = this.perspectiveFit(camera, width, height, nodes);
|
|
3591
|
+
const fromTarget = this.controls.target.clone();
|
|
3592
|
+
const toTarget = new Vector3(fit.target.x, fit.target.y, fit.target.z);
|
|
3593
|
+
const fromDistance = Math.max(camera.position.distanceTo(fromTarget), Number.EPSILON);
|
|
3594
|
+
const toDistance = (fit.cameraZ - fit.target.z) * distanceScale;
|
|
3595
|
+
const near = fit.near * Math.min(distanceScale, 1);
|
|
3596
|
+
const far = Math.max(fit.far * distanceScale, (toDistance + this.graphRadiusAround(toTarget)) * 1.1);
|
|
3597
|
+
camera.near = Math.min(camera.near, near);
|
|
3598
|
+
camera.far = Math.max(camera.far, far);
|
|
3599
|
+
camera.updateProjectionMatrix();
|
|
3600
|
+
return {
|
|
3601
|
+
arrive: () => {
|
|
3602
|
+
camera.near = near;
|
|
3603
|
+
camera.far = far;
|
|
3604
|
+
camera.updateProjectionMatrix();
|
|
3605
|
+
this.materializeViewport();
|
|
3606
|
+
},
|
|
3607
|
+
step: (eased) => {
|
|
3608
|
+
const direction = this.flightDirection.subVectors(camera.position, this.controls.target);
|
|
3609
|
+
if (direction.lengthSq() === 0) direction.set(0, 0, 1);
|
|
3610
|
+
direction.normalize();
|
|
3611
|
+
this.controls.target.lerpVectors(fromTarget, toTarget, eased);
|
|
3612
|
+
camera.position.copy(this.controls.target).addScaledVector(direction, interpolateScale(fromDistance, toDistance, eased));
|
|
3613
|
+
camera.lookAt(this.controls.target);
|
|
3614
|
+
}
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* A 2D flight switches to the destination frustum up front with a zoom that keeps the current
|
|
3619
|
+
* view, then pans and eases the zoom back to 1, so it lands exactly on the fitted frustum.
|
|
3620
|
+
*/
|
|
3621
|
+
planOrthographicFlight(camera, aspect, nodes, distanceScale) {
|
|
3622
|
+
const fit = orthographicFit(aspect, nodes);
|
|
3623
|
+
const fromVisibleHeight = (camera.top - camera.bottom) / camera.zoom;
|
|
3624
|
+
const toVisibleHeight = fit.visibleHeight * distanceScale;
|
|
3625
|
+
setOrthographicFrustum(camera, aspect, toVisibleHeight);
|
|
3626
|
+
const fromZoom = toVisibleHeight / fromVisibleHeight;
|
|
3627
|
+
camera.zoom = fromZoom;
|
|
3628
|
+
camera.updateProjectionMatrix();
|
|
3629
|
+
const fromPosition = camera.position.clone();
|
|
3630
|
+
const toPosition = new Vector3(fit.center.x, fit.center.y, fit.cameraZ);
|
|
3631
|
+
const fromTarget = this.controls.target.clone();
|
|
3632
|
+
return {
|
|
3633
|
+
arrive: () => this.materializeViewport(),
|
|
3634
|
+
step: (eased) => {
|
|
3635
|
+
camera.zoom = interpolateScale(fromZoom, 1, eased);
|
|
3636
|
+
camera.position.lerpVectors(fromPosition, toPosition, eased);
|
|
3637
|
+
this.controls.target.lerpVectors(fromTarget, fit.center, eased);
|
|
3638
|
+
camera.updateProjectionMatrix();
|
|
3639
|
+
}
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
/** Largest distance from `center` to any node's far edge. */
|
|
3643
|
+
graphRadiusAround(center) {
|
|
3644
|
+
let radius = 0;
|
|
3645
|
+
for (const node of this.data.nodes) {
|
|
3646
|
+
const axes = resolveNodeAxes({
|
|
3647
|
+
height: node.height,
|
|
3648
|
+
nodeId: node.id,
|
|
3649
|
+
size: node.size,
|
|
3650
|
+
width: node.width
|
|
3651
|
+
});
|
|
3652
|
+
const distance = Math.hypot(node.position.x - center.x, node.position.y - center.y, (node.position.z ?? 0) - center.z);
|
|
3653
|
+
radius = Math.max(radius, distance + Math.max(axes.width, axes.height));
|
|
3654
|
+
}
|
|
3655
|
+
return radius;
|
|
3656
|
+
}
|
|
3657
|
+
/** Steps a running flight to the frame time; settles and resolves it at the end. */
|
|
3658
|
+
advanceFlight(nowMs) {
|
|
3659
|
+
const flight = this.flight;
|
|
3660
|
+
if (!flight) return;
|
|
3661
|
+
flight.startMs ??= nowMs;
|
|
3662
|
+
const progress = flightProgress(nowMs - flight.startMs, flight.durationMs);
|
|
3663
|
+
try {
|
|
3664
|
+
flight.step(easeInOutCubic(progress));
|
|
3665
|
+
if (progress < 1) {
|
|
3666
|
+
if (this.mode === "2d") this.materializeViewport();
|
|
3667
|
+
this.requestRender();
|
|
3668
|
+
return;
|
|
3669
|
+
}
|
|
3670
|
+
this.flight = null;
|
|
3671
|
+
flight.arrive();
|
|
3672
|
+
} catch (error) {
|
|
3673
|
+
this.cancelFlight();
|
|
3674
|
+
throw error;
|
|
3675
|
+
}
|
|
3676
|
+
flight.resolve(true);
|
|
3677
|
+
}
|
|
3678
|
+
cancelFlight = () => {
|
|
3679
|
+
const flight = this.flight;
|
|
3680
|
+
if (!flight) return;
|
|
3681
|
+
this.flight = null;
|
|
3682
|
+
flight.resolve(false);
|
|
3683
|
+
};
|
|
2685
3684
|
setTheme(theme) {
|
|
2686
3685
|
this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
|
|
2687
3686
|
this.applyBackground();
|
|
@@ -2706,9 +3705,13 @@ var Graphraum = class {
|
|
|
2706
3705
|
this.requestRender();
|
|
2707
3706
|
}
|
|
2708
3707
|
destroy() {
|
|
3708
|
+
this.destroyed = true;
|
|
3709
|
+
this.cancelFlight();
|
|
3710
|
+
this.stopAutoOrbit();
|
|
2709
3711
|
this.resizeObserver.disconnect();
|
|
2710
3712
|
this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
|
|
2711
3713
|
this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
|
|
3714
|
+
for (const type of FLIGHT_CANCEL_EVENTS) this.renderer.domElement.removeEventListener(type, this.cancelFlight);
|
|
2712
3715
|
this.controls.removeEventListener("change", this.handleViewChange);
|
|
2713
3716
|
this.controls.dispose();
|
|
2714
3717
|
if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
|
|
@@ -2720,22 +3723,63 @@ var Graphraum = class {
|
|
|
2720
3723
|
}
|
|
2721
3724
|
requestRender = () => {
|
|
2722
3725
|
if (this.frameRequest !== null) return;
|
|
2723
|
-
this.frameRequest = requestAnimationFrame(() => {
|
|
3726
|
+
this.frameRequest = requestAnimationFrame((nowMs) => {
|
|
2724
3727
|
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();
|
|
3728
|
+
this.advanceFlight(nowMs);
|
|
3729
|
+
this.renderFrame();
|
|
2737
3730
|
});
|
|
2738
3731
|
};
|
|
3732
|
+
/** Renders a pending frame now instead of on the next animation frame. */
|
|
3733
|
+
flushRender() {
|
|
3734
|
+
if (this.frameRequest === null) return;
|
|
3735
|
+
cancelAnimationFrame(this.frameRequest);
|
|
3736
|
+
this.frameRequest = null;
|
|
3737
|
+
this.renderFrame();
|
|
3738
|
+
}
|
|
3739
|
+
renderFrame() {
|
|
3740
|
+
this.readGpuTimers();
|
|
3741
|
+
const timer = this.gpuTimer;
|
|
3742
|
+
const query = timer?.context.createQuery() ?? null;
|
|
3743
|
+
if (timer && query) timer.context.beginQuery(timer.extension.TIME_ELAPSED_EXT, query);
|
|
3744
|
+
const startedAt = performance.now();
|
|
3745
|
+
this.renderer.render(this.scene, this.camera);
|
|
3746
|
+
this.cpuFrameMilliseconds = performance.now() - startedAt;
|
|
3747
|
+
if (timer && query) {
|
|
3748
|
+
timer.context.endQuery(timer.extension.TIME_ELAPSED_EXT);
|
|
3749
|
+
timer.pending.push(query);
|
|
3750
|
+
}
|
|
3751
|
+
for (const listener of this.viewListeners) listener();
|
|
3752
|
+
}
|
|
3753
|
+
stopAutoOrbit() {
|
|
3754
|
+
this.autoOrbit?.dispose();
|
|
3755
|
+
this.autoOrbit = null;
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* One orbit frame. `autoRotate` is on only for this `update(dt)` call: OrbitControls also calls
|
|
3759
|
+
* `update()` without a delta from wheel and key handlers and `fitView`, which would otherwise add
|
|
3760
|
+
* a fixed per-call rotation. A moved camera emits `change` (a render request only, see
|
|
3761
|
+
* `handleViewChange`); flushing that request renders this frame instead of the next one. During
|
|
3762
|
+
* a `flyTo` the orbit still turns the camera but leaves the pending frame to the flight, which
|
|
3763
|
+
* steps from the turned direction before it renders. A
|
|
3764
|
+
* throwing update, render or view listener turns the orbit off rather than leaving it enabled
|
|
3765
|
+
* without a loop.
|
|
3766
|
+
*/
|
|
3767
|
+
stepAutoOrbit = (deltaSeconds) => {
|
|
3768
|
+
try {
|
|
3769
|
+
this.controls.autoRotate = true;
|
|
3770
|
+
this.orbitUpdating = true;
|
|
3771
|
+
try {
|
|
3772
|
+
this.controls.update(deltaSeconds);
|
|
3773
|
+
} finally {
|
|
3774
|
+
this.controls.autoRotate = false;
|
|
3775
|
+
this.orbitUpdating = false;
|
|
3776
|
+
}
|
|
3777
|
+
if (this.flight === null) this.flushRender();
|
|
3778
|
+
} catch (error) {
|
|
3779
|
+
this.stopAutoOrbit();
|
|
3780
|
+
throw error;
|
|
3781
|
+
}
|
|
3782
|
+
};
|
|
2739
3783
|
readGpuTimers() {
|
|
2740
3784
|
const timer = this.gpuTimer;
|
|
2741
3785
|
if (!timer) return;
|
|
@@ -2747,8 +3791,15 @@ var Graphraum = class {
|
|
|
2747
3791
|
timer.context.deleteQuery(query);
|
|
2748
3792
|
}
|
|
2749
3793
|
}
|
|
3794
|
+
/**
|
|
3795
|
+
* Orbit frames skip materialization. In 3D the packed instances do not depend on camera
|
|
3796
|
+
* orientation: there is no viewport culling, density LOD and edge tiers follow counts only,
|
|
3797
|
+
* and the one orientation-dependent value (`worldUnitsPerPixel`, for edge hit slop) is used
|
|
3798
|
+
* only by 2D picking. 3D picking raycasts the instances and screen positions are projected per
|
|
3799
|
+
* call, so an orbit frame needs only a render, whose view listeners still run every frame.
|
|
3800
|
+
*/
|
|
2750
3801
|
handleViewChange = () => {
|
|
2751
|
-
this.materializeViewport();
|
|
3802
|
+
if (!this.orbitUpdating) this.materializeViewport();
|
|
2752
3803
|
this.requestRender();
|
|
2753
3804
|
};
|
|
2754
3805
|
createCamera() {
|
|
@@ -2758,12 +3809,8 @@ var Graphraum = class {
|
|
|
2758
3809
|
createControls() {
|
|
2759
3810
|
const controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
2760
3811
|
controls.enableDamping = false;
|
|
2761
|
-
controls.enableRotate = this.mode === "3d";
|
|
2762
3812
|
controls.screenSpacePanning = true;
|
|
2763
|
-
|
|
2764
|
-
controls.minZoom = .01;
|
|
2765
|
-
controls.mouseButtons.LEFT = MOUSE.PAN;
|
|
2766
|
-
}
|
|
3813
|
+
configureControlsForMode(controls, this.mode);
|
|
2767
3814
|
controls.addEventListener("change", this.handleViewChange);
|
|
2768
3815
|
return controls;
|
|
2769
3816
|
}
|
|
@@ -2854,7 +3901,35 @@ var Graphraum = class {
|
|
|
2854
3901
|
slots.push(slot);
|
|
2855
3902
|
}
|
|
2856
3903
|
markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
|
|
3904
|
+
this.glowLayer.rewrite(this.describeGlowNode);
|
|
2857
3905
|
}
|
|
3906
|
+
/** Rebuilds the opt-in halo pass; graphs without glow release it and skip the visible scan. */
|
|
3907
|
+
syncGlow() {
|
|
3908
|
+
if (!this.hasGlowNodes) {
|
|
3909
|
+
this.glowLayer.dispose();
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
this.glowLayer.setBlending(glowBlendModeFor(this.theme.background));
|
|
3913
|
+
this.glowLayer.sync(this.visibleNodeIndices, this.glowAt, this.densityLodActive, this.describeGlowNode);
|
|
3914
|
+
}
|
|
3915
|
+
glowAt = (nodeIndex) => this.data.nodes[nodeIndex]?.glow ?? 0;
|
|
3916
|
+
describeGlowNode = (nodeIndex, target) => {
|
|
3917
|
+
const node = this.data.nodes[nodeIndex];
|
|
3918
|
+
if (!node) return false;
|
|
3919
|
+
const { height, width } = resolveNodeAxes({
|
|
3920
|
+
height: node.height,
|
|
3921
|
+
nodeId: node.id,
|
|
3922
|
+
size: node.size,
|
|
3923
|
+
width: node.width
|
|
3924
|
+
});
|
|
3925
|
+
target.color.copy(this.getNodeColor(node));
|
|
3926
|
+
target.extent = Math.max(width, height);
|
|
3927
|
+
target.glow = node.glow ?? 0;
|
|
3928
|
+
target.x = node.position.x;
|
|
3929
|
+
target.y = node.position.y;
|
|
3930
|
+
target.z = node.position.z ?? 0;
|
|
3931
|
+
return true;
|
|
3932
|
+
};
|
|
2858
3933
|
getNodeStateIds(state) {
|
|
2859
3934
|
switch (state) {
|
|
2860
3935
|
case "dimmed": return this.dimmedNodeIds;
|
|
@@ -3000,10 +4075,8 @@ var Graphraum = class {
|
|
|
3000
4075
|
const worldPerPixel = this.worldUnitsPerPixel();
|
|
3001
4076
|
const packed = packEdgeInstances({
|
|
3002
4077
|
edgeIndices: visibleEdgeIndices,
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
color: this.theme.selectedEdge
|
|
3006
|
-
} : edge),
|
|
4078
|
+
edgeStates: this.edgeStateStyling(),
|
|
4079
|
+
edgeVisuals: this.data.edges,
|
|
3007
4080
|
endpointPositions: this.canonicalEdgePositions,
|
|
3008
4081
|
defaults: {
|
|
3009
4082
|
color: this.theme.edge,
|
|
@@ -3014,6 +4087,7 @@ var Graphraum = class {
|
|
|
3014
4087
|
maxSegments: this.edgeSegmentCapacity,
|
|
3015
4088
|
endpointAttach: this.theme.endpointAttach,
|
|
3016
4089
|
edgeNodeIndices: this.edgeNodeIndices,
|
|
4090
|
+
mode: this.mode,
|
|
3017
4091
|
nodeOutlines: this.data.nodes.map((node) => ({
|
|
3018
4092
|
height: node.height,
|
|
3019
4093
|
shape: node.shape,
|
|
@@ -3063,6 +4137,7 @@ var Graphraum = class {
|
|
|
3063
4137
|
this.visibleEdgeSegmentCount = segmentCount;
|
|
3064
4138
|
this.visibleEdgeCount = distinctEdges.size;
|
|
3065
4139
|
this.visibleEdgeMarkerCount = packed.markers.length;
|
|
4140
|
+
this.syncGlow();
|
|
3066
4141
|
}
|
|
3067
4142
|
materializeNodes(indices, bounds) {
|
|
3068
4143
|
if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
|
|
@@ -3124,6 +4199,7 @@ var Graphraum = class {
|
|
|
3124
4199
|
return [...clusters.values()].slice(0, this.maxVisibleNodes);
|
|
3125
4200
|
}
|
|
3126
4201
|
disposeGraphObjects() {
|
|
4202
|
+
this.glowLayer.dispose();
|
|
3127
4203
|
this.pickableEdgeSegments = [];
|
|
3128
4204
|
this.visibleEdgeSegmentEdgeIndices = [];
|
|
3129
4205
|
this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
|
|
@@ -3146,6 +4222,42 @@ var Graphraum = class {
|
|
|
3146
4222
|
}
|
|
3147
4223
|
}
|
|
3148
4224
|
};
|
|
4225
|
+
/** The 2D fit: the nodes' padded bounds, framed with 15% slack on the tighter axis. */
|
|
4226
|
+
function orthographicFit(aspect, nodes) {
|
|
4227
|
+
const bounds = new Box3();
|
|
4228
|
+
const minimum = new Vector3();
|
|
4229
|
+
const maximum = new Vector3();
|
|
4230
|
+
for (const node of nodes) {
|
|
4231
|
+
const { height, width } = resolveNodeAxes({
|
|
4232
|
+
height: node.height,
|
|
4233
|
+
nodeId: node.id,
|
|
4234
|
+
size: node.size,
|
|
4235
|
+
width: node.width
|
|
4236
|
+
});
|
|
4237
|
+
const radius = Math.max(width, height);
|
|
4238
|
+
const z = node.position.z ?? 0;
|
|
4239
|
+
bounds.expandByPoint(minimum.set(node.position.x - radius, node.position.y - radius, z - radius));
|
|
4240
|
+
bounds.expandByPoint(maximum.set(node.position.x + radius, node.position.y + radius, z + radius));
|
|
4241
|
+
}
|
|
4242
|
+
const center = bounds.getCenter(new Vector3());
|
|
4243
|
+
const size = bounds.getSize(new Vector3());
|
|
4244
|
+
return {
|
|
4245
|
+
cameraZ: center.z + Math.max(size.z, 1e3),
|
|
4246
|
+
center,
|
|
4247
|
+
visibleHeight: Math.max(size.y, size.x / aspect, 1) * 1.15
|
|
4248
|
+
};
|
|
4249
|
+
}
|
|
4250
|
+
/** Sets a frustum that shows `visibleHeight` world units at zoom 1 and resets the zoom. */
|
|
4251
|
+
function setOrthographicFrustum(camera, aspect, visibleHeight) {
|
|
4252
|
+
camera.left = -visibleHeight * aspect / 2;
|
|
4253
|
+
camera.right = visibleHeight * aspect / 2;
|
|
4254
|
+
camera.top = visibleHeight / 2;
|
|
4255
|
+
camera.bottom = -visibleHeight / 2;
|
|
4256
|
+
camera.zoom = 1;
|
|
4257
|
+
}
|
|
4258
|
+
function hasNodeGlow(node) {
|
|
4259
|
+
return (node.glow ?? 0) > 0;
|
|
4260
|
+
}
|
|
3149
4261
|
function nextCapacity(length) {
|
|
3150
4262
|
let capacity = 1;
|
|
3151
4263
|
while (capacity < length) capacity *= 2;
|