@yggy-tech/graphraum 0.0.0-stage → 0.28.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/LICENSE +201 -0
- package/README.md +80 -2
- package/dist/index.d.ts +677 -0
- package/dist/index.js +3178 -0
- package/package.json +55 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,3178 @@
|
|
|
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";
|
|
2
|
+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
3
|
+
//#region src/node-axes.ts
|
|
4
|
+
function assertPositiveFinite(value, axis, nodeId) {
|
|
5
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
6
|
+
const preamble = nodeId === void 0 ? "" : `Node "${nodeId}" `;
|
|
7
|
+
throw new Error(`${preamble}${axis} must be a positive finite number`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function resolveNodeAxes(input) {
|
|
11
|
+
const { defaultSize = 4, height, nodeId, size, width } = input;
|
|
12
|
+
const resolvedWidth = width ?? size ?? defaultSize;
|
|
13
|
+
const resolvedHeight = height ?? size ?? defaultSize;
|
|
14
|
+
assertPositiveFinite(resolvedWidth, "width", nodeId);
|
|
15
|
+
assertPositiveFinite(resolvedHeight, "height", nodeId);
|
|
16
|
+
return {
|
|
17
|
+
width: resolvedWidth,
|
|
18
|
+
height: resolvedHeight
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/node-shapes.ts
|
|
23
|
+
const graphraumNodeShapes = [
|
|
24
|
+
"circle",
|
|
25
|
+
"square",
|
|
26
|
+
"diamond",
|
|
27
|
+
"hexagon",
|
|
28
|
+
"triangle",
|
|
29
|
+
"pill",
|
|
30
|
+
"rounded"
|
|
31
|
+
];
|
|
32
|
+
const shapeCodes = {
|
|
33
|
+
circle: 0,
|
|
34
|
+
square: 1,
|
|
35
|
+
diamond: 2,
|
|
36
|
+
hexagon: 3,
|
|
37
|
+
triangle: 4,
|
|
38
|
+
pill: 5,
|
|
39
|
+
rounded: 6
|
|
40
|
+
};
|
|
41
|
+
function assertNodeShape(nodeId, shape) {
|
|
42
|
+
if (typeof shape !== "string" || !graphraumNodeShapes.includes(shape)) throw new Error(`Node "${nodeId}" shape must be one of: ${graphraumNodeShapes.map((candidate) => `"${candidate}"`).join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
function encodeNodeShape(shape) {
|
|
45
|
+
return shapeCodes[shape ?? "circle"];
|
|
46
|
+
}
|
|
47
|
+
/** Half-height of the horizontal pill's rounded caps, in unit space. */
|
|
48
|
+
const PILL_CAP_RADIUS = .5;
|
|
49
|
+
/** Half-length of the pill's straight segment, in unit space (leaves room for the caps within [-1, 1]). */
|
|
50
|
+
const PILL_HALF_LENGTH = 1 - PILL_CAP_RADIUS;
|
|
51
|
+
/** Apothem of a regular hexagon whose pointy left/right vertices reach x = ±1. */
|
|
52
|
+
const HEXAGON_APOTHEM = Math.sqrt(3) / 2;
|
|
53
|
+
/** Y of the upward triangle's flat base, given an apex at (0, 1) and an equilateral shape spanning x = [-1, 1]. */
|
|
54
|
+
const TRIANGLE_BASE_Y = 1 - Math.sqrt(3);
|
|
55
|
+
/** Fraction of the rounded rectangle's half-extent used as the corner radius. */
|
|
56
|
+
const ROUNDED_CORNER_RADIUS = .25;
|
|
57
|
+
function containsHexagon(x, y) {
|
|
58
|
+
const absX = Math.abs(x);
|
|
59
|
+
const absY = Math.abs(y);
|
|
60
|
+
return Math.max(absY, absX * HEXAGON_APOTHEM + absY * .5) <= HEXAGON_APOTHEM;
|
|
61
|
+
}
|
|
62
|
+
function edgeCross(ax, ay, bx, by, px, py) {
|
|
63
|
+
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
64
|
+
}
|
|
65
|
+
function containsTriangle(x, y) {
|
|
66
|
+
const apexX = 0;
|
|
67
|
+
const apexY = 1;
|
|
68
|
+
const rightX = 1;
|
|
69
|
+
const leftX = -1;
|
|
70
|
+
const baseY = TRIANGLE_BASE_Y;
|
|
71
|
+
const d1 = edgeCross(apexX, apexY, rightX, baseY, x, y);
|
|
72
|
+
const d2 = edgeCross(rightX, baseY, leftX, baseY, x, y);
|
|
73
|
+
const d3 = edgeCross(leftX, baseY, apexX, apexY, x, y);
|
|
74
|
+
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
|
|
75
|
+
}
|
|
76
|
+
function containsPill(x, y) {
|
|
77
|
+
const dx = x - Math.max(-.5, Math.min(PILL_HALF_LENGTH, x));
|
|
78
|
+
return dx * dx + y * y <= PILL_CAP_RADIUS * PILL_CAP_RADIUS;
|
|
79
|
+
}
|
|
80
|
+
function containsRounded(x, y) {
|
|
81
|
+
const innerHalfExtent = 1 - ROUNDED_CORNER_RADIUS;
|
|
82
|
+
const qx = Math.abs(x) - innerHalfExtent;
|
|
83
|
+
const qy = Math.abs(y) - innerHalfExtent;
|
|
84
|
+
const outsideX = Math.max(qx, 0);
|
|
85
|
+
const outsideY = Math.max(qy, 0);
|
|
86
|
+
return Math.sqrt(outsideX * outsideX + outsideY * outsideY) + Math.min(Math.max(qx, qy), 0) - ROUNDED_CORNER_RADIUS <= 0;
|
|
87
|
+
}
|
|
88
|
+
function containsNodePoint(shape, x, y) {
|
|
89
|
+
switch (shape ?? "circle") {
|
|
90
|
+
case "square": return Math.max(Math.abs(x), Math.abs(y)) <= 1;
|
|
91
|
+
case "diamond": return Math.abs(x) + Math.abs(y) <= 1;
|
|
92
|
+
case "hexagon": return containsHexagon(x, y);
|
|
93
|
+
case "triangle": return containsTriangle(x, y);
|
|
94
|
+
case "pill": return containsPill(x, y);
|
|
95
|
+
case "rounded": return containsRounded(x, y);
|
|
96
|
+
default: return x ** 2 + y ** 2 <= 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/edge-endpoint-attach.ts
|
|
101
|
+
const DEFAULT_ENDPOINT_ATTACH = "boundary";
|
|
102
|
+
const DEFAULT_ENDPOINT_CLEARANCE = .75;
|
|
103
|
+
function isInsideOutline(outline, worldX, worldY, center) {
|
|
104
|
+
const { height, width } = resolveNodeAxes({
|
|
105
|
+
height: outline.height,
|
|
106
|
+
size: outline.size,
|
|
107
|
+
width: outline.width
|
|
108
|
+
});
|
|
109
|
+
const stroke = Math.max(0, outline.strokeWidth ?? 0);
|
|
110
|
+
const halfWidth = width + stroke;
|
|
111
|
+
const halfHeight = height + stroke;
|
|
112
|
+
if (halfWidth <= 0 || halfHeight <= 0) return false;
|
|
113
|
+
return containsNodePoint(outline.shape, (worldX - center.x) / halfWidth, (worldY - center.y) / halfHeight);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Walk from the node center toward the far point and stop on the outline,
|
|
117
|
+
* then pull back by `clearance` so the stroke is not stabbed.
|
|
118
|
+
*/
|
|
119
|
+
function attachPointOnOutline(center, far, outline, clearance = DEFAULT_ENDPOINT_CLEARANCE) {
|
|
120
|
+
const dx = far.x - center.x;
|
|
121
|
+
const dy = far.y - center.y;
|
|
122
|
+
const dz = (far.z ?? 0) - (center.z ?? 0);
|
|
123
|
+
const length = Math.hypot(dx, dy, dz);
|
|
124
|
+
if (length < 1e-6) return {
|
|
125
|
+
x: center.x,
|
|
126
|
+
y: center.y,
|
|
127
|
+
z: center.z ?? 0
|
|
128
|
+
};
|
|
129
|
+
let low = 0;
|
|
130
|
+
let high = 1;
|
|
131
|
+
for (let step = 0; step < 24; step += 1) {
|
|
132
|
+
const mid = (low + high) / 2;
|
|
133
|
+
if (isInsideOutline(outline, center.x + dx * mid, center.y + dy * mid, center)) low = mid;
|
|
134
|
+
else high = mid;
|
|
135
|
+
}
|
|
136
|
+
const clearanceT = Math.min(Math.max(clearance, 0) / length, .45);
|
|
137
|
+
const t = Math.max(0, low - clearanceT);
|
|
138
|
+
return {
|
|
139
|
+
x: center.x + dx * t,
|
|
140
|
+
y: center.y + dy * t,
|
|
141
|
+
z: (center.z ?? 0) + dz * t
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Trim both ends of a center-to-center chord (or curve endpoints) to node boundaries. */
|
|
145
|
+
function trimEdgeEndpoints(input) {
|
|
146
|
+
if ((input.attach ?? "boundary") === "center") return {
|
|
147
|
+
source: {
|
|
148
|
+
x: input.source.x,
|
|
149
|
+
y: input.source.y,
|
|
150
|
+
z: input.source.z ?? 0
|
|
151
|
+
},
|
|
152
|
+
target: {
|
|
153
|
+
x: input.target.x,
|
|
154
|
+
y: input.target.y,
|
|
155
|
+
z: input.target.z ?? 0
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
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);
|
|
161
|
+
const ox = input.target.x - input.source.x;
|
|
162
|
+
const oy = input.target.y - input.source.y;
|
|
163
|
+
const oz = (input.target.z ?? 0) - (input.source.z ?? 0);
|
|
164
|
+
const dx = target.x - source.x;
|
|
165
|
+
const dy = target.y - source.y;
|
|
166
|
+
const dz = (target.z ?? 0) - (source.z ?? 0);
|
|
167
|
+
if (dx * ox + dy * oy + dz * oz <= 1e-8) return {
|
|
168
|
+
source: {
|
|
169
|
+
x: input.source.x,
|
|
170
|
+
y: input.source.y,
|
|
171
|
+
z: input.source.z ?? 0
|
|
172
|
+
},
|
|
173
|
+
target: {
|
|
174
|
+
x: input.target.x,
|
|
175
|
+
y: input.target.y,
|
|
176
|
+
z: input.target.z ?? 0
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
source,
|
|
181
|
+
target
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/edge-styles.ts
|
|
186
|
+
const graphraumEdgeStyles = [
|
|
187
|
+
"solid",
|
|
188
|
+
"dashed",
|
|
189
|
+
"dotted"
|
|
190
|
+
];
|
|
191
|
+
const graphraumEdgeMarkers = ["none", "triangle"];
|
|
192
|
+
const graphraumEdgeMarkerEnds = [
|
|
193
|
+
"target",
|
|
194
|
+
"source",
|
|
195
|
+
"both"
|
|
196
|
+
];
|
|
197
|
+
const graphraumEdgePaths = [
|
|
198
|
+
"straight",
|
|
199
|
+
"quadratic",
|
|
200
|
+
"cubic"
|
|
201
|
+
];
|
|
202
|
+
const styleCodes = {
|
|
203
|
+
solid: 0,
|
|
204
|
+
dashed: 1,
|
|
205
|
+
dotted: 2
|
|
206
|
+
};
|
|
207
|
+
function assertEdgeStyle(edgeId, style) {
|
|
208
|
+
if (typeof style !== "string" || !graphraumEdgeStyles.includes(style)) throw new Error(`Edge "${edgeId}" style must be one of: ${graphraumEdgeStyles.map((s) => `"${s}"`).join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
function assertEdgeMarker(edgeId, marker) {
|
|
211
|
+
if (typeof marker !== "string" || !graphraumEdgeMarkers.includes(marker)) throw new Error(`Edge "${edgeId}" marker must be one of: ${graphraumEdgeMarkers.map((m) => `"${m}"`).join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
function assertEdgeMarkerEnd(edgeId, markerEnd) {
|
|
214
|
+
if (typeof markerEnd !== "string" || !graphraumEdgeMarkerEnds.includes(markerEnd)) throw new Error(`Edge "${edgeId}" markerEnd must be one of: ${graphraumEdgeMarkerEnds.map((m) => `"${m}"`).join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
function assertEdgePath(edgeId, path) {
|
|
217
|
+
if (typeof path !== "string" || !graphraumEdgePaths.includes(path)) throw new Error(`Edge "${edgeId}" path must be one of: ${graphraumEdgePaths.map((p) => `"${p}"`).join(", ")}`);
|
|
218
|
+
}
|
|
219
|
+
function assertControlPoint(edgeId, index, point) {
|
|
220
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || point.z !== void 0 && !Number.isFinite(point.z)) throw new Error(`Edge "${edgeId}" controlPoints[${index}] must have finite coordinates`);
|
|
221
|
+
}
|
|
222
|
+
function assertEdgeControlPoints(edgeId, path, controlPoints) {
|
|
223
|
+
if (controlPoints === void 0) return;
|
|
224
|
+
for (const [index, point] of controlPoints.entries()) assertControlPoint(edgeId, index, point);
|
|
225
|
+
const resolvedPath = path ?? "straight";
|
|
226
|
+
if (resolvedPath === "straight") return;
|
|
227
|
+
if (controlPoints.length === 0) return;
|
|
228
|
+
const expected = resolvedPath === "quadratic" ? 1 : 2;
|
|
229
|
+
if (controlPoints.length !== expected) throw new Error(`Edge "${edgeId}" path "${resolvedPath}" expects ${expected} control point${expected === 1 ? "" : "s"} or none for auto defaults (got ${controlPoints.length})`);
|
|
230
|
+
}
|
|
231
|
+
function assertEdgeVisual(edgeId, visual) {
|
|
232
|
+
if (visual.width !== void 0 && (!Number.isFinite(visual.width) || visual.width <= 0)) throw new Error(`Edge "${edgeId}" visual must have a positive finite width`);
|
|
233
|
+
if (visual.opacity !== void 0 && (!Number.isFinite(visual.opacity) || visual.opacity < 0 || visual.opacity > 1)) throw new Error(`Edge "${edgeId}" visual opacity must be a finite number between 0 and 1`);
|
|
234
|
+
if (visual.markerSize !== void 0 && (!Number.isFinite(visual.markerSize) || visual.markerSize <= 0)) throw new Error(`Edge "${edgeId}" visual must have a positive finite markerSize`);
|
|
235
|
+
if (visual.style !== void 0) assertEdgeStyle(edgeId, visual.style);
|
|
236
|
+
if (visual.marker !== void 0) assertEdgeMarker(edgeId, visual.marker);
|
|
237
|
+
if (visual.markerEnd !== void 0) assertEdgeMarkerEnd(edgeId, visual.markerEnd);
|
|
238
|
+
if (visual.path !== void 0) assertEdgePath(edgeId, visual.path);
|
|
239
|
+
assertEdgeControlPoints(edgeId, visual.path, visual.controlPoints);
|
|
240
|
+
}
|
|
241
|
+
function encodeEdgeStyle(style) {
|
|
242
|
+
return styleCodes[style ?? "solid"];
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/force-layout.ts
|
|
246
|
+
const DEFAULT_FORCE_SETTINGS = {
|
|
247
|
+
centerAttraction: .002,
|
|
248
|
+
damping: .82,
|
|
249
|
+
linkDistance: 24,
|
|
250
|
+
repulsion: 400,
|
|
251
|
+
springStrength: .005
|
|
252
|
+
};
|
|
253
|
+
function normalizeForceSettings(settings = DEFAULT_FORCE_SETTINGS) {
|
|
254
|
+
return {
|
|
255
|
+
centerAttraction: Math.max(0, Math.min(.05, settings.centerAttraction)),
|
|
256
|
+
damping: Math.max(.1, Math.min(.99, settings.damping)),
|
|
257
|
+
linkDistance: Math.max(1, Math.min(500, settings.linkDistance)),
|
|
258
|
+
repulsion: Math.max(0, Math.min(5e3, settings.repulsion)),
|
|
259
|
+
springStrength: Math.max(1e-4, Math.min(.05, settings.springStrength))
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function clampLinkDistance(value) {
|
|
263
|
+
return Math.max(1, Math.min(500, value));
|
|
264
|
+
}
|
|
265
|
+
function clampSpringStrength(value) {
|
|
266
|
+
return Math.max(1e-4, Math.min(.05, value));
|
|
267
|
+
}
|
|
268
|
+
function normalizePerEdgeArray(name, values, edgeCount, clamp) {
|
|
269
|
+
if (values === void 0) return null;
|
|
270
|
+
if (values.length !== edgeCount) throw new Error(`${name} length (${values.length}) must equal edge count (${edgeCount}).`);
|
|
271
|
+
const normalized = new Float32Array(edgeCount);
|
|
272
|
+
for (let index = 0; index < edgeCount; index += 1) normalized[index] = clamp(values[index] ?? 0);
|
|
273
|
+
return normalized;
|
|
274
|
+
}
|
|
275
|
+
function forceIterationCount(nodeCount) {
|
|
276
|
+
return Math.max(8, Math.min(32, Math.ceil(240 / Math.log2(nodeCount + 2))));
|
|
277
|
+
}
|
|
278
|
+
function resolveForceIterations(nodeCount, iterations) {
|
|
279
|
+
if (iterations === void 0) return forceIterationCount(nodeCount);
|
|
280
|
+
if (!Number.isSafeInteger(iterations) || iterations < 1) throw new Error("Force iterations must be a positive integer.");
|
|
281
|
+
return Math.min(64, iterations);
|
|
282
|
+
}
|
|
283
|
+
function initialPositions({ dimensions, nodeCount }) {
|
|
284
|
+
const positions = new Float32Array(nodeCount * 3);
|
|
285
|
+
const extent = Math.max(Math.sqrt(nodeCount) * 6, 24);
|
|
286
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
287
|
+
const offset = index * 3;
|
|
288
|
+
const angle = random(index * 4 + 1) * Math.PI * 2;
|
|
289
|
+
if (dimensions === 2) {
|
|
290
|
+
const radius = Math.sqrt(random(index * 4 + 2)) * extent * .5;
|
|
291
|
+
positions.set([
|
|
292
|
+
Math.cos(angle) * radius,
|
|
293
|
+
Math.sin(angle) * radius,
|
|
294
|
+
0
|
|
295
|
+
], offset);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const zDirection = random(index * 4 + 2) * 2 - 1;
|
|
299
|
+
const planeRadius = Math.sqrt(1 - zDirection * zDirection);
|
|
300
|
+
const radius = Math.cbrt(random(index * 4 + 3)) * extent * .5;
|
|
301
|
+
positions.set([
|
|
302
|
+
Math.cos(angle) * planeRadius * radius,
|
|
303
|
+
Math.sin(angle) * planeRadius * radius,
|
|
304
|
+
zDirection * radius
|
|
305
|
+
], offset);
|
|
306
|
+
}
|
|
307
|
+
return positions;
|
|
308
|
+
}
|
|
309
|
+
function random(seed) {
|
|
310
|
+
let value = Math.imul(seed ^ 2654435769, 2246822507);
|
|
311
|
+
value = Math.imul(value ^ value >>> 13, 3266489909);
|
|
312
|
+
return ((value ^ value >>> 16) >>> 0) / 4294967296;
|
|
313
|
+
}
|
|
314
|
+
function createForceSimulation(request) {
|
|
315
|
+
const settings = normalizeForceSettings(request.settings);
|
|
316
|
+
let nodeCount = request.nodeCount;
|
|
317
|
+
let edges = new Uint32Array(request.edges);
|
|
318
|
+
if (edges.length % 2 !== 0) throw new Error("Force edges must contain source/target pairs.");
|
|
319
|
+
const edgeCount = edges.length / 2;
|
|
320
|
+
let linkDistances = normalizePerEdgeArray("linkDistances", request.linkDistances, edgeCount, clampLinkDistance);
|
|
321
|
+
let springStrengths = normalizePerEdgeArray("springStrengths", request.springStrengths, edgeCount, clampSpringStrength);
|
|
322
|
+
let positions = initialPositions(request);
|
|
323
|
+
let velocities = new Float32Array(positions.length);
|
|
324
|
+
let forces = new Float32Array(positions.length);
|
|
325
|
+
let iteration = 0;
|
|
326
|
+
return {
|
|
327
|
+
get nodeCount() {
|
|
328
|
+
return nodeCount;
|
|
329
|
+
},
|
|
330
|
+
get edges() {
|
|
331
|
+
return edges;
|
|
332
|
+
},
|
|
333
|
+
get positions() {
|
|
334
|
+
return positions;
|
|
335
|
+
},
|
|
336
|
+
/** Add nodes while preserving every existing position and velocity. */
|
|
337
|
+
addNodes(count, initial) {
|
|
338
|
+
if (!Number.isSafeInteger(count) || count < 1) throw new Error("Node count must be a positive integer.");
|
|
339
|
+
if (initial && initial.length !== count * 3) throw new Error("Initial positions must contain one XYZ triplet per added node.");
|
|
340
|
+
const start = nodeCount;
|
|
341
|
+
const nextPositions = new Float32Array((nodeCount + count) * 3);
|
|
342
|
+
nextPositions.set(positions);
|
|
343
|
+
const nextVelocities = new Float32Array(nextPositions.length);
|
|
344
|
+
nextVelocities.set(velocities);
|
|
345
|
+
const nextForces = new Float32Array(nextPositions.length);
|
|
346
|
+
nextForces.set(forces);
|
|
347
|
+
if (initial) nextPositions.set(initial, positions.length);
|
|
348
|
+
else {
|
|
349
|
+
const added = initialPositions({
|
|
350
|
+
dimensions: request.dimensions,
|
|
351
|
+
nodeCount: count
|
|
352
|
+
});
|
|
353
|
+
nextPositions.set(added, positions.length);
|
|
354
|
+
}
|
|
355
|
+
positions = nextPositions;
|
|
356
|
+
velocities = nextVelocities;
|
|
357
|
+
forces = nextForces;
|
|
358
|
+
nodeCount += count;
|
|
359
|
+
return start;
|
|
360
|
+
},
|
|
361
|
+
/** Append endpoint indices for new edges; existing edge indices remain stable. */
|
|
362
|
+
addEdges(nextEdges) {
|
|
363
|
+
if (nextEdges.length % 2 !== 0) throw new Error("Force edges must contain source/target pairs.");
|
|
364
|
+
for (const index of nextEdges) if (index >= nodeCount) throw new Error(`Force edge references missing node index: ${index}`);
|
|
365
|
+
const addedEdgeCount = nextEdges.length / 2;
|
|
366
|
+
const merged = new Uint32Array(edges.length + nextEdges.length);
|
|
367
|
+
merged.set(edges);
|
|
368
|
+
merged.set(nextEdges, edges.length);
|
|
369
|
+
edges = merged;
|
|
370
|
+
if (linkDistances) {
|
|
371
|
+
const next = new Float32Array(linkDistances.length + addedEdgeCount);
|
|
372
|
+
next.set(linkDistances);
|
|
373
|
+
next.fill(settings.linkDistance, linkDistances.length);
|
|
374
|
+
linkDistances = next;
|
|
375
|
+
}
|
|
376
|
+
if (springStrengths) {
|
|
377
|
+
const next = new Float32Array(springStrengths.length + addedEdgeCount);
|
|
378
|
+
next.set(springStrengths);
|
|
379
|
+
next.fill(settings.springStrength, springStrengths.length);
|
|
380
|
+
springStrengths = next;
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
step(alpha) {
|
|
384
|
+
if (nodeCount === 0) return;
|
|
385
|
+
forces.fill(0);
|
|
386
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
387
|
+
const offset = index * 3;
|
|
388
|
+
forces[offset] = -(positions[offset] ?? 0) * settings.centerAttraction * alpha;
|
|
389
|
+
forces[offset + 1] = -(positions[offset + 1] ?? 0) * settings.centerAttraction * alpha;
|
|
390
|
+
forces[offset + 2] = request.dimensions === 3 ? -(positions[offset + 2] ?? 0) * settings.centerAttraction * alpha : 0;
|
|
391
|
+
for (let sample = 0; sample < 8; sample += 1) {
|
|
392
|
+
const targetIndex = Math.floor(random(index * 97 + sample * 17 + iteration * 13) * nodeCount);
|
|
393
|
+
if (targetIndex === index) continue;
|
|
394
|
+
const target = targetIndex * 3;
|
|
395
|
+
const dx = (positions[offset] ?? 0) - (positions[target] ?? 0);
|
|
396
|
+
const dy = (positions[offset + 1] ?? 0) - (positions[target + 1] ?? 0);
|
|
397
|
+
const dz = request.dimensions === 3 ? (positions[offset + 2] ?? 0) - (positions[target + 2] ?? 0) : 0;
|
|
398
|
+
const distanceSquared = dx * dx + dy * dy + dz * dz + 16;
|
|
399
|
+
const scale = Math.min(2, settings.repulsion / distanceSquared) * alpha / Math.sqrt(distanceSquared);
|
|
400
|
+
forces[offset] += dx * scale;
|
|
401
|
+
forces[offset + 1] += dy * scale;
|
|
402
|
+
if (request.dimensions === 3) forces[offset + 2] += dz * scale;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (let index = 0; index < edges.length; index += 2) {
|
|
406
|
+
const edgeIndex = index / 2;
|
|
407
|
+
const source = (edges[index] ?? 0) * 3;
|
|
408
|
+
const target = (edges[index + 1] ?? 0) * 3;
|
|
409
|
+
const dx = (positions[target] ?? 0) - (positions[source] ?? 0);
|
|
410
|
+
const dy = (positions[target + 1] ?? 0) - (positions[source + 1] ?? 0);
|
|
411
|
+
const dz = request.dimensions === 3 ? (positions[target + 2] ?? 0) - (positions[source + 2] ?? 0) : 0;
|
|
412
|
+
const distance = Math.sqrt(Math.max(dx * dx + dy * dy + dz * dz, 1e-4));
|
|
413
|
+
const linkDistance = linkDistances?.[edgeIndex] ?? settings.linkDistance;
|
|
414
|
+
const springStrength = springStrengths?.[edgeIndex] ?? settings.springStrength;
|
|
415
|
+
const force = Math.max(-1, Math.min(1, (distance - linkDistance) * springStrength * alpha)) / distance;
|
|
416
|
+
forces[source] += dx * force;
|
|
417
|
+
forces[source + 1] += dy * force;
|
|
418
|
+
forces[target] -= dx * force;
|
|
419
|
+
forces[target + 1] -= dy * force;
|
|
420
|
+
if (request.dimensions === 3) {
|
|
421
|
+
forces[source + 2] += dz * force;
|
|
422
|
+
forces[target + 2] -= dz * force;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
426
|
+
const offset = index * 3;
|
|
427
|
+
let vx = ((velocities[offset] ?? 0) + (forces[offset] ?? 0)) * settings.damping;
|
|
428
|
+
let vy = ((velocities[offset + 1] ?? 0) + (forces[offset + 1] ?? 0)) * settings.damping;
|
|
429
|
+
let vz = request.dimensions === 3 ? ((velocities[offset + 2] ?? 0) + (forces[offset + 2] ?? 0)) * settings.damping : 0;
|
|
430
|
+
const speed = Math.hypot(vx, vy, vz);
|
|
431
|
+
const maxSpeed = .5 + alpha * 8;
|
|
432
|
+
if (speed > maxSpeed) {
|
|
433
|
+
const scale = maxSpeed / speed;
|
|
434
|
+
vx *= scale;
|
|
435
|
+
vy *= scale;
|
|
436
|
+
vz *= scale;
|
|
437
|
+
}
|
|
438
|
+
velocities[offset] = vx;
|
|
439
|
+
velocities[offset + 1] = vy;
|
|
440
|
+
velocities[offset + 2] = vz;
|
|
441
|
+
positions[offset] = (positions[offset] ?? 0) + vx;
|
|
442
|
+
positions[offset + 1] = (positions[offset + 1] ?? 0) + vy;
|
|
443
|
+
positions[offset + 2] = request.dimensions === 3 ? (positions[offset + 2] ?? 0) + vz : 0;
|
|
444
|
+
}
|
|
445
|
+
const center = [
|
|
446
|
+
0,
|
|
447
|
+
0,
|
|
448
|
+
0
|
|
449
|
+
];
|
|
450
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
451
|
+
const offset = index * 3;
|
|
452
|
+
center[0] += positions[offset] ?? 0;
|
|
453
|
+
center[1] += positions[offset + 1] ?? 0;
|
|
454
|
+
if (request.dimensions === 3) center[2] += positions[offset + 2] ?? 0;
|
|
455
|
+
}
|
|
456
|
+
for (let axis = 0; axis < request.dimensions; axis += 1) center[axis] /= nodeCount;
|
|
457
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
458
|
+
const offset = index * 3;
|
|
459
|
+
for (let axis = 0; axis < request.dimensions; axis += 1) positions[offset + axis] -= center[axis];
|
|
460
|
+
}
|
|
461
|
+
iteration += 1;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function computeForcePositions(request) {
|
|
466
|
+
const simulation = createForceSimulation(request);
|
|
467
|
+
const iterations = resolveForceIterations(request.nodeCount, request.iterations);
|
|
468
|
+
for (let iteration = 0; iteration < iterations; iteration += 1) simulation.step(1 - iteration / iterations);
|
|
469
|
+
return simulation.positions;
|
|
470
|
+
}
|
|
471
|
+
function computeClusteredForcePositions(request) {
|
|
472
|
+
if (request.clusters.length !== request.nodeCount) throw new Error("Cluster index count must match node count.");
|
|
473
|
+
let clusterCount = 0;
|
|
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;
|
|
507
|
+
}
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/graphology.ts
|
|
510
|
+
function projectGraphology(graph) {
|
|
511
|
+
const nodes = graph.mapNodes((id, attributes) => ({
|
|
512
|
+
attributes,
|
|
513
|
+
color: attributes.color,
|
|
514
|
+
id,
|
|
515
|
+
position: {
|
|
516
|
+
x: attributes.x ?? 0,
|
|
517
|
+
y: attributes.y ?? 0,
|
|
518
|
+
z: attributes.z
|
|
519
|
+
},
|
|
520
|
+
shape: attributes.shape,
|
|
521
|
+
size: attributes.size
|
|
522
|
+
}));
|
|
523
|
+
return {
|
|
524
|
+
edges: graph.mapEdges((id, attributes, source, target) => ({
|
|
525
|
+
attributes,
|
|
526
|
+
color: attributes.color,
|
|
527
|
+
id,
|
|
528
|
+
source,
|
|
529
|
+
target
|
|
530
|
+
})),
|
|
531
|
+
nodes
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/** Projects a mutable Graphology graph into Graphraum and keeps the binding alive until disposed. */
|
|
535
|
+
function bindGraphology(renderer, graph) {
|
|
536
|
+
renderer.setData(projectGraphology(graph));
|
|
537
|
+
let flushPending = false;
|
|
538
|
+
let disposed = false;
|
|
539
|
+
const flush = () => {
|
|
540
|
+
flushPending = false;
|
|
541
|
+
if (disposed) return;
|
|
542
|
+
renderer.setData(projectGraphology(graph));
|
|
543
|
+
};
|
|
544
|
+
const schedule = () => {
|
|
545
|
+
if (flushPending) return;
|
|
546
|
+
flushPending = true;
|
|
547
|
+
queueMicrotask(flush);
|
|
548
|
+
};
|
|
549
|
+
graph.on("nodeAttributesUpdated", schedule);
|
|
550
|
+
graph.on("eachNodeAttributesUpdated", schedule);
|
|
551
|
+
graph.on("nodeAdded", schedule);
|
|
552
|
+
graph.on("nodeDropped", schedule);
|
|
553
|
+
graph.on("edgeAdded", schedule);
|
|
554
|
+
graph.on("edgeDropped", schedule);
|
|
555
|
+
graph.on("edgeAttributesUpdated", schedule);
|
|
556
|
+
graph.on("eachEdgeAttributesUpdated", schedule);
|
|
557
|
+
graph.on("cleared", schedule);
|
|
558
|
+
graph.on("edgesCleared", schedule);
|
|
559
|
+
return () => {
|
|
560
|
+
disposed = true;
|
|
561
|
+
graph.off("nodeAttributesUpdated", schedule);
|
|
562
|
+
graph.off("eachNodeAttributesUpdated", schedule);
|
|
563
|
+
graph.off("nodeAdded", schedule);
|
|
564
|
+
graph.off("nodeDropped", schedule);
|
|
565
|
+
graph.off("edgeAdded", schedule);
|
|
566
|
+
graph.off("edgeDropped", schedule);
|
|
567
|
+
graph.off("edgeAttributesUpdated", schedule);
|
|
568
|
+
graph.off("eachEdgeAttributesUpdated", schedule);
|
|
569
|
+
graph.off("cleared", schedule);
|
|
570
|
+
graph.off("edgesCleared", schedule);
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/camera-aspect.ts
|
|
575
|
+
/** Canvas aspect used by perspective cameras. Never 0. */
|
|
576
|
+
function containerAspect(width, height) {
|
|
577
|
+
return Math.max(width, 1) / Math.max(height, 1);
|
|
578
|
+
}
|
|
579
|
+
/** Keep a perspective camera from stretching on a non-square canvas. */
|
|
580
|
+
function applyPerspectiveContainerAspect(camera, width, height) {
|
|
581
|
+
camera.aspect = containerAspect(width, height);
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/compile-graph.ts
|
|
585
|
+
function assertFinitePosition(nodeId, axis, value) {
|
|
586
|
+
if (!Number.isFinite(value)) throw new Error(`Node "${nodeId}" has a non-finite ${axis} position`);
|
|
587
|
+
}
|
|
588
|
+
function assertNotEmpty(value, message) {
|
|
589
|
+
if (value.trim().length === 0) throw new Error(message);
|
|
590
|
+
}
|
|
591
|
+
function compilePresentation(kind, id, presentation) {
|
|
592
|
+
const prefix = `${kind} "${id}" presentation`;
|
|
593
|
+
assertNotEmpty(presentation.title, `${prefix} title must not be empty`);
|
|
594
|
+
if (presentation.subtitle !== void 0) assertNotEmpty(presentation.subtitle, `${prefix} subtitle must not be empty`);
|
|
595
|
+
const propertyIds = /* @__PURE__ */ new Set();
|
|
596
|
+
const properties = (presentation.properties ?? []).map((property) => {
|
|
597
|
+
assertNotEmpty(property.id, `${prefix} property id must not be empty`);
|
|
598
|
+
assertNotEmpty(property.label, `${prefix} property "${property.id}" label must not be empty`);
|
|
599
|
+
if (propertyIds.has(property.id)) throw new Error(`${prefix} has duplicate property id "${property.id}"`);
|
|
600
|
+
propertyIds.add(property.id);
|
|
601
|
+
if (typeof property.value === "number" && !Number.isFinite(property.value)) throw new Error(`${prefix} property "${property.id}" must have a finite numeric value`);
|
|
602
|
+
if (property.value !== null && ![
|
|
603
|
+
"boolean",
|
|
604
|
+
"number",
|
|
605
|
+
"string"
|
|
606
|
+
].includes(typeof property.value)) throw new Error(`${prefix} property "${property.id}" must have a serializable scalar value`);
|
|
607
|
+
return Object.freeze({ ...property });
|
|
608
|
+
});
|
|
609
|
+
const actionIds = /* @__PURE__ */ new Set();
|
|
610
|
+
const actions = (presentation.actions ?? []).map((action) => {
|
|
611
|
+
assertNotEmpty(action.id, `${prefix} action id must not be empty`);
|
|
612
|
+
assertNotEmpty(action.label, `${prefix} action "${action.id}" label must not be empty`);
|
|
613
|
+
if (action.disabled !== void 0 && typeof action.disabled !== "boolean") throw new Error(`${prefix} action "${action.id}" disabled must be a boolean`);
|
|
614
|
+
if (actionIds.has(action.id)) throw new Error(`${prefix} has duplicate action id "${action.id}"`);
|
|
615
|
+
actionIds.add(action.id);
|
|
616
|
+
return Object.freeze({ ...action });
|
|
617
|
+
});
|
|
618
|
+
return Object.freeze({
|
|
619
|
+
...presentation,
|
|
620
|
+
actions: Object.freeze(actions),
|
|
621
|
+
properties: Object.freeze(properties)
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function assertPositiveFiniteVisual(id, field, value) {
|
|
625
|
+
if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) throw new Error(`Node "${id}" visual must have a positive finite ${field}`);
|
|
626
|
+
}
|
|
627
|
+
function assertNonNegativeFiniteVisual(id, field, value) {
|
|
628
|
+
if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`Node "${id}" visual must have a finite non-negative ${field}`);
|
|
629
|
+
}
|
|
630
|
+
function compileNodeVisual(id, visual) {
|
|
631
|
+
assertPositiveFiniteVisual(id, "size", visual.size);
|
|
632
|
+
assertPositiveFiniteVisual(id, "width", visual.width);
|
|
633
|
+
assertPositiveFiniteVisual(id, "height", visual.height);
|
|
634
|
+
assertNonNegativeFiniteVisual(id, "strokeWidth", visual.strokeWidth);
|
|
635
|
+
if (visual.shape !== void 0) assertNodeShape(id, visual.shape);
|
|
636
|
+
return Object.freeze({ ...visual });
|
|
637
|
+
}
|
|
638
|
+
function compileEdgeVisual(id, visual) {
|
|
639
|
+
assertEdgeVisual(id, visual);
|
|
640
|
+
return Object.freeze({ ...visual });
|
|
641
|
+
}
|
|
642
|
+
/** Validates graph identity and compiles lookup-heavy objects into GPU-oriented position buffers. */
|
|
643
|
+
function compileGraph(data, visuals) {
|
|
644
|
+
const nodeIndices = /* @__PURE__ */ new Map();
|
|
645
|
+
const nodePositions = new Float32Array(data.nodes.length * 3);
|
|
646
|
+
const nodePresentations = /* @__PURE__ */ new Map();
|
|
647
|
+
const nodeVisuals = [];
|
|
648
|
+
for (const [index, node] of data.nodes.entries()) {
|
|
649
|
+
if (nodeIndices.has(node.id)) throw new Error(`Duplicate node id: "${node.id}"`);
|
|
650
|
+
assertFinitePosition(node.id, "x", node.position.x);
|
|
651
|
+
assertFinitePosition(node.id, "y", node.position.y);
|
|
652
|
+
assertFinitePosition(node.id, "z", node.position.z ?? 0);
|
|
653
|
+
const encoding = visuals?.node?.(node);
|
|
654
|
+
const visual = compileNodeVisual(node.id, encoding?.visual ?? {
|
|
655
|
+
color: node.color,
|
|
656
|
+
height: node.height,
|
|
657
|
+
shape: node.shape,
|
|
658
|
+
size: node.size,
|
|
659
|
+
strokeColor: node.strokeColor,
|
|
660
|
+
strokeWidth: node.strokeWidth,
|
|
661
|
+
width: node.width
|
|
662
|
+
});
|
|
663
|
+
nodeVisuals.push(visual);
|
|
664
|
+
if (encoding?.presentation) nodePresentations.set(node.id, compilePresentation("Node", node.id, encoding.presentation));
|
|
665
|
+
nodeIndices.set(node.id, index);
|
|
666
|
+
nodePositions.set([
|
|
667
|
+
node.position.x,
|
|
668
|
+
node.position.y,
|
|
669
|
+
node.position.z ?? 0
|
|
670
|
+
], index * 3);
|
|
671
|
+
}
|
|
672
|
+
const edgeIds = /* @__PURE__ */ new Set();
|
|
673
|
+
const edgeNodeIndices = new Uint32Array(data.edges.length * 2);
|
|
674
|
+
const edgePositions = new Float32Array(data.edges.length * 6);
|
|
675
|
+
const edgePresentations = /* @__PURE__ */ new Map();
|
|
676
|
+
const edgeVisuals = [];
|
|
677
|
+
const incidentEdgeIndices = Array.from({ length: data.nodes.length }, () => []);
|
|
678
|
+
for (const [index, edge] of data.edges.entries()) {
|
|
679
|
+
if (edgeIds.has(edge.id)) throw new Error(`Duplicate edge id: "${edge.id}"`);
|
|
680
|
+
edgeIds.add(edge.id);
|
|
681
|
+
const encoding = visuals?.edge?.(edge);
|
|
682
|
+
const visual = compileEdgeVisual(edge.id, {
|
|
683
|
+
color: edge.color,
|
|
684
|
+
width: edge.width,
|
|
685
|
+
opacity: edge.opacity,
|
|
686
|
+
style: edge.style,
|
|
687
|
+
marker: edge.marker,
|
|
688
|
+
markerSize: edge.markerSize,
|
|
689
|
+
markerEnd: edge.markerEnd,
|
|
690
|
+
path: edge.path,
|
|
691
|
+
controlPoints: edge.controlPoints,
|
|
692
|
+
...encoding?.visual
|
|
693
|
+
});
|
|
694
|
+
edgeVisuals.push(visual);
|
|
695
|
+
if (encoding?.presentation) edgePresentations.set(edge.id, compilePresentation("Edge", edge.id, encoding.presentation));
|
|
696
|
+
const sourceIndex = nodeIndices.get(edge.source);
|
|
697
|
+
const targetIndex = nodeIndices.get(edge.target);
|
|
698
|
+
if (sourceIndex === void 0) throw new Error(`Edge "${edge.id}" references missing source "${edge.source}"`);
|
|
699
|
+
if (targetIndex === void 0) throw new Error(`Edge "${edge.id}" references missing target "${edge.target}"`);
|
|
700
|
+
edgeNodeIndices.set([sourceIndex, targetIndex], index * 2);
|
|
701
|
+
incidentEdgeIndices[sourceIndex]?.push(index);
|
|
702
|
+
if (targetIndex !== sourceIndex) incidentEdgeIndices[targetIndex]?.push(index);
|
|
703
|
+
edgePositions.set(nodePositions.subarray(sourceIndex * 3, sourceIndex * 3 + 3), index * 6);
|
|
704
|
+
edgePositions.set(nodePositions.subarray(targetIndex * 3, targetIndex * 3 + 3), index * 6 + 3);
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
edgeNodeIndices,
|
|
708
|
+
edgePositions,
|
|
709
|
+
edgePresentations,
|
|
710
|
+
edgeVisuals,
|
|
711
|
+
incidentEdgeIndices,
|
|
712
|
+
nodeIds: data.nodes.map((node) => node.id),
|
|
713
|
+
nodeIndices,
|
|
714
|
+
nodePositions,
|
|
715
|
+
nodePresentations,
|
|
716
|
+
nodeVisuals
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/data-patch.ts
|
|
721
|
+
/** Merge additions and removals into a dense graph snapshot. Cascades edges whose endpoints vanish. */
|
|
722
|
+
function mergeDataPatch(currentNodes, currentEdges, patch) {
|
|
723
|
+
const removedNodes = new Set(patch.removedNodeIds ?? []);
|
|
724
|
+
const removedEdges = new Set(patch.removedEdgeIds ?? []);
|
|
725
|
+
const nodes = new Map(currentNodes.map((node) => [node.id, node]));
|
|
726
|
+
const edges = new Map(currentEdges.map((edge) => [edge.id, edge]));
|
|
727
|
+
for (const id of removedNodes) nodes.delete(id);
|
|
728
|
+
for (const id of removedEdges) edges.delete(id);
|
|
729
|
+
for (const node of patch.addedNodes ?? []) nodes.set(node.id, node);
|
|
730
|
+
for (const edge of patch.addedEdges ?? []) edges.set(edge.id, edge);
|
|
731
|
+
return {
|
|
732
|
+
nodes: [...nodes.values()],
|
|
733
|
+
edges: [...edges.values()].filter((edge) => nodes.has(edge.source) && nodes.has(edge.target))
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/** True when the merged live graph fits the already-allocated mesh capacities. */
|
|
737
|
+
function dataPatchFitsCapacity(merged, capacity) {
|
|
738
|
+
return merged.nodes.length <= capacity.nodes && merged.edges.length <= capacity.edges;
|
|
739
|
+
}
|
|
740
|
+
/** True when the patch is add-only and can grow dense arrays without rewriting survivors. */
|
|
741
|
+
function isAppendOnlyDataPatch(patch) {
|
|
742
|
+
return (patch.removedNodeIds?.length ?? 0) === 0 && (patch.removedEdgeIds?.length ?? 0) === 0;
|
|
743
|
+
}
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/edge-paths.ts
|
|
746
|
+
const AUTO_OFFSET_FRACTION = .18;
|
|
747
|
+
function segmentCountForPath(path, tier) {
|
|
748
|
+
if (tier === "overview") return 1;
|
|
749
|
+
const resolved = path ?? "straight";
|
|
750
|
+
if (resolved === "quadratic") return 16;
|
|
751
|
+
if (resolved === "cubic") return 24;
|
|
752
|
+
return 1;
|
|
753
|
+
}
|
|
754
|
+
function autoControlPoints(path, x1, y1, z1, x2, y2, z2) {
|
|
755
|
+
const dx = x2 - x1;
|
|
756
|
+
const dy = y2 - y1;
|
|
757
|
+
const length = Math.hypot(dx, dy);
|
|
758
|
+
const offset = length * AUTO_OFFSET_FRACTION;
|
|
759
|
+
const nx = length > 0 ? -dy / length : 0;
|
|
760
|
+
const ny = length > 0 ? dx / length : 1;
|
|
761
|
+
if (path === "quadratic") return [{
|
|
762
|
+
x: (x1 + x2) / 2 + nx * offset,
|
|
763
|
+
y: (y1 + y2) / 2 + ny * offset,
|
|
764
|
+
z: (z1 + z2) / 2
|
|
765
|
+
}];
|
|
766
|
+
if (path === "cubic") return [{
|
|
767
|
+
x: x1 + dx / 3 + nx * offset,
|
|
768
|
+
y: y1 + dy / 3 + ny * offset,
|
|
769
|
+
z: z1 + (z2 - z1) / 3
|
|
770
|
+
}, {
|
|
771
|
+
x: x1 + dx * 2 / 3 - nx * offset,
|
|
772
|
+
y: y1 + dy * 2 / 3 - ny * offset,
|
|
773
|
+
z: z1 + (z2 - z1) * 2 / 3
|
|
774
|
+
}];
|
|
775
|
+
return [];
|
|
776
|
+
}
|
|
777
|
+
function lerp(a, b, t) {
|
|
778
|
+
return a + (b - a) * t;
|
|
779
|
+
}
|
|
780
|
+
function sampleQuadratic(x1, y1, z1, cx, cy, cz, x2, y2, z2, t) {
|
|
781
|
+
const u = 1 - t;
|
|
782
|
+
return {
|
|
783
|
+
x: u * u * x1 + 2 * u * t * cx + t * t * x2,
|
|
784
|
+
y: u * u * y1 + 2 * u * t * cy + t * t * y2,
|
|
785
|
+
z: u * u * z1 + 2 * u * t * cz + t * t * z2
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
function sampleCubic(x1, y1, z1, c1x, c1y, c1z, c2x, c2y, c2z, x2, y2, z2, t) {
|
|
789
|
+
const u = 1 - t;
|
|
790
|
+
return {
|
|
791
|
+
x: u * u * u * x1 + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x2,
|
|
792
|
+
y: u * u * u * y1 + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y2,
|
|
793
|
+
z: u * u * u * z1 + 3 * u * u * t * c1z + 3 * u * t * t * c2z + t * t * t * z2
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
function resolveControlPoints(path, controlPoints, x1, y1, z1, x2, y2, z2) {
|
|
797
|
+
if (path === "straight") return [];
|
|
798
|
+
if (controlPoints && controlPoints.length > 0) return controlPoints;
|
|
799
|
+
return autoControlPoints(path, x1, y1, z1, x2, y2, z2);
|
|
800
|
+
}
|
|
801
|
+
function sampleEdgePath(input) {
|
|
802
|
+
const path = input.tier === "overview" ? "straight" : input.path ?? "straight";
|
|
803
|
+
const segments = segmentCountForPath(path, input.tier);
|
|
804
|
+
if (path === "straight" || segments <= 1) return [{
|
|
805
|
+
x: input.x1,
|
|
806
|
+
y: input.y1,
|
|
807
|
+
z: input.z1
|
|
808
|
+
}, {
|
|
809
|
+
x: input.x2,
|
|
810
|
+
y: input.y2,
|
|
811
|
+
z: input.z2
|
|
812
|
+
}];
|
|
813
|
+
const controls = resolveControlPoints(path, input.controlPoints, input.x1, input.y1, input.z1, input.x2, input.y2, input.z2);
|
|
814
|
+
const points = [];
|
|
815
|
+
for (let index = 0; index <= segments; index += 1) {
|
|
816
|
+
const t = index / segments;
|
|
817
|
+
if (path === "quadratic") {
|
|
818
|
+
const control = controls[0] ?? {
|
|
819
|
+
x: lerp(input.x1, input.x2, .5),
|
|
820
|
+
y: lerp(input.y1, input.y2, .5),
|
|
821
|
+
z: lerp(input.z1, input.z2, .5)
|
|
822
|
+
};
|
|
823
|
+
points.push(sampleQuadratic(input.x1, input.y1, input.z1, control.x, control.y, control.z ?? 0, input.x2, input.y2, input.z2, t));
|
|
824
|
+
} else {
|
|
825
|
+
const c1 = controls[0] ?? {
|
|
826
|
+
x: lerp(input.x1, input.x2, 1 / 3),
|
|
827
|
+
y: lerp(input.y1, input.y2, 1 / 3),
|
|
828
|
+
z: lerp(input.z1, input.z2, 1 / 3)
|
|
829
|
+
};
|
|
830
|
+
const c2 = controls[1] ?? {
|
|
831
|
+
x: lerp(input.x1, input.x2, 2 / 3),
|
|
832
|
+
y: lerp(input.y1, input.y2, 2 / 3),
|
|
833
|
+
z: lerp(input.z1, input.z2, 2 / 3)
|
|
834
|
+
};
|
|
835
|
+
points.push(sampleCubic(input.x1, input.y1, input.z1, c1.x, c1.y, c1.z ?? 0, c2.x, c2.y, c2.z ?? 0, input.x2, input.y2, input.z2, t));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return points;
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/edge-materialize.ts
|
|
842
|
+
function packEdgeInstances(input) {
|
|
843
|
+
const segments = [];
|
|
844
|
+
const markers = [];
|
|
845
|
+
let truncated = false;
|
|
846
|
+
const attach = input.endpointAttach ?? "boundary";
|
|
847
|
+
const clearance = input.endpointClearance ?? .75;
|
|
848
|
+
for (const edgeIndex of input.edgeIndices) {
|
|
849
|
+
const visual = input.edgeVisuals[edgeIndex] ?? {};
|
|
850
|
+
const base = edgeIndex * 6;
|
|
851
|
+
let x1 = input.endpointPositions[base] ?? 0;
|
|
852
|
+
let y1 = input.endpointPositions[base + 1] ?? 0;
|
|
853
|
+
let z1 = input.endpointPositions[base + 2] ?? 0;
|
|
854
|
+
let x2 = input.endpointPositions[base + 3] ?? 0;
|
|
855
|
+
let y2 = input.endpointPositions[base + 4] ?? 0;
|
|
856
|
+
let z2 = input.endpointPositions[base + 5] ?? 0;
|
|
857
|
+
if (attach === "boundary" && input.nodeOutlines && input.edgeNodeIndices) {
|
|
858
|
+
const sourceIndex = input.edgeNodeIndices[edgeIndex * 2] ?? 0;
|
|
859
|
+
const targetIndex = input.edgeNodeIndices[edgeIndex * 2 + 1] ?? 0;
|
|
860
|
+
const sourceOutline = input.nodeOutlines[sourceIndex] ?? {};
|
|
861
|
+
const targetOutline = input.nodeOutlines[targetIndex] ?? {};
|
|
862
|
+
const trimmed = trimEdgeEndpoints({
|
|
863
|
+
attach,
|
|
864
|
+
clearance,
|
|
865
|
+
source: {
|
|
866
|
+
x: x1,
|
|
867
|
+
y: y1,
|
|
868
|
+
z: z1
|
|
869
|
+
},
|
|
870
|
+
target: {
|
|
871
|
+
x: x2,
|
|
872
|
+
y: y2,
|
|
873
|
+
z: z2
|
|
874
|
+
},
|
|
875
|
+
sourceOutline,
|
|
876
|
+
targetOutline
|
|
877
|
+
});
|
|
878
|
+
x1 = trimmed.source.x;
|
|
879
|
+
y1 = trimmed.source.y;
|
|
880
|
+
z1 = trimmed.source.z ?? 0;
|
|
881
|
+
x2 = trimmed.target.x;
|
|
882
|
+
y2 = trimmed.target.y;
|
|
883
|
+
z2 = trimmed.target.z ?? 0;
|
|
884
|
+
}
|
|
885
|
+
const color = visual.color ?? input.defaults.color;
|
|
886
|
+
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
|
+
const style = input.tier === "overview" ? "solid" : visual.style ?? "solid";
|
|
889
|
+
const points = sampleEdgePath({
|
|
890
|
+
path: visual.path,
|
|
891
|
+
controlPoints: visual.controlPoints,
|
|
892
|
+
tier: input.tier,
|
|
893
|
+
x1,
|
|
894
|
+
y1,
|
|
895
|
+
z1,
|
|
896
|
+
x2,
|
|
897
|
+
y2,
|
|
898
|
+
z2
|
|
899
|
+
});
|
|
900
|
+
const segmentSpans = Math.max(0, points.length - 1);
|
|
901
|
+
if (input.maxSegments !== void 0 && segments.length + segmentSpans > input.maxSegments) {
|
|
902
|
+
truncated = true;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
for (let index = 0; index < segmentSpans; index += 1) {
|
|
906
|
+
const from = points[index];
|
|
907
|
+
const to = points[index + 1];
|
|
908
|
+
if (!from || !to) continue;
|
|
909
|
+
segments.push({
|
|
910
|
+
edgeIndex,
|
|
911
|
+
x1: from.x,
|
|
912
|
+
y1: from.y,
|
|
913
|
+
z1: from.z ?? 0,
|
|
914
|
+
x2: to.x,
|
|
915
|
+
y2: to.y,
|
|
916
|
+
z2: to.z ?? 0,
|
|
917
|
+
color,
|
|
918
|
+
width,
|
|
919
|
+
opacity,
|
|
920
|
+
style
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (input.tier === "overview") continue;
|
|
924
|
+
if ((visual.marker ?? "none") === "none") continue;
|
|
925
|
+
const markerSize = (visual.markerSize ?? 1) * width;
|
|
926
|
+
const ends = visual.markerEnd ?? "target";
|
|
927
|
+
const first = points[0];
|
|
928
|
+
const second = points[1] ?? points[0];
|
|
929
|
+
const last = points.at(-1);
|
|
930
|
+
const previous = points.at(-2) ?? last;
|
|
931
|
+
if (!first || !second || !last || !previous) continue;
|
|
932
|
+
const add = (end) => {
|
|
933
|
+
const fromSource = end === "source";
|
|
934
|
+
markers.push({
|
|
935
|
+
edgeIndex,
|
|
936
|
+
end,
|
|
937
|
+
x: fromSource ? first.x : last.x,
|
|
938
|
+
y: fromSource ? first.y : last.y,
|
|
939
|
+
z: fromSource ? first.z ?? 0 : last.z ?? 0,
|
|
940
|
+
dx: fromSource ? first.x - second.x : last.x - previous.x,
|
|
941
|
+
dy: fromSource ? first.y - second.y : last.y - previous.y,
|
|
942
|
+
dz: fromSource ? (first.z ?? 0) - (second.z ?? 0) : (last.z ?? 0) - (previous.z ?? 0),
|
|
943
|
+
color,
|
|
944
|
+
size: markerSize,
|
|
945
|
+
opacity
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
if (ends === "source" || ends === "both") add("source");
|
|
949
|
+
if (ends === "target" || ends === "both") add("target");
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
segments,
|
|
953
|
+
markers,
|
|
954
|
+
truncated
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** Map existing diagnostics lodLevel into the edge visual tier. */
|
|
958
|
+
function edgeTierFromDiagnosticsLod(lodLevel) {
|
|
959
|
+
return lodLevel === "detail" ? "detail" : "overview";
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/edge-picking.ts
|
|
963
|
+
/** Squared distance from a point to the closest point on a finite segment. */
|
|
964
|
+
function distanceSquaredToSegment2d(px, py, x1, y1, x2, y2) {
|
|
965
|
+
const dx = x2 - x1;
|
|
966
|
+
const dy = y2 - y1;
|
|
967
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
968
|
+
if (lengthSquared === 0) {
|
|
969
|
+
const ox = px - x1;
|
|
970
|
+
const oy = py - y1;
|
|
971
|
+
return ox * ox + oy * oy;
|
|
972
|
+
}
|
|
973
|
+
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared));
|
|
974
|
+
const closestX = x1 + t * dx;
|
|
975
|
+
const closestY = y1 + t * dy;
|
|
976
|
+
const ox = px - closestX;
|
|
977
|
+
const oy = py - closestY;
|
|
978
|
+
return ox * ox + oy * oy;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Returns the edge index of the closest segment within its hit slop, or null.
|
|
982
|
+
* When several edges qualify, the nearest wins; ties keep the first encountered edge index.
|
|
983
|
+
*/
|
|
984
|
+
function pickClosestEdgeIndex(point, segments) {
|
|
985
|
+
let nearestEdgeIndex = null;
|
|
986
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
987
|
+
for (const segment of segments) {
|
|
988
|
+
const distanceSquared = distanceSquaredToSegment2d(point.x, point.y, segment.x1, segment.y1, segment.x2, segment.y2);
|
|
989
|
+
const slop = segment.hitSlop;
|
|
990
|
+
if (distanceSquared > slop * slop) continue;
|
|
991
|
+
if (distanceSquared < nearestDistance) {
|
|
992
|
+
nearestDistance = distanceSquared;
|
|
993
|
+
nearestEdgeIndex = segment.edgeIndex;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return nearestEdgeIndex;
|
|
997
|
+
}
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/edge-rendering.ts
|
|
1000
|
+
const vertexShader$1 = `
|
|
1001
|
+
attribute float instanceKind;
|
|
1002
|
+
attribute vec3 instanceEndA;
|
|
1003
|
+
attribute vec3 instanceEndB;
|
|
1004
|
+
attribute vec4 instanceColor;
|
|
1005
|
+
attribute float instanceWidth;
|
|
1006
|
+
attribute float instanceStyle;
|
|
1007
|
+
|
|
1008
|
+
varying vec4 edgeColor;
|
|
1009
|
+
varying float edgeKind;
|
|
1010
|
+
varying float edgeStyle;
|
|
1011
|
+
varying float edgeDashT;
|
|
1012
|
+
varying vec2 edgeUv;
|
|
1013
|
+
|
|
1014
|
+
void main() {
|
|
1015
|
+
edgeColor = instanceColor;
|
|
1016
|
+
edgeKind = instanceKind;
|
|
1017
|
+
edgeStyle = instanceStyle;
|
|
1018
|
+
edgeUv = uv;
|
|
1019
|
+
edgeDashT = 0.0;
|
|
1020
|
+
|
|
1021
|
+
vec4 viewA = modelViewMatrix * vec4(instanceEndA, 1.0);
|
|
1022
|
+
vec4 viewB = modelViewMatrix * vec4(instanceEndB, 1.0);
|
|
1023
|
+
vec4 viewPosition;
|
|
1024
|
+
|
|
1025
|
+
if (instanceKind < 0.5) {
|
|
1026
|
+
// Segment: stretch the unit quad from EndA to EndB in view space.
|
|
1027
|
+
vec2 delta = viewB.xy - viewA.xy;
|
|
1028
|
+
float len = length(delta);
|
|
1029
|
+
vec2 dir = len > 0.0001 ? delta / len : vec2(1.0, 0.0);
|
|
1030
|
+
vec2 perp = vec2(-dir.y, dir.x);
|
|
1031
|
+
vec2 mid = (viewA.xy + viewB.xy) * 0.5;
|
|
1032
|
+
vec2 offset = dir * position.x * len + perp * position.y * instanceWidth;
|
|
1033
|
+
float depth = mix(viewA.z, viewB.z, position.x + 0.5);
|
|
1034
|
+
viewPosition = vec4(mid + offset, depth, 1.0);
|
|
1035
|
+
edgeDashT = (position.x + 0.5) * len / max(instanceWidth * 4.0, 0.0001);
|
|
1036
|
+
} else {
|
|
1037
|
+
// Marker: oriented triangle with its apex pinned to EndA, pointing
|
|
1038
|
+
// along EndA -> EndB (the direction of travel), base trailing behind.
|
|
1039
|
+
vec2 dirVec = viewB.xy - viewA.xy;
|
|
1040
|
+
float dirLen = length(dirVec);
|
|
1041
|
+
vec2 dir = dirLen > 0.0001 ? dirVec / dirLen : vec2(1.0, 0.0);
|
|
1042
|
+
vec2 perp = vec2(-dir.y, dir.x);
|
|
1043
|
+
float t = position.x + 0.5;
|
|
1044
|
+
vec2 offset = dir * instanceWidth * (t - 1.0) + perp * position.y * instanceWidth;
|
|
1045
|
+
viewPosition = vec4(viewA.xy + offset, viewA.z, 1.0);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
gl_Position = projectionMatrix * viewPosition;
|
|
1049
|
+
}
|
|
1050
|
+
`;
|
|
1051
|
+
const fragmentShader$1 = `
|
|
1052
|
+
varying vec4 edgeColor;
|
|
1053
|
+
varying float edgeKind;
|
|
1054
|
+
varying float edgeStyle;
|
|
1055
|
+
varying float edgeDashT;
|
|
1056
|
+
varying vec2 edgeUv;
|
|
1057
|
+
|
|
1058
|
+
void main() {
|
|
1059
|
+
if (edgeKind < 0.5) {
|
|
1060
|
+
if (edgeStyle > 0.5) {
|
|
1061
|
+
float cell = fract(edgeDashT);
|
|
1062
|
+
if (edgeStyle < 1.5) {
|
|
1063
|
+
if (cell > 0.6) discard; // dashed
|
|
1064
|
+
} else {
|
|
1065
|
+
if (abs(cell - 0.5) > 0.15) discard; // dotted
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
// Triangle SDF: apex at edgeUv.x == 1, full width base at edgeUv.x == 0.
|
|
1070
|
+
float halfWidth = (1.0 - edgeUv.x) * 0.5;
|
|
1071
|
+
if (abs(edgeUv.y - 0.5) > halfWidth) discard;
|
|
1072
|
+
}
|
|
1073
|
+
if (edgeColor.a <= 0.0) discard;
|
|
1074
|
+
gl_FragColor = edgeColor;
|
|
1075
|
+
}
|
|
1076
|
+
`;
|
|
1077
|
+
function createEdgeGeometry(capacity) {
|
|
1078
|
+
const geometry = new PlaneGeometry(1, 1);
|
|
1079
|
+
geometry.setAttribute("instanceKind", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1080
|
+
geometry.setAttribute("instanceEndA", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
|
|
1081
|
+
geometry.setAttribute("instanceEndB", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
|
|
1082
|
+
geometry.setAttribute("instanceColor", new InstancedBufferAttribute(new Float32Array(capacity * 4), 4));
|
|
1083
|
+
geometry.setAttribute("instanceWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1084
|
+
geometry.setAttribute("instanceStyle", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1085
|
+
return geometry;
|
|
1086
|
+
}
|
|
1087
|
+
function createEdgeMaterial(depthTest) {
|
|
1088
|
+
return new ShaderMaterial({
|
|
1089
|
+
depthTest,
|
|
1090
|
+
depthWrite: depthTest,
|
|
1091
|
+
fragmentShader: fragmentShader$1,
|
|
1092
|
+
transparent: true,
|
|
1093
|
+
vertexShader: vertexShader$1
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
function getInstancedAttribute(geometry, name) {
|
|
1097
|
+
const attribute = geometry.getAttribute(name);
|
|
1098
|
+
if (!(attribute instanceof InstancedBufferAttribute)) throw new Error(`edge geometry is missing the "${name}" instanced attribute`);
|
|
1099
|
+
return attribute;
|
|
1100
|
+
}
|
|
1101
|
+
function writeEdgeSegmentInstance(geometry, slot, segment) {
|
|
1102
|
+
const kind = getInstancedAttribute(geometry, "instanceKind");
|
|
1103
|
+
const endA = getInstancedAttribute(geometry, "instanceEndA");
|
|
1104
|
+
const endB = getInstancedAttribute(geometry, "instanceEndB");
|
|
1105
|
+
const color = getInstancedAttribute(geometry, "instanceColor");
|
|
1106
|
+
const width = getInstancedAttribute(geometry, "instanceWidth");
|
|
1107
|
+
const style = getInstancedAttribute(geometry, "instanceStyle");
|
|
1108
|
+
kind.setX(slot, 0);
|
|
1109
|
+
endA.setXYZ(slot, segment.x1, segment.y1, segment.z1);
|
|
1110
|
+
endB.setXYZ(slot, segment.x2, segment.y2, segment.z2);
|
|
1111
|
+
const rgb = new Color(segment.color);
|
|
1112
|
+
color.setXYZW(slot, rgb.r, rgb.g, rgb.b, segment.opacity);
|
|
1113
|
+
width.setX(slot, segment.width);
|
|
1114
|
+
style.setX(slot, encodeEdgeStyle(segment.style));
|
|
1115
|
+
}
|
|
1116
|
+
function writeEdgeMarkerInstance(geometry, slot, marker) {
|
|
1117
|
+
const kind = getInstancedAttribute(geometry, "instanceKind");
|
|
1118
|
+
const endA = getInstancedAttribute(geometry, "instanceEndA");
|
|
1119
|
+
const endB = getInstancedAttribute(geometry, "instanceEndB");
|
|
1120
|
+
const color = getInstancedAttribute(geometry, "instanceColor");
|
|
1121
|
+
const width = getInstancedAttribute(geometry, "instanceWidth");
|
|
1122
|
+
const style = getInstancedAttribute(geometry, "instanceStyle");
|
|
1123
|
+
kind.setX(slot, 1);
|
|
1124
|
+
endA.setXYZ(slot, marker.x, marker.y, marker.z);
|
|
1125
|
+
endB.setXYZ(slot, marker.x + marker.dx, marker.y + marker.dy, marker.z + marker.dz);
|
|
1126
|
+
const rgb = new Color(marker.color);
|
|
1127
|
+
color.setXYZW(slot, rgb.r, rgb.g, rgb.b, marker.opacity);
|
|
1128
|
+
width.setX(slot, marker.size);
|
|
1129
|
+
style.setX(slot, 0);
|
|
1130
|
+
}
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/edge-viewport-patch.ts
|
|
1133
|
+
const EDGE_INSTANCE_ATTRIBUTES$1 = [
|
|
1134
|
+
"instanceKind",
|
|
1135
|
+
"instanceEndA",
|
|
1136
|
+
"instanceEndB",
|
|
1137
|
+
"instanceColor",
|
|
1138
|
+
"instanceWidth",
|
|
1139
|
+
"instanceStyle"
|
|
1140
|
+
];
|
|
1141
|
+
/** Records GPU slot ranges for each visible edge after a full viewport materialize. */
|
|
1142
|
+
function buildVisibleEdgeLayouts(segments, markers, segmentInstanceCount) {
|
|
1143
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
1144
|
+
for (const [slot, segment] of segments.entries()) {
|
|
1145
|
+
const layout = layouts.get(segment.edgeIndex) ?? {
|
|
1146
|
+
markerCount: 0,
|
|
1147
|
+
markerStart: segmentInstanceCount,
|
|
1148
|
+
segmentCount: 0,
|
|
1149
|
+
segmentStart: slot
|
|
1150
|
+
};
|
|
1151
|
+
if (layout.segmentCount === 0) layout.segmentStart = slot;
|
|
1152
|
+
layout.segmentCount += 1;
|
|
1153
|
+
layouts.set(segment.edgeIndex, layout);
|
|
1154
|
+
}
|
|
1155
|
+
for (const [offset, marker] of markers.entries()) {
|
|
1156
|
+
const slot = segmentInstanceCount + offset;
|
|
1157
|
+
const layout = layouts.get(marker.edgeIndex) ?? {
|
|
1158
|
+
markerCount: 0,
|
|
1159
|
+
markerStart: slot,
|
|
1160
|
+
segmentCount: 0,
|
|
1161
|
+
segmentStart: -1
|
|
1162
|
+
};
|
|
1163
|
+
if (layout.markerCount === 0) layout.markerStart = slot;
|
|
1164
|
+
layout.markerCount += 1;
|
|
1165
|
+
layouts.set(marker.edgeIndex, layout);
|
|
1166
|
+
}
|
|
1167
|
+
return layouts;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Rewrites GPU edge instances for moved endpoints without repacking the whole viewport.
|
|
1171
|
+
* Returns ok=false when any visible edge would change segment or marker counts.
|
|
1172
|
+
*/
|
|
1173
|
+
function patchVisibleEdgeInstances(geometry, input, pickableSegments) {
|
|
1174
|
+
const nextPickable = [...pickableSegments];
|
|
1175
|
+
let dirtySlotStart = Number.POSITIVE_INFINITY;
|
|
1176
|
+
let dirtySlotEnd = -1;
|
|
1177
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1178
|
+
for (const edgeIndex of input.changedEdgeIndices) {
|
|
1179
|
+
if (seen.has(edgeIndex)) continue;
|
|
1180
|
+
seen.add(edgeIndex);
|
|
1181
|
+
const layout = input.layouts.get(edgeIndex);
|
|
1182
|
+
if (!layout || layout.segmentCount === 0 || layout.segmentStart < 0) continue;
|
|
1183
|
+
const packed = packEdgeInstances({
|
|
1184
|
+
edgeIndices: [edgeIndex],
|
|
1185
|
+
edgeVisuals: input.edgeVisuals,
|
|
1186
|
+
endpointPositions: input.endpointPositions,
|
|
1187
|
+
defaults: input.defaults,
|
|
1188
|
+
tier: input.tier,
|
|
1189
|
+
endpointAttach: input.endpointAttach,
|
|
1190
|
+
edgeNodeIndices: input.edgeNodeIndices,
|
|
1191
|
+
nodeOutlines: input.nodeOutlines
|
|
1192
|
+
});
|
|
1193
|
+
if (packed.segments.length !== layout.segmentCount || packed.markers.length !== layout.markerCount) return {
|
|
1194
|
+
dirtySlotCount: 0,
|
|
1195
|
+
dirtySlotStart: 0,
|
|
1196
|
+
ok: false,
|
|
1197
|
+
pickableSegments: nextPickable
|
|
1198
|
+
};
|
|
1199
|
+
for (const [offset, segment] of packed.segments.entries()) {
|
|
1200
|
+
const slot = layout.segmentStart + offset;
|
|
1201
|
+
writeEdgeSegmentInstance(geometry, slot, segment);
|
|
1202
|
+
nextPickable[slot] = {
|
|
1203
|
+
edgeIndex,
|
|
1204
|
+
hitSlop: Math.max(input.minHitSlop, segment.width * input.worldPerPixel / 2 + 2 * input.worldPerPixel),
|
|
1205
|
+
x1: segment.x1,
|
|
1206
|
+
x2: segment.x2,
|
|
1207
|
+
y1: segment.y1,
|
|
1208
|
+
y2: segment.y2
|
|
1209
|
+
};
|
|
1210
|
+
dirtySlotStart = Math.min(dirtySlotStart, slot);
|
|
1211
|
+
dirtySlotEnd = Math.max(dirtySlotEnd, slot);
|
|
1212
|
+
}
|
|
1213
|
+
for (const [offset, marker] of packed.markers.entries()) {
|
|
1214
|
+
const slot = layout.markerStart + offset;
|
|
1215
|
+
writeEdgeMarkerInstance(geometry, slot, marker);
|
|
1216
|
+
dirtySlotStart = Math.min(dirtySlotStart, slot);
|
|
1217
|
+
dirtySlotEnd = Math.max(dirtySlotEnd, slot);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (dirtySlotEnd < dirtySlotStart) return {
|
|
1221
|
+
dirtySlotCount: 0,
|
|
1222
|
+
dirtySlotStart: 0,
|
|
1223
|
+
ok: true,
|
|
1224
|
+
pickableSegments: nextPickable
|
|
1225
|
+
};
|
|
1226
|
+
for (const name of EDGE_INSTANCE_ATTRIBUTES$1) {
|
|
1227
|
+
const attribute = geometry.getAttribute(name);
|
|
1228
|
+
attribute.clearUpdateRanges();
|
|
1229
|
+
attribute.addUpdateRange(dirtySlotStart * attribute.itemSize, (dirtySlotEnd - dirtySlotStart + 1) * attribute.itemSize);
|
|
1230
|
+
attribute.needsUpdate = true;
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
dirtySlotCount: dirtySlotEnd - dirtySlotStart + 1,
|
|
1234
|
+
dirtySlotStart,
|
|
1235
|
+
ok: true,
|
|
1236
|
+
pickableSegments: nextPickable
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
//#endregion
|
|
1240
|
+
//#region src/instance-color-ranges.ts
|
|
1241
|
+
/**
|
|
1242
|
+
* Marks RGB update ranges for visible instance slots.
|
|
1243
|
+
* Returns how many slots were marked.
|
|
1244
|
+
*/
|
|
1245
|
+
function markInstanceColorSlots(attribute, slots) {
|
|
1246
|
+
attribute.clearUpdateRanges();
|
|
1247
|
+
let count = 0;
|
|
1248
|
+
for (const slot of slots) {
|
|
1249
|
+
if (!Number.isSafeInteger(slot) || slot < 0) continue;
|
|
1250
|
+
attribute.addUpdateRange(slot * 3, 3);
|
|
1251
|
+
count += 1;
|
|
1252
|
+
}
|
|
1253
|
+
if (count > 0) attribute.needsUpdate = true;
|
|
1254
|
+
return count;
|
|
1255
|
+
}
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/layout-positions.ts
|
|
1258
|
+
/** Validates worker-friendly XYZ layout buffers before positions reach the renderer. */
|
|
1259
|
+
function prepareLayoutPositions(nodes, nodeIndices, layout) {
|
|
1260
|
+
if (layout.positions.length !== layout.nodeIds.length * 3) throw new Error("Layout positions must contain one XYZ triplet per node ID");
|
|
1261
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1262
|
+
return layout.nodeIds.map((id, index) => {
|
|
1263
|
+
if (seen.has(id)) throw new Error(`Duplicate layout node ID: "${id}"`);
|
|
1264
|
+
seen.add(id);
|
|
1265
|
+
const nodeIndex = nodeIndices.get(id);
|
|
1266
|
+
if (nodeIndex === void 0) throw new Error(`Cannot position missing node: "${id}"`);
|
|
1267
|
+
const current = nodes[nodeIndex];
|
|
1268
|
+
if (!current) throw new Error(`Cannot position missing node: "${id}"`);
|
|
1269
|
+
const offset = index * 3;
|
|
1270
|
+
const position = {
|
|
1271
|
+
x: layout.positions[offset] ?? NaN,
|
|
1272
|
+
y: layout.positions[offset + 1] ?? NaN,
|
|
1273
|
+
z: layout.positions[offset + 2] ?? NaN
|
|
1274
|
+
};
|
|
1275
|
+
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
|
+
return {
|
|
1277
|
+
colorChanged: false,
|
|
1278
|
+
index: nodeIndex,
|
|
1279
|
+
next: {
|
|
1280
|
+
...current,
|
|
1281
|
+
position
|
|
1282
|
+
},
|
|
1283
|
+
positionChanged: true,
|
|
1284
|
+
shapeChanged: false,
|
|
1285
|
+
sizeChanged: false,
|
|
1286
|
+
strokeChanged: false
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/node-rendering.ts
|
|
1292
|
+
const vertexShader = `
|
|
1293
|
+
attribute float instanceShape;
|
|
1294
|
+
attribute float instanceStrokeWidth;
|
|
1295
|
+
attribute vec3 instanceStrokeColor;
|
|
1296
|
+
varying vec3 nodeColor;
|
|
1297
|
+
varying vec2 nodePoint;
|
|
1298
|
+
varying float nodeShape;
|
|
1299
|
+
varying float nodeStrokeWidth;
|
|
1300
|
+
varying vec3 nodeStrokeColor;
|
|
1301
|
+
|
|
1302
|
+
void main() {
|
|
1303
|
+
nodeColor = instanceColor;
|
|
1304
|
+
nodePoint = position.xy;
|
|
1305
|
+
nodeShape = instanceShape;
|
|
1306
|
+
nodeStrokeWidth = instanceStrokeWidth;
|
|
1307
|
+
nodeStrokeColor = instanceStrokeColor;
|
|
1308
|
+
|
|
1309
|
+
vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
|
1310
|
+
float scaleX = length(instanceMatrix[0].xyz);
|
|
1311
|
+
float scaleY = length(instanceMatrix[1].xyz);
|
|
1312
|
+
center.xy += position.xy * vec2(scaleX, scaleY);
|
|
1313
|
+
gl_Position = projectionMatrix * center;
|
|
1314
|
+
}
|
|
1315
|
+
`;
|
|
1316
|
+
const fragmentShader = `
|
|
1317
|
+
varying vec3 nodeColor;
|
|
1318
|
+
varying vec2 nodePoint;
|
|
1319
|
+
varying float nodeShape;
|
|
1320
|
+
varying float nodeStrokeWidth;
|
|
1321
|
+
varying vec3 nodeStrokeColor;
|
|
1322
|
+
|
|
1323
|
+
// Apothem of a regular hexagon whose pointy left/right vertices reach x = ±1 (matches node-shapes.ts).
|
|
1324
|
+
const float HEXAGON_APOTHEM = 0.8660254037844386;
|
|
1325
|
+
// Y of the upward triangle's flat base, apex at (0, 1) (matches node-shapes.ts).
|
|
1326
|
+
const float TRIANGLE_BASE_Y = -0.7320508075688772;
|
|
1327
|
+
// Half-height of the horizontal pill's rounded caps, in unit space (matches node-shapes.ts).
|
|
1328
|
+
const float PILL_CAP_RADIUS = 0.5;
|
|
1329
|
+
// Half-length of the pill's straight segment, in unit space (matches node-shapes.ts).
|
|
1330
|
+
const float PILL_HALF_LENGTH = 0.5;
|
|
1331
|
+
// Fraction of the rounded rectangle's half-extent used as the corner radius (matches node-shapes.ts).
|
|
1332
|
+
const float ROUNDED_CORNER_RADIUS = 0.25;
|
|
1333
|
+
|
|
1334
|
+
float hexagonDistance(vec2 p) {
|
|
1335
|
+
vec2 a = abs(p);
|
|
1336
|
+
return HEXAGON_APOTHEM - max(a.y, a.x * HEXAGON_APOTHEM + a.y * 0.5);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Signed distance to an upward-pointing equilateral triangle, positive inside.
|
|
1340
|
+
float triangleDistance(vec2 p) {
|
|
1341
|
+
vec2 apex = vec2(0.0, 1.0);
|
|
1342
|
+
vec2 right = vec2(1.0, TRIANGLE_BASE_Y);
|
|
1343
|
+
vec2 left = vec2(-1.0, TRIANGLE_BASE_Y);
|
|
1344
|
+
vec2 e0 = right - apex;
|
|
1345
|
+
vec2 e1 = left - right;
|
|
1346
|
+
vec2 e2 = apex - left;
|
|
1347
|
+
vec2 v0 = p - apex;
|
|
1348
|
+
vec2 v1 = p - right;
|
|
1349
|
+
vec2 v2 = p - left;
|
|
1350
|
+
vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0);
|
|
1351
|
+
vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0);
|
|
1352
|
+
vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0);
|
|
1353
|
+
float s = sign(e0.x * e2.y - e0.y * e2.x);
|
|
1354
|
+
vec2 d0 = vec2(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x));
|
|
1355
|
+
vec2 d1 = vec2(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x));
|
|
1356
|
+
vec2 d2 = vec2(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x));
|
|
1357
|
+
vec2 d = min(min(d0, d1), d2);
|
|
1358
|
+
return sqrt(d.x) * sign(d.y);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
float pillDistance(vec2 p) {
|
|
1362
|
+
float clampedX = clamp(p.x, -PILL_HALF_LENGTH, PILL_HALF_LENGTH);
|
|
1363
|
+
return PILL_CAP_RADIUS - length(vec2(p.x - clampedX, p.y));
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
float roundedDistance(vec2 p) {
|
|
1367
|
+
vec2 outsideOffset = abs(p) - vec2(1.0 - ROUNDED_CORNER_RADIUS);
|
|
1368
|
+
vec2 outside = max(outsideOffset, 0.0);
|
|
1369
|
+
float outsideDistance = length(outside) + min(max(outsideOffset.x, outsideOffset.y), 0.0) - ROUNDED_CORNER_RADIUS;
|
|
1370
|
+
return -outsideDistance;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
void main() {
|
|
1374
|
+
float distanceToEdge;
|
|
1375
|
+
if (nodeShape < 0.5) {
|
|
1376
|
+
distanceToEdge = 1.0 - length(nodePoint);
|
|
1377
|
+
} else if (nodeShape < 1.5) {
|
|
1378
|
+
distanceToEdge = 1.0 - max(abs(nodePoint.x), abs(nodePoint.y));
|
|
1379
|
+
} else if (nodeShape < 2.5) {
|
|
1380
|
+
distanceToEdge = 1.0 - abs(nodePoint.x) - abs(nodePoint.y);
|
|
1381
|
+
} else if (nodeShape < 3.5) {
|
|
1382
|
+
distanceToEdge = hexagonDistance(nodePoint);
|
|
1383
|
+
} else if (nodeShape < 4.5) {
|
|
1384
|
+
distanceToEdge = triangleDistance(nodePoint);
|
|
1385
|
+
} else if (nodeShape < 5.5) {
|
|
1386
|
+
distanceToEdge = pillDistance(nodePoint);
|
|
1387
|
+
} else {
|
|
1388
|
+
distanceToEdge = roundedDistance(nodePoint);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
float outerAlpha = smoothstep(0.0, fwidth(distanceToEdge), distanceToEdge);
|
|
1392
|
+
if (outerAlpha <= 0.0) discard;
|
|
1393
|
+
|
|
1394
|
+
vec3 fillColor = nodeColor;
|
|
1395
|
+
if (nodeStrokeWidth > 0.0) {
|
|
1396
|
+
// Stroke band occupies [outer edge, outer edge - nodeStrokeWidth]; below that, the fill takes over.
|
|
1397
|
+
float innerEdge = distanceToEdge - nodeStrokeWidth;
|
|
1398
|
+
float strokeToFill = smoothstep(0.0, fwidth(innerEdge), innerEdge);
|
|
1399
|
+
fillColor = mix(nodeStrokeColor, nodeColor, strokeToFill);
|
|
1400
|
+
}
|
|
1401
|
+
gl_FragColor = vec4(fillColor, outerAlpha);
|
|
1402
|
+
}
|
|
1403
|
+
`;
|
|
1404
|
+
function createNodeGeometry(capacity) {
|
|
1405
|
+
const geometry = new PlaneGeometry(2, 2);
|
|
1406
|
+
geometry.setAttribute("instanceShape", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1407
|
+
geometry.setAttribute("instanceStrokeWidth", new InstancedBufferAttribute(new Float32Array(capacity), 1));
|
|
1408
|
+
geometry.setAttribute("instanceStrokeColor", new InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
|
|
1409
|
+
return geometry;
|
|
1410
|
+
}
|
|
1411
|
+
/** Allocate InstancedMesh.instanceColor before first compile so Three injects the attribute. */
|
|
1412
|
+
function allocateNodeInstanceColors(mesh, capacity) {
|
|
1413
|
+
mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
|
|
1414
|
+
}
|
|
1415
|
+
function createNodeMaterial(depthTest) {
|
|
1416
|
+
return new ShaderMaterial({
|
|
1417
|
+
depthTest,
|
|
1418
|
+
depthWrite: depthTest,
|
|
1419
|
+
fragmentShader,
|
|
1420
|
+
transparent: true,
|
|
1421
|
+
vertexShader
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
function setNodeShapeAt(attribute, index, shape) {
|
|
1425
|
+
attribute.setX(index, encodeNodeShape(shape));
|
|
1426
|
+
}
|
|
1427
|
+
function setNodeStrokeAt(strokeWidthAttribute, strokeColorAttribute, index, { strokeColor, strokeWidthUv }) {
|
|
1428
|
+
strokeWidthAttribute.setX(index, strokeWidthUv);
|
|
1429
|
+
const rgb = new Color(strokeColor);
|
|
1430
|
+
strokeColorAttribute.setXYZ(index, rgb.r, rgb.g, rgb.b);
|
|
1431
|
+
}
|
|
1432
|
+
//#endregion
|
|
1433
|
+
//#region src/node-stroke.ts
|
|
1434
|
+
/**
|
|
1435
|
+
* Ceiling for the stroke ring as a fraction of the shape's half-extent, so a large
|
|
1436
|
+
* `strokeWidth` in world units cannot swallow the fill entirely.
|
|
1437
|
+
*/
|
|
1438
|
+
const MAX_STROKE_WIDTH_UV = .45;
|
|
1439
|
+
/**
|
|
1440
|
+
* Resolves a node's stroke into shader-ready values: `strokeColor` falls back to the theme
|
|
1441
|
+
* default, and `strokeWidthUv` converts the world-unit `strokeWidth` into a fraction of
|
|
1442
|
+
* `max(width, height)` — the same unit space `containsNodePoint` and the node fragment shader
|
|
1443
|
+
* use — so the ring reads consistently regardless of the node's rendered size.
|
|
1444
|
+
*/
|
|
1445
|
+
function resolveNodeStroke(node, theme) {
|
|
1446
|
+
const strokeColor = node.strokeColor ?? theme.nodeStroke;
|
|
1447
|
+
const strokeWidth = node.strokeWidth ?? 0;
|
|
1448
|
+
if (strokeWidth <= 0) return {
|
|
1449
|
+
strokeColor,
|
|
1450
|
+
strokeWidthUv: 0
|
|
1451
|
+
};
|
|
1452
|
+
const { height, width } = resolveNodeAxes({
|
|
1453
|
+
height: node.height,
|
|
1454
|
+
nodeId: node.id,
|
|
1455
|
+
size: node.size,
|
|
1456
|
+
width: node.width
|
|
1457
|
+
});
|
|
1458
|
+
return {
|
|
1459
|
+
strokeColor,
|
|
1460
|
+
strokeWidthUv: Math.min(strokeWidth / Math.max(width, height), MAX_STROKE_WIDTH_UV)
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
//#endregion
|
|
1464
|
+
//#region src/node-updates.ts
|
|
1465
|
+
function assertValidNode(node) {
|
|
1466
|
+
for (const [axis, value] of [
|
|
1467
|
+
["x", node.position.x],
|
|
1468
|
+
["y", node.position.y],
|
|
1469
|
+
["z", node.position.z ?? 0]
|
|
1470
|
+
]) if (!Number.isFinite(value)) throw new Error(`Node "${node.id}" has a non-finite ${axis} position`);
|
|
1471
|
+
resolveNodeAxes({
|
|
1472
|
+
height: node.height,
|
|
1473
|
+
nodeId: node.id,
|
|
1474
|
+
size: node.size,
|
|
1475
|
+
width: node.width
|
|
1476
|
+
});
|
|
1477
|
+
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
|
+
if (node.shape !== void 0) assertNodeShape(node.id, node.shape);
|
|
1479
|
+
}
|
|
1480
|
+
/** Validates a complete update batch before the renderer mutates CPU or GPU state. */
|
|
1481
|
+
function prepareNodeUpdates(nodes, nodeIndices, updates) {
|
|
1482
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1483
|
+
return updates.map((update) => {
|
|
1484
|
+
if (seen.has(update.id)) throw new Error(`Duplicate node update: "${update.id}"`);
|
|
1485
|
+
seen.add(update.id);
|
|
1486
|
+
const index = nodeIndices.get(update.id);
|
|
1487
|
+
if (index === void 0) throw new Error(`Cannot update missing node: "${update.id}"`);
|
|
1488
|
+
const current = nodes[index];
|
|
1489
|
+
if (!current) throw new Error(`Cannot update missing node: "${update.id}"`);
|
|
1490
|
+
const next = {
|
|
1491
|
+
...current,
|
|
1492
|
+
...Object.hasOwn(update, "color") ? { color: update.color } : {},
|
|
1493
|
+
...update.position ? { position: { ...update.position } } : {},
|
|
1494
|
+
...Object.hasOwn(update, "shape") ? { shape: update.shape } : {},
|
|
1495
|
+
...Object.hasOwn(update, "size") ? { size: update.size } : {},
|
|
1496
|
+
...Object.hasOwn(update, "width") ? { width: update.width } : {},
|
|
1497
|
+
...Object.hasOwn(update, "height") ? { height: update.height } : {},
|
|
1498
|
+
...Object.hasOwn(update, "strokeWidth") ? { strokeWidth: update.strokeWidth } : {},
|
|
1499
|
+
...Object.hasOwn(update, "strokeColor") ? { strokeColor: update.strokeColor } : {}
|
|
1500
|
+
};
|
|
1501
|
+
assertValidNode(next);
|
|
1502
|
+
return {
|
|
1503
|
+
colorChanged: Object.hasOwn(update, "color"),
|
|
1504
|
+
index,
|
|
1505
|
+
next,
|
|
1506
|
+
positionChanged: update.position !== void 0,
|
|
1507
|
+
shapeChanged: Object.hasOwn(update, "shape"),
|
|
1508
|
+
sizeChanged: Object.hasOwn(update, "size") || Object.hasOwn(update, "width") || Object.hasOwn(update, "height"),
|
|
1509
|
+
strokeChanged: Object.hasOwn(update, "strokeWidth") || Object.hasOwn(update, "strokeColor")
|
|
1510
|
+
};
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/label-budget.ts
|
|
1515
|
+
/** Picks visible label ids by importance within a hard DOM budget. */
|
|
1516
|
+
function selectBudgetedLabelIds({ candidates, maxLabels }) {
|
|
1517
|
+
if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("maxLabels must be a non-negative integer.");
|
|
1518
|
+
return candidates.filter((candidate) => candidate.visible).sort((left, right) => {
|
|
1519
|
+
if (right.importance !== left.importance) return right.importance - left.importance;
|
|
1520
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
1521
|
+
}).slice(0, maxLabels).map((candidate) => candidate.id);
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Labels for a sparse focus set: keep focus ids that are visible, then optionally fill
|
|
1525
|
+
* remaining budget from other visible candidates by importance. Focus order is preserved.
|
|
1526
|
+
*/
|
|
1527
|
+
function selectFocusLabelIds({ focusIds, candidates, maxLabels, fill = "importance" }) {
|
|
1528
|
+
if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("maxLabels must be a non-negative integer.");
|
|
1529
|
+
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
1530
|
+
const chosen = [];
|
|
1531
|
+
const chosenSet = /* @__PURE__ */ new Set();
|
|
1532
|
+
for (const id of focusIds) {
|
|
1533
|
+
if (chosen.length >= maxLabels) break;
|
|
1534
|
+
if (chosenSet.has(id)) continue;
|
|
1535
|
+
if (!byId.get(id)?.visible) continue;
|
|
1536
|
+
chosen.push(id);
|
|
1537
|
+
chosenSet.add(id);
|
|
1538
|
+
}
|
|
1539
|
+
if (fill === "none" || chosen.length >= maxLabels) return chosen;
|
|
1540
|
+
const fillers = selectBudgetedLabelIds({
|
|
1541
|
+
candidates: candidates.filter((candidate) => !chosenSet.has(candidate.id)),
|
|
1542
|
+
maxLabels: maxLabels - chosen.length
|
|
1543
|
+
});
|
|
1544
|
+
return [...chosen, ...fillers];
|
|
1545
|
+
}
|
|
1546
|
+
/** Builds an ordered focus id list: selected, then hovered, then unique neighbors. */
|
|
1547
|
+
function orderFocusNodeIds(input) {
|
|
1548
|
+
const ordered = [];
|
|
1549
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1550
|
+
for (const id of [
|
|
1551
|
+
...input.selectedIds,
|
|
1552
|
+
...input.hoveredIds,
|
|
1553
|
+
...input.neighborIds
|
|
1554
|
+
]) {
|
|
1555
|
+
if (seen.has(id)) continue;
|
|
1556
|
+
seen.add(id);
|
|
1557
|
+
ordered.push(id);
|
|
1558
|
+
}
|
|
1559
|
+
return ordered;
|
|
1560
|
+
}
|
|
1561
|
+
//#endregion
|
|
1562
|
+
//#region src/overlay-budget.ts
|
|
1563
|
+
function uniqueIds(ids) {
|
|
1564
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1565
|
+
const ordered = [];
|
|
1566
|
+
for (const id of ids) {
|
|
1567
|
+
if (seen.has(id)) continue;
|
|
1568
|
+
seen.add(id);
|
|
1569
|
+
ordered.push(id);
|
|
1570
|
+
}
|
|
1571
|
+
return ordered;
|
|
1572
|
+
}
|
|
1573
|
+
function boundOverlayIds(ids, max, kind) {
|
|
1574
|
+
if (!Number.isSafeInteger(max) || max < 0) throw new Error(`Overlay ${kind} cap must be a non-negative integer.`);
|
|
1575
|
+
const list = uniqueIds(ids);
|
|
1576
|
+
if (list.length > max) throw new Error(`Overlay supports at most ${max} ${kind} (requested ${list.length}).`);
|
|
1577
|
+
return list;
|
|
1578
|
+
}
|
|
1579
|
+
function selectRichNodeIds(input) {
|
|
1580
|
+
if (!Number.isSafeInteger(input.maxRichNodes) || input.maxRichNodes < 0) throw new Error("maxRichNodes must be a non-negative integer.");
|
|
1581
|
+
return (input.policy === "selected" ? uniqueIds(input.selectedIds) : input.policy === "hovered" ? uniqueIds([...input.hoveredIds, ...input.selectedIds]) : uniqueIds([...input.selectedIds, ...input.hoveredIds])).slice(0, input.maxRichNodes);
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/overlay.ts
|
|
1585
|
+
function assertElement(element, kind) {
|
|
1586
|
+
if (element !== null && !(element instanceof HTMLElement)) throw new Error(`${kind} renderer must return an HTMLElement or null`);
|
|
1587
|
+
}
|
|
1588
|
+
function resolveLabelPolicy(options) {
|
|
1589
|
+
if (options.labelPolicy) return options.labelPolicy;
|
|
1590
|
+
return options.autoLabels === true ? "auto" : "manual";
|
|
1591
|
+
}
|
|
1592
|
+
/** A bounded DOM layer for labels, a focused-node toolbar, and optional rich HTML cards. */
|
|
1593
|
+
var GraphraumOverlay = class {
|
|
1594
|
+
graph;
|
|
1595
|
+
container;
|
|
1596
|
+
options;
|
|
1597
|
+
root = document.createElement("div");
|
|
1598
|
+
labels = /* @__PURE__ */ new Map();
|
|
1599
|
+
richNodes = /* @__PURE__ */ new Map();
|
|
1600
|
+
toolbar = null;
|
|
1601
|
+
toolbarNodeId = null;
|
|
1602
|
+
stopObservingView;
|
|
1603
|
+
stopObservingFocus;
|
|
1604
|
+
previousContainerPosition;
|
|
1605
|
+
labelPolicy;
|
|
1606
|
+
focusLabelFill;
|
|
1607
|
+
autoToolbar;
|
|
1608
|
+
autoRichNodes;
|
|
1609
|
+
maxLabels;
|
|
1610
|
+
maxRichNodes;
|
|
1611
|
+
constructor(graph, container, options) {
|
|
1612
|
+
this.graph = graph;
|
|
1613
|
+
this.container = container;
|
|
1614
|
+
this.options = options;
|
|
1615
|
+
const maxLabels = options.maxLabels ?? 50;
|
|
1616
|
+
if (!Number.isSafeInteger(maxLabels) || maxLabels < 0) throw new Error("Overlay maxLabels must be a non-negative integer.");
|
|
1617
|
+
this.maxLabels = maxLabels;
|
|
1618
|
+
const maxRichNodes = options.maxRichNodes ?? 24;
|
|
1619
|
+
if (!Number.isSafeInteger(maxRichNodes) || maxRichNodes < 0) throw new Error("Overlay maxRichNodes must be a non-negative integer.");
|
|
1620
|
+
this.maxRichNodes = maxRichNodes;
|
|
1621
|
+
this.labelPolicy = resolveLabelPolicy(options);
|
|
1622
|
+
this.focusLabelFill = options.focusLabelFill ?? "importance";
|
|
1623
|
+
this.autoToolbar = options.autoToolbar ?? false;
|
|
1624
|
+
this.autoRichNodes = options.autoRichNodes ?? false;
|
|
1625
|
+
this.root.className = `graphraum-overlay ${options.overlayClassName ?? ""}`.trim();
|
|
1626
|
+
this.root.style.cssText = "inset:0;pointer-events:none;position:absolute;";
|
|
1627
|
+
this.previousContainerPosition = getComputedStyle(this.container).position === "static" ? this.container.style.position : null;
|
|
1628
|
+
if (this.previousContainerPosition !== null) this.container.style.position = "relative";
|
|
1629
|
+
this.container.append(this.root);
|
|
1630
|
+
this.stopObservingView = graph.onViewChange(() => this.update());
|
|
1631
|
+
this.stopObservingFocus = graph.onFocusChange(() => this.update());
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Replaces the labeled node set. When `labelPolicy` is `auto` or `focus`, the next view/focus
|
|
1635
|
+
* change overwrites this list from the policy.
|
|
1636
|
+
*/
|
|
1637
|
+
setLabels(nodeIds) {
|
|
1638
|
+
this.syncLabels(boundOverlayIds(nodeIds, this.maxLabels, "labels"));
|
|
1639
|
+
this.updatePositions();
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Replaces the rich HTML node set. When `autoRichNodes` is set, the next focus change
|
|
1643
|
+
* overwrites this list from the policy.
|
|
1644
|
+
*/
|
|
1645
|
+
setRichNodes(nodeIds) {
|
|
1646
|
+
if (this.autoRichNodes) return;
|
|
1647
|
+
this.syncRichNodes(boundOverlayIds(nodeIds, this.maxRichNodes, "rich nodes"));
|
|
1648
|
+
this.updatePositions();
|
|
1649
|
+
}
|
|
1650
|
+
setToolbar(nodeId) {
|
|
1651
|
+
if (this.autoToolbar) return;
|
|
1652
|
+
this.toolbar?.element.remove();
|
|
1653
|
+
this.toolbar = null;
|
|
1654
|
+
this.toolbarNodeId = nodeId;
|
|
1655
|
+
if (nodeId !== null) this.toolbar = this.createEntry(nodeId, this.options.renderToolbar, "Toolbar", "auto");
|
|
1656
|
+
this.updatePositions();
|
|
1657
|
+
}
|
|
1658
|
+
destroy() {
|
|
1659
|
+
this.stopObservingView();
|
|
1660
|
+
this.stopObservingFocus();
|
|
1661
|
+
this.root.remove();
|
|
1662
|
+
if (this.previousContainerPosition !== null && this.container.querySelector(".graphraum-overlay") === null) this.container.style.position = this.previousContainerPosition;
|
|
1663
|
+
this.labels.clear();
|
|
1664
|
+
this.richNodes.clear();
|
|
1665
|
+
this.toolbar = null;
|
|
1666
|
+
}
|
|
1667
|
+
update() {
|
|
1668
|
+
if (this.labelPolicy === "auto") this.syncLabels(selectBudgetedLabelIds({
|
|
1669
|
+
candidates: this.graph.getLabelCandidates(),
|
|
1670
|
+
maxLabels: this.maxLabels
|
|
1671
|
+
}));
|
|
1672
|
+
else if (this.labelPolicy === "focus") {
|
|
1673
|
+
const selectedIds = this.graph.getSelectedNodeIds();
|
|
1674
|
+
const hoveredIds = this.graph.getHoveredNodeIds();
|
|
1675
|
+
const neighborIds = [...selectedIds, ...hoveredIds].flatMap((id) => this.graph.getNeighborIds(id));
|
|
1676
|
+
this.syncLabels(selectFocusLabelIds({
|
|
1677
|
+
focusIds: orderFocusNodeIds({
|
|
1678
|
+
selectedIds,
|
|
1679
|
+
hoveredIds,
|
|
1680
|
+
neighborIds
|
|
1681
|
+
}),
|
|
1682
|
+
candidates: this.graph.getLabelCandidates(),
|
|
1683
|
+
maxLabels: this.maxLabels,
|
|
1684
|
+
fill: this.focusLabelFill
|
|
1685
|
+
}));
|
|
1686
|
+
}
|
|
1687
|
+
if (this.autoToolbar) this.syncToolbar();
|
|
1688
|
+
if (this.autoRichNodes) this.syncRichNodesFromFocus();
|
|
1689
|
+
this.updatePositions();
|
|
1690
|
+
}
|
|
1691
|
+
syncRichNodesFromFocus() {
|
|
1692
|
+
if (!this.autoRichNodes || !this.options.renderRichNode) return;
|
|
1693
|
+
this.syncRichNodes(selectRichNodeIds({
|
|
1694
|
+
hoveredIds: this.graph.getHoveredNodeIds(),
|
|
1695
|
+
maxRichNodes: this.maxRichNodes,
|
|
1696
|
+
policy: this.autoRichNodes,
|
|
1697
|
+
selectedIds: this.graph.getSelectedNodeIds()
|
|
1698
|
+
}));
|
|
1699
|
+
}
|
|
1700
|
+
syncToolbar() {
|
|
1701
|
+
if (!this.options.renderToolbar) return;
|
|
1702
|
+
const nextId = this.autoToolbar === "hovered" ? this.graph.getHoveredNodeIds()[0] ?? this.graph.getSelectedNodeIds()[0] ?? null : this.graph.getSelectedNodeIds()[0] ?? null;
|
|
1703
|
+
if (nextId === this.toolbarNodeId && this.toolbar) return;
|
|
1704
|
+
this.toolbar?.element.remove();
|
|
1705
|
+
this.toolbar = null;
|
|
1706
|
+
this.toolbarNodeId = nextId;
|
|
1707
|
+
if (nextId !== null) this.toolbar = this.createEntry(nextId, this.options.renderToolbar, "Toolbar", "auto");
|
|
1708
|
+
}
|
|
1709
|
+
syncLabels(nodeIds) {
|
|
1710
|
+
const nextIds = new Set(nodeIds);
|
|
1711
|
+
for (const [nodeId, entry] of this.labels) {
|
|
1712
|
+
if (nextIds.has(nodeId)) continue;
|
|
1713
|
+
entry.element.remove();
|
|
1714
|
+
this.labels.delete(nodeId);
|
|
1715
|
+
}
|
|
1716
|
+
for (const nodeId of nodeIds) {
|
|
1717
|
+
if (this.labels.has(nodeId)) continue;
|
|
1718
|
+
const entry = this.createEntry(nodeId, this.options.renderLabel, "Label", "none");
|
|
1719
|
+
if (entry) this.labels.set(nodeId, entry);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
syncRichNodes(nodeIds) {
|
|
1723
|
+
const nextIds = new Set(nodeIds);
|
|
1724
|
+
for (const [nodeId, entry] of this.richNodes) {
|
|
1725
|
+
if (nextIds.has(nodeId)) continue;
|
|
1726
|
+
entry.element.remove();
|
|
1727
|
+
this.richNodes.delete(nodeId);
|
|
1728
|
+
}
|
|
1729
|
+
for (const nodeId of nodeIds) {
|
|
1730
|
+
if (this.richNodes.has(nodeId)) continue;
|
|
1731
|
+
const entry = this.createEntry(nodeId, this.options.renderRichNode, "Rich", "auto");
|
|
1732
|
+
if (entry) this.richNodes.set(nodeId, entry);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
updatePositions() {
|
|
1736
|
+
for (const [nodeId, entry] of this.labels) this.updateEntry(nodeId, entry, this.options.renderLabel, "label", "none");
|
|
1737
|
+
for (const [nodeId, entry] of this.richNodes) this.updateEntry(nodeId, entry, this.options.renderRichNode, "rich", "auto");
|
|
1738
|
+
if (this.toolbar && this.toolbarNodeId) this.updateEntry(this.toolbarNodeId, this.toolbar, this.options.renderToolbar, "toolbar", "auto");
|
|
1739
|
+
}
|
|
1740
|
+
createEntry(nodeId, render, kind, pointerEvents) {
|
|
1741
|
+
if (!render || !this.graph.getNodeScreenPosition(nodeId)) return null;
|
|
1742
|
+
const presentation = this.graph.getNodePresentation(nodeId);
|
|
1743
|
+
const element = render({
|
|
1744
|
+
id: nodeId,
|
|
1745
|
+
presentation
|
|
1746
|
+
});
|
|
1747
|
+
assertElement(element, kind);
|
|
1748
|
+
if (!element) return null;
|
|
1749
|
+
const className = `graphraum-overlay-${kind.toLowerCase()}`;
|
|
1750
|
+
element.classList.add("graphraum-overlay-entry", className);
|
|
1751
|
+
if (kind === "Label" && this.options.labelClassName) this.applyClassName(element, this.options.labelClassName);
|
|
1752
|
+
if (kind === "Toolbar" && this.options.toolbarClassName) this.applyClassName(element, this.options.toolbarClassName);
|
|
1753
|
+
if (kind === "Rich" && this.options.richNodeClassName) this.applyClassName(element, this.options.richNodeClassName);
|
|
1754
|
+
element.dataset.graphraumNodeId = nodeId;
|
|
1755
|
+
element.style.position = "absolute";
|
|
1756
|
+
element.style.pointerEvents = pointerEvents;
|
|
1757
|
+
this.root.append(element);
|
|
1758
|
+
return {
|
|
1759
|
+
element,
|
|
1760
|
+
presentation
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
updateEntry(nodeId, entry, render, kind, pointerEvents) {
|
|
1764
|
+
const position = this.graph.getNodeScreenPosition(nodeId);
|
|
1765
|
+
entry.element.hidden = !position?.visible;
|
|
1766
|
+
if (!position?.visible) return;
|
|
1767
|
+
const anchor = kind === "rich" ? "translate(-50%, 12px)" : "translate(-50%, -100%)";
|
|
1768
|
+
entry.element.style.transform = `translate(${position.x}px, ${position.y}px) ${anchor}`;
|
|
1769
|
+
const presentation = this.graph.getNodePresentation(nodeId);
|
|
1770
|
+
if (presentation === entry.presentation || !render) return;
|
|
1771
|
+
const replacement = render({
|
|
1772
|
+
id: nodeId,
|
|
1773
|
+
presentation
|
|
1774
|
+
});
|
|
1775
|
+
assertElement(replacement, `${kind[0].toUpperCase()}${kind.slice(1)}`);
|
|
1776
|
+
if (!replacement) {
|
|
1777
|
+
entry.element.remove();
|
|
1778
|
+
if (kind === "toolbar") this.toolbar = null;
|
|
1779
|
+
else if (kind === "rich") this.richNodes.delete(nodeId);
|
|
1780
|
+
else this.labels.delete(nodeId);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
const className = `graphraum-overlay-${kind}`;
|
|
1784
|
+
replacement.classList.add("graphraum-overlay-entry", className);
|
|
1785
|
+
if (kind === "label" && this.options.labelClassName) this.applyClassName(replacement, this.options.labelClassName);
|
|
1786
|
+
if (kind === "toolbar" && this.options.toolbarClassName) this.applyClassName(replacement, this.options.toolbarClassName);
|
|
1787
|
+
if (kind === "rich" && this.options.richNodeClassName) this.applyClassName(replacement, this.options.richNodeClassName);
|
|
1788
|
+
replacement.dataset.graphraumNodeId = nodeId;
|
|
1789
|
+
replacement.style.cssText = `${entry.element.style.cssText};position:absolute;pointer-events:${pointerEvents};`;
|
|
1790
|
+
entry.element.replaceWith(replacement);
|
|
1791
|
+
entry.element = replacement;
|
|
1792
|
+
entry.presentation = presentation;
|
|
1793
|
+
}
|
|
1794
|
+
applyClassName(element, className) {
|
|
1795
|
+
for (const token of className.split(/\s+/g)) if (token) element.classList.add(token);
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
//#endregion
|
|
1799
|
+
//#region src/spatial-grid-2d.ts
|
|
1800
|
+
function nodeAxes(node) {
|
|
1801
|
+
return resolveNodeAxes({
|
|
1802
|
+
height: node.height,
|
|
1803
|
+
nodeId: node.id,
|
|
1804
|
+
size: node.size,
|
|
1805
|
+
width: node.width
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Coarse occupancy radius for cell bucketing and bounding-box checks: half of the node's
|
|
1810
|
+
* larger axis, padded by its world-unit `strokeWidth`. This is intentionally conservative
|
|
1811
|
+
* (a circle-equivalent bound around a possibly non-square shape) — exact hit testing in
|
|
1812
|
+
* `pick` uses the true `width`/`height` per axis via `containsNodePoint`.
|
|
1813
|
+
*/
|
|
1814
|
+
function nodeOccupancyRadius(node) {
|
|
1815
|
+
const { height, width } = nodeAxes(node);
|
|
1816
|
+
return Math.max(width, height) + (node.strokeWidth ?? 0);
|
|
1817
|
+
}
|
|
1818
|
+
/** A mutable uniform grid shared by 2D picking and viewport visibility queries. */
|
|
1819
|
+
var SpatialGrid2D = class {
|
|
1820
|
+
cellSize;
|
|
1821
|
+
cells = /* @__PURE__ */ new Map();
|
|
1822
|
+
cellKeys = [];
|
|
1823
|
+
nodes = [];
|
|
1824
|
+
maxRadius = 0;
|
|
1825
|
+
constructor(cellSize = 32) {
|
|
1826
|
+
this.cellSize = cellSize;
|
|
1827
|
+
if (!Number.isFinite(cellSize) || cellSize <= 0) throw new Error("Spatial grid cell size must be positive.");
|
|
1828
|
+
}
|
|
1829
|
+
set(index, node) {
|
|
1830
|
+
const previousKey = this.cellKeys[index];
|
|
1831
|
+
if (previousKey) {
|
|
1832
|
+
const previousCell = this.cells.get(previousKey);
|
|
1833
|
+
if (previousCell) {
|
|
1834
|
+
const position = previousCell.indexOf(index);
|
|
1835
|
+
if (position >= 0) previousCell.splice(position, 1);
|
|
1836
|
+
if (previousCell.length === 0) this.cells.delete(previousKey);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
const key = this.key(this.coordinate(node.position.x), this.coordinate(node.position.y));
|
|
1840
|
+
const cell = this.cells.get(key) ?? [];
|
|
1841
|
+
cell.push(index);
|
|
1842
|
+
this.cells.set(key, cell);
|
|
1843
|
+
this.cellKeys[index] = key;
|
|
1844
|
+
this.nodes[index] = node;
|
|
1845
|
+
this.maxRadius = Math.max(this.maxRadius, nodeOccupancyRadius(node));
|
|
1846
|
+
}
|
|
1847
|
+
pick(x, y) {
|
|
1848
|
+
const cellRadius = Math.ceil(this.maxRadius / this.cellSize);
|
|
1849
|
+
const centerX = this.coordinate(x);
|
|
1850
|
+
const centerY = this.coordinate(y);
|
|
1851
|
+
let nearestIndex = null;
|
|
1852
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
1853
|
+
for (let cellY = centerY - cellRadius; cellY <= centerY + cellRadius; cellY += 1) for (let cellX = centerX - cellRadius; cellX <= centerX + cellRadius; cellX += 1) for (const index of this.cells.get(this.key(cellX, cellY)) ?? []) {
|
|
1854
|
+
const node = this.nodes[index];
|
|
1855
|
+
if (!node) continue;
|
|
1856
|
+
const { height, width } = nodeAxes(node);
|
|
1857
|
+
const offsetX = node.position.x - x;
|
|
1858
|
+
const offsetY = node.position.y - y;
|
|
1859
|
+
const distance = offsetX ** 2 + offsetY ** 2;
|
|
1860
|
+
if (containsNodePoint(node.shape, offsetX / width, offsetY / height) && distance < nearestDistance) {
|
|
1861
|
+
nearestDistance = distance;
|
|
1862
|
+
nearestIndex = index;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
return nearestIndex;
|
|
1866
|
+
}
|
|
1867
|
+
queryBounds(bounds, overscan = 0) {
|
|
1868
|
+
const left = bounds.left - overscan;
|
|
1869
|
+
const right = bounds.right + overscan;
|
|
1870
|
+
const bottom = bounds.bottom - overscan;
|
|
1871
|
+
const top = bounds.top + overscan;
|
|
1872
|
+
const indices = [];
|
|
1873
|
+
const minimumCellX = this.coordinate(left - this.maxRadius);
|
|
1874
|
+
const maximumCellX = this.coordinate(right + this.maxRadius);
|
|
1875
|
+
const minimumCellY = this.coordinate(bottom - this.maxRadius);
|
|
1876
|
+
const maximumCellY = this.coordinate(top + this.maxRadius);
|
|
1877
|
+
const queriedCellCount = (maximumCellX - minimumCellX + 1) * (maximumCellY - minimumCellY + 1);
|
|
1878
|
+
const candidateCells = Number.isSafeInteger(queriedCellCount) && queriedCellCount <= this.cells.size ? this.cellsInBounds(minimumCellX, maximumCellX, minimumCellY, maximumCellY) : this.cells.values();
|
|
1879
|
+
for (const cell of candidateCells) for (const index of cell) {
|
|
1880
|
+
const node = this.nodes[index];
|
|
1881
|
+
if (!node) continue;
|
|
1882
|
+
const radius = nodeOccupancyRadius(node);
|
|
1883
|
+
if (node.position.x + radius >= left && node.position.x - radius <= right && node.position.y + radius >= bottom && node.position.y - radius <= top) indices.push(index);
|
|
1884
|
+
}
|
|
1885
|
+
return indices;
|
|
1886
|
+
}
|
|
1887
|
+
*cellsInBounds(minimumX, maximumX, minimumY, maximumY) {
|
|
1888
|
+
for (let y = minimumY; y <= maximumY; y += 1) for (let x = minimumX; x <= maximumX; x += 1) yield this.cells.get(this.key(x, y)) ?? [];
|
|
1889
|
+
}
|
|
1890
|
+
coordinate(value) {
|
|
1891
|
+
return Math.floor(value / this.cellSize);
|
|
1892
|
+
}
|
|
1893
|
+
key(x, y) {
|
|
1894
|
+
return `${x}:${y}`;
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
//#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
|
+
//#region src/viewport-lod.ts
|
|
1956
|
+
/** Single source of truth for the density/overview/detail tier reported in diagnostics and used to drive edge LOD packing. */
|
|
1957
|
+
function resolveLodLevel(densityLodActive, edgeCandidateCount, visibleEdgeSegmentCount) {
|
|
1958
|
+
if (densityLodActive) return "density";
|
|
1959
|
+
return edgeCandidateCount > visibleEdgeSegmentCount ? "overview" : "detail";
|
|
1960
|
+
}
|
|
1961
|
+
/** Collects unique edges touching visible nodes without scanning the complete edge list. */
|
|
1962
|
+
function collectIncidentEdges(visibleNodeIndices, incidentEdgeIndices) {
|
|
1963
|
+
const edges = /* @__PURE__ */ new Set();
|
|
1964
|
+
for (const nodeIndex of visibleNodeIndices) for (const edgeIndex of incidentEdgeIndices[nodeIndex] ?? []) edges.add(edgeIndex);
|
|
1965
|
+
return [...edges];
|
|
1966
|
+
}
|
|
1967
|
+
/** Applies a deterministic, evenly distributed overview budget. */
|
|
1968
|
+
function applyEdgeBudget(edgeIndices, maximum) {
|
|
1969
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error("Maximum visible edges must be a positive integer.");
|
|
1970
|
+
if (edgeIndices.length <= maximum) return edgeIndices;
|
|
1971
|
+
const step = edgeIndices.length / maximum;
|
|
1972
|
+
return Array.from({ length: maximum }, (_, index) => edgeIndices[Math.floor(index * step)]).filter((edgeIndex) => edgeIndex !== void 0);
|
|
1973
|
+
}
|
|
1974
|
+
/** Keeps density LOD stable while camera movement hovers around the visible-node budget. */
|
|
1975
|
+
function shouldUseDensityLod(candidateCount, maximum, active) {
|
|
1976
|
+
if (!Number.isSafeInteger(candidateCount) || candidateCount < 0) throw new Error("Visible node candidate count must be a non-negative integer.");
|
|
1977
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error("Maximum visible nodes must be a positive integer.");
|
|
1978
|
+
return candidateCount > maximum * (active ? .85 : 1.1);
|
|
1979
|
+
}
|
|
1980
|
+
//#endregion
|
|
1981
|
+
//#region src/graphraum.ts
|
|
1982
|
+
/** Importance boost so selected nodes stay labeled when autoLabels budgets the overlay. */
|
|
1983
|
+
const SELECTED_LABEL_IMPORTANCE_BOOST = 1e3;
|
|
1984
|
+
function disposeMaterial(material) {
|
|
1985
|
+
for (const item of Array.isArray(material) ? material : [material]) item.dispose();
|
|
1986
|
+
}
|
|
1987
|
+
const EDGE_INSTANCE_ATTRIBUTES = [
|
|
1988
|
+
"instanceKind",
|
|
1989
|
+
"instanceEndA",
|
|
1990
|
+
"instanceEndB",
|
|
1991
|
+
"instanceColor",
|
|
1992
|
+
"instanceWidth",
|
|
1993
|
+
"instanceStyle"
|
|
1994
|
+
];
|
|
1995
|
+
/** A small, explicit WebGL graph renderer: one instanced node mesh and one batched edge geometry. */
|
|
1996
|
+
var Graphraum = class {
|
|
1997
|
+
container;
|
|
1998
|
+
renderer;
|
|
1999
|
+
scene = new Scene();
|
|
2000
|
+
raycaster = new Raycaster();
|
|
2001
|
+
pointer = new Vector2();
|
|
2002
|
+
pickCenter = new Vector3();
|
|
2003
|
+
pickRight = new Vector3();
|
|
2004
|
+
pickUp = new Vector3();
|
|
2005
|
+
theme;
|
|
2006
|
+
backgroundTexture = null;
|
|
2007
|
+
resizeObserver;
|
|
2008
|
+
maxVisibleEdges;
|
|
2009
|
+
maxVisibleNodes;
|
|
2010
|
+
viewportCulling;
|
|
2011
|
+
viewportOverscan;
|
|
2012
|
+
visuals;
|
|
2013
|
+
camera;
|
|
2014
|
+
controls;
|
|
2015
|
+
data = {
|
|
2016
|
+
nodes: [],
|
|
2017
|
+
edges: []
|
|
2018
|
+
};
|
|
2019
|
+
nodeIds = [];
|
|
2020
|
+
nodeIndices = /* @__PURE__ */ new Map();
|
|
2021
|
+
edgeNodeIndices = /* @__PURE__ */ new Uint32Array();
|
|
2022
|
+
canonicalEdgePositions = /* @__PURE__ */ new Float32Array();
|
|
2023
|
+
incidentEdgeIndices = [];
|
|
2024
|
+
nodePresentations = /* @__PURE__ */ new Map();
|
|
2025
|
+
edgePresentations = /* @__PURE__ */ new Map();
|
|
2026
|
+
spatialGrid2d = new SpatialGrid2D();
|
|
2027
|
+
nodeMesh = null;
|
|
2028
|
+
nodePickMesh = null;
|
|
2029
|
+
edgeMesh = null;
|
|
2030
|
+
nodeCapacity = 0;
|
|
2031
|
+
edgeCapacity = 0;
|
|
2032
|
+
markerCapacity = 0;
|
|
2033
|
+
edgeInstanceCapacity = 0;
|
|
2034
|
+
selectedNodeIds = /* @__PURE__ */ new Set();
|
|
2035
|
+
selectedEdgeIds = /* @__PURE__ */ new Set();
|
|
2036
|
+
hoveredNodeIds = /* @__PURE__ */ new Set();
|
|
2037
|
+
focusedNodeIds = /* @__PURE__ */ new Set();
|
|
2038
|
+
dimmedNodeIds = /* @__PURE__ */ new Set();
|
|
2039
|
+
visibleNodeSlots = /* @__PURE__ */ new Map();
|
|
2040
|
+
visibleEdgeSlots = /* @__PURE__ */ new Map();
|
|
2041
|
+
visibleEdgeLayouts = /* @__PURE__ */ new Map();
|
|
2042
|
+
lastEdgeLodTier = "detail";
|
|
2043
|
+
visibleNodeIndices = [];
|
|
2044
|
+
pickableEdgeSegments = [];
|
|
2045
|
+
visibleEdgeSegmentEdgeIndices = [];
|
|
2046
|
+
visibleNodeCount = 0;
|
|
2047
|
+
visibleNodeCandidateCount = 0;
|
|
2048
|
+
densityLodActive = false;
|
|
2049
|
+
visibleEdgeSegmentCount = 0;
|
|
2050
|
+
visibleEdgeCount = 0;
|
|
2051
|
+
visibleEdgeMarkerCount = 0;
|
|
2052
|
+
visibleEdgeCandidateCount = 0;
|
|
2053
|
+
edgeSegmentCapacity = 0;
|
|
2054
|
+
mode;
|
|
2055
|
+
frameRequest = null;
|
|
2056
|
+
cpuFrameMilliseconds = 0;
|
|
2057
|
+
gpuFrameMilliseconds = null;
|
|
2058
|
+
gpuTimer = null;
|
|
2059
|
+
viewListeners = /* @__PURE__ */ new Set();
|
|
2060
|
+
focusListeners = /* @__PURE__ */ new Set();
|
|
2061
|
+
lastHoveredNodeId = null;
|
|
2062
|
+
constructor(container, options = {}) {
|
|
2063
|
+
this.container = container;
|
|
2064
|
+
this.mode = options.mode ?? "2d";
|
|
2065
|
+
this.maxVisibleEdges = options.maxVisibleEdges ?? 1e5;
|
|
2066
|
+
this.maxVisibleNodes = options.maxVisibleNodes ?? 1e5;
|
|
2067
|
+
this.viewportCulling = options.viewportCulling ?? true;
|
|
2068
|
+
this.viewportOverscan = options.viewportOverscan ?? 16;
|
|
2069
|
+
this.visuals = options.visuals;
|
|
2070
|
+
if (!Number.isSafeInteger(this.maxVisibleEdges) || this.maxVisibleEdges < 1) throw new Error("Maximum visible edges must be a positive integer.");
|
|
2071
|
+
if (!Number.isSafeInteger(this.maxVisibleNodes) || this.maxVisibleNodes < 1) throw new Error("Maximum visible nodes must be a positive integer.");
|
|
2072
|
+
if (!Number.isFinite(this.viewportOverscan) || this.viewportOverscan < 0) throw new Error("Viewport overscan must be a non-negative finite number.");
|
|
2073
|
+
this.theme = resolveGraphraumTheme(options.theme);
|
|
2074
|
+
this.renderer = new WebGLRenderer({
|
|
2075
|
+
antialias: options.antialias ?? false,
|
|
2076
|
+
alpha: true
|
|
2077
|
+
});
|
|
2078
|
+
this.applyBackground();
|
|
2079
|
+
const context = this.renderer.getContext();
|
|
2080
|
+
if (typeof WebGL2RenderingContext !== "undefined" && context instanceof WebGL2RenderingContext) {
|
|
2081
|
+
const extension = context.getExtension("EXT_disjoint_timer_query_webgl2");
|
|
2082
|
+
if (extension) this.gpuTimer = {
|
|
2083
|
+
context,
|
|
2084
|
+
extension,
|
|
2085
|
+
pending: []
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2));
|
|
2089
|
+
this.renderer.domElement.style.display = "block";
|
|
2090
|
+
this.renderer.domElement.style.height = "100%";
|
|
2091
|
+
this.renderer.domElement.style.width = "100%";
|
|
2092
|
+
this.container.append(this.renderer.domElement);
|
|
2093
|
+
this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
|
|
2094
|
+
this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
|
|
2095
|
+
this.camera = this.createCamera();
|
|
2096
|
+
this.controls = this.createControls();
|
|
2097
|
+
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
2098
|
+
this.resizeObserver.observe(container);
|
|
2099
|
+
this.resize();
|
|
2100
|
+
}
|
|
2101
|
+
setData(data) {
|
|
2102
|
+
this.replaceData(data, true);
|
|
2103
|
+
}
|
|
2104
|
+
/** Merge a topology patch without fitting or resetting the current camera. */
|
|
2105
|
+
applyDataPatch(patch) {
|
|
2106
|
+
const addedNodeIds = new Set((patch.addedNodes ?? []).map((node) => node.id));
|
|
2107
|
+
const addedEdgeIds = new Set((patch.addedEdges ?? []).map((edge) => edge.id));
|
|
2108
|
+
if (addedNodeIds.size !== (patch.addedNodes ?? []).length) throw new Error("Duplicate added node ID.");
|
|
2109
|
+
if (addedEdgeIds.size !== (patch.addedEdges ?? []).length) throw new Error("Duplicate added edge ID.");
|
|
2110
|
+
if (isAppendOnlyDataPatch(patch) && this.canAppendDataPatch(patch)) {
|
|
2111
|
+
this.appendDataPatch(patch);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
const merged = mergeDataPatch(this.data.nodes, this.data.edges, patch);
|
|
2115
|
+
if (this.canMutateDataInPlace(merged)) {
|
|
2116
|
+
this.mutateDataInPlace(merged);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
this.replaceData(merged, false);
|
|
2120
|
+
}
|
|
2121
|
+
canAppendDataPatch(patch) {
|
|
2122
|
+
if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return false;
|
|
2123
|
+
const nodeIds = new Set(this.data.nodes.map((node) => node.id));
|
|
2124
|
+
if ((patch.addedNodes ?? []).some((node) => nodeIds.has(node.id))) return false;
|
|
2125
|
+
const edgeIds = new Set(this.data.edges.map((edge) => edge.id));
|
|
2126
|
+
if ((patch.addedEdges ?? []).some((edge) => edgeIds.has(edge.id))) return false;
|
|
2127
|
+
const allNodeIds = /* @__PURE__ */ new Set([...nodeIds, ...(patch.addedNodes ?? []).map((node) => node.id)]);
|
|
2128
|
+
return (patch.addedNodes?.length ?? 0) + this.data.nodes.length <= this.nodeCapacity && (patch.addedEdges?.length ?? 0) + this.data.edges.length <= this.edgeCapacity && (patch.addedEdges ?? []).every((edge) => allNodeIds.has(edge.source) && allNodeIds.has(edge.target));
|
|
2129
|
+
}
|
|
2130
|
+
canMutateDataInPlace(merged) {
|
|
2131
|
+
if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return false;
|
|
2132
|
+
return dataPatchFitsCapacity(merged, {
|
|
2133
|
+
edges: this.edgeCapacity,
|
|
2134
|
+
nodes: this.nodeCapacity
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
mutateDataInPlace(merged) {
|
|
2138
|
+
const compiled = compileGraph(merged, this.visuals);
|
|
2139
|
+
this.data = {
|
|
2140
|
+
edges: merged.edges.map((edge, index) => ({
|
|
2141
|
+
...edge,
|
|
2142
|
+
...compiled.edgeVisuals[index]
|
|
2143
|
+
})),
|
|
2144
|
+
nodes: merged.nodes.map((node, index) => ({
|
|
2145
|
+
...node,
|
|
2146
|
+
...compiled.nodeVisuals[index],
|
|
2147
|
+
position: { ...node.position }
|
|
2148
|
+
}))
|
|
2149
|
+
};
|
|
2150
|
+
this.nodeIds = compiled.nodeIds;
|
|
2151
|
+
this.nodeIndices = new Map(compiled.nodeIndices);
|
|
2152
|
+
this.edgeNodeIndices = compiled.edgeNodeIndices;
|
|
2153
|
+
this.canonicalEdgePositions = compiled.edgePositions;
|
|
2154
|
+
this.incidentEdgeIndices = compiled.incidentEdgeIndices;
|
|
2155
|
+
this.nodePresentations = new Map(compiled.nodePresentations);
|
|
2156
|
+
this.edgePresentations = new Map(compiled.edgePresentations);
|
|
2157
|
+
this.spatialGrid2d = new SpatialGrid2D();
|
|
2158
|
+
for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
|
|
2159
|
+
const liveNodeIds = new Set(this.nodeIds);
|
|
2160
|
+
const liveEdgeIds = new Set(this.data.edges.map((edge) => edge.id));
|
|
2161
|
+
this.selectedNodeIds = new Set([...this.selectedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2162
|
+
this.hoveredNodeIds = new Set([...this.hoveredNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2163
|
+
this.focusedNodeIds = new Set([...this.focusedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2164
|
+
this.dimmedNodeIds = new Set([...this.dimmedNodeIds].filter((id) => liveNodeIds.has(id)));
|
|
2165
|
+
this.selectedEdgeIds = new Set([...this.selectedEdgeIds].filter((id) => liveEdgeIds.has(id)));
|
|
2166
|
+
this.materializeViewport();
|
|
2167
|
+
this.requestRender();
|
|
2168
|
+
}
|
|
2169
|
+
appendDataPatch(patch) {
|
|
2170
|
+
const addedNodes = patch.addedNodes ?? [];
|
|
2171
|
+
const addedEdges = patch.addedEdges ?? [];
|
|
2172
|
+
if (addedNodes.length === 0 && addedEdges.length === 0) return;
|
|
2173
|
+
const nodeById = new Map(this.data.nodes.map((node) => [node.id, node]));
|
|
2174
|
+
for (const node of addedNodes) nodeById.set(node.id, node);
|
|
2175
|
+
const edgeEndpointNodes = addedEdges.flatMap((edge) => {
|
|
2176
|
+
const source = nodeById.get(edge.source);
|
|
2177
|
+
const target = nodeById.get(edge.target);
|
|
2178
|
+
return source && target ? [source, target] : [];
|
|
2179
|
+
});
|
|
2180
|
+
const patchNodes = [...addedNodes, ...edgeEndpointNodes].filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index);
|
|
2181
|
+
const compiled = compileGraph({
|
|
2182
|
+
nodes: patchNodes,
|
|
2183
|
+
edges: addedEdges
|
|
2184
|
+
}, this.visuals);
|
|
2185
|
+
const nodeVisuals = new Map(patchNodes.map((node, index) => [node.id, compiled.nodeVisuals[index]]));
|
|
2186
|
+
const nextNodes = [...this.data.nodes, ...addedNodes.map((node) => ({
|
|
2187
|
+
...node,
|
|
2188
|
+
...nodeVisuals.get(node.id)
|
|
2189
|
+
}))];
|
|
2190
|
+
const nextNodeIndices = new Map(this.nodeIndices);
|
|
2191
|
+
for (const [index, node] of nextNodes.entries()) nextNodeIndices.set(node.id, index);
|
|
2192
|
+
const nextEdges = [...this.data.edges, ...addedEdges.map((edge, index) => ({
|
|
2193
|
+
...edge,
|
|
2194
|
+
...compiled.edgeVisuals[index]
|
|
2195
|
+
}))];
|
|
2196
|
+
const nextEdgeIndices = new Uint32Array(nextEdges.length * 2);
|
|
2197
|
+
nextEdgeIndices.set(this.edgeNodeIndices);
|
|
2198
|
+
const nextEdgePositions = new Float32Array(nextEdges.length * 6);
|
|
2199
|
+
nextEdgePositions.set(this.canonicalEdgePositions);
|
|
2200
|
+
const nextIncidentEdges = this.incidentEdgeIndices.map((indices) => [...indices]);
|
|
2201
|
+
for (const [index, edge] of nextEdges.entries()) {
|
|
2202
|
+
const source = nextNodeIndices.get(edge.source);
|
|
2203
|
+
const target = nextNodeIndices.get(edge.target);
|
|
2204
|
+
if (source === void 0 || target === void 0) continue;
|
|
2205
|
+
nextEdgeIndices[index * 2] = source;
|
|
2206
|
+
nextEdgeIndices[index * 2 + 1] = target;
|
|
2207
|
+
if (!nextIncidentEdges[source]) nextIncidentEdges[source] = [];
|
|
2208
|
+
if (!nextIncidentEdges[target]) nextIncidentEdges[target] = [];
|
|
2209
|
+
if (index >= this.data.edges.length) {
|
|
2210
|
+
nextIncidentEdges[source]?.push(index);
|
|
2211
|
+
nextIncidentEdges[target]?.push(index);
|
|
2212
|
+
const sourceNode = nextNodes[source];
|
|
2213
|
+
const targetNode = nextNodes[target];
|
|
2214
|
+
if (sourceNode && targetNode) nextEdgePositions.set([
|
|
2215
|
+
sourceNode.position.x,
|
|
2216
|
+
sourceNode.position.y,
|
|
2217
|
+
sourceNode.position.z ?? 0,
|
|
2218
|
+
targetNode.position.x,
|
|
2219
|
+
targetNode.position.y,
|
|
2220
|
+
targetNode.position.z ?? 0
|
|
2221
|
+
], index * 6);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
this.data = {
|
|
2225
|
+
nodes: nextNodes,
|
|
2226
|
+
edges: nextEdges
|
|
2227
|
+
};
|
|
2228
|
+
this.nodeIds = nextNodes.map((node) => node.id);
|
|
2229
|
+
this.nodeIndices = nextNodeIndices;
|
|
2230
|
+
this.edgeNodeIndices = nextEdgeIndices;
|
|
2231
|
+
this.canonicalEdgePositions = nextEdgePositions;
|
|
2232
|
+
this.incidentEdgeIndices = nextIncidentEdges;
|
|
2233
|
+
for (const node of addedNodes) {
|
|
2234
|
+
const index = nextNodeIndices.get(node.id);
|
|
2235
|
+
const nextNode = index === void 0 ? void 0 : this.data.nodes[index];
|
|
2236
|
+
if (index !== void 0 && nextNode) this.spatialGrid2d.set(index, nextNode);
|
|
2237
|
+
}
|
|
2238
|
+
for (const [id, presentation] of compiled.nodePresentations) this.nodePresentations.set(id, presentation);
|
|
2239
|
+
for (const [id, presentation] of compiled.edgePresentations) this.edgePresentations.set(id, presentation);
|
|
2240
|
+
this.materializeViewport();
|
|
2241
|
+
this.requestRender();
|
|
2242
|
+
}
|
|
2243
|
+
replaceData(data, fitView) {
|
|
2244
|
+
const compiled = compileGraph(data, this.visuals);
|
|
2245
|
+
this.disposeGraphObjects();
|
|
2246
|
+
this.data = {
|
|
2247
|
+
edges: data.edges.map((edge, index) => ({
|
|
2248
|
+
...edge,
|
|
2249
|
+
...compiled.edgeVisuals[index]
|
|
2250
|
+
})),
|
|
2251
|
+
nodes: data.nodes.map((node, index) => ({
|
|
2252
|
+
...node,
|
|
2253
|
+
...compiled.nodeVisuals[index],
|
|
2254
|
+
position: { ...node.position }
|
|
2255
|
+
}))
|
|
2256
|
+
};
|
|
2257
|
+
this.nodeIds = compiled.nodeIds;
|
|
2258
|
+
this.nodeIndices = new Map(compiled.nodeIndices);
|
|
2259
|
+
this.edgeNodeIndices = compiled.edgeNodeIndices;
|
|
2260
|
+
this.canonicalEdgePositions = compiled.edgePositions;
|
|
2261
|
+
this.incidentEdgeIndices = compiled.incidentEdgeIndices;
|
|
2262
|
+
this.nodePresentations = new Map(compiled.nodePresentations);
|
|
2263
|
+
this.edgePresentations = new Map(compiled.edgePresentations);
|
|
2264
|
+
this.spatialGrid2d = new SpatialGrid2D();
|
|
2265
|
+
for (const [index, node] of this.data.nodes.entries()) this.spatialGrid2d.set(index, node);
|
|
2266
|
+
this.nodeCapacity = Math.min(this.maxVisibleNodes, nextCapacity(data.nodes.length));
|
|
2267
|
+
const nodeCapacity = this.nodeCapacity;
|
|
2268
|
+
const nodeMesh = new InstancedMesh(createNodeGeometry(nodeCapacity), createNodeMaterial(this.mode === "3d"), nodeCapacity);
|
|
2269
|
+
allocateNodeInstanceColors(nodeMesh, nodeCapacity);
|
|
2270
|
+
nodeMesh.renderOrder = this.mode === "2d" ? 1 : 0;
|
|
2271
|
+
this.nodeMesh = nodeMesh;
|
|
2272
|
+
this.scene.add(nodeMesh);
|
|
2273
|
+
const nodePickMesh = new InstancedMesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial(), nodeCapacity);
|
|
2274
|
+
this.nodePickMesh = nodePickMesh;
|
|
2275
|
+
const capacities = edgeInstanceCapacities(data.edges.length, this.maxVisibleEdges);
|
|
2276
|
+
this.edgeCapacity = capacities.edgeCapacity;
|
|
2277
|
+
this.edgeSegmentCapacity = capacities.segmentCapacity;
|
|
2278
|
+
this.markerCapacity = capacities.markerCapacity;
|
|
2279
|
+
this.edgeInstanceCapacity = capacities.edgeInstanceCapacity;
|
|
2280
|
+
const edgeMesh = new InstancedMesh(createEdgeGeometry(this.edgeInstanceCapacity), createEdgeMaterial(this.mode === "3d"), this.edgeInstanceCapacity);
|
|
2281
|
+
edgeMesh.frustumCulled = false;
|
|
2282
|
+
edgeMesh.renderOrder = this.mode === "2d" ? -1 : 0;
|
|
2283
|
+
this.edgeMesh = edgeMesh;
|
|
2284
|
+
this.scene.add(edgeMesh);
|
|
2285
|
+
if (fitView) this.fitView();
|
|
2286
|
+
else {
|
|
2287
|
+
this.materializeViewport();
|
|
2288
|
+
this.requestRender();
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
updateNodes(updates) {
|
|
2292
|
+
if (updates.length === 0) return;
|
|
2293
|
+
const objects = this.getGraphObjects("update nodes");
|
|
2294
|
+
const prepared = prepareNodeUpdates(this.data.nodes, this.nodeIndices, updates);
|
|
2295
|
+
this.applyPreparedNodeUpdates(prepared, objects);
|
|
2296
|
+
}
|
|
2297
|
+
/** Applies a transferable XYZ layout batch. Call repeatedly as a worker produces progressive positions. */
|
|
2298
|
+
applyLayout(layout) {
|
|
2299
|
+
const prepared = prepareLayoutPositions(this.data.nodes, this.nodeIndices, layout);
|
|
2300
|
+
if (prepared.length === 0) return;
|
|
2301
|
+
const objects = this.getGraphObjects("apply a layout");
|
|
2302
|
+
this.applyPreparedNodeUpdates(prepared, objects);
|
|
2303
|
+
}
|
|
2304
|
+
getGraphObjects(action) {
|
|
2305
|
+
if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) throw new Error(`Cannot ${action} before graph data is set`);
|
|
2306
|
+
return {
|
|
2307
|
+
edgeMesh: this.edgeMesh,
|
|
2308
|
+
nodeMesh: this.nodeMesh,
|
|
2309
|
+
nodePickMesh: this.nodePickMesh
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
applyPreparedNodeUpdates(prepared, { nodeMesh, nodePickMesh }) {
|
|
2313
|
+
const nodes = [...this.data.nodes];
|
|
2314
|
+
const matrix = new Matrix4();
|
|
2315
|
+
const nodeShape = nodeMesh.geometry.getAttribute("instanceShape");
|
|
2316
|
+
const nodeStrokeWidth = nodeMesh.geometry.getAttribute("instanceStrokeWidth");
|
|
2317
|
+
const nodeStrokeColor = nodeMesh.geometry.getAttribute("instanceStrokeColor");
|
|
2318
|
+
const viewportBounds = this.getViewportBounds2d();
|
|
2319
|
+
let visibilityChanged = false;
|
|
2320
|
+
let edgeEndpointsChanged = false;
|
|
2321
|
+
const changedEdgeIndices = /* @__PURE__ */ new Set();
|
|
2322
|
+
for (const update of prepared) {
|
|
2323
|
+
nodes[update.index] = update.next;
|
|
2324
|
+
if (update.positionChanged || update.sizeChanged) {
|
|
2325
|
+
const wasVisible = this.visibleNodeSlots.has(update.index);
|
|
2326
|
+
this.spatialGrid2d.set(update.index, update.next);
|
|
2327
|
+
visibilityChanged ||= wasVisible !== this.isNodeVisible2d(update.next, viewportBounds);
|
|
2328
|
+
}
|
|
2329
|
+
const nodeSlot = this.visibleNodeSlots.get(update.index);
|
|
2330
|
+
if (update.positionChanged || update.sizeChanged) {
|
|
2331
|
+
if (nodeSlot !== void 0) {
|
|
2332
|
+
const { height, width } = resolveNodeAxes({
|
|
2333
|
+
height: update.next.height,
|
|
2334
|
+
nodeId: update.next.id,
|
|
2335
|
+
size: update.next.size,
|
|
2336
|
+
width: update.next.width
|
|
2337
|
+
});
|
|
2338
|
+
const maxAxis = Math.max(width, height);
|
|
2339
|
+
matrix.makeScale(width, height, maxAxis);
|
|
2340
|
+
matrix.setPosition(update.next.position.x, update.next.position.y, update.next.position.z ?? 0);
|
|
2341
|
+
nodeMesh.setMatrixAt(nodeSlot, matrix);
|
|
2342
|
+
nodeMesh.instanceMatrix.addUpdateRange(nodeSlot * 16, 16);
|
|
2343
|
+
const pickScale = maxAxis * Math.SQRT2;
|
|
2344
|
+
matrix.makeScale(pickScale, pickScale, pickScale);
|
|
2345
|
+
matrix.setPosition(update.next.position.x, update.next.position.y, update.next.position.z ?? 0);
|
|
2346
|
+
nodePickMesh.setMatrixAt(nodeSlot, matrix);
|
|
2347
|
+
nodePickMesh.instanceMatrix.addUpdateRange(nodeSlot * 16, 16);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
if (update.colorChanged && nodeSlot !== void 0) {
|
|
2351
|
+
nodeMesh.setColorAt(nodeSlot, this.getNodeColor(update.next));
|
|
2352
|
+
nodeMesh.instanceColor?.addUpdateRange(nodeSlot * 3, 3);
|
|
2353
|
+
}
|
|
2354
|
+
if (update.shapeChanged && nodeSlot !== void 0) {
|
|
2355
|
+
setNodeShapeAt(nodeShape, nodeSlot, update.next.shape);
|
|
2356
|
+
nodeShape.addUpdateRange(nodeSlot, 1);
|
|
2357
|
+
}
|
|
2358
|
+
if (update.strokeChanged && nodeSlot !== void 0) {
|
|
2359
|
+
setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, nodeSlot, resolveNodeStroke(update.next, this.theme));
|
|
2360
|
+
nodeStrokeWidth.addUpdateRange(nodeSlot, 1);
|
|
2361
|
+
nodeStrokeColor.addUpdateRange(nodeSlot * 3, 3);
|
|
2362
|
+
}
|
|
2363
|
+
if (update.positionChanged) {
|
|
2364
|
+
const incidentEdges = this.incidentEdgeIndices[update.index] ?? [];
|
|
2365
|
+
if (incidentEdges.length > 0) edgeEndpointsChanged = true;
|
|
2366
|
+
for (const edgeIndex of incidentEdges) {
|
|
2367
|
+
changedEdgeIndices.add(edgeIndex);
|
|
2368
|
+
const sourceIndex = this.edgeNodeIndices[edgeIndex * 2];
|
|
2369
|
+
const canonicalOffset = edgeIndex * 6 + (sourceIndex === update.index ? 0 : 3);
|
|
2370
|
+
this.canonicalEdgePositions.set([
|
|
2371
|
+
update.next.position.x,
|
|
2372
|
+
update.next.position.y,
|
|
2373
|
+
update.next.position.z ?? 0
|
|
2374
|
+
], canonicalOffset);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
if (this.densityLodActive && prepared.some((update) => update.positionChanged || update.sizeChanged)) visibilityChanged = true;
|
|
2379
|
+
this.data = {
|
|
2380
|
+
...this.data,
|
|
2381
|
+
nodes
|
|
2382
|
+
};
|
|
2383
|
+
if (visibilityChanged) {
|
|
2384
|
+
this.materializeViewport();
|
|
2385
|
+
this.requestRender();
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
if (edgeEndpointsChanged) {
|
|
2389
|
+
if (!this.patchVisibleEdgeEndpoints([...changedEdgeIndices])) this.materializeViewport();
|
|
2390
|
+
}
|
|
2391
|
+
if (prepared.some((update) => update.positionChanged || update.sizeChanged)) {
|
|
2392
|
+
nodeMesh.instanceMatrix.needsUpdate = true;
|
|
2393
|
+
nodePickMesh.instanceMatrix.needsUpdate = true;
|
|
2394
|
+
}
|
|
2395
|
+
if (prepared.some((update) => update.colorChanged) && nodeMesh.instanceColor) nodeMesh.instanceColor.needsUpdate = true;
|
|
2396
|
+
if (prepared.some((update) => update.shapeChanged)) nodeShape.needsUpdate = true;
|
|
2397
|
+
if (prepared.some((update) => update.strokeChanged)) {
|
|
2398
|
+
nodeStrokeWidth.needsUpdate = true;
|
|
2399
|
+
nodeStrokeColor.needsUpdate = true;
|
|
2400
|
+
}
|
|
2401
|
+
this.requestRender();
|
|
2402
|
+
}
|
|
2403
|
+
/** Patches GPU edge instances for moved endpoints when the visible edge set is unchanged. */
|
|
2404
|
+
patchVisibleEdgeEndpoints(changedEdgeIndices) {
|
|
2405
|
+
if (!this.edgeMesh || changedEdgeIndices.length === 0) return true;
|
|
2406
|
+
const worldPerPixel = this.worldUnitsPerPixel();
|
|
2407
|
+
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
|
+
const result = patchVisibleEdgeInstances(this.edgeMesh.geometry, {
|
|
2413
|
+
changedEdgeIndices,
|
|
2414
|
+
defaults: {
|
|
2415
|
+
color: this.theme.edge,
|
|
2416
|
+
opacity: this.theme.edgeOpacity,
|
|
2417
|
+
width: this.theme.edgeWidth
|
|
2418
|
+
},
|
|
2419
|
+
edgeNodeIndices: this.edgeNodeIndices,
|
|
2420
|
+
edgeVisuals,
|
|
2421
|
+
endpointAttach: this.theme.endpointAttach,
|
|
2422
|
+
endpointPositions: this.canonicalEdgePositions,
|
|
2423
|
+
layouts: this.visibleEdgeLayouts,
|
|
2424
|
+
minHitSlop,
|
|
2425
|
+
nodeOutlines: this.data.nodes.map((node) => ({
|
|
2426
|
+
height: node.height,
|
|
2427
|
+
shape: node.shape,
|
|
2428
|
+
size: node.size,
|
|
2429
|
+
strokeWidth: node.strokeWidth,
|
|
2430
|
+
width: node.width
|
|
2431
|
+
})),
|
|
2432
|
+
tier: this.lastEdgeLodTier,
|
|
2433
|
+
worldPerPixel
|
|
2434
|
+
}, this.pickableEdgeSegments);
|
|
2435
|
+
if (!result.ok) return false;
|
|
2436
|
+
this.pickableEdgeSegments = result.pickableSegments;
|
|
2437
|
+
return true;
|
|
2438
|
+
}
|
|
2439
|
+
setSelection(nodeIds) {
|
|
2440
|
+
this.setNodeState("selected", nodeIds);
|
|
2441
|
+
}
|
|
2442
|
+
/** Highlights edges without changing node selection or source graph data. */
|
|
2443
|
+
setEdgeSelection(edgeIds) {
|
|
2444
|
+
this.selectedEdgeIds = new Set(edgeIds);
|
|
2445
|
+
this.materializeViewport();
|
|
2446
|
+
this.requestRender();
|
|
2447
|
+
}
|
|
2448
|
+
/** Applies an application-owned visual state without changing source graph data. */
|
|
2449
|
+
setNodeState(state, nodeIds) {
|
|
2450
|
+
const nextSelection = new Set(nodeIds);
|
|
2451
|
+
const currentSelection = this.getNodeStateIds(state);
|
|
2452
|
+
const changedNodeIds = new Set([...currentSelection, ...nextSelection].filter((nodeId) => currentSelection.has(nodeId) !== nextSelection.has(nodeId)));
|
|
2453
|
+
if (changedNodeIds.size === 0) return;
|
|
2454
|
+
this.setNodeStateIds(state, nextSelection);
|
|
2455
|
+
this.applySelectionColors(changedNodeIds);
|
|
2456
|
+
this.requestRender();
|
|
2457
|
+
if (state === "selected" || state === "hovered") this.notifyFocusChange();
|
|
2458
|
+
}
|
|
2459
|
+
/** One-hop neighbor node ids via incident edges (undirected adjacency). */
|
|
2460
|
+
getNeighborIds(nodeId) {
|
|
2461
|
+
const index = this.nodeIndices.get(nodeId);
|
|
2462
|
+
if (index === void 0) return [];
|
|
2463
|
+
const neighbors = /* @__PURE__ */ new Set();
|
|
2464
|
+
for (const edgeIndex of this.incidentEdgeIndices[index] ?? []) {
|
|
2465
|
+
const edge = this.data.edges[edgeIndex];
|
|
2466
|
+
if (!edge) continue;
|
|
2467
|
+
if (edge.source !== nodeId) neighbors.add(edge.source);
|
|
2468
|
+
if (edge.target !== nodeId) neighbors.add(edge.target);
|
|
2469
|
+
}
|
|
2470
|
+
return [...neighbors];
|
|
2471
|
+
}
|
|
2472
|
+
getSelectedNodeIds() {
|
|
2473
|
+
return [...this.selectedNodeIds];
|
|
2474
|
+
}
|
|
2475
|
+
getHoveredNodeIds() {
|
|
2476
|
+
return [...this.hoveredNodeIds];
|
|
2477
|
+
}
|
|
2478
|
+
/** Schedules one render on the next animation frame. Repeated calls in one frame are coalesced. */
|
|
2479
|
+
render() {
|
|
2480
|
+
this.requestRender();
|
|
2481
|
+
}
|
|
2482
|
+
setMode(mode) {
|
|
2483
|
+
if (mode === this.mode) return;
|
|
2484
|
+
this.mode = mode;
|
|
2485
|
+
this.controls.removeEventListener("change", this.handleViewChange);
|
|
2486
|
+
this.controls.dispose();
|
|
2487
|
+
this.camera = this.createCamera();
|
|
2488
|
+
this.controls = this.createControls();
|
|
2489
|
+
if (this.nodeMesh && !Array.isArray(this.nodeMesh.material)) {
|
|
2490
|
+
this.nodeMesh.material.depthTest = mode === "3d";
|
|
2491
|
+
this.nodeMesh.material.depthWrite = mode === "3d";
|
|
2492
|
+
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
|
+
}
|
|
2501
|
+
this.fitView();
|
|
2502
|
+
}
|
|
2503
|
+
getMode() {
|
|
2504
|
+
return this.mode;
|
|
2505
|
+
}
|
|
2506
|
+
getNodePresentation(nodeId) {
|
|
2507
|
+
return this.nodePresentations.get(nodeId) ?? null;
|
|
2508
|
+
}
|
|
2509
|
+
/** Returns canvas-relative screen coordinates for a node, or null when the node does not exist. */
|
|
2510
|
+
getNodeScreenPosition(nodeId) {
|
|
2511
|
+
const index = this.nodeIndices.get(nodeId);
|
|
2512
|
+
const node = index === void 0 ? void 0 : this.data.nodes[index];
|
|
2513
|
+
if (!node) return null;
|
|
2514
|
+
const projected = new Vector3(node.position.x, node.position.y, node.position.z ?? 0).project(this.camera);
|
|
2515
|
+
const width = this.renderer.domElement.clientWidth;
|
|
2516
|
+
const height = this.renderer.domElement.clientHeight;
|
|
2517
|
+
return {
|
|
2518
|
+
visible: projected.x >= -1 && projected.x <= 1 && projected.y >= -1 && projected.y <= 1 && projected.z >= -1 && projected.z <= 1,
|
|
2519
|
+
x: (projected.x + 1) * width / 2,
|
|
2520
|
+
y: (1 - projected.y) * height / 2
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* Candidates for a budgeted DOM label layer. Importance is incident edge degree, with a boost
|
|
2525
|
+
* for selected nodes so selection stays labeled. Visibility comes from screen projection.
|
|
2526
|
+
*/
|
|
2527
|
+
getLabelCandidates() {
|
|
2528
|
+
return this.data.nodes.map((node, index) => {
|
|
2529
|
+
const degree = this.incidentEdgeIndices[index]?.length ?? 0;
|
|
2530
|
+
return {
|
|
2531
|
+
id: node.id,
|
|
2532
|
+
importance: degree + (this.selectedNodeIds.has(node.id) ? SELECTED_LABEL_IMPORTANCE_BOOST : 0),
|
|
2533
|
+
visible: this.getNodeScreenPosition(node.id)?.visible ?? false
|
|
2534
|
+
};
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
/** Converts client coordinates to graph world coordinates on the z=0 plane. */
|
|
2538
|
+
screenToWorld(clientX, clientY) {
|
|
2539
|
+
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
2540
|
+
if (bounds.width === 0 || bounds.height === 0) return null;
|
|
2541
|
+
const worldX = (clientX - bounds.left) / bounds.width * 2 - 1;
|
|
2542
|
+
const worldY = -((clientY - bounds.top) / bounds.height) * 2 + 1;
|
|
2543
|
+
this.pointer.set(worldX, worldY);
|
|
2544
|
+
if (this.camera instanceof OrthographicCamera) {
|
|
2545
|
+
const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
|
|
2546
|
+
return {
|
|
2547
|
+
x: world.x,
|
|
2548
|
+
y: world.y,
|
|
2549
|
+
z: world.z
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
2553
|
+
const plane = new Plane(new Vector3(0, 0, 1), 0);
|
|
2554
|
+
const world = this.raycaster.ray.intersectPlane(plane, new Vector3());
|
|
2555
|
+
if (!world) return null;
|
|
2556
|
+
return {
|
|
2557
|
+
x: world.x,
|
|
2558
|
+
y: world.y,
|
|
2559
|
+
z: world.z
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
/** Creates a bounded DOM layer for labels, a toolbar, and optional rich HTML focus cards. */
|
|
2563
|
+
createOverlay(options = {}) {
|
|
2564
|
+
return new GraphraumOverlay(this, this.container, options);
|
|
2565
|
+
}
|
|
2566
|
+
/** Subscribes to camera and viewport changes. Returns an unsubscribe function. */
|
|
2567
|
+
onViewChange(listener) {
|
|
2568
|
+
this.viewListeners.add(listener);
|
|
2569
|
+
listener();
|
|
2570
|
+
return () => this.viewListeners.delete(listener);
|
|
2571
|
+
}
|
|
2572
|
+
/** Subscribes to selection and hover changes that affect overlay focus chrome. */
|
|
2573
|
+
onFocusChange(listener) {
|
|
2574
|
+
this.focusListeners.add(listener);
|
|
2575
|
+
listener();
|
|
2576
|
+
return () => this.focusListeners.delete(listener);
|
|
2577
|
+
}
|
|
2578
|
+
notifyFocusChange() {
|
|
2579
|
+
for (const listener of this.focusListeners) listener();
|
|
2580
|
+
}
|
|
2581
|
+
handlePointerMove = (event) => {
|
|
2582
|
+
const nodeId = this.pickNode(event.clientX, event.clientY);
|
|
2583
|
+
if (nodeId === this.lastHoveredNodeId) return;
|
|
2584
|
+
this.lastHoveredNodeId = nodeId;
|
|
2585
|
+
this.setNodeState("hovered", nodeId ? [nodeId] : []);
|
|
2586
|
+
};
|
|
2587
|
+
handlePointerLeave = () => {
|
|
2588
|
+
if (this.lastHoveredNodeId === null && this.hoveredNodeIds.size === 0) return;
|
|
2589
|
+
this.lastHoveredNodeId = null;
|
|
2590
|
+
this.setNodeState("hovered", []);
|
|
2591
|
+
};
|
|
2592
|
+
getEdgePresentation(edgeId) {
|
|
2593
|
+
return this.edgePresentations.get(edgeId) ?? null;
|
|
2594
|
+
}
|
|
2595
|
+
getDiagnostics() {
|
|
2596
|
+
return {
|
|
2597
|
+
aggregatedNodeClusters: this.densityLodActive ? this.visibleNodeCount : 0,
|
|
2598
|
+
cpuFrameMilliseconds: this.cpuFrameMilliseconds,
|
|
2599
|
+
gpuFrameMilliseconds: this.gpuFrameMilliseconds,
|
|
2600
|
+
gpuDrawCalls: this.renderer.info.render.calls,
|
|
2601
|
+
gpuGeometries: this.renderer.info.memory.geometries,
|
|
2602
|
+
gpuTextures: this.renderer.info.memory.textures,
|
|
2603
|
+
lodLevel: resolveLodLevel(this.densityLodActive, this.visibleEdgeCandidateCount, this.visibleEdgeCount),
|
|
2604
|
+
pickingStrategy: this.mode === "2d" ? "spatial-grid-2d" : "raycaster-3d",
|
|
2605
|
+
selectedEdges: this.selectedEdgeIds.size,
|
|
2606
|
+
selectedNodes: this.selectedNodeIds.size,
|
|
2607
|
+
totalEdges: this.data.edges.length,
|
|
2608
|
+
totalNodes: this.data.nodes.length,
|
|
2609
|
+
visibleEdgeCandidates: this.visibleEdgeCandidateCount,
|
|
2610
|
+
visibleEdgeMarkers: this.visibleEdgeMarkerCount,
|
|
2611
|
+
visibleEdgeSegments: this.visibleEdgeSegmentCount,
|
|
2612
|
+
visibleEdges: this.visibleEdgeCount,
|
|
2613
|
+
visibleNodes: this.visibleNodeCount,
|
|
2614
|
+
visibleNodeCandidates: this.visibleNodeCandidateCount
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
/** Picks a node under the pointer. Prefer `pickHit` when edges must be selectable too. */
|
|
2618
|
+
pick(clientX, clientY) {
|
|
2619
|
+
const hit = this.pickHit(clientX, clientY);
|
|
2620
|
+
return hit?.kind === "node" ? hit.id : null;
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* Picks the top-most graph element under the pointer. Nodes win over edges when both hit.
|
|
2624
|
+
* 2D uses the spatial grid for nodes and world-space segment distance for edges; 3D uses
|
|
2625
|
+
* the node pick mesh then the edge instance mesh.
|
|
2626
|
+
*/
|
|
2627
|
+
pickHit(clientX, clientY) {
|
|
2628
|
+
const nodeId = this.pickNode(clientX, clientY);
|
|
2629
|
+
if (nodeId) return {
|
|
2630
|
+
kind: "node",
|
|
2631
|
+
id: nodeId
|
|
2632
|
+
};
|
|
2633
|
+
const edgeId = this.pickEdge(clientX, clientY);
|
|
2634
|
+
return edgeId ? {
|
|
2635
|
+
kind: "edge",
|
|
2636
|
+
id: edgeId
|
|
2637
|
+
} : null;
|
|
2638
|
+
}
|
|
2639
|
+
fitView() {
|
|
2640
|
+
if (!this.nodeMesh || this.data.nodes.length === 0) {
|
|
2641
|
+
this.requestRender();
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const bounds = new Box3();
|
|
2645
|
+
const minimum = new Vector3();
|
|
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());
|
|
2661
|
+
const width = Math.max(this.container.clientWidth, 1);
|
|
2662
|
+
const height = Math.max(this.container.clientHeight, 1);
|
|
2663
|
+
const aspect = width / height;
|
|
2664
|
+
if (this.camera instanceof OrthographicCamera) {
|
|
2665
|
+
const visibleHeight = Math.max(size.y, size.x / aspect, 1) * 1.15;
|
|
2666
|
+
this.camera.left = -visibleHeight * aspect / 2;
|
|
2667
|
+
this.camera.right = visibleHeight * aspect / 2;
|
|
2668
|
+
this.camera.top = visibleHeight / 2;
|
|
2669
|
+
this.camera.bottom = -visibleHeight / 2;
|
|
2670
|
+
this.camera.position.set(center.x, center.y, center.z + Math.max(size.z, 1e3));
|
|
2671
|
+
} else {
|
|
2672
|
+
applyPerspectiveContainerAspect(this.camera, width, height);
|
|
2673
|
+
const distance = Math.max(size.length() / 2, 1) / Math.sin(this.camera.fov * Math.PI / 360);
|
|
2674
|
+
this.camera.position.set(center.x, center.y, center.z + distance * 1.15);
|
|
2675
|
+
this.camera.near = Math.max(distance / 1e4, .1);
|
|
2676
|
+
this.camera.far = distance * 10;
|
|
2677
|
+
}
|
|
2678
|
+
this.camera.lookAt(center);
|
|
2679
|
+
this.camera.updateProjectionMatrix();
|
|
2680
|
+
this.controls.target.copy(center);
|
|
2681
|
+
this.controls.update();
|
|
2682
|
+
this.materializeViewport();
|
|
2683
|
+
this.requestRender();
|
|
2684
|
+
}
|
|
2685
|
+
setTheme(theme) {
|
|
2686
|
+
this.theme = typeof theme === "string" ? resolveGraphraumTheme(theme) : resolveGraphraumTheme(theme, this.theme);
|
|
2687
|
+
this.applyBackground();
|
|
2688
|
+
if (this.nodeMesh) this.materializeViewport();
|
|
2689
|
+
this.requestRender();
|
|
2690
|
+
}
|
|
2691
|
+
setBackground(background) {
|
|
2692
|
+
this.setTheme({ background });
|
|
2693
|
+
}
|
|
2694
|
+
resize() {
|
|
2695
|
+
const width = Math.max(this.container.clientWidth, 1);
|
|
2696
|
+
const height = Math.max(this.container.clientHeight, 1);
|
|
2697
|
+
this.renderer.setSize(width, height, false);
|
|
2698
|
+
if (this.camera instanceof PerspectiveCamera) {
|
|
2699
|
+
applyPerspectiveContainerAspect(this.camera, width, height);
|
|
2700
|
+
this.camera.updateProjectionMatrix();
|
|
2701
|
+
} else if (this.camera instanceof OrthographicCamera) {
|
|
2702
|
+
this.fitView();
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
this.materializeViewport();
|
|
2706
|
+
this.requestRender();
|
|
2707
|
+
}
|
|
2708
|
+
destroy() {
|
|
2709
|
+
this.resizeObserver.disconnect();
|
|
2710
|
+
this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
|
|
2711
|
+
this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
|
|
2712
|
+
this.controls.removeEventListener("change", this.handleViewChange);
|
|
2713
|
+
this.controls.dispose();
|
|
2714
|
+
if (this.frameRequest !== null) cancelAnimationFrame(this.frameRequest);
|
|
2715
|
+
if (this.gpuTimer) for (const query of this.gpuTimer.pending) this.gpuTimer.context.deleteQuery(query);
|
|
2716
|
+
this.disposeBackgroundTexture();
|
|
2717
|
+
this.disposeGraphObjects();
|
|
2718
|
+
this.renderer.dispose();
|
|
2719
|
+
this.renderer.domElement.remove();
|
|
2720
|
+
}
|
|
2721
|
+
requestRender = () => {
|
|
2722
|
+
if (this.frameRequest !== null) return;
|
|
2723
|
+
this.frameRequest = requestAnimationFrame(() => {
|
|
2724
|
+
this.frameRequest = null;
|
|
2725
|
+
this.readGpuTimers();
|
|
2726
|
+
const timer = this.gpuTimer;
|
|
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();
|
|
2737
|
+
});
|
|
2738
|
+
};
|
|
2739
|
+
readGpuTimers() {
|
|
2740
|
+
const timer = this.gpuTimer;
|
|
2741
|
+
if (!timer) return;
|
|
2742
|
+
while (timer.pending.length > 0) {
|
|
2743
|
+
const query = timer.pending[0];
|
|
2744
|
+
if (!query || !timer.context.getQueryParameter(query, timer.context.QUERY_RESULT_AVAILABLE)) return;
|
|
2745
|
+
timer.pending.shift();
|
|
2746
|
+
if (!timer.context.getParameter(timer.extension.GPU_DISJOINT_EXT)) this.gpuFrameMilliseconds = timer.context.getQueryParameter(query, timer.context.QUERY_RESULT) / 1e6;
|
|
2747
|
+
timer.context.deleteQuery(query);
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
handleViewChange = () => {
|
|
2751
|
+
this.materializeViewport();
|
|
2752
|
+
this.requestRender();
|
|
2753
|
+
};
|
|
2754
|
+
createCamera() {
|
|
2755
|
+
const aspect = containerAspect(this.container.clientWidth, this.container.clientHeight);
|
|
2756
|
+
return this.mode === "2d" ? new OrthographicCamera(-1, 1, 1, -1, -1e5, 1e5) : new PerspectiveCamera(45, aspect, .1, 1e5);
|
|
2757
|
+
}
|
|
2758
|
+
createControls() {
|
|
2759
|
+
const controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
2760
|
+
controls.enableDamping = false;
|
|
2761
|
+
controls.enableRotate = this.mode === "3d";
|
|
2762
|
+
controls.screenSpacePanning = true;
|
|
2763
|
+
if (this.mode === "2d") {
|
|
2764
|
+
controls.minZoom = .01;
|
|
2765
|
+
controls.mouseButtons.LEFT = MOUSE.PAN;
|
|
2766
|
+
}
|
|
2767
|
+
controls.addEventListener("change", this.handleViewChange);
|
|
2768
|
+
return controls;
|
|
2769
|
+
}
|
|
2770
|
+
pickNode(clientX, clientY) {
|
|
2771
|
+
if (!this.nodeMesh || !this.nodePickMesh) return null;
|
|
2772
|
+
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
2773
|
+
if (bounds.width === 0 || bounds.height === 0) return null;
|
|
2774
|
+
this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
|
|
2775
|
+
if (this.camera instanceof OrthographicCamera) {
|
|
2776
|
+
const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
|
|
2777
|
+
const index = this.spatialGrid2d.pick(world.x, world.y);
|
|
2778
|
+
return index === null ? null : this.nodeIds[index] ?? null;
|
|
2779
|
+
}
|
|
2780
|
+
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
2781
|
+
const checkedInstances = /* @__PURE__ */ new Set();
|
|
2782
|
+
for (const hit of this.raycaster.intersectObject(this.nodePickMesh, false)) {
|
|
2783
|
+
if (hit.instanceId === void 0 || checkedInstances.has(hit.instanceId)) continue;
|
|
2784
|
+
checkedInstances.add(hit.instanceId);
|
|
2785
|
+
const nodeIndex = this.visibleNodeIndices[hit.instanceId];
|
|
2786
|
+
if (nodeIndex === void 0) continue;
|
|
2787
|
+
const node = this.data.nodes[nodeIndex];
|
|
2788
|
+
if (!node) continue;
|
|
2789
|
+
const { height, width } = resolveNodeAxes({
|
|
2790
|
+
height: node.height,
|
|
2791
|
+
nodeId: node.id,
|
|
2792
|
+
size: node.size,
|
|
2793
|
+
width: node.width
|
|
2794
|
+
});
|
|
2795
|
+
this.pickCenter.set(node.position.x, node.position.y, node.position.z ?? 0).project(this.camera);
|
|
2796
|
+
this.pickRight.set(1, 0, 0).applyQuaternion(this.camera.quaternion).multiplyScalar(width);
|
|
2797
|
+
this.pickRight.set(this.pickRight.x + node.position.x, this.pickRight.y + node.position.y, this.pickRight.z + (node.position.z ?? 0));
|
|
2798
|
+
this.pickRight.project(this.camera);
|
|
2799
|
+
this.pickUp.set(0, 1, 0).applyQuaternion(this.camera.quaternion).multiplyScalar(height);
|
|
2800
|
+
this.pickUp.set(this.pickUp.x + node.position.x, this.pickUp.y + node.position.y, this.pickUp.z + (node.position.z ?? 0));
|
|
2801
|
+
this.pickUp.project(this.camera);
|
|
2802
|
+
const radiusNdcX = Math.abs(this.pickRight.x - this.pickCenter.x);
|
|
2803
|
+
const radiusNdcY = Math.abs(this.pickUp.y - this.pickCenter.y);
|
|
2804
|
+
if (radiusNdcX === 0 || radiusNdcY === 0) continue;
|
|
2805
|
+
const offsetX = (this.pointer.x - this.pickCenter.x) / radiusNdcX;
|
|
2806
|
+
const offsetY = (this.pointer.y - this.pickCenter.y) / radiusNdcY;
|
|
2807
|
+
if (containsNodePoint(node.shape, offsetX, offsetY)) return this.nodeIds[nodeIndex] ?? null;
|
|
2808
|
+
}
|
|
2809
|
+
return null;
|
|
2810
|
+
}
|
|
2811
|
+
pickEdge(clientX, clientY) {
|
|
2812
|
+
if (!this.edgeMesh || this.pickableEdgeSegments.length === 0) return null;
|
|
2813
|
+
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
2814
|
+
if (bounds.width === 0 || bounds.height === 0) return null;
|
|
2815
|
+
this.pointer.set((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1);
|
|
2816
|
+
if (this.camera instanceof OrthographicCamera) {
|
|
2817
|
+
const world = new Vector3(this.pointer.x, this.pointer.y, 0).unproject(this.camera);
|
|
2818
|
+
const edgeIndex = pickClosestEdgeIndex({
|
|
2819
|
+
x: world.x,
|
|
2820
|
+
y: world.y
|
|
2821
|
+
}, this.pickableEdgeSegments);
|
|
2822
|
+
return edgeIndex === null ? null : this.data.edges[edgeIndex]?.id ?? null;
|
|
2823
|
+
}
|
|
2824
|
+
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
2825
|
+
for (const hit of this.raycaster.intersectObject(this.edgeMesh, false)) {
|
|
2826
|
+
if (hit.instanceId === void 0) continue;
|
|
2827
|
+
if (hit.instanceId >= this.visibleEdgeSegmentCount) continue;
|
|
2828
|
+
const edgeIndex = this.visibleEdgeSegmentEdgeIndices[hit.instanceId];
|
|
2829
|
+
if (edgeIndex === void 0) continue;
|
|
2830
|
+
return this.data.edges[edgeIndex]?.id ?? null;
|
|
2831
|
+
}
|
|
2832
|
+
return null;
|
|
2833
|
+
}
|
|
2834
|
+
worldUnitsPerPixel() {
|
|
2835
|
+
const width = Math.max(this.renderer.domElement.clientWidth, 1);
|
|
2836
|
+
if (this.camera instanceof OrthographicCamera) return (this.camera.right - this.camera.left) / (width * this.camera.zoom);
|
|
2837
|
+
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
2838
|
+
const left = this.screenToWorld(bounds.left, bounds.top);
|
|
2839
|
+
const right = this.screenToWorld(bounds.left + 1, bounds.top);
|
|
2840
|
+
if (!left || !right) return 1;
|
|
2841
|
+
return Math.hypot(right.x - left.x, right.y - left.y) || 1;
|
|
2842
|
+
}
|
|
2843
|
+
applySelectionColors(nodeIds) {
|
|
2844
|
+
if (!this.nodeMesh?.instanceColor) return;
|
|
2845
|
+
const slots = [];
|
|
2846
|
+
for (const nodeId of nodeIds) {
|
|
2847
|
+
const index = this.nodeIndices.get(nodeId);
|
|
2848
|
+
if (index === void 0) continue;
|
|
2849
|
+
const slot = this.visibleNodeSlots.get(index);
|
|
2850
|
+
if (slot === void 0) continue;
|
|
2851
|
+
const node = this.data.nodes[index];
|
|
2852
|
+
if (!node) continue;
|
|
2853
|
+
this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
|
|
2854
|
+
slots.push(slot);
|
|
2855
|
+
}
|
|
2856
|
+
markInstanceColorSlots(this.nodeMesh.instanceColor, slots);
|
|
2857
|
+
}
|
|
2858
|
+
getNodeStateIds(state) {
|
|
2859
|
+
switch (state) {
|
|
2860
|
+
case "dimmed": return this.dimmedNodeIds;
|
|
2861
|
+
case "focused": return this.focusedNodeIds;
|
|
2862
|
+
case "hovered": return this.hoveredNodeIds;
|
|
2863
|
+
case "selected": return this.selectedNodeIds;
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
setNodeStateIds(state, nodeIds) {
|
|
2867
|
+
switch (state) {
|
|
2868
|
+
case "dimmed":
|
|
2869
|
+
this.dimmedNodeIds = nodeIds;
|
|
2870
|
+
return;
|
|
2871
|
+
case "focused":
|
|
2872
|
+
this.focusedNodeIds = nodeIds;
|
|
2873
|
+
return;
|
|
2874
|
+
case "hovered":
|
|
2875
|
+
this.hoveredNodeIds = nodeIds;
|
|
2876
|
+
return;
|
|
2877
|
+
case "selected": this.selectedNodeIds = nodeIds;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
getNodeColor(node) {
|
|
2881
|
+
if (this.focusedNodeIds.has(node.id)) return new Color(this.theme.focusedNode);
|
|
2882
|
+
if (this.selectedNodeIds.has(node.id)) return new Color(this.theme.selectedNode);
|
|
2883
|
+
if (this.hoveredNodeIds.has(node.id)) return new Color(this.theme.hoveredNode);
|
|
2884
|
+
if (this.dimmedNodeIds.has(node.id)) return new Color(this.theme.dimmedNode);
|
|
2885
|
+
return new Color(node.color ?? this.theme.node);
|
|
2886
|
+
}
|
|
2887
|
+
getViewportBounds2d() {
|
|
2888
|
+
if (!(this.camera instanceof OrthographicCamera) || !this.viewportCulling) return null;
|
|
2889
|
+
const bottomLeft = new Vector3(-1, -1, 0).unproject(this.camera);
|
|
2890
|
+
const topRight = new Vector3(1, 1, 0).unproject(this.camera);
|
|
2891
|
+
return {
|
|
2892
|
+
bottom: bottomLeft.y,
|
|
2893
|
+
left: bottomLeft.x,
|
|
2894
|
+
right: topRight.x,
|
|
2895
|
+
top: topRight.y
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
2898
|
+
isNodeVisible2d(node, bounds) {
|
|
2899
|
+
if (!bounds) return true;
|
|
2900
|
+
const { height, width } = resolveNodeAxes({
|
|
2901
|
+
height: node.height,
|
|
2902
|
+
nodeId: node.id,
|
|
2903
|
+
size: node.size,
|
|
2904
|
+
width: node.width
|
|
2905
|
+
});
|
|
2906
|
+
return node.position.x + width >= bounds.left - this.viewportOverscan && node.position.x - width <= bounds.right + this.viewportOverscan && node.position.y + height >= bounds.bottom - this.viewportOverscan && node.position.y - height <= bounds.top + this.viewportOverscan;
|
|
2907
|
+
}
|
|
2908
|
+
disposeBackgroundTexture() {
|
|
2909
|
+
if (!this.backgroundTexture) return;
|
|
2910
|
+
this.backgroundTexture.dispose();
|
|
2911
|
+
this.backgroundTexture = null;
|
|
2912
|
+
}
|
|
2913
|
+
createBackgroundTexture(source, repeat) {
|
|
2914
|
+
const texture = source instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas ? new CanvasTexture(source) : new Texture(source);
|
|
2915
|
+
texture.colorSpace = SRGBColorSpace;
|
|
2916
|
+
texture.needsUpdate = true;
|
|
2917
|
+
const [repeatX, repeatY] = repeat ?? [1, 1];
|
|
2918
|
+
if (repeatX !== 1 || repeatY !== 1) {
|
|
2919
|
+
texture.wrapS = RepeatWrapping;
|
|
2920
|
+
texture.wrapT = RepeatWrapping;
|
|
2921
|
+
texture.repeat.set(repeatX, repeatY);
|
|
2922
|
+
}
|
|
2923
|
+
return texture;
|
|
2924
|
+
}
|
|
2925
|
+
applyBackground() {
|
|
2926
|
+
this.disposeBackgroundTexture();
|
|
2927
|
+
const background = normalizeGraphraumBackground(this.theme.background);
|
|
2928
|
+
if (background === null) {
|
|
2929
|
+
this.scene.background = null;
|
|
2930
|
+
this.renderer.setClearColor(0, 0);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (typeof background === "object") {
|
|
2934
|
+
this.backgroundTexture = this.createBackgroundTexture(background.source, background.repeat);
|
|
2935
|
+
this.scene.background = this.backgroundTexture;
|
|
2936
|
+
this.renderer.setClearColor(0, 1);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
const color = new Color(background);
|
|
2940
|
+
this.scene.background = color;
|
|
2941
|
+
this.renderer.setClearColor(color, 1);
|
|
2942
|
+
}
|
|
2943
|
+
materializeViewport() {
|
|
2944
|
+
if (!this.nodeMesh || !this.nodePickMesh || !this.edgeMesh) return;
|
|
2945
|
+
const bounds = this.getViewportBounds2d();
|
|
2946
|
+
const visibleNodeCandidates = bounds ? this.spatialGrid2d.queryBounds(bounds, this.viewportOverscan) : this.data.nodes.map((_, index) => index);
|
|
2947
|
+
this.densityLodActive = shouldUseDensityLod(visibleNodeCandidates.length, this.maxVisibleNodes, this.densityLodActive);
|
|
2948
|
+
const materializedNodes = this.materializeNodes(visibleNodeCandidates, bounds);
|
|
2949
|
+
const visibleNodeIndices = materializedNodes.map(({ index }) => index);
|
|
2950
|
+
const edgeCandidates = bounds ? collectIncidentEdges(visibleNodeIndices, this.incidentEdgeIndices) : this.data.edges.map((_, index) => index);
|
|
2951
|
+
const visibleEdgeIndices = applyEdgeBudget(edgeCandidates, this.maxVisibleEdges);
|
|
2952
|
+
const matrix = new Matrix4();
|
|
2953
|
+
this.visibleNodeSlots = /* @__PURE__ */ new Map();
|
|
2954
|
+
this.visibleNodeIndices = visibleNodeIndices;
|
|
2955
|
+
this.nodeMesh.instanceMatrix.clearUpdateRanges();
|
|
2956
|
+
this.nodePickMesh.instanceMatrix.clearUpdateRanges();
|
|
2957
|
+
this.nodeMesh.instanceColor?.clearUpdateRanges();
|
|
2958
|
+
const nodeShape = this.nodeMesh.geometry.getAttribute("instanceShape");
|
|
2959
|
+
const nodeStrokeWidth = this.nodeMesh.geometry.getAttribute("instanceStrokeWidth");
|
|
2960
|
+
const nodeStrokeColor = this.nodeMesh.geometry.getAttribute("instanceStrokeColor");
|
|
2961
|
+
nodeShape.clearUpdateRanges();
|
|
2962
|
+
nodeStrokeWidth.clearUpdateRanges();
|
|
2963
|
+
nodeStrokeColor.clearUpdateRanges();
|
|
2964
|
+
for (const [slot, materialized] of materializedNodes.entries()) {
|
|
2965
|
+
const nodeIndex = materialized.index;
|
|
2966
|
+
const node = this.data.nodes[nodeIndex];
|
|
2967
|
+
if (!node) continue;
|
|
2968
|
+
this.visibleNodeSlots.set(nodeIndex, slot);
|
|
2969
|
+
const { height, width } = materialized;
|
|
2970
|
+
const maxAxis = Math.max(width, height);
|
|
2971
|
+
matrix.makeScale(width, height, maxAxis);
|
|
2972
|
+
matrix.setPosition(materialized.x, materialized.y, materialized.z);
|
|
2973
|
+
this.nodeMesh.setMatrixAt(slot, matrix);
|
|
2974
|
+
const pickScale = maxAxis * Math.SQRT2;
|
|
2975
|
+
matrix.makeScale(pickScale, pickScale, pickScale);
|
|
2976
|
+
matrix.setPosition(materialized.x, materialized.y, materialized.z);
|
|
2977
|
+
this.nodePickMesh.setMatrixAt(slot, matrix);
|
|
2978
|
+
this.nodeMesh.setColorAt(slot, this.getNodeColor(node));
|
|
2979
|
+
setNodeShapeAt(nodeShape, slot, node.shape);
|
|
2980
|
+
setNodeStrokeAt(nodeStrokeWidth, nodeStrokeColor, slot, resolveNodeStroke(node, this.theme));
|
|
2981
|
+
}
|
|
2982
|
+
this.nodeMesh.count = materializedNodes.length;
|
|
2983
|
+
this.nodePickMesh.count = materializedNodes.length;
|
|
2984
|
+
this.nodeMesh.instanceMatrix.addUpdateRange(0, materializedNodes.length * 16);
|
|
2985
|
+
this.nodeMesh.instanceMatrix.needsUpdate = true;
|
|
2986
|
+
this.nodePickMesh.instanceMatrix.addUpdateRange(0, materializedNodes.length * 16);
|
|
2987
|
+
this.nodePickMesh.instanceMatrix.needsUpdate = true;
|
|
2988
|
+
if (this.nodeMesh.instanceColor) {
|
|
2989
|
+
this.nodeMesh.instanceColor.addUpdateRange(0, materializedNodes.length * 3);
|
|
2990
|
+
this.nodeMesh.instanceColor.needsUpdate = true;
|
|
2991
|
+
}
|
|
2992
|
+
nodeShape.addUpdateRange(0, materializedNodes.length);
|
|
2993
|
+
nodeShape.needsUpdate = true;
|
|
2994
|
+
nodeStrokeWidth.addUpdateRange(0, materializedNodes.length);
|
|
2995
|
+
nodeStrokeWidth.needsUpdate = true;
|
|
2996
|
+
nodeStrokeColor.addUpdateRange(0, materializedNodes.length * 3);
|
|
2997
|
+
nodeStrokeColor.needsUpdate = true;
|
|
2998
|
+
const lodLevel = resolveLodLevel(this.densityLodActive, edgeCandidates.length, visibleEdgeIndices.length);
|
|
2999
|
+
this.lastEdgeLodTier = edgeTierFromDiagnosticsLod(lodLevel);
|
|
3000
|
+
const worldPerPixel = this.worldUnitsPerPixel();
|
|
3001
|
+
const packed = packEdgeInstances({
|
|
3002
|
+
edgeIndices: visibleEdgeIndices,
|
|
3003
|
+
edgeVisuals: this.data.edges.map((edge) => this.selectedEdgeIds.has(edge.id) ? {
|
|
3004
|
+
...edge,
|
|
3005
|
+
color: this.theme.selectedEdge
|
|
3006
|
+
} : edge),
|
|
3007
|
+
endpointPositions: this.canonicalEdgePositions,
|
|
3008
|
+
defaults: {
|
|
3009
|
+
color: this.theme.edge,
|
|
3010
|
+
opacity: this.theme.edgeOpacity,
|
|
3011
|
+
width: this.theme.edgeWidth
|
|
3012
|
+
},
|
|
3013
|
+
tier: this.lastEdgeLodTier,
|
|
3014
|
+
maxSegments: this.edgeSegmentCapacity,
|
|
3015
|
+
endpointAttach: this.theme.endpointAttach,
|
|
3016
|
+
edgeNodeIndices: this.edgeNodeIndices,
|
|
3017
|
+
nodeOutlines: this.data.nodes.map((node) => ({
|
|
3018
|
+
height: node.height,
|
|
3019
|
+
shape: node.shape,
|
|
3020
|
+
size: node.size,
|
|
3021
|
+
strokeWidth: node.strokeWidth,
|
|
3022
|
+
width: node.width
|
|
3023
|
+
}))
|
|
3024
|
+
});
|
|
3025
|
+
const edgeGeometry = this.edgeMesh.geometry;
|
|
3026
|
+
this.visibleEdgeSlots = /* @__PURE__ */ new Map();
|
|
3027
|
+
this.visibleEdgeLayouts = buildVisibleEdgeLayouts(packed.segments, packed.markers, packed.segments.length);
|
|
3028
|
+
this.pickableEdgeSegments = [];
|
|
3029
|
+
this.visibleEdgeSegmentEdgeIndices = [];
|
|
3030
|
+
let edgeSlot = 0;
|
|
3031
|
+
const distinctEdges = /* @__PURE__ */ new Set();
|
|
3032
|
+
const minHitSlop = Math.max(4 * worldPerPixel, this.theme.edgeWidth * worldPerPixel);
|
|
3033
|
+
for (const segment of packed.segments) {
|
|
3034
|
+
writeEdgeSegmentInstance(edgeGeometry, edgeSlot, segment);
|
|
3035
|
+
if (!this.visibleEdgeSlots.has(segment.edgeIndex)) this.visibleEdgeSlots.set(segment.edgeIndex, edgeSlot);
|
|
3036
|
+
distinctEdges.add(segment.edgeIndex);
|
|
3037
|
+
this.visibleEdgeSegmentEdgeIndices.push(segment.edgeIndex);
|
|
3038
|
+
this.pickableEdgeSegments.push({
|
|
3039
|
+
edgeIndex: segment.edgeIndex,
|
|
3040
|
+
hitSlop: Math.max(minHitSlop, segment.width * worldPerPixel / 2 + 2 * worldPerPixel),
|
|
3041
|
+
x1: segment.x1,
|
|
3042
|
+
y1: segment.y1,
|
|
3043
|
+
x2: segment.x2,
|
|
3044
|
+
y2: segment.y2
|
|
3045
|
+
});
|
|
3046
|
+
edgeSlot += 1;
|
|
3047
|
+
}
|
|
3048
|
+
const segmentCount = edgeSlot;
|
|
3049
|
+
for (const marker of packed.markers) {
|
|
3050
|
+
writeEdgeMarkerInstance(edgeGeometry, edgeSlot, marker);
|
|
3051
|
+
edgeSlot += 1;
|
|
3052
|
+
}
|
|
3053
|
+
this.edgeMesh.count = edgeSlot;
|
|
3054
|
+
for (const name of EDGE_INSTANCE_ATTRIBUTES) {
|
|
3055
|
+
const attribute = edgeGeometry.getAttribute(name);
|
|
3056
|
+
attribute.clearUpdateRanges();
|
|
3057
|
+
attribute.addUpdateRange(0, edgeSlot * attribute.itemSize);
|
|
3058
|
+
attribute.needsUpdate = true;
|
|
3059
|
+
}
|
|
3060
|
+
this.visibleNodeCandidateCount = visibleNodeCandidates.length;
|
|
3061
|
+
this.visibleNodeCount = materializedNodes.length;
|
|
3062
|
+
this.visibleEdgeCandidateCount = edgeCandidates.length;
|
|
3063
|
+
this.visibleEdgeSegmentCount = segmentCount;
|
|
3064
|
+
this.visibleEdgeCount = distinctEdges.size;
|
|
3065
|
+
this.visibleEdgeMarkerCount = packed.markers.length;
|
|
3066
|
+
}
|
|
3067
|
+
materializeNodes(indices, bounds) {
|
|
3068
|
+
if (!this.densityLodActive || !bounds) return indices.slice(0, this.maxVisibleNodes).flatMap((index) => {
|
|
3069
|
+
const node = this.data.nodes[index];
|
|
3070
|
+
if (!node) return [];
|
|
3071
|
+
const { height, width } = resolveNodeAxes({
|
|
3072
|
+
height: node.height,
|
|
3073
|
+
nodeId: node.id,
|
|
3074
|
+
size: node.size,
|
|
3075
|
+
width: node.width
|
|
3076
|
+
});
|
|
3077
|
+
return [{
|
|
3078
|
+
height,
|
|
3079
|
+
index,
|
|
3080
|
+
width,
|
|
3081
|
+
x: node.position.x,
|
|
3082
|
+
y: node.position.y,
|
|
3083
|
+
z: node.position.z ?? 0
|
|
3084
|
+
}];
|
|
3085
|
+
});
|
|
3086
|
+
const aspect = Math.max(this.container.clientWidth, 1) / Math.max(this.container.clientHeight, 1);
|
|
3087
|
+
const columns = Math.max(1, Math.floor(Math.sqrt(this.maxVisibleNodes * aspect)));
|
|
3088
|
+
const rows = Math.max(1, Math.floor(this.maxVisibleNodes / columns));
|
|
3089
|
+
const cellWidth = Math.max((bounds.right - bounds.left) / columns, Number.EPSILON);
|
|
3090
|
+
const cellHeight = Math.max((bounds.top - bounds.bottom) / rows, Number.EPSILON);
|
|
3091
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
3092
|
+
for (const index of indices) {
|
|
3093
|
+
const node = this.data.nodes[index];
|
|
3094
|
+
if (!node) continue;
|
|
3095
|
+
const axes = resolveNodeAxes({
|
|
3096
|
+
height: node.height,
|
|
3097
|
+
nodeId: node.id,
|
|
3098
|
+
size: node.size,
|
|
3099
|
+
width: node.width
|
|
3100
|
+
});
|
|
3101
|
+
const axisSize = Math.max(axes.width, axes.height);
|
|
3102
|
+
const key = `${Math.min(columns - 1, Math.max(0, Math.floor((node.position.x - bounds.left) / cellWidth)))}:${Math.min(rows - 1, Math.max(0, Math.floor((node.position.y - bounds.bottom) / cellHeight)))}`;
|
|
3103
|
+
const cluster = clusters.get(key);
|
|
3104
|
+
if (!cluster) {
|
|
3105
|
+
clusters.set(key, {
|
|
3106
|
+
count: 1,
|
|
3107
|
+
height: axisSize,
|
|
3108
|
+
index,
|
|
3109
|
+
width: axisSize,
|
|
3110
|
+
x: node.position.x,
|
|
3111
|
+
y: node.position.y,
|
|
3112
|
+
z: node.position.z ?? 0
|
|
3113
|
+
});
|
|
3114
|
+
continue;
|
|
3115
|
+
}
|
|
3116
|
+
cluster.count += 1;
|
|
3117
|
+
cluster.x += (node.position.x - cluster.x) / cluster.count;
|
|
3118
|
+
cluster.y += (node.position.y - cluster.y) / cluster.count;
|
|
3119
|
+
cluster.z += ((node.position.z ?? 0) - cluster.z) / cluster.count;
|
|
3120
|
+
const grownAxis = Math.min(Math.max(cluster.width, axisSize) + .25, 16);
|
|
3121
|
+
cluster.width = grownAxis;
|
|
3122
|
+
cluster.height = grownAxis;
|
|
3123
|
+
}
|
|
3124
|
+
return [...clusters.values()].slice(0, this.maxVisibleNodes);
|
|
3125
|
+
}
|
|
3126
|
+
disposeGraphObjects() {
|
|
3127
|
+
this.pickableEdgeSegments = [];
|
|
3128
|
+
this.visibleEdgeSegmentEdgeIndices = [];
|
|
3129
|
+
this.visibleEdgeLayouts = /* @__PURE__ */ new Map();
|
|
3130
|
+
if (this.nodeMesh) {
|
|
3131
|
+
this.scene.remove(this.nodeMesh);
|
|
3132
|
+
this.nodeMesh.geometry.dispose();
|
|
3133
|
+
disposeMaterial(this.nodeMesh.material);
|
|
3134
|
+
this.nodeMesh = null;
|
|
3135
|
+
}
|
|
3136
|
+
if (this.nodePickMesh) {
|
|
3137
|
+
this.nodePickMesh.geometry.dispose();
|
|
3138
|
+
disposeMaterial(this.nodePickMesh.material);
|
|
3139
|
+
this.nodePickMesh = null;
|
|
3140
|
+
}
|
|
3141
|
+
if (this.edgeMesh) {
|
|
3142
|
+
this.scene.remove(this.edgeMesh);
|
|
3143
|
+
this.edgeMesh.geometry.dispose();
|
|
3144
|
+
disposeMaterial(this.edgeMesh.material);
|
|
3145
|
+
this.edgeMesh = null;
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
function nextCapacity(length) {
|
|
3150
|
+
let capacity = 1;
|
|
3151
|
+
while (capacity < length) capacity *= 2;
|
|
3152
|
+
return capacity;
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* Segment capacity is edgeCapacity × DETAIL_MAX_SEGMENTS (cubic worst case).
|
|
3156
|
+
* Marker capacity is always exactly 2× edge capacity (both ends) — never derived from
|
|
3157
|
+
* `nextCapacity(edgeCount * 2)` independently, which can undershoot for small edge counts
|
|
3158
|
+
* and let a later append with `markerEnd: "both"` overrun the instanced buffer.
|
|
3159
|
+
*/
|
|
3160
|
+
function edgeInstanceCapacities(edgeCount, maxVisibleEdges) {
|
|
3161
|
+
const edgeCapacity = Math.min(maxVisibleEdges, nextCapacity(edgeCount));
|
|
3162
|
+
const segmentCapacity = edgeCapacity * 24;
|
|
3163
|
+
const markerCapacity = edgeCapacity * 2;
|
|
3164
|
+
return {
|
|
3165
|
+
edgeCapacity,
|
|
3166
|
+
segmentCapacity,
|
|
3167
|
+
markerCapacity,
|
|
3168
|
+
edgeInstanceCapacity: segmentCapacity + markerCapacity
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
//#endregion
|
|
3172
|
+
//#region src/visuals.ts
|
|
3173
|
+
/** Defines typed, serializable graph visuals without coupling application data to the render loop. */
|
|
3174
|
+
function defineVisuals(visuals) {
|
|
3175
|
+
return visuals;
|
|
3176
|
+
}
|
|
3177
|
+
//#endregion
|
|
3178
|
+
export { DEFAULT_ENDPOINT_ATTACH, DEFAULT_ENDPOINT_CLEARANCE, DEFAULT_FORCE_SETTINGS, Graphraum, GraphraumOverlay, attachPointOnOutline, bindGraphology, boundOverlayIds, computeClusteredForcePositions, computeForcePositions, createForceSimulation, defineVisuals, forceIterationCount, graphraumEdgePaths, graphraumNodeShapes, graphraumTheme, graphraumThemeDark, graphraumThemeLight, graphraumThemes, isTransparentGraphraumBackground, normalizeGraphraumBackground, orderFocusNodeIds, resolveGraphraumTheme, selectBudgetedLabelIds, selectFocusLabelIds, selectRichNodeIds, trimEdgeEndpoints };
|