@fusefactory/fuse-three-forcegraph 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +397 -0
- package/dist/index.d.mts +1468 -0
- package/dist/index.mjs +4213 -0
- package/index.ts +34 -0
- package/package.json +47 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,4213 @@
|
|
|
1
|
+
import CameraControls from "camera-controls";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
|
|
4
|
+
//#region core/EventEmitter.ts
|
|
5
|
+
var EventEmitter = class {
|
|
6
|
+
listeners = {};
|
|
7
|
+
listenerCount(event) {
|
|
8
|
+
return this.listeners[event] ? this.listeners[event].length : 0;
|
|
9
|
+
}
|
|
10
|
+
on(event, listener) {
|
|
11
|
+
if (!this.listeners[event]) this.listeners[event] = [];
|
|
12
|
+
this.listeners[event].push(listener);
|
|
13
|
+
return () => this.off(event, listener);
|
|
14
|
+
}
|
|
15
|
+
off(event, listener) {
|
|
16
|
+
if (!this.listeners[event]) return;
|
|
17
|
+
const index = this.listeners[event].indexOf(listener);
|
|
18
|
+
if (index === -1) return;
|
|
19
|
+
this.listeners[event].splice(index, 1);
|
|
20
|
+
}
|
|
21
|
+
emit(event, ...args) {
|
|
22
|
+
if (!this.listeners[event]) return;
|
|
23
|
+
this.listeners[event].forEach((listener) => listener(...args));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region controls/InputProcessor.ts
|
|
29
|
+
/**
|
|
30
|
+
* Manages pointer/mouse input and emits interaction events
|
|
31
|
+
* Uses GPU picking for efficient node detection
|
|
32
|
+
*/
|
|
33
|
+
var InputProcessor = class extends EventEmitter {
|
|
34
|
+
CLICK_THRESHOLD = 200;
|
|
35
|
+
HOVER_TO_POP_MS = 800;
|
|
36
|
+
pointer = new THREE.Vector2();
|
|
37
|
+
canvasPointer = new THREE.Vector2();
|
|
38
|
+
isPointerDown = false;
|
|
39
|
+
isDragging = false;
|
|
40
|
+
mouseDownTime = 0;
|
|
41
|
+
draggedIndex = -1;
|
|
42
|
+
currentHoverIndex = -1;
|
|
43
|
+
hoverStartTime = 0;
|
|
44
|
+
hoverProgress = 0;
|
|
45
|
+
hasPopped = false;
|
|
46
|
+
lastClientX = null;
|
|
47
|
+
lastClientY = null;
|
|
48
|
+
constructor(pickFn, canvas, viewport) {
|
|
49
|
+
super();
|
|
50
|
+
this.pickFn = pickFn;
|
|
51
|
+
this.canvas = canvas;
|
|
52
|
+
this.viewport = viewport;
|
|
53
|
+
this.setupEventListeners();
|
|
54
|
+
}
|
|
55
|
+
setupEventListeners() {
|
|
56
|
+
this.canvas.addEventListener("pointermove", this.handlePointerMove);
|
|
57
|
+
this.canvas.addEventListener("pointerdown", this.handlePointerDown);
|
|
58
|
+
this.canvas.addEventListener("pointerup", this.handlePointerUp);
|
|
59
|
+
this.canvas.addEventListener("pointerleave", this.handlePointerLeave);
|
|
60
|
+
}
|
|
61
|
+
handlePointerMove = (event) => {
|
|
62
|
+
this.updatePointer(event);
|
|
63
|
+
this.lastClientX = event.clientX;
|
|
64
|
+
this.lastClientY = event.clientY;
|
|
65
|
+
const pickedIndex = this.pickFn(this.canvasPointer.x, this.canvasPointer.y);
|
|
66
|
+
this.updateHover(pickedIndex);
|
|
67
|
+
if (this.isPointerDown && this.draggedIndex >= 0) {
|
|
68
|
+
if (!this.isDragging) {
|
|
69
|
+
this.isDragging = true;
|
|
70
|
+
this.emit("pointer:dragstart", {
|
|
71
|
+
index: this.draggedIndex,
|
|
72
|
+
x: this.canvasPointer.x,
|
|
73
|
+
y: this.canvasPointer.y,
|
|
74
|
+
clientX: event.clientX,
|
|
75
|
+
clientY: event.clientY
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
this.emit("pointer:drag", {
|
|
79
|
+
index: this.draggedIndex,
|
|
80
|
+
x: this.canvasPointer.x,
|
|
81
|
+
y: this.canvasPointer.y,
|
|
82
|
+
clientX: event.clientX,
|
|
83
|
+
clientY: event.clientY
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
handlePointerDown = (event) => {
|
|
88
|
+
this.updatePointer(event);
|
|
89
|
+
this.mouseDownTime = Date.now();
|
|
90
|
+
this.isPointerDown = true;
|
|
91
|
+
const pickedIndex = this.pickFn(this.canvasPointer.x, this.canvasPointer.y);
|
|
92
|
+
this.draggedIndex = pickedIndex;
|
|
93
|
+
if (pickedIndex >= 0) this.emit("pointer:down", {
|
|
94
|
+
index: pickedIndex,
|
|
95
|
+
x: this.canvasPointer.x,
|
|
96
|
+
y: this.canvasPointer.y,
|
|
97
|
+
clientX: event.clientX,
|
|
98
|
+
clientY: event.clientY
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
handlePointerUp = (event) => {
|
|
102
|
+
const wasClick = Date.now() - this.mouseDownTime < this.CLICK_THRESHOLD;
|
|
103
|
+
if (this.isDragging) this.emit("pointer:dragend", {
|
|
104
|
+
index: this.draggedIndex,
|
|
105
|
+
x: this.canvasPointer.x,
|
|
106
|
+
y: this.canvasPointer.y,
|
|
107
|
+
clientX: event.clientX,
|
|
108
|
+
clientY: event.clientY
|
|
109
|
+
});
|
|
110
|
+
else if (wasClick && this.draggedIndex >= 0) this.emit("pointer:click", {
|
|
111
|
+
index: this.draggedIndex,
|
|
112
|
+
x: this.canvasPointer.x,
|
|
113
|
+
y: this.canvasPointer.y,
|
|
114
|
+
clientX: event.clientX,
|
|
115
|
+
clientY: event.clientY
|
|
116
|
+
});
|
|
117
|
+
else if (wasClick && this.draggedIndex < 0) this.emit("pointer:clickempty", {
|
|
118
|
+
x: this.canvasPointer.x,
|
|
119
|
+
y: this.canvasPointer.y,
|
|
120
|
+
clientX: event.clientX,
|
|
121
|
+
clientY: event.clientY
|
|
122
|
+
});
|
|
123
|
+
this.isPointerDown = false;
|
|
124
|
+
this.isDragging = false;
|
|
125
|
+
this.draggedIndex = -1;
|
|
126
|
+
};
|
|
127
|
+
handlePointerLeave = () => {
|
|
128
|
+
this.lastClientX = null;
|
|
129
|
+
this.lastClientY = null;
|
|
130
|
+
this.updateHover(-1);
|
|
131
|
+
};
|
|
132
|
+
updateHover(index) {
|
|
133
|
+
const prevIndex = this.currentHoverIndex;
|
|
134
|
+
if (index === prevIndex) {
|
|
135
|
+
if (index >= 0) {
|
|
136
|
+
const elapsed = Date.now() - this.hoverStartTime;
|
|
137
|
+
this.hoverProgress = Math.min(1, elapsed / this.HOVER_TO_POP_MS);
|
|
138
|
+
this.emit("pointer:hover", {
|
|
139
|
+
index,
|
|
140
|
+
progress: this.hoverProgress,
|
|
141
|
+
x: this.canvasPointer.x,
|
|
142
|
+
y: this.canvasPointer.y,
|
|
143
|
+
clientX: this.lastClientX,
|
|
144
|
+
clientY: this.lastClientY
|
|
145
|
+
});
|
|
146
|
+
if (this.hoverProgress >= 1 && !this.hasPopped) {
|
|
147
|
+
this.hasPopped = true;
|
|
148
|
+
this.emit("pointer:pop", {
|
|
149
|
+
index,
|
|
150
|
+
x: this.canvasPointer.x,
|
|
151
|
+
y: this.canvasPointer.y,
|
|
152
|
+
clientX: this.lastClientX,
|
|
153
|
+
clientY: this.lastClientY
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (prevIndex >= 0) this.emit("pointer:hoverend", { index: prevIndex });
|
|
160
|
+
if (index >= 0) {
|
|
161
|
+
this.currentHoverIndex = index;
|
|
162
|
+
this.hoverStartTime = Date.now();
|
|
163
|
+
this.hoverProgress = 0;
|
|
164
|
+
this.hasPopped = false;
|
|
165
|
+
this.emit("pointer:hoverstart", {
|
|
166
|
+
index,
|
|
167
|
+
x: this.canvasPointer.x,
|
|
168
|
+
y: this.canvasPointer.y,
|
|
169
|
+
clientX: this.lastClientX,
|
|
170
|
+
clientY: this.lastClientY
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
this.currentHoverIndex = -1;
|
|
174
|
+
this.hoverStartTime = 0;
|
|
175
|
+
this.hoverProgress = 0;
|
|
176
|
+
this.hasPopped = false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Update hover state even when pointer is stationary
|
|
181
|
+
* Called from render loop
|
|
182
|
+
*/
|
|
183
|
+
update() {
|
|
184
|
+
if (this.lastClientX !== null && this.lastClientY !== null) {
|
|
185
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
186
|
+
const x = this.lastClientX - rect.left;
|
|
187
|
+
const y = this.lastClientY - rect.top;
|
|
188
|
+
const pickedIndex = this.pickFn(x, y);
|
|
189
|
+
this.updateHover(pickedIndex);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
updatePointer(event) {
|
|
193
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
194
|
+
const x = event.clientX - rect.left;
|
|
195
|
+
const y = event.clientY - rect.top;
|
|
196
|
+
const ndcX = x / rect.width * 2 - 1;
|
|
197
|
+
const ndcY = -(y / rect.height) * 2 + 1;
|
|
198
|
+
this.pointer.set(ndcX, ndcY);
|
|
199
|
+
this.canvasPointer.set(x, y);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Update viewport dimensions on resize
|
|
203
|
+
*/
|
|
204
|
+
resize(width, height) {
|
|
205
|
+
this.viewport.width = width;
|
|
206
|
+
this.viewport.height = height;
|
|
207
|
+
}
|
|
208
|
+
dispose() {
|
|
209
|
+
this.canvas.removeEventListener("pointermove", this.handlePointerMove);
|
|
210
|
+
this.canvas.removeEventListener("pointerdown", this.handlePointerDown);
|
|
211
|
+
this.canvas.removeEventListener("pointerup", this.handlePointerUp);
|
|
212
|
+
this.canvas.removeEventListener("pointerleave", this.handlePointerLeave);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region controls/handlers/HoverHandler.ts
|
|
218
|
+
var HoverHandler = class extends EventEmitter {
|
|
219
|
+
constructor(getNodeByIndex) {
|
|
220
|
+
super();
|
|
221
|
+
this.getNodeByIndex = getNodeByIndex;
|
|
222
|
+
this.on("pointer:hoverstart", this.handleHoverStart);
|
|
223
|
+
this.on("pointer:hover", this.handleHover);
|
|
224
|
+
this.on("pointer:pop", this.handlePop);
|
|
225
|
+
this.on("pointer:hoverend", this.handleHoverEnd);
|
|
226
|
+
}
|
|
227
|
+
handleHoverStart = (data) => {
|
|
228
|
+
const node = this.getNodeByIndex(data.index);
|
|
229
|
+
if (!node) return;
|
|
230
|
+
this.emit("node:hoverstart", {
|
|
231
|
+
node,
|
|
232
|
+
x: data.x,
|
|
233
|
+
y: data.y,
|
|
234
|
+
clientX: data.clientX,
|
|
235
|
+
clientY: data.clientY
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
handleHover = (data) => {
|
|
239
|
+
const node = this.getNodeByIndex(data.index);
|
|
240
|
+
if (!node) return;
|
|
241
|
+
const progress = data.progress ?? 0;
|
|
242
|
+
this.emit("node:hover", {
|
|
243
|
+
node,
|
|
244
|
+
x: data.x,
|
|
245
|
+
y: data.y,
|
|
246
|
+
progress,
|
|
247
|
+
clientX: data.clientX,
|
|
248
|
+
clientY: data.clientY
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
handlePop = (data) => {
|
|
252
|
+
const node = this.getNodeByIndex(data.index);
|
|
253
|
+
if (!node) return;
|
|
254
|
+
this.emit("node:pop", {
|
|
255
|
+
node,
|
|
256
|
+
x: data.x,
|
|
257
|
+
y: data.y,
|
|
258
|
+
clientX: data.clientX,
|
|
259
|
+
clientY: data.clientY
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
handleHoverEnd = (data) => {
|
|
263
|
+
if (!this.getNodeByIndex(data.index)) return;
|
|
264
|
+
this.emit("node:hoverend");
|
|
265
|
+
};
|
|
266
|
+
dispose() {
|
|
267
|
+
this.off("pointer:hoverstart", this.handleHoverStart);
|
|
268
|
+
this.off("pointer:hover", this.handleHover);
|
|
269
|
+
this.off("pointer:pop", this.handlePop);
|
|
270
|
+
this.off("pointer:hoverend", this.handleHoverEnd);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region controls/handlers/ClickHandler.ts
|
|
276
|
+
var ClickHandler = class extends EventEmitter {
|
|
277
|
+
constructor(getNodeByIndex) {
|
|
278
|
+
super();
|
|
279
|
+
this.getNodeByIndex = getNodeByIndex;
|
|
280
|
+
this.on("pointer:click", this.handleClick);
|
|
281
|
+
}
|
|
282
|
+
handleClick = (data) => {
|
|
283
|
+
const node = this.getNodeByIndex(data.index);
|
|
284
|
+
if (!node) return;
|
|
285
|
+
this.emit("node:click", {
|
|
286
|
+
node,
|
|
287
|
+
x: data.x,
|
|
288
|
+
y: data.y,
|
|
289
|
+
clientX: data.clientX,
|
|
290
|
+
clientY: data.clientY
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
dispose() {
|
|
294
|
+
this.off("pointer:click", this.handleClick);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region types/iGraphNode.ts
|
|
300
|
+
let NodeState = /* @__PURE__ */ function(NodeState) {
|
|
301
|
+
NodeState[NodeState["Hidden"] = 0] = "Hidden";
|
|
302
|
+
NodeState[NodeState["Passive"] = 1] = "Passive";
|
|
303
|
+
NodeState[NodeState["Fixed"] = 2] = "Fixed";
|
|
304
|
+
NodeState[NodeState["Active"] = 3] = "Active";
|
|
305
|
+
return NodeState;
|
|
306
|
+
}({});
|
|
307
|
+
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region controls/handlers/DragHandler.ts
|
|
310
|
+
var DragHandler = class extends EventEmitter {
|
|
311
|
+
isDragging = false;
|
|
312
|
+
raycaster = new THREE.Raycaster();
|
|
313
|
+
dragPlane = new THREE.Plane();
|
|
314
|
+
pointer = new THREE.Vector2();
|
|
315
|
+
constructor(getNodeByIndex, cameraController, forceSimulation, viewport) {
|
|
316
|
+
super();
|
|
317
|
+
this.getNodeByIndex = getNodeByIndex;
|
|
318
|
+
this.cameraController = cameraController;
|
|
319
|
+
this.forceSimulation = forceSimulation;
|
|
320
|
+
this.viewport = viewport;
|
|
321
|
+
this.on("pointer:dragstart", this.handleDragStart);
|
|
322
|
+
this.on("pointer:drag", this.handleDrag);
|
|
323
|
+
this.on("pointer:dragend", this.handleDragEnd);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Set up drag plane perpendicular to camera, passing through a world point
|
|
327
|
+
*/
|
|
328
|
+
setupDragPlane(worldPoint) {
|
|
329
|
+
if (!this.cameraController) return;
|
|
330
|
+
const camera = this.cameraController.camera;
|
|
331
|
+
const cameraDirection = new THREE.Vector3();
|
|
332
|
+
camera.getWorldDirection(cameraDirection);
|
|
333
|
+
const planeNormal = cameraDirection.clone().negate();
|
|
334
|
+
this.dragPlane.setFromNormalAndCoplanarPoint(planeNormal, worldPoint);
|
|
335
|
+
}
|
|
336
|
+
handleDragStart = (data) => {
|
|
337
|
+
const node = this.getNodeByIndex(data.index);
|
|
338
|
+
if (!node) return;
|
|
339
|
+
let state = node.state ?? NodeState.Active;
|
|
340
|
+
if (node.state === void 0) state = NodeState.Fixed;
|
|
341
|
+
if (state !== NodeState.Active || node.metadata?.hidden) return;
|
|
342
|
+
this.isDragging = true;
|
|
343
|
+
if (this.cameraController) this.cameraController.setEnabled(false);
|
|
344
|
+
if (this.cameraController) {
|
|
345
|
+
this.cameraController.camera;
|
|
346
|
+
const target = this.cameraController.getTarget();
|
|
347
|
+
this.setupDragPlane(target);
|
|
348
|
+
}
|
|
349
|
+
if (this.forceSimulation) {
|
|
350
|
+
const worldPos = this.screenToWorld(data.x, data.y);
|
|
351
|
+
if (worldPos) this.forceSimulation.startDrag(data.index, worldPos);
|
|
352
|
+
}
|
|
353
|
+
this.emit("node:dragstart", { node });
|
|
354
|
+
};
|
|
355
|
+
handleDrag = (data) => {
|
|
356
|
+
const node = this.getNodeByIndex(data.index);
|
|
357
|
+
if (!node) return;
|
|
358
|
+
if (this.forceSimulation) {
|
|
359
|
+
const worldPos = this.screenToWorld(data.x, data.y);
|
|
360
|
+
if (worldPos) this.forceSimulation.updateDrag(worldPos);
|
|
361
|
+
}
|
|
362
|
+
this.emit("node:drag", {
|
|
363
|
+
node,
|
|
364
|
+
x: data.x,
|
|
365
|
+
y: data.y
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
handleDragEnd = (data) => {
|
|
369
|
+
const node = this.getNodeByIndex(data.index);
|
|
370
|
+
if (!node) return;
|
|
371
|
+
this.isDragging = false;
|
|
372
|
+
if (this.cameraController) this.cameraController.setEnabled(true);
|
|
373
|
+
if (this.forceSimulation) this.forceSimulation.endDrag();
|
|
374
|
+
this.emit("node:dragend", { node });
|
|
375
|
+
};
|
|
376
|
+
/**
|
|
377
|
+
* Convert screen coordinates to world position on drag plane
|
|
378
|
+
*/
|
|
379
|
+
screenToWorld(clientX, clientY) {
|
|
380
|
+
if (!this.cameraController || !this.viewport) return null;
|
|
381
|
+
this.pointer.x = clientX / this.viewport.width * 2 - 1;
|
|
382
|
+
this.pointer.y = -(clientY / this.viewport.height) * 2 + 1;
|
|
383
|
+
this.raycaster.setFromCamera(this.pointer, this.cameraController.camera);
|
|
384
|
+
const target = new THREE.Vector3();
|
|
385
|
+
return this.raycaster.ray.intersectPlane(this.dragPlane, target);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Update viewport dimensions on resize
|
|
389
|
+
*/
|
|
390
|
+
resize(width, height) {
|
|
391
|
+
if (this.viewport) {
|
|
392
|
+
this.viewport.width = width;
|
|
393
|
+
this.viewport.height = height;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
dispose() {
|
|
397
|
+
this.off("pointer:dragstart", this.handleDragStart);
|
|
398
|
+
this.off("pointer:drag", this.handleDrag);
|
|
399
|
+
this.off("pointer:dragend", this.handleDragEnd);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region ui/Tooltips.ts
|
|
405
|
+
var Tooltip = class {
|
|
406
|
+
element;
|
|
407
|
+
isVisible = false;
|
|
408
|
+
cleanup;
|
|
409
|
+
constructor(type, container) {
|
|
410
|
+
this.type = type;
|
|
411
|
+
this.container = container;
|
|
412
|
+
this.element = this.createElement();
|
|
413
|
+
}
|
|
414
|
+
createElement() {
|
|
415
|
+
const tooltip = document.createElement("div");
|
|
416
|
+
tooltip.className = `tooltip tooltip-${this.type}`;
|
|
417
|
+
tooltip.style.position = "absolute";
|
|
418
|
+
tooltip.style.visibility = "hidden";
|
|
419
|
+
tooltip.style.pointerEvents = "none";
|
|
420
|
+
tooltip.style.zIndex = "1000";
|
|
421
|
+
this.container.appendChild(tooltip);
|
|
422
|
+
return tooltip;
|
|
423
|
+
}
|
|
424
|
+
open(content) {
|
|
425
|
+
this.element.innerHTML = content;
|
|
426
|
+
this.element.style.visibility = "visible";
|
|
427
|
+
this.isVisible = true;
|
|
428
|
+
}
|
|
429
|
+
openWithRenderer(renderFn) {
|
|
430
|
+
const result = renderFn();
|
|
431
|
+
if (typeof result === "function") this.cleanup = result;
|
|
432
|
+
this.element.style.visibility = "visible";
|
|
433
|
+
this.isVisible = true;
|
|
434
|
+
}
|
|
435
|
+
close() {
|
|
436
|
+
this.element.style.visibility = "hidden";
|
|
437
|
+
this.isVisible = false;
|
|
438
|
+
if (this.cleanup) {
|
|
439
|
+
this.cleanup();
|
|
440
|
+
this.cleanup = void 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
updatePos(x, y) {
|
|
444
|
+
if (this.isVisible) {
|
|
445
|
+
this.element.style.left = `${x}px`;
|
|
446
|
+
this.element.style.top = `${y}px`;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
getElement() {
|
|
450
|
+
return this.element;
|
|
451
|
+
}
|
|
452
|
+
destroy() {
|
|
453
|
+
if (this.cleanup) this.cleanup();
|
|
454
|
+
this.element.remove();
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region ui/TooltipManager.ts
|
|
460
|
+
var TooltipManager = class {
|
|
461
|
+
mainTooltip;
|
|
462
|
+
previewTooltip;
|
|
463
|
+
chainTooltips = /* @__PURE__ */ new Map();
|
|
464
|
+
config;
|
|
465
|
+
closeButton = null;
|
|
466
|
+
onCloseCallback;
|
|
467
|
+
constructor(container, canvas, config) {
|
|
468
|
+
this.container = container;
|
|
469
|
+
this.canvas = canvas;
|
|
470
|
+
this.config = {
|
|
471
|
+
useDefaultStyles: true,
|
|
472
|
+
...config
|
|
473
|
+
};
|
|
474
|
+
this.mainTooltip = new Tooltip("main", container, this.config.useDefaultStyles);
|
|
475
|
+
this.previewTooltip = new Tooltip("preview", container, this.config.useDefaultStyles);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Show grab cursor when hovering over a node
|
|
479
|
+
*/
|
|
480
|
+
showGrabIcon(x, y, progress) {
|
|
481
|
+
if (this.config.cursorHandler) {
|
|
482
|
+
const state = progress > 0 ? "grab" : "pointer";
|
|
483
|
+
this.config.cursorHandler(this.canvas, state);
|
|
484
|
+
} else this.canvas.style.cursor = progress > 0 ? "grab" : "pointer";
|
|
485
|
+
}
|
|
486
|
+
hideGrabIcon() {
|
|
487
|
+
if (this.config.cursorHandler) this.config.cursorHandler(this.canvas, "default");
|
|
488
|
+
else this.canvas.style.cursor = "default";
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Show preview tooltip (triggered by pop event when hover reaches 1.0)
|
|
492
|
+
*/
|
|
493
|
+
showPreview(node, x, y) {
|
|
494
|
+
if (this.config.renderer) this.previewTooltip.openWithRenderer(() => this.config.renderer(this.previewTooltip.getElement(), node, "preview", x, y));
|
|
495
|
+
else {
|
|
496
|
+
const content = this.generatePreviewContent(node);
|
|
497
|
+
this.previewTooltip.open(content);
|
|
498
|
+
}
|
|
499
|
+
this.previewTooltip.updatePos(x + 20, y + 20);
|
|
500
|
+
}
|
|
501
|
+
hidePreview() {
|
|
502
|
+
this.previewTooltip.close();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Show full tooltip (triggered by click)
|
|
506
|
+
*/
|
|
507
|
+
showFull(node, x, y) {
|
|
508
|
+
if (this.config.renderer) this.mainTooltip.openWithRenderer(() => this.config.renderer(this.mainTooltip.getElement(), node, "full", x, y));
|
|
509
|
+
else {
|
|
510
|
+
const content = this.generateFullContent(node);
|
|
511
|
+
this.mainTooltip.open(content);
|
|
512
|
+
}
|
|
513
|
+
if (!this.config.renderer) this.addCloseButton();
|
|
514
|
+
this.mainTooltip.updatePos(x + 20, y + 20);
|
|
515
|
+
}
|
|
516
|
+
hideFull() {
|
|
517
|
+
this.mainTooltip.close();
|
|
518
|
+
this.removeCloseButton();
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Set callback for when tooltip is closed
|
|
522
|
+
*/
|
|
523
|
+
setOnCloseCallback(callback) {
|
|
524
|
+
this.onCloseCallback = callback;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Add close button to tooltip
|
|
528
|
+
*/
|
|
529
|
+
addCloseButton() {
|
|
530
|
+
this.removeCloseButton();
|
|
531
|
+
const element = this.mainTooltip.getElement();
|
|
532
|
+
element.style.pointerEvents = "auto";
|
|
533
|
+
this.closeButton = document.createElement("button");
|
|
534
|
+
this.closeButton.innerHTML = "×";
|
|
535
|
+
this.closeButton.className = "tooltip-close";
|
|
536
|
+
this.closeButton.style.cssText = `
|
|
537
|
+
position: absolute;
|
|
538
|
+
top: 8px;
|
|
539
|
+
right: 8px;
|
|
540
|
+
background: rgba(255, 255, 255, 0.2);
|
|
541
|
+
border: none;
|
|
542
|
+
color: white;
|
|
543
|
+
width: 24px;
|
|
544
|
+
height: 24px;
|
|
545
|
+
border-radius: 50%;
|
|
546
|
+
cursor: pointer;
|
|
547
|
+
font-size: 20px;
|
|
548
|
+
line-height: 1;
|
|
549
|
+
display: flex;
|
|
550
|
+
align-items: center;
|
|
551
|
+
justify-content: center;
|
|
552
|
+
transition: background 0.2s;
|
|
553
|
+
padding: 0;
|
|
554
|
+
`;
|
|
555
|
+
this.closeButton.addEventListener("mouseenter", () => {
|
|
556
|
+
if (this.closeButton) this.closeButton.style.background = "rgba(255, 255, 255, 0.3)";
|
|
557
|
+
});
|
|
558
|
+
this.closeButton.addEventListener("mouseleave", () => {
|
|
559
|
+
if (this.closeButton) this.closeButton.style.background = "rgba(255, 255, 255, 0.2)";
|
|
560
|
+
});
|
|
561
|
+
this.closeButton.addEventListener("click", (e) => {
|
|
562
|
+
e.stopPropagation();
|
|
563
|
+
this.hideFull();
|
|
564
|
+
if (this.onCloseCallback) this.onCloseCallback();
|
|
565
|
+
});
|
|
566
|
+
element.appendChild(this.closeButton);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Remove close button from tooltip
|
|
570
|
+
*/
|
|
571
|
+
removeCloseButton() {
|
|
572
|
+
if (this.closeButton) {
|
|
573
|
+
this.closeButton.remove();
|
|
574
|
+
this.closeButton = null;
|
|
575
|
+
}
|
|
576
|
+
const element = this.mainTooltip.getElement();
|
|
577
|
+
element.style.pointerEvents = "none";
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Hide all tooltips
|
|
581
|
+
*/
|
|
582
|
+
hideAll() {
|
|
583
|
+
this.hideGrabIcon();
|
|
584
|
+
this.hidePreview();
|
|
585
|
+
this.hideFull();
|
|
586
|
+
this.hideChainTooltips();
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Generate preview content (subset of fields)
|
|
590
|
+
*/
|
|
591
|
+
generatePreviewContent(node) {
|
|
592
|
+
return `
|
|
593
|
+
<div class="tooltip-preview" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
594
|
+
<h3 style="margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">${this.escapeHtml(node.title || node.id)}</h3>
|
|
595
|
+
<p style="margin: 0; font-size: 13px; opacity: 0.8;">${this.escapeHtml(node.group || "unknown")}</p>
|
|
596
|
+
</div>
|
|
597
|
+
`;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Generate full tooltip content (all fields)
|
|
601
|
+
*/
|
|
602
|
+
generateFullContent(node) {
|
|
603
|
+
return `
|
|
604
|
+
<div class="tooltip-full" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding-right: 24px;">
|
|
605
|
+
<h2 style="margin: 0 0 12px 0; font-size: 18px; font-weight: 600;">${this.escapeHtml(node.title || node.id)}</h2>
|
|
606
|
+
${node.thumbnailUrl ? `<img src="${this.escapeHtml(node.thumbnailUrl)}" alt="thumbnail" style="max-width: 100%; height: auto; border-radius: 4px; margin: 0 0 12px 0;" />` : ""}
|
|
607
|
+
<p style="margin: 0 0 8px 0; font-size: 13px;"><strong>Group:</strong> ${this.escapeHtml(node.group || "unknown")}</p>
|
|
608
|
+
${node.description ? `<p style="margin: 0 0 8px 0; font-size: 13px; line-height: 1.5;">${this.escapeHtml(node.description)}</p>` : ""}
|
|
609
|
+
<p style="margin: 0; font-size: 12px; opacity: 0.6;"><strong>ID:</strong> ${this.escapeHtml(node.id)}</p>
|
|
610
|
+
</div>
|
|
611
|
+
`;
|
|
612
|
+
}
|
|
613
|
+
escapeHtml(text) {
|
|
614
|
+
const div = document.createElement("div");
|
|
615
|
+
div.textContent = text;
|
|
616
|
+
return div.innerHTML;
|
|
617
|
+
}
|
|
618
|
+
showMainTooltip(content, x, y) {
|
|
619
|
+
this.mainTooltip.open(content);
|
|
620
|
+
this.mainTooltip.updatePos(x, y);
|
|
621
|
+
}
|
|
622
|
+
hideMainTooltip() {
|
|
623
|
+
this.mainTooltip.close();
|
|
624
|
+
}
|
|
625
|
+
showChainTooltip(nodeId, content, x, y) {
|
|
626
|
+
if (!this.chainTooltips.has(nodeId)) this.chainTooltips.set(nodeId, new Tooltip("label", this.container, this.config.useDefaultStyles));
|
|
627
|
+
const tooltip = this.chainTooltips.get(nodeId);
|
|
628
|
+
tooltip.open(content);
|
|
629
|
+
tooltip.updatePos(x, y);
|
|
630
|
+
}
|
|
631
|
+
hideChainTooltips() {
|
|
632
|
+
this.chainTooltips.forEach((tooltip) => tooltip.close());
|
|
633
|
+
}
|
|
634
|
+
updateMainTooltipPos(x, y) {
|
|
635
|
+
this.mainTooltip.updatePos(x, y);
|
|
636
|
+
}
|
|
637
|
+
dispose() {
|
|
638
|
+
this.removeCloseButton();
|
|
639
|
+
this.mainTooltip.destroy();
|
|
640
|
+
this.previewTooltip.destroy();
|
|
641
|
+
this.chainTooltips.forEach((tooltip) => tooltip.destroy());
|
|
642
|
+
this.chainTooltips.clear();
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region controls/InteractionManager.ts
|
|
648
|
+
var InteractionManager = class {
|
|
649
|
+
pointerInput;
|
|
650
|
+
hoverHandler;
|
|
651
|
+
clickHandler;
|
|
652
|
+
dragHandler;
|
|
653
|
+
tooltipManager;
|
|
654
|
+
isDragging = false;
|
|
655
|
+
isTooltipSticky = false;
|
|
656
|
+
stickyNodeId = null;
|
|
657
|
+
searchHighlightIds = [];
|
|
658
|
+
constructor(pickFunction, canvas, viewport, getNodeByIndex, cameraController, forceSimulation, tooltipConfig, graphScene, getConnectedNodeIds) {
|
|
659
|
+
this.graphScene = graphScene;
|
|
660
|
+
this.getConnectedNodeIds = getConnectedNodeIds;
|
|
661
|
+
this.pointerInput = new InputProcessor(pickFunction, canvas, viewport);
|
|
662
|
+
const tooltipContainer = document.body;
|
|
663
|
+
this.tooltipManager = new TooltipManager(tooltipContainer, canvas, tooltipConfig);
|
|
664
|
+
this.tooltipManager.setOnCloseCallback(() => {
|
|
665
|
+
this.isTooltipSticky = false;
|
|
666
|
+
});
|
|
667
|
+
this.hoverHandler = new HoverHandler(getNodeByIndex);
|
|
668
|
+
this.clickHandler = new ClickHandler(getNodeByIndex);
|
|
669
|
+
this.dragHandler = new DragHandler(getNodeByIndex, cameraController, forceSimulation, viewport);
|
|
670
|
+
this.wireEvents();
|
|
671
|
+
this.wireTooltipEvents();
|
|
672
|
+
this.wireVisualFeedbackEvents();
|
|
673
|
+
}
|
|
674
|
+
wireEvents() {
|
|
675
|
+
this.pointerInput.on("pointer:hoverstart", (data) => {
|
|
676
|
+
this.hoverHandler.emit("pointer:hoverstart", data);
|
|
677
|
+
});
|
|
678
|
+
this.pointerInput.on("pointer:hover", (data) => {
|
|
679
|
+
this.hoverHandler.emit("pointer:hover", data);
|
|
680
|
+
});
|
|
681
|
+
this.pointerInput.on("pointer:hoverend", (data) => {
|
|
682
|
+
this.hoverHandler.emit("pointer:hoverend", data);
|
|
683
|
+
});
|
|
684
|
+
this.pointerInput.on("pointer:pop", (data) => {
|
|
685
|
+
this.hoverHandler.emit("pointer:pop", data);
|
|
686
|
+
});
|
|
687
|
+
this.pointerInput.on("pointer:click", (data) => {
|
|
688
|
+
this.clickHandler.emit("pointer:click", data);
|
|
689
|
+
});
|
|
690
|
+
this.pointerInput.on("pointer:clickempty", () => {
|
|
691
|
+
if (this.isTooltipSticky) {
|
|
692
|
+
this.tooltipManager.hideFull();
|
|
693
|
+
this.isTooltipSticky = false;
|
|
694
|
+
this.stickyNodeId = null;
|
|
695
|
+
const linkRenderer = this.graphScene.getLinkRenderer();
|
|
696
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
697
|
+
if (this.searchHighlightIds.length > 0) {
|
|
698
|
+
linkRenderer?.clearHighlights(.05);
|
|
699
|
+
nodeRenderer?.highlight(this.searchHighlightIds);
|
|
700
|
+
} else {
|
|
701
|
+
linkRenderer?.clearHighlights(.05);
|
|
702
|
+
nodeRenderer?.clearHighlights(.05);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
this.pointerInput.on("pointer:dragstart", (data) => {
|
|
707
|
+
this.dragHandler.emit("pointer:dragstart", data);
|
|
708
|
+
this.isDragging = true;
|
|
709
|
+
});
|
|
710
|
+
this.pointerInput.on("pointer:drag", (data) => {
|
|
711
|
+
this.dragHandler.emit("pointer:drag", data);
|
|
712
|
+
});
|
|
713
|
+
this.pointerInput.on("pointer:dragend", (data) => {
|
|
714
|
+
this.dragHandler.emit("pointer:dragend", data);
|
|
715
|
+
this.isDragging = false;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
wireTooltipEvents() {
|
|
719
|
+
this.hoverHandler.on("node:hover", ({ x, y, progress }) => {
|
|
720
|
+
this.tooltipManager.showGrabIcon(x, y, progress);
|
|
721
|
+
});
|
|
722
|
+
this.hoverHandler.on("node:pop", ({ node, x, y, clientX, clientY }) => {
|
|
723
|
+
if (!this.isTooltipSticky) {
|
|
724
|
+
this.tooltipManager.showPreview(node, clientX ?? x, clientY ?? y);
|
|
725
|
+
window.dispatchEvent(new CustomEvent("audio:hover"));
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
this.hoverHandler.on("node:hoverend", () => {
|
|
729
|
+
this.tooltipManager.hideGrabIcon();
|
|
730
|
+
if (!this.isTooltipSticky) this.tooltipManager.hidePreview();
|
|
731
|
+
});
|
|
732
|
+
this.clickHandler.on("node:click", ({ node, x, y, clientX, clientY }) => {
|
|
733
|
+
if (this.isTooltipSticky && this.stickyNodeId === node.id) {
|
|
734
|
+
this.tooltipManager.hideFull();
|
|
735
|
+
this.isTooltipSticky = false;
|
|
736
|
+
this.stickyNodeId = null;
|
|
737
|
+
const linkRenderer = this.graphScene.getLinkRenderer();
|
|
738
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
739
|
+
if (this.searchHighlightIds.length > 0) {
|
|
740
|
+
linkRenderer?.clearHighlights(.05);
|
|
741
|
+
nodeRenderer?.highlight(this.searchHighlightIds);
|
|
742
|
+
} else {
|
|
743
|
+
linkRenderer?.clearHighlights(.05);
|
|
744
|
+
nodeRenderer?.clearHighlights(.05);
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
this.tooltipManager.hidePreview();
|
|
748
|
+
this.tooltipManager.showFull(node, clientX ?? x, clientY ?? y);
|
|
749
|
+
this.isTooltipSticky = true;
|
|
750
|
+
this.stickyNodeId = node.id;
|
|
751
|
+
window.dispatchEvent(new CustomEvent("audio:open"));
|
|
752
|
+
this.graphScene.getLinkRenderer()?.highlightConnectedLinks(node.id);
|
|
753
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
754
|
+
if (this.getConnectedNodeIds) {
|
|
755
|
+
const connectedIds = this.getConnectedNodeIds(node.id);
|
|
756
|
+
connectedIds.push(node.id);
|
|
757
|
+
nodeRenderer?.highlight(connectedIds);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Wire visual feedback events (opacity, highlighting)
|
|
764
|
+
* Centralized control of visual responses to interactions
|
|
765
|
+
*/
|
|
766
|
+
wireVisualFeedbackEvents() {
|
|
767
|
+
if (!this.graphScene) return;
|
|
768
|
+
this.hoverHandler.on("node:hoverstart", ({ node }) => {
|
|
769
|
+
this.graphScene.getLinkRenderer()?.highlightConnectedLinks(node.id);
|
|
770
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
771
|
+
if (this.getConnectedNodeIds) {
|
|
772
|
+
const connectedIds = this.getConnectedNodeIds(node.id);
|
|
773
|
+
connectedIds.push(node.id);
|
|
774
|
+
nodeRenderer?.highlight(connectedIds);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
this.hoverHandler.on("node:hoverend", () => {
|
|
778
|
+
const linkRenderer = this.graphScene.getLinkRenderer();
|
|
779
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
780
|
+
if (this.isTooltipSticky && this.stickyNodeId) {
|
|
781
|
+
linkRenderer?.highlightConnectedLinks(this.stickyNodeId);
|
|
782
|
+
if (this.getConnectedNodeIds) {
|
|
783
|
+
const connectedIds = this.getConnectedNodeIds(this.stickyNodeId);
|
|
784
|
+
connectedIds.push(this.stickyNodeId);
|
|
785
|
+
nodeRenderer?.highlight(connectedIds);
|
|
786
|
+
}
|
|
787
|
+
} else if (this.searchHighlightIds.length > 0) {
|
|
788
|
+
linkRenderer?.clearHighlights(.05);
|
|
789
|
+
nodeRenderer?.highlight(this.searchHighlightIds);
|
|
790
|
+
} else {
|
|
791
|
+
linkRenderer?.clearHighlights(.05);
|
|
792
|
+
nodeRenderer?.clearHighlights(.05);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
getPointerInput() {
|
|
797
|
+
return this.pointerInput;
|
|
798
|
+
}
|
|
799
|
+
getHoverHandler() {
|
|
800
|
+
return this.hoverHandler;
|
|
801
|
+
}
|
|
802
|
+
getClickHandler() {
|
|
803
|
+
return this.clickHandler;
|
|
804
|
+
}
|
|
805
|
+
getDragHandler() {
|
|
806
|
+
return this.dragHandler;
|
|
807
|
+
}
|
|
808
|
+
getTooltipManager() {
|
|
809
|
+
return this.tooltipManager;
|
|
810
|
+
}
|
|
811
|
+
isTooltipStickyOpen() {
|
|
812
|
+
return this.isTooltipSticky;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Update viewport dimensions on resize
|
|
816
|
+
*/
|
|
817
|
+
resize(width, height) {
|
|
818
|
+
this.pointerInput.resize(width, height);
|
|
819
|
+
this.dragHandler.resize(width, height);
|
|
820
|
+
}
|
|
821
|
+
cleanup() {
|
|
822
|
+
this.tooltipManager.hideAll();
|
|
823
|
+
this.isTooltipSticky = false;
|
|
824
|
+
this.isDragging = false;
|
|
825
|
+
}
|
|
826
|
+
dispose() {
|
|
827
|
+
this.cleanup();
|
|
828
|
+
this.pointerInput.dispose();
|
|
829
|
+
this.hoverHandler.dispose();
|
|
830
|
+
this.clickHandler.dispose();
|
|
831
|
+
this.dragHandler.dispose();
|
|
832
|
+
this.tooltipManager.dispose();
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region types/iCameraMode.ts
|
|
838
|
+
let CameraMode = /* @__PURE__ */ function(CameraMode) {
|
|
839
|
+
CameraMode["Orbit"] = "orbit";
|
|
840
|
+
CameraMode["Map"] = "map";
|
|
841
|
+
return CameraMode;
|
|
842
|
+
}({});
|
|
843
|
+
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region rendering/CameraController.ts
|
|
846
|
+
var CameraController = class {
|
|
847
|
+
camera;
|
|
848
|
+
controls;
|
|
849
|
+
sceneBounds = null;
|
|
850
|
+
autorotateEnabled = false;
|
|
851
|
+
autorotateSpeed = .5;
|
|
852
|
+
userIsActive = true;
|
|
853
|
+
constructor(domelement, config) {
|
|
854
|
+
this.camera = new THREE.PerspectiveCamera(config?.fov ?? 50, config?.aspect ?? window.innerWidth / window.innerHeight, config?.near ?? 1e-5, config?.far ?? 1e4);
|
|
855
|
+
if (config?.position) this.camera.position.copy(config.position);
|
|
856
|
+
else this.camera.position.set(0, 0, 2);
|
|
857
|
+
this.controls = new CameraControls(this.camera, domelement);
|
|
858
|
+
console.log(this.controls, domelement);
|
|
859
|
+
this.setupDefaultControls(config);
|
|
860
|
+
}
|
|
861
|
+
setupDefaultControls(config) {
|
|
862
|
+
this.controls.smoothTime = .8;
|
|
863
|
+
this.controls.dollyToCursor = true;
|
|
864
|
+
this.controls.minDistance = config?.minDistance ?? 100;
|
|
865
|
+
this.controls.maxDistance = config?.maxDistance ?? 4e3;
|
|
866
|
+
this.setMode(CameraMode.Orbit);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Set camera control mode
|
|
870
|
+
*/
|
|
871
|
+
setMode(mode) {
|
|
872
|
+
switch (mode) {
|
|
873
|
+
case CameraMode.Map:
|
|
874
|
+
this.controls.mouseButtons.left = CameraControls.ACTION.TRUCK;
|
|
875
|
+
this.controls.mouseButtons.right = CameraControls.ACTION.TRUCK;
|
|
876
|
+
this.controls.mouseButtons.wheel = CameraControls.ACTION.DOLLY;
|
|
877
|
+
this.controls.touches.one = CameraControls.ACTION.TOUCH_TRUCK;
|
|
878
|
+
this.controls.touches.two = CameraControls.ACTION.TOUCH_DOLLY_TRUCK;
|
|
879
|
+
this.controls.touches.three = CameraControls.ACTION.TOUCH_TRUCK;
|
|
880
|
+
this.controls.maxPolarAngle = Math.PI / 2 - .1;
|
|
881
|
+
break;
|
|
882
|
+
case CameraMode.Orbit:
|
|
883
|
+
default:
|
|
884
|
+
this.controls.mouseButtons.left = CameraControls.ACTION.ROTATE;
|
|
885
|
+
this.controls.mouseButtons.right = CameraControls.ACTION.TRUCK;
|
|
886
|
+
this.controls.mouseButtons.wheel = CameraControls.ACTION.DOLLY;
|
|
887
|
+
this.controls.touches.one = CameraControls.ACTION.TOUCH_ROTATE;
|
|
888
|
+
this.controls.touches.two = CameraControls.ACTION.TOUCH_DOLLY_TRUCK;
|
|
889
|
+
this.controls.touches.three = CameraControls.ACTION.TOUCH_TRUCK;
|
|
890
|
+
this.controls.maxPolarAngle = Math.PI;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Set camera boundary
|
|
896
|
+
*/
|
|
897
|
+
setBoundary(box) {
|
|
898
|
+
this.controls.setBoundary(box);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Update camera controls (should be called in render loop)
|
|
902
|
+
* Only autorotate if enabled and user is inactive
|
|
903
|
+
*/
|
|
904
|
+
update(delta) {
|
|
905
|
+
if (this.autorotateEnabled && !this.userIsActive) this.controls.azimuthAngle += this.autorotateSpeed * delta;
|
|
906
|
+
return this.controls.update(delta);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Set autorotate enabled/disabled
|
|
910
|
+
*/
|
|
911
|
+
setAutorotate(enabled, speed) {
|
|
912
|
+
this.autorotateEnabled = enabled;
|
|
913
|
+
this.autorotateSpeed = speed ? speed * THREE.MathUtils.DEG2RAD : 0;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Set user activity state
|
|
917
|
+
*/
|
|
918
|
+
setUserIsActive(isActive) {
|
|
919
|
+
this.userIsActive = isActive;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Configure camera controls
|
|
923
|
+
*/
|
|
924
|
+
configureControls(config) {
|
|
925
|
+
Object.assign(this.controls, config);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Resize camera when window resizes
|
|
929
|
+
*/
|
|
930
|
+
resize(width, height) {
|
|
931
|
+
this.camera.aspect = width / height;
|
|
932
|
+
this.camera.updateProjectionMatrix();
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Animate camera to look at a specific target from a specific position
|
|
936
|
+
*/
|
|
937
|
+
async setLookAt(position, target, enableTransition = true) {
|
|
938
|
+
await this.controls.setLookAt(position.x, position.y, position.z, target.x, target.y, target.z, enableTransition);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Reset camera to default position
|
|
942
|
+
*/
|
|
943
|
+
async reset(enableTransition = true) {
|
|
944
|
+
return this.controls.setLookAt(0, 0, 50, 0, 0, 0, enableTransition);
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Enable/disable controls
|
|
948
|
+
*/
|
|
949
|
+
setEnabled(enabled) {
|
|
950
|
+
this.controls.enabled = enabled;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Get current camera target (look-at point)
|
|
954
|
+
*/
|
|
955
|
+
getTarget(out) {
|
|
956
|
+
const target = out ?? new THREE.Vector3();
|
|
957
|
+
this.controls.getTarget(target);
|
|
958
|
+
return target;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Dispose camera manager and clean up
|
|
962
|
+
*/
|
|
963
|
+
dispose() {
|
|
964
|
+
this.controls.dispose();
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Clear camera bounds (remove boundary restrictions)
|
|
968
|
+
*/
|
|
969
|
+
clearBounds() {
|
|
970
|
+
this.sceneBounds = null;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Get current scene bounds
|
|
974
|
+
*/
|
|
975
|
+
getSceneBounds() {
|
|
976
|
+
return this.sceneBounds;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Update scene bounds used by the camera manager.
|
|
980
|
+
* Stores the bounds for future use (e.g. constraining controls).
|
|
981
|
+
*/
|
|
982
|
+
updateBounds(box) {
|
|
983
|
+
this.sceneBounds = box.clone();
|
|
984
|
+
this.controls.setBoundary(box);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Unified configuration method
|
|
988
|
+
*/
|
|
989
|
+
setOptions(options) {
|
|
990
|
+
if (options.controls) this.configureControls(options.controls);
|
|
991
|
+
if (options.autoRotate !== void 0) this.setAutorotate(options.autoRotate, options.autoRotateSpeed);
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region assets/glsl/lines/lines.frag
|
|
997
|
+
var lines_default$1 = "precision mediump float;\n\nvarying vec3 vColor;\r\nflat varying float vAlphaIndex;\n\nuniform sampler2D uAlphaTexture;\r\nuniform vec2 uAlphaTextureSize; \n\nvec2 alphaUvFromIndex(float idx) {\r\n float width = uAlphaTextureSize.x;\r\n float texX = mod(idx, width);\r\n float texY = floor(idx / width);\r\n return (vec2(texX + 0.5, texY + 0.5) / uAlphaTextureSize);\r\n}\n\nvoid main() {\r\n vec2 uv = alphaUvFromIndex(vAlphaIndex);\r\n float a = texture2D(uAlphaTexture, uv).r;\n\n gl_FragColor = vec4(vColor, a);\r\n #include <colorspace_fragment>\r\n}";
|
|
998
|
+
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region assets/glsl/lines/lines.vert
|
|
1001
|
+
var lines_default = "attribute float t;\n\nattribute vec2 instanceLinkA;\r\nattribute vec2 instanceLinkB;\r\nattribute vec3 instanceColorA;\r\nattribute vec3 instanceColorB;\r\nattribute float instanceAlphaIndex;\n\nuniform sampler2D uPositionsTexture;\r\nuniform vec2 uPositionsTextureSize;\r\nuniform float uNoiseStrength;\r\nuniform float uTime;\n\nvarying vec3 vColor;\r\nflat varying float vAlphaIndex;\n\nvec3 organicNoise(vec3 p, float seed) {\r\n float frequency = 1.5;\r\n float branchiness = 5.;\r\n \r\n \n vec3 n1 = sin(p * frequency + seed) * 0.5;\r\n vec3 n2 = sin(p * frequency * 2.3 + seed * 1.5) * 0.25 * (1.0 + branchiness);\r\n vec3 n3 = sin(p * frequency * 4.7 + seed * 2.3) * 0.125 * (1.0 + branchiness);\r\n \r\n return n1 + n2 + n3;\r\n}\n\nfloat smoothCurve(float t) {\r\n return t * t * (3.0 - 2.0 * t);\r\n}\n\nvoid main() {\r\n \n vColor = mix(instanceColorA, instanceColorB, t);\r\n vAlphaIndex = instanceAlphaIndex;\n\n \n vec2 uvA = (instanceLinkA + vec2(0.5)) / uPositionsTextureSize;\r\n vec2 uvB = (instanceLinkB + vec2(0.5)) / uPositionsTextureSize;\n\n vec4 posA = texture2D(uPositionsTexture, uvA);\r\n vec4 posB = texture2D(uPositionsTexture, uvB);\r\n if (posA.w < 0.5 || posB.w < 0.5) {\r\n \n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); \r\n return;\r\n }\r\n vec3 linear = mix(posA.xyz, posB.xyz, t);\r\n \r\n \n vec3 dir = normalize(posB.xyz - posA.xyz);\r\n float dist = length(posB.xyz - posA.xyz);\r\n vec3 up = abs(dir.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);\r\n vec3 perp = normalize(cross(dir, up));\r\n vec3 upVec = normalize(cross(perp, dir));\n\n \r\n \n float sinPhase = instanceAlphaIndex * 0.1 + uTime * 0.3;\r\n float sinWave = sin(t * 3.14159265 * 2.0 + sinPhase);\r\n \r\n \n float tSmooth = smoothCurve(t);\r\n float curve = sin(t * 3.14159265) * tSmooth;\r\n \r\n \n float curvature = 0.1;\r\n curve = pow(curve, 1.0 - curvature * 0.5);\r\n \r\n float intensity = curve * uNoiseStrength;\n\n vec3 pos = linear;\n\n if (intensity > 0.001) {\r\n \n vec3 samplePos = linear * 0.5 + vec3(instanceAlphaIndex, 0.0, instanceAlphaIndex * 0.7);\r\n vec3 noise = organicNoise(samplePos, instanceAlphaIndex);\r\n \r\n \n float sinModulation = 0.5 + 0.5 * sinWave;\r\n pos += perp * noise.x * intensity * sinModulation;\r\n pos += upVec * noise.y * intensity * 0.7;\r\n pos += dir * noise.z * intensity * 0.3;\r\n }\n\n vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);\r\n gl_Position = projectionMatrix * mvPosition;\r\n}";
|
|
1002
|
+
|
|
1003
|
+
//#endregion
|
|
1004
|
+
//#region core/StyleRegistry.ts
|
|
1005
|
+
/**
|
|
1006
|
+
* Default styles
|
|
1007
|
+
*/
|
|
1008
|
+
const DEFAULT_NODE_STYLE = {
|
|
1009
|
+
color: new THREE.Color(214748364),
|
|
1010
|
+
size: 6
|
|
1011
|
+
};
|
|
1012
|
+
const DEFAULT_LINK_STYLE = { color: new THREE.Color(8947848) };
|
|
1013
|
+
/**
|
|
1014
|
+
* StyleRegistry - Manages visual styles for node and link categories
|
|
1015
|
+
*
|
|
1016
|
+
* Usage:
|
|
1017
|
+
* styleRegistry.setNodeStyle('person', { color: 0xff0000, size: 20 })
|
|
1018
|
+
* styleRegistry.setLinkStyle('friendship', { color: '#00ff00', width: 2 })
|
|
1019
|
+
*
|
|
1020
|
+
* const style = styleRegistry.getNodeStyle('person')
|
|
1021
|
+
*/
|
|
1022
|
+
var StyleRegistry = class {
|
|
1023
|
+
nodeStyles = /* @__PURE__ */ new Map();
|
|
1024
|
+
linkStyles = /* @__PURE__ */ new Map();
|
|
1025
|
+
defaultNodeStyle = { ...DEFAULT_NODE_STYLE };
|
|
1026
|
+
defaultLinkStyle = { ...DEFAULT_LINK_STYLE };
|
|
1027
|
+
/**
|
|
1028
|
+
* Convert color input to THREE.Color
|
|
1029
|
+
*/
|
|
1030
|
+
toColor(input) {
|
|
1031
|
+
if (input instanceof THREE.Color) return input.clone();
|
|
1032
|
+
return new THREE.Color(input);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Set the default style for nodes without a category
|
|
1036
|
+
*/
|
|
1037
|
+
setDefaultNodeStyle(style) {
|
|
1038
|
+
if (style.color !== void 0) this.defaultNodeStyle.color = this.toColor(style.color);
|
|
1039
|
+
if (style.size !== void 0) this.defaultNodeStyle.size = style.size;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Set the default style for links without a category
|
|
1043
|
+
*/
|
|
1044
|
+
setDefaultLinkStyle(style) {
|
|
1045
|
+
if (style.color !== void 0) this.defaultLinkStyle.color = this.toColor(style.color);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Register a style for a node category
|
|
1049
|
+
*/
|
|
1050
|
+
setNodeStyle(category, style) {
|
|
1051
|
+
this.nodeStyles.set(category, {
|
|
1052
|
+
color: this.toColor(style.color),
|
|
1053
|
+
size: style.size
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Register multiple node styles at once
|
|
1058
|
+
*/
|
|
1059
|
+
setNodeStyles(styles) {
|
|
1060
|
+
for (const [category, style] of Object.entries(styles)) this.setNodeStyle(category, style);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Register a style for a link category
|
|
1064
|
+
*/
|
|
1065
|
+
setLinkStyle(category, style) {
|
|
1066
|
+
this.linkStyles.set(category, { color: this.toColor(style.color) });
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Register multiple link styles at once
|
|
1070
|
+
*/
|
|
1071
|
+
setLinkStyles(styles) {
|
|
1072
|
+
for (const [category, style] of Object.entries(styles)) this.setLinkStyle(category, style);
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Get the resolved style for a node category
|
|
1076
|
+
*/
|
|
1077
|
+
getNodeStyle(category) {
|
|
1078
|
+
if (!category) return this.defaultNodeStyle;
|
|
1079
|
+
return this.nodeStyles.get(category) || this.defaultNodeStyle;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Get the resolved style for a link category
|
|
1083
|
+
*/
|
|
1084
|
+
getLinkStyle(category) {
|
|
1085
|
+
if (!category) return this.defaultLinkStyle;
|
|
1086
|
+
return this.linkStyles.get(category) || this.defaultLinkStyle;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Check if a node category exists
|
|
1090
|
+
*/
|
|
1091
|
+
hasNodeStyle(category) {
|
|
1092
|
+
return this.nodeStyles.has(category);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Check if a link category exists
|
|
1096
|
+
*/
|
|
1097
|
+
hasLinkStyle(category) {
|
|
1098
|
+
return this.linkStyles.has(category);
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Remove a node style
|
|
1102
|
+
*/
|
|
1103
|
+
removeNodeStyle(category) {
|
|
1104
|
+
this.nodeStyles.delete(category);
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Remove a link style
|
|
1108
|
+
*/
|
|
1109
|
+
removeLinkStyle(category) {
|
|
1110
|
+
this.linkStyles.delete(category);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Clear all styles
|
|
1114
|
+
*/
|
|
1115
|
+
clear() {
|
|
1116
|
+
this.nodeStyles.clear();
|
|
1117
|
+
this.linkStyles.clear();
|
|
1118
|
+
this.defaultNodeStyle = { ...DEFAULT_NODE_STYLE };
|
|
1119
|
+
this.defaultLinkStyle = { ...DEFAULT_LINK_STYLE };
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Get all registered node categories
|
|
1123
|
+
*/
|
|
1124
|
+
getNodeCategories() {
|
|
1125
|
+
return Array.from(this.nodeStyles.keys());
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Get all registered link categories
|
|
1129
|
+
*/
|
|
1130
|
+
getLinkCategories() {
|
|
1131
|
+
return Array.from(this.linkStyles.keys());
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
const styleRegistry = new StyleRegistry();
|
|
1135
|
+
|
|
1136
|
+
//#endregion
|
|
1137
|
+
//#region textures/StaticAssets.ts
|
|
1138
|
+
/**
|
|
1139
|
+
* Manages read-only GPU textures created at initialization
|
|
1140
|
+
* These never change during simulation (except for mode changes)
|
|
1141
|
+
*
|
|
1142
|
+
* Node data: radii, colors
|
|
1143
|
+
* Link data: source/target indices, properties
|
|
1144
|
+
*/
|
|
1145
|
+
var StaticAssets = class {
|
|
1146
|
+
nodeRadiiTexture = null;
|
|
1147
|
+
nodeColorsTexture = null;
|
|
1148
|
+
linkIndicesTexture = null;
|
|
1149
|
+
linkPropertiesTexture = null;
|
|
1150
|
+
nodeLinkMapTexture = null;
|
|
1151
|
+
nodeTextureSize = 0;
|
|
1152
|
+
linkTextureSize = 0;
|
|
1153
|
+
constructor() {}
|
|
1154
|
+
/**
|
|
1155
|
+
* Set/update node radii texture
|
|
1156
|
+
*/
|
|
1157
|
+
setNodeRadii(radii, textureSize) {
|
|
1158
|
+
const expectedSize = textureSize * textureSize;
|
|
1159
|
+
if (radii.length !== expectedSize) {
|
|
1160
|
+
console.warn(`[StaticAssets] Radii array size mismatch! Got ${radii.length}, expected ${expectedSize}`);
|
|
1161
|
+
const correctedRadii = new Float32Array(expectedSize);
|
|
1162
|
+
correctedRadii.set(radii.slice(0, Math.min(radii.length, expectedSize)));
|
|
1163
|
+
radii = correctedRadii;
|
|
1164
|
+
}
|
|
1165
|
+
if (this.nodeRadiiTexture && this.nodeTextureSize === textureSize) {
|
|
1166
|
+
this.nodeRadiiTexture.image.data = radii;
|
|
1167
|
+
this.nodeRadiiTexture.needsUpdate = true;
|
|
1168
|
+
} else {
|
|
1169
|
+
this.nodeRadiiTexture?.dispose();
|
|
1170
|
+
this.nodeRadiiTexture = this.createTexture(radii, textureSize, THREE.RedFormat);
|
|
1171
|
+
this.nodeTextureSize = textureSize;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Set/update node colors texture
|
|
1176
|
+
*/
|
|
1177
|
+
setNodeColors(colors, textureSize) {
|
|
1178
|
+
if (this.nodeColorsTexture && this.nodeTextureSize === textureSize) {
|
|
1179
|
+
this.nodeColorsTexture.image.data = colors;
|
|
1180
|
+
this.nodeColorsTexture.needsUpdate = true;
|
|
1181
|
+
} else {
|
|
1182
|
+
this.nodeColorsTexture?.dispose();
|
|
1183
|
+
this.nodeColorsTexture = this.createTexture(colors, textureSize, THREE.RGBAFormat);
|
|
1184
|
+
this.nodeTextureSize = textureSize;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Set link indices (source/target pairs)
|
|
1189
|
+
* Format: [sourceX, sourceY, targetX, targetY] per link
|
|
1190
|
+
*/
|
|
1191
|
+
setLinkIndices(linkIndices, textureSize) {
|
|
1192
|
+
this.linkIndicesTexture?.dispose();
|
|
1193
|
+
this.linkIndicesTexture = this.createTexture(linkIndices, textureSize, THREE.RGBAFormat);
|
|
1194
|
+
this.linkTextureSize = textureSize;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Set link properties (strength, distance, etc.)
|
|
1198
|
+
*/
|
|
1199
|
+
setLinkProperties(linkProperties, textureSize) {
|
|
1200
|
+
this.linkPropertiesTexture?.dispose();
|
|
1201
|
+
this.linkPropertiesTexture = this.createTexture(linkProperties, textureSize, THREE.RGBAFormat);
|
|
1202
|
+
this.linkTextureSize = textureSize;
|
|
1203
|
+
}
|
|
1204
|
+
setNodeLinkMap(linkMap, textureSize) {
|
|
1205
|
+
this.nodeLinkMapTexture?.dispose();
|
|
1206
|
+
this.nodeLinkMapTexture = this.createTexture(linkMap, textureSize, THREE.RGBAFormat);
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Create a data texture with proper settings for GPU compute
|
|
1210
|
+
*/
|
|
1211
|
+
createTexture(data, size, format) {
|
|
1212
|
+
const texture = new THREE.DataTexture(data, size, size, format, THREE.FloatType);
|
|
1213
|
+
texture.needsUpdate = true;
|
|
1214
|
+
texture.minFilter = THREE.NearestFilter;
|
|
1215
|
+
texture.magFilter = THREE.NearestFilter;
|
|
1216
|
+
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
1217
|
+
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
1218
|
+
texture.generateMipmaps = false;
|
|
1219
|
+
return texture;
|
|
1220
|
+
}
|
|
1221
|
+
getNodeRadiiTexture() {
|
|
1222
|
+
return this.nodeRadiiTexture;
|
|
1223
|
+
}
|
|
1224
|
+
getNodeColorsTexture() {
|
|
1225
|
+
return this.nodeColorsTexture;
|
|
1226
|
+
}
|
|
1227
|
+
getLinkIndicesTexture() {
|
|
1228
|
+
return this.linkIndicesTexture;
|
|
1229
|
+
}
|
|
1230
|
+
getLinkPropertiesTexture() {
|
|
1231
|
+
return this.linkPropertiesTexture;
|
|
1232
|
+
}
|
|
1233
|
+
getLinkMapTexture() {
|
|
1234
|
+
return this.nodeLinkMapTexture;
|
|
1235
|
+
}
|
|
1236
|
+
getNodeTextureSize() {
|
|
1237
|
+
return this.nodeTextureSize;
|
|
1238
|
+
}
|
|
1239
|
+
getLinkTextureSize() {
|
|
1240
|
+
return this.linkTextureSize;
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Check if assets are ready
|
|
1244
|
+
*/
|
|
1245
|
+
hasNodeAssets() {
|
|
1246
|
+
return this.nodeRadiiTexture !== null;
|
|
1247
|
+
}
|
|
1248
|
+
hasLinkAssets() {
|
|
1249
|
+
return this.linkIndicesTexture !== null;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Cleanup
|
|
1253
|
+
*/
|
|
1254
|
+
dispose() {
|
|
1255
|
+
this.nodeRadiiTexture?.dispose();
|
|
1256
|
+
this.nodeColorsTexture?.dispose();
|
|
1257
|
+
this.linkIndicesTexture?.dispose();
|
|
1258
|
+
this.linkPropertiesTexture?.dispose();
|
|
1259
|
+
this.nodeLinkMapTexture?.dispose();
|
|
1260
|
+
this.nodeRadiiTexture = null;
|
|
1261
|
+
this.nodeColorsTexture = null;
|
|
1262
|
+
this.linkIndicesTexture = null;
|
|
1263
|
+
this.linkPropertiesTexture = null;
|
|
1264
|
+
this.nodeTextureSize = 0;
|
|
1265
|
+
this.linkTextureSize = 0;
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
const staticAssets = new StaticAssets();
|
|
1269
|
+
|
|
1270
|
+
//#endregion
|
|
1271
|
+
//#region rendering/links/LinksOpacity.ts
|
|
1272
|
+
/**
|
|
1273
|
+
* LinksOpacity
|
|
1274
|
+
*/
|
|
1275
|
+
var LinksOpacity = class {
|
|
1276
|
+
renderer;
|
|
1277
|
+
size;
|
|
1278
|
+
rtA;
|
|
1279
|
+
rtB;
|
|
1280
|
+
ping = 0;
|
|
1281
|
+
scene;
|
|
1282
|
+
camera;
|
|
1283
|
+
quad;
|
|
1284
|
+
computeMaterial;
|
|
1285
|
+
copyMaterial;
|
|
1286
|
+
targetTex = null;
|
|
1287
|
+
constructor(renderer, size) {
|
|
1288
|
+
this.renderer = renderer;
|
|
1289
|
+
this.size = size;
|
|
1290
|
+
const params = {
|
|
1291
|
+
minFilter: THREE.NearestFilter,
|
|
1292
|
+
magFilter: THREE.NearestFilter,
|
|
1293
|
+
wrapS: THREE.ClampToEdgeWrapping,
|
|
1294
|
+
wrapT: THREE.ClampToEdgeWrapping,
|
|
1295
|
+
format: THREE.RGBAFormat,
|
|
1296
|
+
type: THREE.FloatType,
|
|
1297
|
+
depthBuffer: false,
|
|
1298
|
+
stencilBuffer: false
|
|
1299
|
+
};
|
|
1300
|
+
this.rtA = new THREE.WebGLRenderTarget(size, size, params);
|
|
1301
|
+
this.rtB = new THREE.WebGLRenderTarget(size, size, params);
|
|
1302
|
+
this.scene = new THREE.Scene();
|
|
1303
|
+
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
1304
|
+
const geo = new THREE.PlaneGeometry(2, 2);
|
|
1305
|
+
this.computeMaterial = new THREE.ShaderMaterial({
|
|
1306
|
+
vertexShader: `
|
|
1307
|
+
void main() {
|
|
1308
|
+
gl_Position = vec4(position, 1.0);
|
|
1309
|
+
}
|
|
1310
|
+
`,
|
|
1311
|
+
fragmentShader: `
|
|
1312
|
+
precision highp float;
|
|
1313
|
+
|
|
1314
|
+
uniform sampler2D uPrev;
|
|
1315
|
+
uniform sampler2D uTarget;
|
|
1316
|
+
uniform float uStep;
|
|
1317
|
+
uniform vec2 uResolution;
|
|
1318
|
+
|
|
1319
|
+
void main() {
|
|
1320
|
+
// compute stable texcoord from pixel coords
|
|
1321
|
+
vec2 uv = gl_FragCoord.xy / uResolution;
|
|
1322
|
+
float prev = texture2D(uPrev, uv).r;
|
|
1323
|
+
float target = texture2D(uTarget, uv).r;
|
|
1324
|
+
|
|
1325
|
+
// Lerp towards target
|
|
1326
|
+
float nextVal = mix(prev, target, uStep);
|
|
1327
|
+
|
|
1328
|
+
// Clamp to valid range
|
|
1329
|
+
nextVal = clamp(nextVal, 0.0, 1.0);
|
|
1330
|
+
|
|
1331
|
+
gl_FragColor = vec4(nextVal, 0.0, 0.0, 1.0);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
`,
|
|
1335
|
+
uniforms: {
|
|
1336
|
+
uPrev: { value: null },
|
|
1337
|
+
uTarget: { value: null },
|
|
1338
|
+
uStep: { value: .01 },
|
|
1339
|
+
uResolution: { value: new THREE.Vector2(size, size) }
|
|
1340
|
+
},
|
|
1341
|
+
depthTest: false,
|
|
1342
|
+
depthWrite: false
|
|
1343
|
+
});
|
|
1344
|
+
this.copyMaterial = new THREE.ShaderMaterial({
|
|
1345
|
+
vertexShader: this.computeMaterial.vertexShader,
|
|
1346
|
+
fragmentShader: `
|
|
1347
|
+
precision highp float;
|
|
1348
|
+
uniform sampler2D uSrc;
|
|
1349
|
+
uniform vec2 uResolution;
|
|
1350
|
+
void main() {
|
|
1351
|
+
vec2 uv = gl_FragCoord.xy / uResolution;
|
|
1352
|
+
vec4 s = texture2D(uSrc, uv);
|
|
1353
|
+
gl_FragColor = vec4(s.r, 0.0, 0.0, 1.0);
|
|
1354
|
+
}
|
|
1355
|
+
`,
|
|
1356
|
+
uniforms: {
|
|
1357
|
+
uSrc: { value: null },
|
|
1358
|
+
uResolution: { value: new THREE.Vector2(size, size) }
|
|
1359
|
+
},
|
|
1360
|
+
depthTest: false,
|
|
1361
|
+
depthWrite: false
|
|
1362
|
+
});
|
|
1363
|
+
this.quad = new THREE.Mesh(geo, this.computeMaterial);
|
|
1364
|
+
this.scene.add(this.quad);
|
|
1365
|
+
this.targetTex = new THREE.DataTexture(new Float32Array(size * size * 4).fill(0), size, size, THREE.RGBAFormat, THREE.FloatType);
|
|
1366
|
+
this.targetTex.minFilter = THREE.NearestFilter;
|
|
1367
|
+
this.targetTex.magFilter = THREE.NearestFilter;
|
|
1368
|
+
this.targetTex.wrapS = THREE.ClampToEdgeWrapping;
|
|
1369
|
+
this.targetTex.wrapT = THREE.ClampToEdgeWrapping;
|
|
1370
|
+
this.targetTex.flipY = false;
|
|
1371
|
+
this.targetTex.needsUpdate = true;
|
|
1372
|
+
}
|
|
1373
|
+
init(initialAlphaTexture) {
|
|
1374
|
+
this.copyMaterial.uniforms.uSrc.value = initialAlphaTexture;
|
|
1375
|
+
this.copyMaterial.uniforms.uResolution.value.set(this.size, this.size);
|
|
1376
|
+
this.quad.material = this.copyMaterial;
|
|
1377
|
+
const prevRT = this.renderer.getRenderTarget();
|
|
1378
|
+
this.renderer.setRenderTarget(this.rtA);
|
|
1379
|
+
this.renderer.render(this.scene, this.camera);
|
|
1380
|
+
this.renderer.setRenderTarget(this.rtB);
|
|
1381
|
+
this.renderer.render(this.scene, this.camera);
|
|
1382
|
+
this.renderer.setRenderTarget(prevRT);
|
|
1383
|
+
this.ping = 0;
|
|
1384
|
+
this.quad.material = this.computeMaterial;
|
|
1385
|
+
}
|
|
1386
|
+
setTargets(targets) {
|
|
1387
|
+
const total = this.size * this.size;
|
|
1388
|
+
const data = this.targetTex && this.targetTex.image && this.targetTex.image.data.length === total * 4 ? this.targetTex.image.data : new Float32Array(total * 4);
|
|
1389
|
+
for (let i = 0; i < total; i++) {
|
|
1390
|
+
const v = targets[i] ?? 0;
|
|
1391
|
+
const base = i * 4;
|
|
1392
|
+
data[base] = v;
|
|
1393
|
+
data[base + 1] = 0;
|
|
1394
|
+
data[base + 2] = 0;
|
|
1395
|
+
data[base + 3] = 1;
|
|
1396
|
+
}
|
|
1397
|
+
if (this.targetTex) {
|
|
1398
|
+
this.targetTex.image.data = data;
|
|
1399
|
+
this.targetTex.needsUpdate = true;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
runComputePass() {
|
|
1403
|
+
const prevRT = this.getPrevRT();
|
|
1404
|
+
this.computeMaterial.uniforms.uPrev.value = prevRT.texture;
|
|
1405
|
+
if (this.targetTex) this.computeMaterial.uniforms.uTarget.value = this.targetTex;
|
|
1406
|
+
const targetRT = this.getNextRT();
|
|
1407
|
+
this.renderer.setRenderTarget(targetRT);
|
|
1408
|
+
this.renderer.render(this.scene, this.camera);
|
|
1409
|
+
this.renderer.setRenderTarget(null);
|
|
1410
|
+
this.ping = 1 - this.ping;
|
|
1411
|
+
}
|
|
1412
|
+
update() {
|
|
1413
|
+
this.runComputePass();
|
|
1414
|
+
}
|
|
1415
|
+
getCurrentAlphaTexture() {
|
|
1416
|
+
return this.getPrevRT().texture;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Set the step size for alpha transitions
|
|
1420
|
+
* @param step - Step size (default: 0.01)
|
|
1421
|
+
*/
|
|
1422
|
+
setStep(step) {
|
|
1423
|
+
this.computeMaterial.uniforms.uStep.value = step;
|
|
1424
|
+
}
|
|
1425
|
+
getPrevRT() {
|
|
1426
|
+
return this.ping === 0 ? this.rtA : this.rtB;
|
|
1427
|
+
}
|
|
1428
|
+
getNextRT() {
|
|
1429
|
+
return this.ping === 0 ? this.rtB : this.rtA;
|
|
1430
|
+
}
|
|
1431
|
+
dispose() {
|
|
1432
|
+
this.rtA.dispose();
|
|
1433
|
+
this.rtB.dispose();
|
|
1434
|
+
this.copyMaterial.dispose();
|
|
1435
|
+
this.computeMaterial.dispose();
|
|
1436
|
+
this.quad.geometry.dispose();
|
|
1437
|
+
if (this.targetTex) this.targetTex.dispose();
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
//#endregion
|
|
1442
|
+
//#region rendering/links/LinksRenderer.ts
|
|
1443
|
+
/**
|
|
1444
|
+
* LinksRenderer - Renders links as line segments
|
|
1445
|
+
* Uses shared SimulationBuffers for node positions
|
|
1446
|
+
* Manages link-specific opacity and highlighting
|
|
1447
|
+
*/
|
|
1448
|
+
var LinksRenderer = class {
|
|
1449
|
+
lines = null;
|
|
1450
|
+
links = [];
|
|
1451
|
+
nodeIndexMap = /* @__PURE__ */ new Map();
|
|
1452
|
+
linkIndexMap = /* @__PURE__ */ new Map();
|
|
1453
|
+
interpolationSteps = 24;
|
|
1454
|
+
params = {
|
|
1455
|
+
noiseStrength: .2,
|
|
1456
|
+
defaultAlpha: .02,
|
|
1457
|
+
highlightAlpha: .8,
|
|
1458
|
+
dimmedAlpha: .02,
|
|
1459
|
+
is2D: true
|
|
1460
|
+
};
|
|
1461
|
+
linkOpacity = null;
|
|
1462
|
+
simulationBuffers = null;
|
|
1463
|
+
constructor(scene, renderer) {
|
|
1464
|
+
this.scene = scene;
|
|
1465
|
+
this.renderer = renderer;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Create link geometry and materials
|
|
1469
|
+
* Receives shared buffers as dependencies
|
|
1470
|
+
*/
|
|
1471
|
+
create(links, nodes, simulationBuffers) {
|
|
1472
|
+
this.links = links;
|
|
1473
|
+
this.simulationBuffers = simulationBuffers;
|
|
1474
|
+
const textureSize = simulationBuffers.getTextureSize();
|
|
1475
|
+
const linkCount = links.length;
|
|
1476
|
+
this.buildNodeMapping(nodes);
|
|
1477
|
+
const { geometry, linkIndexMap } = this.createLinkGeometry(links, nodes, textureSize);
|
|
1478
|
+
this.linkIndexMap = linkIndexMap;
|
|
1479
|
+
const { linkIndicesData, linkPropertiesData, nodeLinkMapData, linksTextureSize } = this.buildLinkTextures(links, nodes, textureSize);
|
|
1480
|
+
staticAssets.setLinkIndices(linkIndicesData, linksTextureSize);
|
|
1481
|
+
staticAssets.setLinkProperties(linkPropertiesData, linksTextureSize);
|
|
1482
|
+
staticAssets.setNodeLinkMap(nodeLinkMapData, textureSize);
|
|
1483
|
+
const alphaTextureSize = Math.ceil(Math.sqrt(linkCount));
|
|
1484
|
+
this.linkOpacity = new LinksOpacity(this.renderer, alphaTextureSize);
|
|
1485
|
+
const total = alphaTextureSize * alphaTextureSize;
|
|
1486
|
+
const initialAlphaData = new Float32Array(total * 4);
|
|
1487
|
+
for (let i = 0; i < total; i++) {
|
|
1488
|
+
initialAlphaData[i * 4] = this.params.defaultAlpha;
|
|
1489
|
+
initialAlphaData[i * 4 + 1] = 0;
|
|
1490
|
+
initialAlphaData[i * 4 + 2] = 0;
|
|
1491
|
+
initialAlphaData[i * 4 + 3] = 1;
|
|
1492
|
+
}
|
|
1493
|
+
const initialAlphaTexture = new THREE.DataTexture(initialAlphaData, alphaTextureSize, alphaTextureSize, THREE.RGBAFormat, THREE.FloatType);
|
|
1494
|
+
initialAlphaTexture.needsUpdate = true;
|
|
1495
|
+
this.linkOpacity.init(initialAlphaTexture);
|
|
1496
|
+
initialAlphaTexture.dispose();
|
|
1497
|
+
const material = this.createRenderMaterial(textureSize, alphaTextureSize);
|
|
1498
|
+
this.lines = new THREE.LineSegments(geometry, material);
|
|
1499
|
+
this.lines.frustumCulled = false;
|
|
1500
|
+
this.lines.renderOrder = 998;
|
|
1501
|
+
this.scene.add(this.lines);
|
|
1502
|
+
this.clearHighlights();
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Update position texture from simulation
|
|
1506
|
+
* This is the SHARED texture used by both nodes and links
|
|
1507
|
+
*/
|
|
1508
|
+
setPositionTexture(positionTexture) {
|
|
1509
|
+
if (!this.lines) return;
|
|
1510
|
+
const material = this.lines.material;
|
|
1511
|
+
if (material.uniforms.uPositionsTexture) material.uniforms.uPositionsTexture.value = positionTexture;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Update shader uniforms (called each frame)
|
|
1515
|
+
*/
|
|
1516
|
+
update(time) {
|
|
1517
|
+
if (!this.lines) return;
|
|
1518
|
+
const material = this.lines.material;
|
|
1519
|
+
if (material.uniforms.uTime) material.uniforms.uTime.value = time;
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Update link opacity (called every frame)
|
|
1523
|
+
*/
|
|
1524
|
+
updateOpacity() {
|
|
1525
|
+
if (!this.linkOpacity) return;
|
|
1526
|
+
this.linkOpacity.update();
|
|
1527
|
+
if (this.lines && this.lines.material) {
|
|
1528
|
+
const material = this.lines.material;
|
|
1529
|
+
if (material.uniforms.uAlphaTexture) material.uniforms.uAlphaTexture.value = this.linkOpacity.getCurrentAlphaTexture();
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Highlight links connected to a specific node
|
|
1534
|
+
*/
|
|
1535
|
+
highlightConnectedLinks(nodeId, step = .01) {
|
|
1536
|
+
if (!this.lines || !this.linkOpacity) return;
|
|
1537
|
+
const linkCount = this.lines.geometry instanceof THREE.InstancedBufferGeometry ? this.lines.geometry.instanceCount : 0;
|
|
1538
|
+
if (linkCount === 0 || linkCount === void 0) return;
|
|
1539
|
+
const targets = new Array(linkCount).fill(this.params.dimmedAlpha);
|
|
1540
|
+
let hasConnections = false;
|
|
1541
|
+
for (const [linkId, instanceIndex] of this.linkIndexMap.entries()) {
|
|
1542
|
+
const [sourceId, targetId] = linkId.split("-");
|
|
1543
|
+
if (sourceId === nodeId || targetId === nodeId) {
|
|
1544
|
+
targets[instanceIndex] = this.params.highlightAlpha;
|
|
1545
|
+
hasConnections = true;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (!hasConnections) return;
|
|
1549
|
+
this.linkOpacity.setStep(step);
|
|
1550
|
+
this.linkOpacity.setTargets(targets);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Clear all highlights (return to defaultAlpha)
|
|
1554
|
+
*/
|
|
1555
|
+
clearHighlights(step = .1) {
|
|
1556
|
+
if (!this.linkOpacity) return;
|
|
1557
|
+
const linkCount = this.lines?.geometry instanceof THREE.InstancedBufferGeometry ? this.lines.geometry.instanceCount : 0;
|
|
1558
|
+
if (linkCount === 0 || linkCount === void 0) return;
|
|
1559
|
+
const targets = new Array(linkCount).fill(this.params.defaultAlpha);
|
|
1560
|
+
this.linkOpacity.setStep(step);
|
|
1561
|
+
this.linkOpacity.setTargets(targets);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Update noise strength parameter
|
|
1565
|
+
*/
|
|
1566
|
+
setNoiseStrength(strength) {
|
|
1567
|
+
this.params.noiseStrength = strength;
|
|
1568
|
+
this.refreshUniforms();
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Update alpha parameters
|
|
1572
|
+
*/
|
|
1573
|
+
setAlphaParams(params) {
|
|
1574
|
+
if (params.defaultAlpha !== void 0) this.params.defaultAlpha = params.defaultAlpha;
|
|
1575
|
+
if (params.highlightAlpha !== void 0) this.params.highlightAlpha = params.highlightAlpha;
|
|
1576
|
+
if (params.dimmedAlpha !== void 0) this.params.dimmedAlpha = params.dimmedAlpha;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Unified configuration method
|
|
1580
|
+
*/
|
|
1581
|
+
setOptions(options) {
|
|
1582
|
+
if (options.visible !== void 0) this.setVisible(options.visible);
|
|
1583
|
+
if (options.noiseStrength !== void 0) this.setNoiseStrength(options.noiseStrength);
|
|
1584
|
+
if (options.alpha) {
|
|
1585
|
+
this.setAlphaParams({
|
|
1586
|
+
defaultAlpha: options.alpha.default,
|
|
1587
|
+
highlightAlpha: options.alpha.highlight,
|
|
1588
|
+
dimmedAlpha: options.alpha.dimmed
|
|
1589
|
+
});
|
|
1590
|
+
this.clearHighlights();
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Refresh shader uniforms when params change
|
|
1595
|
+
*/
|
|
1596
|
+
refreshUniforms() {
|
|
1597
|
+
if (!this.lines) return;
|
|
1598
|
+
const material = this.lines.material;
|
|
1599
|
+
if (material.uniforms.uNoiseStrength) material.uniforms.uNoiseStrength.value = this.params.noiseStrength;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Handle window resize
|
|
1603
|
+
*/
|
|
1604
|
+
resize(width, height) {}
|
|
1605
|
+
/**
|
|
1606
|
+
* Set visibility of links
|
|
1607
|
+
*/
|
|
1608
|
+
setVisible(visible) {
|
|
1609
|
+
if (this.lines) this.lines.visible = visible;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Check if links are visible
|
|
1613
|
+
*/
|
|
1614
|
+
isVisible() {
|
|
1615
|
+
return this.lines?.visible ?? false;
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Cleanup
|
|
1619
|
+
*/
|
|
1620
|
+
dispose() {
|
|
1621
|
+
if (this.lines) {
|
|
1622
|
+
this.scene.remove(this.lines);
|
|
1623
|
+
this.lines.geometry.dispose();
|
|
1624
|
+
this.lines.material.dispose();
|
|
1625
|
+
this.lines = null;
|
|
1626
|
+
}
|
|
1627
|
+
this.linkOpacity?.dispose();
|
|
1628
|
+
this.linkIndexMap.clear();
|
|
1629
|
+
this.nodeIndexMap.clear();
|
|
1630
|
+
this.simulationBuffers = null;
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Build node index mapping
|
|
1634
|
+
*/
|
|
1635
|
+
buildNodeMapping(nodes) {
|
|
1636
|
+
this.nodeIndexMap.clear();
|
|
1637
|
+
nodes.forEach((node, i) => {
|
|
1638
|
+
this.nodeIndexMap.set(node.id, i);
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Create link geometry with interpolated segments
|
|
1643
|
+
*/
|
|
1644
|
+
createLinkGeometry(links, nodes, nodeTextureSize) {
|
|
1645
|
+
const linkIndexMap = /* @__PURE__ */ new Map();
|
|
1646
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
1647
|
+
nodes.forEach((node) => nodeMap.set(node.id, node));
|
|
1648
|
+
const baseGeometry = new THREE.BufferGeometry();
|
|
1649
|
+
const baseVertices = this.interpolationSteps * 2;
|
|
1650
|
+
const tValues = new Float32Array(baseVertices);
|
|
1651
|
+
const positions = new Float32Array(baseVertices * 3);
|
|
1652
|
+
let vertexIndex = 0;
|
|
1653
|
+
for (let i = 0; i < this.interpolationSteps; i++) {
|
|
1654
|
+
const t0 = i / this.interpolationSteps;
|
|
1655
|
+
const t1 = (i + 1) / this.interpolationSteps;
|
|
1656
|
+
tValues[vertexIndex] = t0;
|
|
1657
|
+
vertexIndex++;
|
|
1658
|
+
tValues[vertexIndex] = t1;
|
|
1659
|
+
vertexIndex++;
|
|
1660
|
+
}
|
|
1661
|
+
baseGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
1662
|
+
baseGeometry.setAttribute("t", new THREE.BufferAttribute(tValues, 1));
|
|
1663
|
+
const geometry = new THREE.InstancedBufferGeometry();
|
|
1664
|
+
geometry.index = baseGeometry.index;
|
|
1665
|
+
geometry.attributes = baseGeometry.attributes;
|
|
1666
|
+
geometry.instanceCount = links.length;
|
|
1667
|
+
const instanceLinkA = new Float32Array(links.length * 2);
|
|
1668
|
+
const instanceLinkB = new Float32Array(links.length * 2);
|
|
1669
|
+
const instanceColorA = new Float32Array(links.length * 3);
|
|
1670
|
+
const instanceColorB = new Float32Array(links.length * 3);
|
|
1671
|
+
const instanceAlphaIndex = new Float32Array(links.length);
|
|
1672
|
+
links.forEach((link, i) => {
|
|
1673
|
+
const sourceId = link.source && typeof link.source === "object" ? link.source.id : link.source;
|
|
1674
|
+
const targetId = link.target && typeof link.target === "object" ? link.target.id : link.target;
|
|
1675
|
+
if (!sourceId || !targetId) {
|
|
1676
|
+
console.warn("Link missing source or target:", link);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const sourceIndex = this.nodeIndexMap.get(sourceId);
|
|
1680
|
+
const targetIndex = this.nodeIndexMap.get(targetId);
|
|
1681
|
+
if (sourceIndex === void 0 || targetIndex === void 0) {
|
|
1682
|
+
console.warn("Node not found for link:", link);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const sourceX = sourceIndex % nodeTextureSize;
|
|
1686
|
+
const sourceY = Math.floor(sourceIndex / nodeTextureSize);
|
|
1687
|
+
const targetX = targetIndex % nodeTextureSize;
|
|
1688
|
+
const targetY = Math.floor(targetIndex / nodeTextureSize);
|
|
1689
|
+
const sourceNode = nodeMap.get(sourceId);
|
|
1690
|
+
const targetNode = nodeMap.get(targetId);
|
|
1691
|
+
let srcColor;
|
|
1692
|
+
let tgtColor;
|
|
1693
|
+
srcColor = styleRegistry.getNodeStyle(sourceNode?.category).color;
|
|
1694
|
+
tgtColor = styleRegistry.getNodeStyle(targetNode?.category).color;
|
|
1695
|
+
instanceLinkA[i * 2] = sourceX;
|
|
1696
|
+
instanceLinkA[i * 2 + 1] = sourceY;
|
|
1697
|
+
instanceLinkB[i * 2] = targetX;
|
|
1698
|
+
instanceLinkB[i * 2 + 1] = targetY;
|
|
1699
|
+
instanceColorA[i * 3] = srcColor.r;
|
|
1700
|
+
instanceColorA[i * 3 + 1] = srcColor.g;
|
|
1701
|
+
instanceColorA[i * 3 + 2] = srcColor.b;
|
|
1702
|
+
instanceColorB[i * 3] = tgtColor.r;
|
|
1703
|
+
instanceColorB[i * 3 + 1] = tgtColor.g;
|
|
1704
|
+
instanceColorB[i * 3 + 2] = tgtColor.b;
|
|
1705
|
+
instanceAlphaIndex[i] = i;
|
|
1706
|
+
const linkId = `${sourceId}-${targetId}`;
|
|
1707
|
+
linkIndexMap.set(linkId, i);
|
|
1708
|
+
});
|
|
1709
|
+
geometry.setAttribute("instanceLinkA", new THREE.InstancedBufferAttribute(instanceLinkA, 2));
|
|
1710
|
+
geometry.setAttribute("instanceLinkB", new THREE.InstancedBufferAttribute(instanceLinkB, 2));
|
|
1711
|
+
geometry.setAttribute("instanceColorA", new THREE.InstancedBufferAttribute(instanceColorA, 3));
|
|
1712
|
+
geometry.setAttribute("instanceColorB", new THREE.InstancedBufferAttribute(instanceColorB, 3));
|
|
1713
|
+
geometry.setAttribute("instanceAlphaIndex", new THREE.InstancedBufferAttribute(instanceAlphaIndex, 1));
|
|
1714
|
+
return {
|
|
1715
|
+
geometry,
|
|
1716
|
+
linkIndexMap
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Build all link-related textures for simulation
|
|
1721
|
+
* - linkIndicesData: parent node INDEX per link entry (organized by child)
|
|
1722
|
+
* - linkPropertiesData: strength/distance per link entry
|
|
1723
|
+
* - nodeLinkMapData: per-node metadata (startX, startY, count, hasLinks)
|
|
1724
|
+
*/
|
|
1725
|
+
buildLinkTextures(links, nodes, pointsTexSize, groupOrder = [
|
|
1726
|
+
"root",
|
|
1727
|
+
"series",
|
|
1728
|
+
"artwork",
|
|
1729
|
+
"exhibition",
|
|
1730
|
+
"media"
|
|
1731
|
+
]) {
|
|
1732
|
+
const getId = (v) => typeof v === "string" ? v : v?.id ?? "";
|
|
1733
|
+
const counts = new Array(this.nodeIndexMap.size).fill(0);
|
|
1734
|
+
for (const link of links) {
|
|
1735
|
+
if (!link?.source || !link?.target) continue;
|
|
1736
|
+
const tId = getId(link.target);
|
|
1737
|
+
const tIdx = this.nodeIndexMap.get(tId);
|
|
1738
|
+
if (tIdx !== void 0 && tIdx >= 0 && tIdx < counts.length) counts[tIdx] = (counts[tIdx] ?? 0) + 1;
|
|
1739
|
+
}
|
|
1740
|
+
const totalTexels = counts.reduce((a, b) => a + b, 0);
|
|
1741
|
+
const minSize = Math.ceil(Math.sqrt(Math.max(1, totalTexels)));
|
|
1742
|
+
const linksTexSize = Math.max(1, 2 ** Math.ceil(Math.log2(minSize)));
|
|
1743
|
+
const totalLinkTexels = linksTexSize * linksTexSize;
|
|
1744
|
+
const linkIndicesData = new Float32Array(totalLinkTexels * 4).fill(-1);
|
|
1745
|
+
const linkPropertiesData = new Float32Array(totalLinkTexels * 4).fill(0);
|
|
1746
|
+
const infoTotal = pointsTexSize * pointsTexSize;
|
|
1747
|
+
const nodeLinkMapData = new Float32Array(infoTotal * 4).fill(0);
|
|
1748
|
+
let writeIndex = 0;
|
|
1749
|
+
for (let nodeIdx = 0; nodeIdx < counts.length; nodeIdx++) {
|
|
1750
|
+
const count = counts[nodeIdx] || 0;
|
|
1751
|
+
const startX = writeIndex % linksTexSize;
|
|
1752
|
+
const startY = Math.floor(writeIndex / linksTexSize);
|
|
1753
|
+
const infoIdx = nodeIdx * 4;
|
|
1754
|
+
nodeLinkMapData[infoIdx] = startX;
|
|
1755
|
+
nodeLinkMapData[infoIdx + 1] = startY;
|
|
1756
|
+
nodeLinkMapData[infoIdx + 2] = count;
|
|
1757
|
+
nodeLinkMapData[infoIdx + 3] = count > 0 ? 1 : 0;
|
|
1758
|
+
writeIndex += count;
|
|
1759
|
+
}
|
|
1760
|
+
const nodeCategories = Array.from({ length: counts.length }, () => "");
|
|
1761
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1762
|
+
const node = nodes[i];
|
|
1763
|
+
if (!node) continue;
|
|
1764
|
+
const nodeIdx = this.nodeIndexMap.get(node.id);
|
|
1765
|
+
if (nodeIdx !== void 0 && nodeIdx >= 0) nodeCategories[nodeIdx] = node.category ?? "";
|
|
1766
|
+
}
|
|
1767
|
+
const categoryRankMap = /* @__PURE__ */ new Map();
|
|
1768
|
+
groupOrder.forEach((g, i) => categoryRankMap.set(g, i));
|
|
1769
|
+
let nextRank = groupOrder.length;
|
|
1770
|
+
const nodeCursor = Array.from({ length: counts.length }).fill(0);
|
|
1771
|
+
for (const link of links) {
|
|
1772
|
+
if (!link?.source || !link?.target) continue;
|
|
1773
|
+
const sId = getId(link.source);
|
|
1774
|
+
const tId = getId(link.target);
|
|
1775
|
+
const sIdx = this.nodeIndexMap.get(sId);
|
|
1776
|
+
const tIdx = this.nodeIndexMap.get(tId);
|
|
1777
|
+
if (sIdx === void 0 || tIdx === void 0 || sIdx < 0 || tIdx < 0) continue;
|
|
1778
|
+
const rawStartX = nodeLinkMapData[tIdx * 4] ?? 0;
|
|
1779
|
+
const baseStartTexel = (nodeLinkMapData[tIdx * 4 + 1] ?? 0) * linksTexSize + rawStartX;
|
|
1780
|
+
const offset = nodeCursor[tIdx] || 0;
|
|
1781
|
+
const texelIndex = baseStartTexel + offset;
|
|
1782
|
+
if (texelIndex < totalLinkTexels) {
|
|
1783
|
+
const di = texelIndex * 4;
|
|
1784
|
+
linkIndicesData[di] = sIdx;
|
|
1785
|
+
const category = nodeCategories[tIdx] || "";
|
|
1786
|
+
if (category && !categoryRankMap.has(category)) categoryRankMap.set(category, nextRank++);
|
|
1787
|
+
const rank = categoryRankMap.get(category) ?? 0;
|
|
1788
|
+
const strength = 1 / (rank + 1);
|
|
1789
|
+
const distance = .3 ** rank;
|
|
1790
|
+
linkPropertiesData[di] = strength;
|
|
1791
|
+
linkPropertiesData[di + 1] = distance;
|
|
1792
|
+
}
|
|
1793
|
+
nodeCursor[tIdx] = offset + 1;
|
|
1794
|
+
}
|
|
1795
|
+
return {
|
|
1796
|
+
linkIndicesData,
|
|
1797
|
+
linkPropertiesData,
|
|
1798
|
+
nodeLinkMapData,
|
|
1799
|
+
linksTextureSize: linksTexSize
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Create render material with all uniforms
|
|
1804
|
+
*/
|
|
1805
|
+
createRenderMaterial(nodeTextureSize, alphaTextureSize) {
|
|
1806
|
+
return new THREE.ShaderMaterial({
|
|
1807
|
+
vertexShader: lines_default,
|
|
1808
|
+
fragmentShader: lines_default$1,
|
|
1809
|
+
transparent: true,
|
|
1810
|
+
depthTest: false,
|
|
1811
|
+
depthWrite: false,
|
|
1812
|
+
uniforms: {
|
|
1813
|
+
uPositionsTexture: { value: this.simulationBuffers.getCurrentPositionTexture() },
|
|
1814
|
+
uPositionsTextureSize: { value: new THREE.Vector2(nodeTextureSize, nodeTextureSize) },
|
|
1815
|
+
uAlphaTexture: { value: this.linkOpacity.getCurrentAlphaTexture() },
|
|
1816
|
+
uAlphaTextureSize: { value: new THREE.Vector2(alphaTextureSize, alphaTextureSize) },
|
|
1817
|
+
uNoiseStrength: { value: this.params.noiseStrength },
|
|
1818
|
+
uTime: { value: 0 }
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
//#endregion
|
|
1825
|
+
//#region assets/glsl/points/pickPoint.frag
|
|
1826
|
+
var pickPoint_default$1 = "precision mediump float; \r\nvarying float vIndex; \n\nvoid main(){\n\n vec2 center = vec2(0.5, 0.5);\r\n vec2 uv = gl_PointCoord - center;\r\n float dist = length(uv);\n\n \n if (dist > 0.4) {\r\n discard;\r\n }\n\n float id = vIndex + 1.0;\r\n float r = mod(id, 256.0);\r\n float g = mod(floor(id / 256.0), 256.0);\r\n float b = mod(floor(id / 65536.0), 256.0);\r\n gl_FragColor = vec4(r/255.0, g/255.0, b/255.0, 1.0);\r\n}";
|
|
1827
|
+
|
|
1828
|
+
//#endregion
|
|
1829
|
+
//#region assets/glsl/points/pickPoint.vert
|
|
1830
|
+
var pickPoint_default = "attribute vec2 pointIndices;\r\nattribute float scale;\n\nattribute float alphaIndex;\r\nuniform sampler2D uPositionsTexture;\r\nuniform vec2 uPositionsTextureSize;\r\nuniform vec2 uResolution;\r\nuniform float uAspectRatio;\r\nuniform float uPixelRatio;\n\nvarying float vIndex;\n\nvoid main() {\r\n vIndex = alphaIndex;\n\n vec2 posUV = (pointIndices + vec2(0.5)) / uPositionsTextureSize;\n\n vec4 posTex = texture2D(uPositionsTexture, posUV);\r\n vec3 gpuPos = vec3(posTex.r, posTex.g, posTex.b);\r\n if(posTex.w < 0.5) {\r\n \n gl_PointSize = 0.0;\r\n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); \r\n return;\r\n }\r\n vec4 mvPosition = modelViewMatrix * vec4(gpuPos, 1.0);\n\n float distance = 1.0 / max(1e-6, length(mvPosition.xyz));\r\n gl_PointSize = scale * uPixelRatio * distance;\r\n gl_Position = projectionMatrix * mvPosition;\r\n \r\n }";
|
|
1831
|
+
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region assets/glsl/points/points.frag
|
|
1834
|
+
var points_default$1 = "uniform sampler2D pointTexture;\r\nuniform sampler2D uAlphaTexture;\r\nuniform vec2 uTextureSize;\r\nvarying vec3 vColor;\r\nvarying float vAlphaIndex;\r\nvarying float vScale;\r\nvarying vec2 vPointIndices;\n\nvarying float vSelected;\n\nvoid main() {\r\n \n vec2 center = vec2(0.5, 0.5);\r\n vec2 uv = gl_PointCoord - center;\r\n float dist = length(uv);\n\n \n vec2 alphaUV = (vPointIndices + 0.5) / uTextureSize;\r\n float alpha = texture2D(uAlphaTexture, alphaUV).r;\n\n \n if (dist > 0.5) {\r\n discard;\r\n }\r\n vec3 color = vColor ;\n\n gl_FragColor = vec4(color, alpha);\r\n #include <colorspace_fragment>\r\n}";
|
|
1835
|
+
|
|
1836
|
+
//#endregion
|
|
1837
|
+
//#region assets/glsl/points/points.vert
|
|
1838
|
+
var points_default = "attribute float scale;\r\nattribute float selected;\r\nattribute float alphaIndex;\r\nattribute vec2 pointIndices;\n\nuniform vec2 uResolution;\r\nuniform float uAspectRatio;\r\nuniform float uPixelRatio;\r\nuniform sampler2D uPositionsTexture;\r\nuniform vec2 uPositionsTextureSize;\n\nvarying float vSelected;\r\nvarying vec3 vColor;\r\nvarying float vAlphaIndex;\r\nvarying float vScale;\r\nvarying vec2 vPointIndices;\n\nvoid main() {\r\n vColor = color;\r\n vSelected = selected;\r\n vScale = scale;\r\n vAlphaIndex = alphaIndex;\r\n vPointIndices = pointIndices;\n\n vec2 posUV = (pointIndices + vec2(0.5)) / uPositionsTextureSize;\n\n vec4 posTex = texture2D(uPositionsTexture, posUV);\r\n vec3 gpuPos = vec3(posTex.r, posTex.g, posTex.b);\r\n bool isActive = posTex.w >= 0.5;\n\n vec4 mvPosition = modelViewMatrix * vec4(gpuPos, 1.0);\n\n float distance = 1.0 / length(mvPosition.xyz + 1e-7);\r\n \r\n if (!isActive) {\r\n \n gl_PointSize = 0.0;\r\n \n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); \r\n } else {\r\n gl_PointSize = scale * uPixelRatio * distance;\r\n gl_Position = projectionMatrix * mvPosition;\r\n }\r\n}";
|
|
1839
|
+
|
|
1840
|
+
//#endregion
|
|
1841
|
+
//#region rendering/nodes/NodeOpacity.ts
|
|
1842
|
+
/**
|
|
1843
|
+
* NodeOpacity - Manages per-node opacity using GPU compute
|
|
1844
|
+
* Similar to LinkOpacity but optimized for nodes
|
|
1845
|
+
*/
|
|
1846
|
+
var NodeOpacity = class {
|
|
1847
|
+
renderer;
|
|
1848
|
+
size;
|
|
1849
|
+
rtA;
|
|
1850
|
+
rtB;
|
|
1851
|
+
ping = 0;
|
|
1852
|
+
scene;
|
|
1853
|
+
camera;
|
|
1854
|
+
quad;
|
|
1855
|
+
computeMaterial;
|
|
1856
|
+
copyMaterial;
|
|
1857
|
+
targetTex = null;
|
|
1858
|
+
constructor(renderer, size) {
|
|
1859
|
+
this.renderer = renderer;
|
|
1860
|
+
this.size = size;
|
|
1861
|
+
const params = {
|
|
1862
|
+
minFilter: THREE.NearestFilter,
|
|
1863
|
+
magFilter: THREE.NearestFilter,
|
|
1864
|
+
wrapS: THREE.ClampToEdgeWrapping,
|
|
1865
|
+
wrapT: THREE.ClampToEdgeWrapping,
|
|
1866
|
+
format: THREE.RGBAFormat,
|
|
1867
|
+
type: THREE.FloatType,
|
|
1868
|
+
depthBuffer: false,
|
|
1869
|
+
stencilBuffer: false
|
|
1870
|
+
};
|
|
1871
|
+
this.rtA = new THREE.WebGLRenderTarget(size, size, params);
|
|
1872
|
+
this.rtB = new THREE.WebGLRenderTarget(size, size, params);
|
|
1873
|
+
this.scene = new THREE.Scene();
|
|
1874
|
+
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
1875
|
+
const geo = new THREE.PlaneGeometry(2, 2);
|
|
1876
|
+
this.computeMaterial = new THREE.ShaderMaterial({
|
|
1877
|
+
vertexShader: `
|
|
1878
|
+
void main() {
|
|
1879
|
+
gl_Position = vec4(position, 1.0);
|
|
1880
|
+
}
|
|
1881
|
+
`,
|
|
1882
|
+
fragmentShader: `
|
|
1883
|
+
precision highp float;
|
|
1884
|
+
|
|
1885
|
+
uniform sampler2D uPrev;
|
|
1886
|
+
uniform sampler2D uTarget;
|
|
1887
|
+
uniform float uStep;
|
|
1888
|
+
uniform vec2 uResolution;
|
|
1889
|
+
|
|
1890
|
+
void main() {
|
|
1891
|
+
// compute stable texcoord from pixel coords
|
|
1892
|
+
vec2 uv = gl_FragCoord.xy / uResolution;
|
|
1893
|
+
float prev = texture2D(uPrev, uv).r;
|
|
1894
|
+
float target = texture2D(uTarget, uv).r;
|
|
1895
|
+
|
|
1896
|
+
// Lerp towards target
|
|
1897
|
+
float nextVal = mix(prev, target, uStep);
|
|
1898
|
+
|
|
1899
|
+
// Clamp to valid range
|
|
1900
|
+
nextVal = clamp(nextVal, 0.0, 1.0);
|
|
1901
|
+
|
|
1902
|
+
gl_FragColor = vec4(nextVal, 0.0, 0.0, 1.0);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
`,
|
|
1906
|
+
uniforms: {
|
|
1907
|
+
uPrev: { value: null },
|
|
1908
|
+
uTarget: { value: null },
|
|
1909
|
+
uStep: { value: .01 },
|
|
1910
|
+
uResolution: { value: new THREE.Vector2(size, size) }
|
|
1911
|
+
},
|
|
1912
|
+
depthTest: false,
|
|
1913
|
+
depthWrite: false
|
|
1914
|
+
});
|
|
1915
|
+
this.copyMaterial = new THREE.ShaderMaterial({
|
|
1916
|
+
vertexShader: this.computeMaterial.vertexShader,
|
|
1917
|
+
fragmentShader: `
|
|
1918
|
+
precision highp float;
|
|
1919
|
+
uniform sampler2D uSrc;
|
|
1920
|
+
uniform vec2 uResolution;
|
|
1921
|
+
void main() {
|
|
1922
|
+
vec2 uv = gl_FragCoord.xy / uResolution;
|
|
1923
|
+
vec4 s = texture2D(uSrc, uv);
|
|
1924
|
+
gl_FragColor = vec4(s.r, 0.0, 0.0, 1.0);
|
|
1925
|
+
}
|
|
1926
|
+
`,
|
|
1927
|
+
uniforms: {
|
|
1928
|
+
uSrc: { value: null },
|
|
1929
|
+
uResolution: { value: new THREE.Vector2(size, size) }
|
|
1930
|
+
},
|
|
1931
|
+
depthTest: false,
|
|
1932
|
+
depthWrite: false
|
|
1933
|
+
});
|
|
1934
|
+
this.quad = new THREE.Mesh(geo, this.computeMaterial);
|
|
1935
|
+
this.scene.add(this.quad);
|
|
1936
|
+
this.targetTex = new THREE.DataTexture(new Float32Array(size * size * 4).fill(0), size, size, THREE.RGBAFormat, THREE.FloatType);
|
|
1937
|
+
this.targetTex.minFilter = THREE.NearestFilter;
|
|
1938
|
+
this.targetTex.magFilter = THREE.NearestFilter;
|
|
1939
|
+
this.targetTex.wrapS = THREE.ClampToEdgeWrapping;
|
|
1940
|
+
this.targetTex.wrapT = THREE.ClampToEdgeWrapping;
|
|
1941
|
+
this.targetTex.flipY = false;
|
|
1942
|
+
this.targetTex.needsUpdate = true;
|
|
1943
|
+
}
|
|
1944
|
+
init(initialAlphaTexture) {
|
|
1945
|
+
this.copyMaterial.uniforms.uSrc.value = initialAlphaTexture;
|
|
1946
|
+
this.copyMaterial.uniforms.uResolution.value.set(this.size, this.size);
|
|
1947
|
+
this.quad.material = this.copyMaterial;
|
|
1948
|
+
const prevRT = this.renderer.getRenderTarget();
|
|
1949
|
+
this.renderer.setRenderTarget(this.rtA);
|
|
1950
|
+
this.renderer.render(this.scene, this.camera);
|
|
1951
|
+
this.renderer.setRenderTarget(this.rtB);
|
|
1952
|
+
this.renderer.render(this.scene, this.camera);
|
|
1953
|
+
this.renderer.setRenderTarget(prevRT);
|
|
1954
|
+
this.ping = 0;
|
|
1955
|
+
this.quad.material = this.computeMaterial;
|
|
1956
|
+
}
|
|
1957
|
+
setTargets(targets) {
|
|
1958
|
+
const total = this.size * this.size;
|
|
1959
|
+
const data = this.targetTex && this.targetTex.image && this.targetTex.image.data.length === total * 4 ? this.targetTex.image.data : new Float32Array(total * 4);
|
|
1960
|
+
for (let i = 0; i < total; i++) {
|
|
1961
|
+
const v = targets[i] ?? 0;
|
|
1962
|
+
const base = i * 4;
|
|
1963
|
+
data[base] = v;
|
|
1964
|
+
data[base + 1] = 0;
|
|
1965
|
+
data[base + 2] = 0;
|
|
1966
|
+
data[base + 3] = 1;
|
|
1967
|
+
}
|
|
1968
|
+
if (this.targetTex) {
|
|
1969
|
+
this.targetTex.image.data = data;
|
|
1970
|
+
this.targetTex.needsUpdate = true;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
runComputePass() {
|
|
1974
|
+
const prevRT = this.getPrevRT();
|
|
1975
|
+
this.computeMaterial.uniforms.uPrev.value = prevRT.texture;
|
|
1976
|
+
if (this.targetTex) this.computeMaterial.uniforms.uTarget.value = this.targetTex;
|
|
1977
|
+
const targetRT = this.getNextRT();
|
|
1978
|
+
this.renderer.setRenderTarget(targetRT);
|
|
1979
|
+
this.renderer.render(this.scene, this.camera);
|
|
1980
|
+
this.renderer.setRenderTarget(null);
|
|
1981
|
+
this.ping = 1 - this.ping;
|
|
1982
|
+
}
|
|
1983
|
+
update() {
|
|
1984
|
+
this.runComputePass();
|
|
1985
|
+
}
|
|
1986
|
+
getCurrentAlphaTexture() {
|
|
1987
|
+
return this.getPrevRT().texture;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Set the step size for alpha transitions
|
|
1991
|
+
* @param step - Step size (default: 0.01)
|
|
1992
|
+
*/
|
|
1993
|
+
setStep(step) {
|
|
1994
|
+
this.computeMaterial.uniforms.uStep.value = step;
|
|
1995
|
+
}
|
|
1996
|
+
getPrevRT() {
|
|
1997
|
+
return this.ping === 0 ? this.rtA : this.rtB;
|
|
1998
|
+
}
|
|
1999
|
+
getNextRT() {
|
|
2000
|
+
return this.ping === 0 ? this.rtB : this.rtA;
|
|
2001
|
+
}
|
|
2002
|
+
dispose() {
|
|
2003
|
+
this.rtA.dispose();
|
|
2004
|
+
this.rtB.dispose();
|
|
2005
|
+
this.copyMaterial.dispose();
|
|
2006
|
+
this.computeMaterial.dispose();
|
|
2007
|
+
this.quad.geometry.dispose();
|
|
2008
|
+
if (this.targetTex) this.targetTex.dispose();
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
//#endregion
|
|
2013
|
+
//#region rendering/nodes/NodesRenderer.ts
|
|
2014
|
+
var NodesRenderer = class {
|
|
2015
|
+
points = null;
|
|
2016
|
+
pickMaterial = null;
|
|
2017
|
+
nodeIndexMap = /* @__PURE__ */ new Map();
|
|
2018
|
+
idArray = [];
|
|
2019
|
+
nodeOpacity = null;
|
|
2020
|
+
targets = null;
|
|
2021
|
+
params = {
|
|
2022
|
+
defaultAlpha: 1,
|
|
2023
|
+
highlightAlpha: 1,
|
|
2024
|
+
dimmedAlpha: .2
|
|
2025
|
+
};
|
|
2026
|
+
simulationBuffers = null;
|
|
2027
|
+
pickBuffer = null;
|
|
2028
|
+
constructor(scene, renderer) {
|
|
2029
|
+
this.scene = scene;
|
|
2030
|
+
this.renderer = renderer;
|
|
2031
|
+
}
|
|
2032
|
+
create(nodes, simulationBuffers, pickBuffer) {
|
|
2033
|
+
this.simulationBuffers = simulationBuffers;
|
|
2034
|
+
this.pickBuffer = pickBuffer;
|
|
2035
|
+
const textureSize = simulationBuffers.getTextureSize();
|
|
2036
|
+
const nodeCount = nodes.length;
|
|
2037
|
+
this.buildNodeMapping(nodes);
|
|
2038
|
+
this.nodeOpacity = new NodeOpacity(this.renderer, textureSize);
|
|
2039
|
+
const total = textureSize * textureSize;
|
|
2040
|
+
this.targets = new Float32Array(total).fill(this.params.defaultAlpha);
|
|
2041
|
+
const initialAlphaData = new Float32Array(total * 4);
|
|
2042
|
+
for (let i = 0; i < total; i++) {
|
|
2043
|
+
initialAlphaData[i * 4] = this.params.defaultAlpha;
|
|
2044
|
+
initialAlphaData[i * 4 + 1] = 0;
|
|
2045
|
+
initialAlphaData[i * 4 + 2] = 0;
|
|
2046
|
+
initialAlphaData[i * 4 + 3] = 1;
|
|
2047
|
+
}
|
|
2048
|
+
const initialAlphaTexture = new THREE.DataTexture(initialAlphaData, textureSize, textureSize, THREE.RGBAFormat, THREE.FloatType);
|
|
2049
|
+
initialAlphaTexture.needsUpdate = true;
|
|
2050
|
+
this.nodeOpacity.init(initialAlphaTexture);
|
|
2051
|
+
initialAlphaTexture.dispose();
|
|
2052
|
+
const nodeData = this.createNodeData(nodes, textureSize);
|
|
2053
|
+
staticAssets.setNodeRadii(nodeData.radii, textureSize);
|
|
2054
|
+
staticAssets.setNodeColors(nodeData.colors, textureSize);
|
|
2055
|
+
const geometry = this.createGeometry(nodeData, nodeCount);
|
|
2056
|
+
const material = this.createRenderMaterial(textureSize);
|
|
2057
|
+
this.pickMaterial = this.createPickMaterial(textureSize);
|
|
2058
|
+
this.points = new THREE.Points(geometry, material);
|
|
2059
|
+
this.points.frustumCulled = false;
|
|
2060
|
+
this.points.renderOrder = 999;
|
|
2061
|
+
this.scene.add(this.points);
|
|
2062
|
+
this.clearHighlights();
|
|
2063
|
+
}
|
|
2064
|
+
setPositionTexture(positionTexture) {
|
|
2065
|
+
if (!this.points) return;
|
|
2066
|
+
const material = this.points.material;
|
|
2067
|
+
if (material.uniforms.uPositionsTexture) material.uniforms.uPositionsTexture.value = positionTexture;
|
|
2068
|
+
if (this.pickMaterial?.uniforms.uPositionsTexture) this.pickMaterial.uniforms.uPositionsTexture.value = positionTexture;
|
|
2069
|
+
}
|
|
2070
|
+
pick(pixelX, pixelY, camera) {
|
|
2071
|
+
if (!this.points || !this.pickBuffer || !this.pickMaterial) return -1;
|
|
2072
|
+
const originalMaterial = this.points.material;
|
|
2073
|
+
this.points.material = this.pickMaterial;
|
|
2074
|
+
this.pickBuffer.render(this.scene, camera);
|
|
2075
|
+
this.points.material = originalMaterial;
|
|
2076
|
+
return this.pickBuffer.readIdAt(pixelX, pixelY);
|
|
2077
|
+
}
|
|
2078
|
+
highlight(nodeIds, step = .1) {
|
|
2079
|
+
if (!this.nodeOpacity || !this.simulationBuffers || !this.targets) return;
|
|
2080
|
+
const indices = nodeIds.map((id) => this.nodeIndexMap.get(String(id))).filter((idx) => idx !== void 0);
|
|
2081
|
+
if (indices.length === 0) return;
|
|
2082
|
+
this.targets.fill(this.params.dimmedAlpha);
|
|
2083
|
+
indices.forEach((idx) => {
|
|
2084
|
+
if (idx < this.targets.length) this.targets[idx] = this.params.highlightAlpha;
|
|
2085
|
+
});
|
|
2086
|
+
this.nodeOpacity.setStep(step);
|
|
2087
|
+
this.nodeOpacity.setTargets(this.targets);
|
|
2088
|
+
}
|
|
2089
|
+
clearHighlights(step = .1) {
|
|
2090
|
+
if (!this.nodeOpacity || !this.simulationBuffers || !this.targets) return;
|
|
2091
|
+
this.targets.fill(this.params.defaultAlpha);
|
|
2092
|
+
this.nodeOpacity.setStep(step);
|
|
2093
|
+
this.nodeOpacity.setTargets(this.targets);
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Update node opacity (called every frame)
|
|
2097
|
+
*/
|
|
2098
|
+
updateOpacity() {
|
|
2099
|
+
if (!this.nodeOpacity) return;
|
|
2100
|
+
this.nodeOpacity.update();
|
|
2101
|
+
if (this.points && this.points.material) {
|
|
2102
|
+
const material = this.points.material;
|
|
2103
|
+
if (material.uniforms.uAlphaTexture) material.uniforms.uAlphaTexture.value = this.nodeOpacity.getCurrentAlphaTexture();
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
resize(width, height) {
|
|
2107
|
+
if (!this.points) return;
|
|
2108
|
+
const material = this.points.material;
|
|
2109
|
+
if (material.uniforms.uResolution) material.uniforms.uResolution.value.set(width, height);
|
|
2110
|
+
if (material.uniforms.uAspectRatio) material.uniforms.uAspectRatio.value = width / height;
|
|
2111
|
+
if (material.uniforms.uPixelRatio) material.uniforms.uPixelRatio.value = window.devicePixelRatio;
|
|
2112
|
+
if (this.pickMaterial) {
|
|
2113
|
+
if (this.pickMaterial.uniforms.uResolution) this.pickMaterial.uniforms.uResolution.value.set(width, height);
|
|
2114
|
+
if (this.pickMaterial.uniforms.uAspectRatio) this.pickMaterial.uniforms.uAspectRatio.value = width / height;
|
|
2115
|
+
if (this.pickMaterial.uniforms.uPixelRatio) this.pickMaterial.uniforms.uPixelRatio.value = window.devicePixelRatio;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
dispose() {
|
|
2119
|
+
if (this.points) {
|
|
2120
|
+
this.scene.remove(this.points);
|
|
2121
|
+
this.points.geometry.dispose();
|
|
2122
|
+
this.points.material.dispose();
|
|
2123
|
+
this.points = null;
|
|
2124
|
+
}
|
|
2125
|
+
this.pickMaterial?.dispose();
|
|
2126
|
+
this.nodeOpacity?.dispose();
|
|
2127
|
+
this.nodeIndexMap.clear();
|
|
2128
|
+
this.idArray = [];
|
|
2129
|
+
this.simulationBuffers = null;
|
|
2130
|
+
this.pickBuffer = null;
|
|
2131
|
+
}
|
|
2132
|
+
buildNodeMapping(nodes) {
|
|
2133
|
+
this.nodeIndexMap.clear();
|
|
2134
|
+
this.idArray = [];
|
|
2135
|
+
nodes.forEach((node, i) => {
|
|
2136
|
+
this.nodeIndexMap.set(String(node.id), i);
|
|
2137
|
+
this.idArray[i] = node.id;
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Create all node data in one pass
|
|
2142
|
+
* Returns: colors, sizes, radii, nodeIds, pointIndices, alphaIndex
|
|
2143
|
+
*/
|
|
2144
|
+
createNodeData(nodes, textureSize) {
|
|
2145
|
+
const count = nodes.length;
|
|
2146
|
+
const totalTexels = textureSize * textureSize;
|
|
2147
|
+
const colorBuffer = new Float32Array(count * 3);
|
|
2148
|
+
const sizeBuffer = new Float32Array(count);
|
|
2149
|
+
const pointIndices = new Float32Array(count * 2);
|
|
2150
|
+
const alphaIndex = new Float32Array(count);
|
|
2151
|
+
const radii = new Float32Array(totalTexels);
|
|
2152
|
+
const colors = new Float32Array(totalTexels * 4);
|
|
2153
|
+
const fovRadians = 75 * Math.PI / 180;
|
|
2154
|
+
const pixelToWorldRatio = 2 * Math.tan(fovRadians / 2) / window.innerHeight;
|
|
2155
|
+
nodes.forEach((node, i) => {
|
|
2156
|
+
const style = styleRegistry.getNodeStyle(node.category);
|
|
2157
|
+
colorBuffer[i * 3] = style.color.r;
|
|
2158
|
+
colorBuffer[i * 3 + 1] = style.color.g;
|
|
2159
|
+
colorBuffer[i * 3 + 2] = style.color.b;
|
|
2160
|
+
sizeBuffer[i] = style.size;
|
|
2161
|
+
const x = i % textureSize;
|
|
2162
|
+
const y = Math.floor(i / textureSize);
|
|
2163
|
+
pointIndices[i * 2] = x;
|
|
2164
|
+
pointIndices[i * 2 + 1] = y;
|
|
2165
|
+
alphaIndex[i] = i;
|
|
2166
|
+
radii[i] = style.size * window.devicePixelRatio * pixelToWorldRatio / 2;
|
|
2167
|
+
colors[i * 4] = style.color.r;
|
|
2168
|
+
colors[i * 4 + 1] = style.color.g;
|
|
2169
|
+
colors[i * 4 + 2] = style.color.b;
|
|
2170
|
+
colors[i * 4 + 3] = 1;
|
|
2171
|
+
});
|
|
2172
|
+
return {
|
|
2173
|
+
colorBuffer,
|
|
2174
|
+
sizeBuffer,
|
|
2175
|
+
pointIndices,
|
|
2176
|
+
alphaIndex,
|
|
2177
|
+
radii,
|
|
2178
|
+
colors
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
createGeometry(nodeData, count) {
|
|
2182
|
+
const geometry = new THREE.BufferGeometry();
|
|
2183
|
+
geometry.setAttribute("color", new THREE.BufferAttribute(nodeData.colorBuffer, 3));
|
|
2184
|
+
geometry.setAttribute("scale", new THREE.BufferAttribute(nodeData.sizeBuffer, 1));
|
|
2185
|
+
geometry.setAttribute("pointIndices", new THREE.BufferAttribute(nodeData.pointIndices, 2));
|
|
2186
|
+
geometry.setAttribute("alphaIndex", new THREE.BufferAttribute(nodeData.alphaIndex, 1));
|
|
2187
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(count * 3), 3));
|
|
2188
|
+
return geometry;
|
|
2189
|
+
}
|
|
2190
|
+
createRenderMaterial(textureSize) {
|
|
2191
|
+
return new THREE.ShaderMaterial({
|
|
2192
|
+
vertexShader: points_default,
|
|
2193
|
+
fragmentShader: points_default$1,
|
|
2194
|
+
vertexColors: true,
|
|
2195
|
+
transparent: true,
|
|
2196
|
+
depthTest: true,
|
|
2197
|
+
depthWrite: true,
|
|
2198
|
+
side: THREE.DoubleSide,
|
|
2199
|
+
uniforms: {
|
|
2200
|
+
uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
|
|
2201
|
+
uAspectRatio: { value: window.innerWidth / window.innerHeight },
|
|
2202
|
+
uPixelRatio: { value: window.devicePixelRatio },
|
|
2203
|
+
pointTexture: { value: this.texture },
|
|
2204
|
+
uAlphaTexture: { value: this.nodeOpacity.getCurrentAlphaTexture() },
|
|
2205
|
+
uTextureSize: { value: new THREE.Vector2(textureSize, textureSize) },
|
|
2206
|
+
uPositionsTexture: { value: this.simulationBuffers.getCurrentPositionTexture() },
|
|
2207
|
+
uPositionsTextureSize: { value: new THREE.Vector2(textureSize, textureSize) }
|
|
2208
|
+
}
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
createPickMaterial(textureSize) {
|
|
2212
|
+
return new THREE.ShaderMaterial({
|
|
2213
|
+
vertexShader: pickPoint_default,
|
|
2214
|
+
fragmentShader: pickPoint_default$1,
|
|
2215
|
+
uniforms: {
|
|
2216
|
+
uPositionsTexture: { value: this.simulationBuffers.getCurrentPositionTexture() },
|
|
2217
|
+
uPositionsTextureSize: { value: new THREE.Vector2(textureSize, textureSize) },
|
|
2218
|
+
uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
|
|
2219
|
+
uAspectRatio: { value: window.innerWidth / window.innerHeight },
|
|
2220
|
+
uPixelRatio: { value: window.devicePixelRatio }
|
|
2221
|
+
},
|
|
2222
|
+
blending: THREE.NoBlending,
|
|
2223
|
+
depthTest: true,
|
|
2224
|
+
depthWrite: true,
|
|
2225
|
+
transparent: true
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
//#endregion
|
|
2231
|
+
//#region rendering/GraphScene.ts
|
|
2232
|
+
/**
|
|
2233
|
+
* GraphScene - Manages the 3D scene, camera, and renderers
|
|
2234
|
+
* Responsibilities:
|
|
2235
|
+
* - Scene setup and lifecycle
|
|
2236
|
+
* - Node and link rendering
|
|
2237
|
+
* - Camera control
|
|
2238
|
+
* - Tooltip management
|
|
2239
|
+
* - Mode application (visual changes)
|
|
2240
|
+
*/
|
|
2241
|
+
var GraphScene = class {
|
|
2242
|
+
scene;
|
|
2243
|
+
renderer;
|
|
2244
|
+
camera;
|
|
2245
|
+
nodeRenderer = null;
|
|
2246
|
+
clock = new THREE.Clock();
|
|
2247
|
+
linkRenderer = null;
|
|
2248
|
+
constructor(canvas, cameraConfig) {
|
|
2249
|
+
this.scene = new THREE.Scene();
|
|
2250
|
+
this.scene.background = new THREE.Color(0);
|
|
2251
|
+
this.renderer = new THREE.WebGLRenderer({
|
|
2252
|
+
canvas,
|
|
2253
|
+
antialias: true,
|
|
2254
|
+
alpha: true,
|
|
2255
|
+
depth: true
|
|
2256
|
+
});
|
|
2257
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
2258
|
+
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
2259
|
+
this.camera = new CameraController(this.renderer.domElement, cameraConfig);
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Initialize scene with nodes and links
|
|
2263
|
+
*/
|
|
2264
|
+
create(nodes, links, simulationBuffers, pickBuffer, groupOrder) {
|
|
2265
|
+
this.nodeRenderer = new NodesRenderer(this.scene, this.renderer);
|
|
2266
|
+
this.nodeRenderer.create(nodes, simulationBuffers, pickBuffer);
|
|
2267
|
+
this.linkRenderer = new LinksRenderer(this.scene, this.renderer);
|
|
2268
|
+
this.linkRenderer.create(links, nodes, simulationBuffers);
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Apply visual mode (colors, sizes, camera position)
|
|
2272
|
+
*/
|
|
2273
|
+
applyMode(mode, options = {}) {
|
|
2274
|
+
const duration = options.transitionDuration ?? 1;
|
|
2275
|
+
options.cameraTransitionDuration;
|
|
2276
|
+
switch (mode) {
|
|
2277
|
+
case "default":
|
|
2278
|
+
this.applyDefaultMode(duration);
|
|
2279
|
+
break;
|
|
2280
|
+
default: console.warn(`Unknown mode: ${mode}`);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Update position textures from simulation
|
|
2285
|
+
*/
|
|
2286
|
+
updatePositions(positionTexture) {
|
|
2287
|
+
this.nodeRenderer?.setPositionTexture(positionTexture);
|
|
2288
|
+
this.linkRenderer?.setPositionTexture(positionTexture);
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Update time-based uniforms (called each frame with elapsed time)
|
|
2292
|
+
*/
|
|
2293
|
+
update(elapsedTime) {
|
|
2294
|
+
this.linkRenderer?.update(elapsedTime);
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* GPU picking at canvas coordinates
|
|
2298
|
+
*/
|
|
2299
|
+
pick(pixelX, pixelY) {
|
|
2300
|
+
if (!this.nodeRenderer) return -1;
|
|
2301
|
+
return this.nodeRenderer.pick(pixelX, pixelY, this.camera.camera);
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Render the scene
|
|
2305
|
+
*/
|
|
2306
|
+
render() {
|
|
2307
|
+
const delta = this.clock.getDelta();
|
|
2308
|
+
this.camera.update(delta);
|
|
2309
|
+
this.nodeRenderer?.updateOpacity();
|
|
2310
|
+
this.linkRenderer?.updateOpacity();
|
|
2311
|
+
this.renderer.render(this.scene, this.camera.camera);
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Handle window resize
|
|
2315
|
+
*/
|
|
2316
|
+
resize(width, height) {
|
|
2317
|
+
this.camera.resize(width, height);
|
|
2318
|
+
this.nodeRenderer?.resize(width, height);
|
|
2319
|
+
this.linkRenderer?.resize(width, height);
|
|
2320
|
+
this.renderer.setSize(width, height);
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Cleanup all resources
|
|
2324
|
+
*/
|
|
2325
|
+
dispose() {
|
|
2326
|
+
this.nodeRenderer?.dispose();
|
|
2327
|
+
this.nodeRenderer = null;
|
|
2328
|
+
this.camera.dispose();
|
|
2329
|
+
this.scene.clear();
|
|
2330
|
+
}
|
|
2331
|
+
applyDefaultMode(duration) {}
|
|
2332
|
+
getCamera() {
|
|
2333
|
+
return this.camera;
|
|
2334
|
+
}
|
|
2335
|
+
getNodeRenderer() {
|
|
2336
|
+
return this.nodeRenderer;
|
|
2337
|
+
}
|
|
2338
|
+
getLinkRenderer() {
|
|
2339
|
+
return this.linkRenderer;
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
|
|
2343
|
+
//#endregion
|
|
2344
|
+
//#region assets/glsl/force-sim/attractor.frag
|
|
2345
|
+
var attractor_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform sampler2D uNodeCategoriesTexture; \nuniform sampler2D uAttractorsTexture; \nuniform sampler2D uAttractorCategoriesTexture; \nuniform sampler2D uAttractorParamsTexture; \nuniform float uTextureSize;\r\nuniform float uAttractorCount;\r\nuniform float uAttractorStrength; \nuniform float uAlpha;\r\nuniform bool uIs3D;\n\nvarying vec2 vUv;\n\nconst float ATTRACT_ALL = -1.0;\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n vec3 velocity = texture2D(uVelocityTexture, vUv).xyz;\r\n vec3 pos = currentPosition.xyz;\r\n \r\n \n vec4 nodeCategories = texture2D(uNodeCategoriesTexture, vUv);\r\n \r\n vec3 totalForce = vec3(0.0);\r\n \r\n \n for (float i = 0.0; i < 32.0; i++) {\r\n if (i >= uAttractorCount) break;\r\n \r\n \n float u = (i + 0.5) / 32.0;\r\n vec4 attractorData = texture2D(uAttractorsTexture, vec2(u, 0.5));\r\n vec4 attractorCategories = texture2D(uAttractorCategoriesTexture, vec2(u, 0.5));\r\n vec4 attractorParams = texture2D(uAttractorParamsTexture, vec2(u, 0.5));\r\n \r\n vec3 attractorPos = attractorData.xyz;\r\n float attractorStrength = attractorParams.r;\r\n \n \r\n \n \n bool shouldAttract = false;\r\n \r\n if (attractorCategories.r < -1.5) {\r\n \n shouldAttract = true;\r\n } else {\r\n \n for (int n = 0; n < 4; n++) {\r\n float nc = (n == 0) ? nodeCategories.r : (n == 1) ? nodeCategories.g : (n == 2) ? nodeCategories.b : nodeCategories.a;\r\n if (nc < -0.5) continue; \n\n if (attractorCategories.r >= 0.0 && abs(attractorCategories.r - nc) < 0.5) { shouldAttract = true; break; }\r\n if (attractorCategories.g >= 0.0 && abs(attractorCategories.g - nc) < 0.5) { shouldAttract = true; break; }\r\n if (attractorCategories.b >= 0.0 && abs(attractorCategories.b - nc) < 0.5) { shouldAttract = true; break; }\r\n if (attractorCategories.a >= 0.0 && abs(attractorCategories.a - nc) < 0.5) { shouldAttract = true; break; }\r\n }\r\n }\r\n \r\n if (!shouldAttract) continue;\r\n \r\n \n vec3 toAttractor = attractorPos - pos;\r\n if (!uIs3D) {\r\n toAttractor.z = 0.0;\r\n }\r\n \r\n float dist = length(toAttractor);\r\n vec3 direction = dist > 0.001 ? toAttractor / dist : vec3(0.0);\r\n \r\n \n \n \n float effectiveDist = min(dist, 10.0);\r\n \r\n \n float forceMagnitude = attractorStrength * uAttractorStrength * effectiveDist;\n\n totalForce += direction * forceMagnitude;\r\n }\r\n \r\n \n velocity += totalForce * uAlpha;\r\n \r\n gl_FragColor = vec4(velocity, 0.0);\r\n}";
|
|
2346
|
+
|
|
2347
|
+
//#endregion
|
|
2348
|
+
//#region assets/glsl/force-sim/compute.vert
|
|
2349
|
+
var compute_default = "varying vec2 vUv;\n\nvoid main() {\r\n vUv = uv;\r\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\r\n}";
|
|
2350
|
+
|
|
2351
|
+
//#endregion
|
|
2352
|
+
//#region types/iAttractor.ts
|
|
2353
|
+
/**
|
|
2354
|
+
* Apply defaults to an attractor
|
|
2355
|
+
*/
|
|
2356
|
+
function resolveAttractor(attractor) {
|
|
2357
|
+
return {
|
|
2358
|
+
id: attractor.id,
|
|
2359
|
+
position: {
|
|
2360
|
+
x: attractor.position.x,
|
|
2361
|
+
y: attractor.position.y,
|
|
2362
|
+
z: attractor.position.z
|
|
2363
|
+
},
|
|
2364
|
+
groups: [...attractor.groups || []],
|
|
2365
|
+
strength: attractor.strength ?? 1,
|
|
2366
|
+
radius: attractor.radius ?? 0
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
//#endregion
|
|
2371
|
+
//#region simulation/BasePass.ts
|
|
2372
|
+
/**
|
|
2373
|
+
* Base class for GPU force simulation passes
|
|
2374
|
+
* Each pass operates on simulation buffers and can read from static assets
|
|
2375
|
+
*/
|
|
2376
|
+
var BasePass = class {
|
|
2377
|
+
material = null;
|
|
2378
|
+
enabled = true;
|
|
2379
|
+
/**
|
|
2380
|
+
* Execute the pass (renders to current velocity target)
|
|
2381
|
+
*/
|
|
2382
|
+
execute(context) {
|
|
2383
|
+
if (!this.enabled || !this.material) return;
|
|
2384
|
+
this.updateUniforms(context);
|
|
2385
|
+
this.render(context);
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Render the compute shader
|
|
2389
|
+
*/
|
|
2390
|
+
render(context) {
|
|
2391
|
+
if (!this.material) return;
|
|
2392
|
+
const mesh = new THREE.Mesh(context.quad, this.material);
|
|
2393
|
+
context.scene.clear();
|
|
2394
|
+
context.scene.add(mesh);
|
|
2395
|
+
context.renderer.render(context.scene, context.camera);
|
|
2396
|
+
context.scene.remove(mesh);
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Create a shader material helper
|
|
2400
|
+
*/
|
|
2401
|
+
createMaterial(vertexShader, fragmentShader, uniforms) {
|
|
2402
|
+
return new THREE.ShaderMaterial({
|
|
2403
|
+
uniforms,
|
|
2404
|
+
vertexShader,
|
|
2405
|
+
fragmentShader,
|
|
2406
|
+
transparent: false,
|
|
2407
|
+
depthTest: false,
|
|
2408
|
+
depthWrite: false
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Safe uniform setter - avoids TypeScript strict null check issues
|
|
2413
|
+
*/
|
|
2414
|
+
setUniform(name, value) {
|
|
2415
|
+
if (this.material?.uniforms[name]) this.material.uniforms[name].value = value;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Enable or disable this pass
|
|
2419
|
+
*/
|
|
2420
|
+
setEnabled(enabled) {
|
|
2421
|
+
this.enabled = enabled;
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Check if pass is enabled
|
|
2425
|
+
*/
|
|
2426
|
+
isEnabled() {
|
|
2427
|
+
return this.enabled;
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Get the material for external access
|
|
2431
|
+
*/
|
|
2432
|
+
getMaterial() {
|
|
2433
|
+
return this.material;
|
|
2434
|
+
}
|
|
2435
|
+
/**
|
|
2436
|
+
* Cleanup resources
|
|
2437
|
+
*/
|
|
2438
|
+
dispose() {
|
|
2439
|
+
this.material?.dispose();
|
|
2440
|
+
this.material = null;
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
//#endregion
|
|
2445
|
+
//#region simulation/passes/AttractorPass.ts
|
|
2446
|
+
const MAX_ATTRACTORS = 32;
|
|
2447
|
+
const MAX_GROUPS = 4;
|
|
2448
|
+
/**
|
|
2449
|
+
* Attractor force pass - attracts nodes to attractor points based on group membership
|
|
2450
|
+
*
|
|
2451
|
+
* Usage:
|
|
2452
|
+
* attractorPass.setAttractors([
|
|
2453
|
+
* { id: 'center', position: { x: 0, y: 0, z: 0 }, categories: ['root'] }, // categories acts as groups
|
|
2454
|
+
* { id: 'left', position: { x: -100, y: 0, z: 0 }, categories: ['artwork', 'series'] }
|
|
2455
|
+
* ])
|
|
2456
|
+
*/
|
|
2457
|
+
var AttractorPass = class extends BasePass {
|
|
2458
|
+
attractors = [];
|
|
2459
|
+
groupMap = /* @__PURE__ */ new Map();
|
|
2460
|
+
attractorsTexture = null;
|
|
2461
|
+
attractorGroupsTexture = null;
|
|
2462
|
+
attractorParamsTexture = null;
|
|
2463
|
+
nodeGroupsTexture = null;
|
|
2464
|
+
getName() {
|
|
2465
|
+
return "attractor";
|
|
2466
|
+
}
|
|
2467
|
+
initMaterial(context) {
|
|
2468
|
+
this.createAttractorTextures();
|
|
2469
|
+
this.material = this.createMaterial(compute_default, attractor_default, {
|
|
2470
|
+
uPositionsTexture: { value: null },
|
|
2471
|
+
uVelocityTexture: { value: null },
|
|
2472
|
+
uNodeCategoriesTexture: { value: this.nodeGroupsTexture },
|
|
2473
|
+
uAttractorsTexture: { value: this.attractorsTexture },
|
|
2474
|
+
uAttractorCategoriesTexture: { value: this.attractorGroupsTexture },
|
|
2475
|
+
uAttractorParamsTexture: { value: this.attractorParamsTexture },
|
|
2476
|
+
uTextureSize: { value: context.textureSize },
|
|
2477
|
+
uAttractorCount: { value: 0 },
|
|
2478
|
+
uAttractorStrength: { value: 1 },
|
|
2479
|
+
uAlpha: { value: 0 },
|
|
2480
|
+
uIs3D: { value: false }
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
updateUniforms(context) {
|
|
2484
|
+
const c = context.config;
|
|
2485
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2486
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2487
|
+
this.setUniform("uNodeCategoriesTexture", this.nodeGroupsTexture);
|
|
2488
|
+
this.setUniform("uAttractorsTexture", this.attractorsTexture);
|
|
2489
|
+
this.setUniform("uAttractorCategoriesTexture", this.attractorGroupsTexture);
|
|
2490
|
+
this.setUniform("uAttractorParamsTexture", this.attractorParamsTexture);
|
|
2491
|
+
this.setUniform("uAttractorCount", this.attractors.length);
|
|
2492
|
+
this.setUniform("uAttractorStrength", c.attractorStrength ?? 1);
|
|
2493
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2494
|
+
this.setUniform("uIs3D", c.is3D);
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Set attractors for the simulation
|
|
2498
|
+
*/
|
|
2499
|
+
setAttractors(attractors) {
|
|
2500
|
+
this.attractors = attractors.slice(0, MAX_ATTRACTORS).map(resolveAttractor);
|
|
2501
|
+
this.updateAttractorTextures();
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Add a single attractor
|
|
2505
|
+
*/
|
|
2506
|
+
addAttractor(attractor) {
|
|
2507
|
+
if (this.attractors.length >= MAX_ATTRACTORS) {
|
|
2508
|
+
console.warn(`[AttractorPass] Maximum ${MAX_ATTRACTORS} attractors supported`);
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
this.attractors.push(resolveAttractor(attractor));
|
|
2512
|
+
this.updateAttractorTextures();
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Remove an attractor by ID
|
|
2516
|
+
*/
|
|
2517
|
+
removeAttractor(id) {
|
|
2518
|
+
this.attractors = this.attractors.filter((a) => a.id !== id);
|
|
2519
|
+
this.updateAttractorTextures();
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Update an existing attractor's position
|
|
2523
|
+
*/
|
|
2524
|
+
updateAttractorPosition(id, position) {
|
|
2525
|
+
const attractor = this.attractors.find((a) => a.id === id);
|
|
2526
|
+
if (attractor) {
|
|
2527
|
+
attractor.position.x = position.x;
|
|
2528
|
+
attractor.position.y = position.y;
|
|
2529
|
+
attractor.position.z = position.z;
|
|
2530
|
+
this.updateAttractorTextures();
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Get current attractors
|
|
2535
|
+
*/
|
|
2536
|
+
getAttractors() {
|
|
2537
|
+
return this.attractors;
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Set node groups from graph data
|
|
2541
|
+
* Call this when graph data changes or grouping criteria changes
|
|
2542
|
+
*/
|
|
2543
|
+
setNodeGroups(groups, textureSize) {
|
|
2544
|
+
this.groupMap.clear();
|
|
2545
|
+
let nextIndex = 0;
|
|
2546
|
+
for (const nodeGroups of groups) for (const group of nodeGroups) if (group && !this.groupMap.has(group)) this.groupMap.set(group, nextIndex++);
|
|
2547
|
+
const totalTexels = textureSize * textureSize;
|
|
2548
|
+
const data = new Float32Array(totalTexels * 4);
|
|
2549
|
+
data.fill(-1);
|
|
2550
|
+
for (let i = 0; i < groups.length && i < totalTexels; i++) {
|
|
2551
|
+
const nodeGroups = groups[i];
|
|
2552
|
+
const baseIdx = i * 4;
|
|
2553
|
+
for (let j = 0; j < 4; j++) if (j < nodeGroups.length) {
|
|
2554
|
+
const group = nodeGroups[j];
|
|
2555
|
+
data[baseIdx + j] = group ? this.groupMap.get(group) ?? -1 : -1;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
this.nodeGroupsTexture?.dispose();
|
|
2559
|
+
this.nodeGroupsTexture = new THREE.DataTexture(data, textureSize, textureSize, THREE.RGBAFormat, THREE.FloatType);
|
|
2560
|
+
this.nodeGroupsTexture.needsUpdate = true;
|
|
2561
|
+
this.nodeGroupsTexture.minFilter = THREE.NearestFilter;
|
|
2562
|
+
this.nodeGroupsTexture.magFilter = THREE.NearestFilter;
|
|
2563
|
+
this.updateAttractorTextures();
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* Get the group map (group name -> index)
|
|
2567
|
+
*/
|
|
2568
|
+
getGroupMap() {
|
|
2569
|
+
return new Map(this.groupMap);
|
|
2570
|
+
}
|
|
2571
|
+
createAttractorTextures() {
|
|
2572
|
+
const attractorData = new Float32Array(MAX_ATTRACTORS * 4);
|
|
2573
|
+
this.attractorsTexture = new THREE.DataTexture(attractorData, MAX_ATTRACTORS, 1, THREE.RGBAFormat, THREE.FloatType);
|
|
2574
|
+
this.attractorsTexture.needsUpdate = true;
|
|
2575
|
+
this.attractorsTexture.minFilter = THREE.NearestFilter;
|
|
2576
|
+
this.attractorsTexture.magFilter = THREE.NearestFilter;
|
|
2577
|
+
const groupData = new Float32Array(MAX_ATTRACTORS * 4).fill(-1);
|
|
2578
|
+
this.attractorGroupsTexture = new THREE.DataTexture(groupData, MAX_ATTRACTORS, 1, THREE.RGBAFormat, THREE.FloatType);
|
|
2579
|
+
this.attractorGroupsTexture.needsUpdate = true;
|
|
2580
|
+
this.attractorGroupsTexture.minFilter = THREE.NearestFilter;
|
|
2581
|
+
this.attractorGroupsTexture.magFilter = THREE.NearestFilter;
|
|
2582
|
+
const paramsData = new Float32Array(MAX_ATTRACTORS * 4).fill(0);
|
|
2583
|
+
this.attractorParamsTexture = new THREE.DataTexture(paramsData, MAX_ATTRACTORS, 1, THREE.RGBAFormat, THREE.FloatType);
|
|
2584
|
+
this.attractorParamsTexture.needsUpdate = true;
|
|
2585
|
+
this.attractorParamsTexture.minFilter = THREE.NearestFilter;
|
|
2586
|
+
this.attractorParamsTexture.magFilter = THREE.NearestFilter;
|
|
2587
|
+
this.nodeGroupsTexture = null;
|
|
2588
|
+
}
|
|
2589
|
+
updateAttractorTextures() {
|
|
2590
|
+
if (!this.attractorsTexture || !this.attractorGroupsTexture || !this.attractorParamsTexture) return;
|
|
2591
|
+
const attractorData = this.attractorsTexture.image.data;
|
|
2592
|
+
const groupData = this.attractorGroupsTexture.image.data;
|
|
2593
|
+
const paramsData = this.attractorParamsTexture.image.data;
|
|
2594
|
+
attractorData.fill(0);
|
|
2595
|
+
groupData.fill(-1);
|
|
2596
|
+
paramsData.fill(0);
|
|
2597
|
+
for (let i = 0; i < this.attractors.length; i++) {
|
|
2598
|
+
const attractor = this.attractors[i];
|
|
2599
|
+
if (!attractor) continue;
|
|
2600
|
+
const baseIdx = i * 4;
|
|
2601
|
+
attractorData[baseIdx] = attractor.position.x;
|
|
2602
|
+
attractorData[baseIdx + 1] = attractor.position.y;
|
|
2603
|
+
attractorData[baseIdx + 2] = attractor.position.z;
|
|
2604
|
+
attractorData[baseIdx + 3] = 0;
|
|
2605
|
+
paramsData[baseIdx] = attractor.strength;
|
|
2606
|
+
paramsData[baseIdx + 1] = attractor.radius;
|
|
2607
|
+
if (attractor.groups.length === 0) groupData[baseIdx] = -2;
|
|
2608
|
+
else for (let j = 0; j < Math.min(attractor.groups.length, MAX_GROUPS); j++) {
|
|
2609
|
+
const groupName = attractor.groups[j];
|
|
2610
|
+
if (groupName) {
|
|
2611
|
+
const groupIndex = this.groupMap.get(groupName);
|
|
2612
|
+
groupData[baseIdx + j] = groupIndex !== void 0 ? groupIndex : -1;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
this.attractorsTexture.needsUpdate = true;
|
|
2617
|
+
this.attractorGroupsTexture.needsUpdate = true;
|
|
2618
|
+
this.attractorParamsTexture.needsUpdate = true;
|
|
2619
|
+
}
|
|
2620
|
+
dispose() {
|
|
2621
|
+
super.dispose();
|
|
2622
|
+
this.attractorsTexture?.dispose();
|
|
2623
|
+
this.attractorGroupsTexture?.dispose();
|
|
2624
|
+
this.attractorParamsTexture?.dispose();
|
|
2625
|
+
this.nodeGroupsTexture?.dispose();
|
|
2626
|
+
this.attractorsTexture = null;
|
|
2627
|
+
this.attractorGroupsTexture = null;
|
|
2628
|
+
this.attractorParamsTexture = null;
|
|
2629
|
+
this.nodeGroupsTexture = null;
|
|
2630
|
+
}
|
|
2631
|
+
};
|
|
2632
|
+
|
|
2633
|
+
//#endregion
|
|
2634
|
+
//#region assets/glsl/force-sim/collision.frag
|
|
2635
|
+
var collision_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform float uTextureSize;\r\nuniform float uAlpha; \nuniform float uMaxDistance; \nuniform float uCollisionStrength;\r\nuniform sampler2D uRadiiTexture;\n\nvarying vec2 vUv;\n\nfloat rand(vec2 co){\r\n return fract(sin(dot(co, vec2(12.9898,78.233))) * 43758.5453);\r\n}\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n if(currentPosition.w < 0.5) discard;\r\n vec2 pos = currentPosition.xy;\r\n vec2 velocity = texture2D(uVelocityTexture, vUv).xy;\r\n float particleIndex = floor(vUv.y * uTextureSize) * uTextureSize + floor(vUv.x * uTextureSize);\n\n float texStep = 1.0 / uTextureSize;\r\n float halfTex = 0.5 * texStep;\r\n float eps = 1e-6;\r\n float steepness = 1.4;\n\n float r1 = texture2D(uRadiiTexture, vUv).r;\r\n vec2 push = vec2(0.0);\r\n int count = 0;\n\n for (float y = 0.0; y < 1.0; y += texStep) {\r\n for (float x = 0.0; x < 1.0; x += texStep) {\r\n vec2 otherUV = vec2(x + halfTex, y + halfTex);\r\n float otherIndex = floor(y * uTextureSize) * uTextureSize + floor(x * uTextureSize);\n\n vec4 otherPosition = texture2D(uPositionsTexture, otherUV);\r\n \r\n \n if (otherPosition.w < 1.5) continue;\n\n vec2 otherPos = otherPosition.xy;\n\n vec2 delta = pos - otherPos;\r\n float dist = length(delta);\r\n\n float r2 = texture2D(uRadiiTexture, otherUV).r;\r\n float rsum = r1 + r2;\r\n \r\n if (dist < rsum && dist > eps) {\r\n float normalizedDist = dist / rsum; \r\n float exponentialFalloff = exp(-normalizedDist * steepness); \r\n vec2 direction = delta / dist;\r\n push += direction * exponentialFalloff * uCollisionStrength;\r\n count++;\r\n }\r\n }\r\n }\n\n if (count > 0) {\r\n push /= float(count);\r\n float pushMagnitude = length(push);\r\n float maxPush = 0.05; \r\n push = normalize(push) * maxPush;\r\n velocity += push; \n }\r\n \r\n gl_FragColor = vec4(velocity.xy, 0.0, 1.0);\r\n}";
|
|
2636
|
+
|
|
2637
|
+
//#endregion
|
|
2638
|
+
//#region simulation/passes/CollisionPass.ts
|
|
2639
|
+
/**
|
|
2640
|
+
* Collision force pass - prevents nodes from overlapping
|
|
2641
|
+
* Reads configuration directly from context.config
|
|
2642
|
+
*/
|
|
2643
|
+
var CollisionPass = class extends BasePass {
|
|
2644
|
+
getName() {
|
|
2645
|
+
return "collision";
|
|
2646
|
+
}
|
|
2647
|
+
initMaterial(context) {
|
|
2648
|
+
this.material = this.createMaterial(compute_default, collision_default, {
|
|
2649
|
+
uPositionsTexture: { value: null },
|
|
2650
|
+
uVelocityTexture: { value: null },
|
|
2651
|
+
uTextureSize: { value: context.textureSize },
|
|
2652
|
+
uRadiiTexture: { value: context.assets.getNodeRadiiTexture() },
|
|
2653
|
+
uCollisionStrength: { value: 0 },
|
|
2654
|
+
uCollisionRadius: { value: 0 },
|
|
2655
|
+
uAlpha: { value: 0 },
|
|
2656
|
+
uMaxDistance: { value: 0 },
|
|
2657
|
+
uIs3D: { value: true }
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
updateUniforms(context) {
|
|
2661
|
+
const c = context.config;
|
|
2662
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2663
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2664
|
+
this.setUniform("uCollisionStrength", c.collisionStrength);
|
|
2665
|
+
this.setUniform("uCollisionRadius", c.collisionRadius);
|
|
2666
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2667
|
+
this.setUniform("uMaxDistance", c.collisionMaxDistance);
|
|
2668
|
+
this.setUniform("uIs3D", c.is3D);
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2672
|
+
//#endregion
|
|
2673
|
+
//#region assets/glsl/force-sim/drag.frag
|
|
2674
|
+
var drag_default = "precision highp float;\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform vec2 uDraggedUV; \r\nuniform vec3 uDragTarget;\r\nuniform float uDragStrength;\r\nuniform float uDeltaTime;\r\nvarying vec2 vUv;\n\nvoid main() {\r\n vec3 currentVel = texture2D(uVelocityTexture, vUv).xyz;\r\n vec3 outVel = currentVel;\n\n float texSize = float(textureSize(uPositionsTexture, 0).x);\r\n float eps = 0.5 / texSize;\r\n float distUV = distance(vUv, uDraggedUV);\n\n \n if (distUV < eps) {\r\n vec3 pos = texture2D(uPositionsTexture, vUv).xyz;\r\n vec3 diff = uDragTarget - pos;\r\n \r\n \n \n vec3 desiredVelocity = diff / max(uDeltaTime, 0.001);\r\n \r\n \n \n outVel = desiredVelocity * uDragStrength ;\r\n }\n\n gl_FragColor = vec4(outVel, 1.0);\r\n}";
|
|
2675
|
+
|
|
2676
|
+
//#endregion
|
|
2677
|
+
//#region simulation/passes/DragPass.ts
|
|
2678
|
+
/**
|
|
2679
|
+
* Drag force pass - user interaction dragging
|
|
2680
|
+
* Reads configuration from context.config, maintains drag state internally
|
|
2681
|
+
*/
|
|
2682
|
+
var DragPass = class extends BasePass {
|
|
2683
|
+
draggedIndex = null;
|
|
2684
|
+
dragUV = null;
|
|
2685
|
+
dragTarget = new THREE.Vector3(0, 0, 0);
|
|
2686
|
+
getName() {
|
|
2687
|
+
return "drag";
|
|
2688
|
+
}
|
|
2689
|
+
initMaterial(context) {
|
|
2690
|
+
this.material = this.createMaterial(compute_default, drag_default, {
|
|
2691
|
+
uPositionsTexture: { value: null },
|
|
2692
|
+
uVelocityTexture: { value: null },
|
|
2693
|
+
uDraggedUV: { value: new THREE.Vector2(-1, -1) },
|
|
2694
|
+
uDragTarget: { value: new THREE.Vector3(0, 0, 0) },
|
|
2695
|
+
uDragStrength: { value: 0 },
|
|
2696
|
+
uTextureSize: { value: context.textureSize },
|
|
2697
|
+
uDeltaTime: { value: 0 }
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
updateUniforms(context) {
|
|
2701
|
+
const c = context.config;
|
|
2702
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2703
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2704
|
+
this.setUniform("uDraggedUV", this.dragUV || new THREE.Vector2(-1, -1));
|
|
2705
|
+
this.setUniform("uDragTarget", this.dragTarget);
|
|
2706
|
+
this.setUniform("uDragStrength", c.dragStrength);
|
|
2707
|
+
this.setUniform("uDeltaTime", c.deltaTime);
|
|
2708
|
+
}
|
|
2709
|
+
startDrag(index, targetWorldPos, textureSize) {
|
|
2710
|
+
this.draggedIndex = index;
|
|
2711
|
+
this.dragUV = this.indexToUV(index, textureSize);
|
|
2712
|
+
this.dragTarget.copy(targetWorldPos);
|
|
2713
|
+
this.setEnabled(true);
|
|
2714
|
+
}
|
|
2715
|
+
updateDrag(targetWorldPos) {
|
|
2716
|
+
if (this.draggedIndex === null) return;
|
|
2717
|
+
this.dragTarget.copy(targetWorldPos);
|
|
2718
|
+
}
|
|
2719
|
+
endDrag() {
|
|
2720
|
+
this.draggedIndex = null;
|
|
2721
|
+
this.dragUV = null;
|
|
2722
|
+
this.setEnabled(false);
|
|
2723
|
+
}
|
|
2724
|
+
isDragging() {
|
|
2725
|
+
return this.draggedIndex !== null;
|
|
2726
|
+
}
|
|
2727
|
+
indexToUV(index, textureSize) {
|
|
2728
|
+
const x = (index % textureSize + .5) / textureSize;
|
|
2729
|
+
const y = (Math.floor(index / textureSize) + .5) / textureSize;
|
|
2730
|
+
return new THREE.Vector2(x, y);
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
|
|
2734
|
+
//#endregion
|
|
2735
|
+
//#region assets/glsl/force-sim/elastic.frag
|
|
2736
|
+
var elastic_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform sampler2D uOriginalPositionsTexture;\r\nuniform float uElasticStrength;\r\nuniform float uAlpha;\n\nvarying vec2 vUv;\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n vec3 currentVelocity = texture2D(uVelocityTexture, vUv).xyz;\r\n vec4 originalPosition = texture2D(uOriginalPositionsTexture, vUv);\n\n \n vec3 displacement = originalPosition.xyz - currentPosition.xyz;\r\n float distance = length(displacement);\r\n \r\n \n vec3 force = vec3(0.0);\r\n if (distance > 0.0) {\r\n vec3 direction = displacement / distance; \n \n force = direction * distance * uElasticStrength;\r\n }\r\n \r\n \n vec3 newVelocity = currentVelocity + force * uAlpha;\r\n \r\n gl_FragColor = vec4(newVelocity, 1.0);\r\n}";
|
|
2737
|
+
|
|
2738
|
+
//#endregion
|
|
2739
|
+
//#region simulation/passes/ElasticPass.ts
|
|
2740
|
+
/**
|
|
2741
|
+
* Elastic force pass - spring to original position
|
|
2742
|
+
* Reads configuration directly from context.config
|
|
2743
|
+
*/
|
|
2744
|
+
var ElasticPass = class extends BasePass {
|
|
2745
|
+
getName() {
|
|
2746
|
+
return "elastic";
|
|
2747
|
+
}
|
|
2748
|
+
initMaterial(context) {
|
|
2749
|
+
this.material = this.createMaterial(compute_default, elastic_default, {
|
|
2750
|
+
uPositionsTexture: { value: null },
|
|
2751
|
+
uVelocityTexture: { value: null },
|
|
2752
|
+
uOriginalPositionsTexture: { value: context.simBuffers.getOriginalPositionTexture() },
|
|
2753
|
+
uElasticStrength: { value: 0 },
|
|
2754
|
+
uAlpha: { value: 0 }
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
updateUniforms(context) {
|
|
2758
|
+
const c = context.config;
|
|
2759
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2760
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2761
|
+
this.setUniform("uOriginalPositionsTexture", context.simBuffers.getOriginalPositionTexture());
|
|
2762
|
+
this.setUniform("uElasticStrength", c.elasticStrength);
|
|
2763
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2764
|
+
}
|
|
2765
|
+
};
|
|
2766
|
+
|
|
2767
|
+
//#endregion
|
|
2768
|
+
//#region assets/glsl/force-sim/gravity.frag
|
|
2769
|
+
var gravity_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform float uGravity;\r\nuniform float uAlpha;\r\nuniform float uSpaceSize;\r\nuniform float uTime; \n\nvarying vec2 vUv;\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n vec2 velocity = texture2D(uVelocityTexture, vUv).xy;\r\n \r\n \n vec2 pos = currentPosition.xy;\r\n \r\n \n vec2 center = vec2(0.0);\r\n \r\n \n vec2 toCenter = center - pos;\r\n float distToCenter = length(toCenter);\r\n \r\n \n if (distToCenter > 0.0) {\r\n velocity += normalize(toCenter) * uAlpha * distToCenter;\r\n }\r\n \r\n gl_FragColor = vec4(velocity, 0.0, 0.0);\r\n}";
|
|
2770
|
+
|
|
2771
|
+
//#endregion
|
|
2772
|
+
//#region simulation/passes/GravityPass.ts
|
|
2773
|
+
/**
|
|
2774
|
+
* Gravity force pass - attracts nodes to center
|
|
2775
|
+
* Reads configuration directly from context.config
|
|
2776
|
+
*/
|
|
2777
|
+
var GravityPass = class extends BasePass {
|
|
2778
|
+
getName() {
|
|
2779
|
+
return "gravity";
|
|
2780
|
+
}
|
|
2781
|
+
initMaterial(context) {
|
|
2782
|
+
this.material = this.createMaterial(compute_default, gravity_default, {
|
|
2783
|
+
uPositionsTexture: { value: null },
|
|
2784
|
+
uVelocityTexture: { value: null },
|
|
2785
|
+
uGravity: { value: 0 },
|
|
2786
|
+
uAlpha: { value: 0 },
|
|
2787
|
+
uSpaceSize: { value: 0 },
|
|
2788
|
+
uTime: { value: 0 }
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
updateUniforms(context) {
|
|
2792
|
+
const c = context.config;
|
|
2793
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2794
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2795
|
+
this.setUniform("uGravity", c.gravity);
|
|
2796
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2797
|
+
this.setUniform("uSpaceSize", c.spaceSize);
|
|
2798
|
+
this.setUniform("uTime", performance.now() / 1e3);
|
|
2799
|
+
}
|
|
2800
|
+
};
|
|
2801
|
+
|
|
2802
|
+
//#endregion
|
|
2803
|
+
//#region assets/glsl/force-sim/integrate.frag
|
|
2804
|
+
var integrate_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform float uDeltaTime;\r\nuniform float uDamping;\r\nuniform float uSpaceSize;\n\nvarying vec2 vUv;\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n vec4 velocityTex = texture2D(uVelocityTexture, vUv);\n\n \n vec3 velocity = velocityTex.rgb;\n\n \n \n \n \n \n float state = currentPosition.w;\n\n \n vec3 newPosition = currentPosition.xyz;\r\n \r\n \n if (state > 2.5) {\r\n newPosition += velocity * uDeltaTime;\r\n }\n\n gl_FragColor = vec4(newPosition, currentPosition.w);\r\n}";
|
|
2805
|
+
|
|
2806
|
+
//#endregion
|
|
2807
|
+
//#region simulation/passes/IntegratePass.ts
|
|
2808
|
+
/**
|
|
2809
|
+
* Integration pass - updates positions from velocities
|
|
2810
|
+
* Reads configuration directly from context.config
|
|
2811
|
+
*/
|
|
2812
|
+
var IntegratePass = class extends BasePass {
|
|
2813
|
+
getName() {
|
|
2814
|
+
return "integrate";
|
|
2815
|
+
}
|
|
2816
|
+
initMaterial(context) {
|
|
2817
|
+
this.material = this.createMaterial(compute_default, integrate_default, {
|
|
2818
|
+
uPositionsTexture: { value: null },
|
|
2819
|
+
uVelocityTexture: { value: null },
|
|
2820
|
+
uDeltaTime: { value: 0 },
|
|
2821
|
+
uDamping: { value: 0 },
|
|
2822
|
+
uSpaceSize: { value: 0 },
|
|
2823
|
+
uAlpha: { value: 0 }
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
updateUniforms(context) {
|
|
2827
|
+
const c = context.config;
|
|
2828
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getPreviousPositionTexture());
|
|
2829
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2830
|
+
this.setUniform("uDeltaTime", c.deltaTime);
|
|
2831
|
+
this.setUniform("uDamping", c.damping);
|
|
2832
|
+
this.setUniform("uSpaceSize", c.spaceSize);
|
|
2833
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
//#endregion
|
|
2838
|
+
//#region assets/glsl/force-sim/link.frag
|
|
2839
|
+
var link_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform sampler2D uLinksTexture; \nuniform sampler2D uLinksMapTexture; \nuniform sampler2D uLinkPropertiesTexture; \nuniform float uTextureSize;\r\nuniform float uLinksTextureSize;\r\nuniform float uSpringStrength; \nuniform float uSpringLength; \nuniform float uSpringDamping; \nuniform float uAlpha;\r\nuniform int uMaxLinks;\n\nvarying vec2 vUv;\n\nconst float JIGGLE = 1e-6;\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n vec3 velocity = texture2D(uVelocityTexture, vUv).xyz;\r\n vec3 pos = currentPosition.xyz;\n\n \n vec4 mapEntry = texture2D(uLinksMapTexture, vUv);\r\n float startXf = mapEntry.r;\r\n float startYf = mapEntry.g;\r\n int count = int(mapEntry.b);\n\n if (count <= 0) {\r\n gl_FragColor = vec4(velocity, 0.0);\r\n return;\r\n }\n\n \n float maxIndexF = uLinksTextureSize - 1.0;\r\n float startXi = clamp(floor(startXf + 0.5), 0.0, maxIndexF);\r\n float startYi = clamp(floor(startYf + 0.5), 0.0, maxIndexF);\n\n \n float baseIndex = startXi + startYi * uLinksTextureSize;\r\n float totalLinkTexels = uLinksTextureSize * uLinksTextureSize;\n\n vec3 velocityDelta = vec3(0.0);\n\n \n for (int i = 0; i < count; i++) {\n\n float idx = baseIndex + float(i);\n\n \n if (idx < 0.0 || idx >= totalLinkTexels) break;\n\n float lx = mod(idx, uLinksTextureSize);\r\n float ly = floor(idx / uLinksTextureSize);\r\n vec2 linkUV = vec2((lx + 0.5) / uLinksTextureSize, (ly + 0.5) / uLinksTextureSize);\n\n \n float otherIndexF = texture2D(uLinksTexture, linkUV).r;\r\n vec3 linkProps = texture2D(uLinkPropertiesTexture, linkUV).rgb;\r\n \r\n \n float strength = uSpringStrength;\r\n if (linkProps.r > 0.0) strength *= linkProps.r;\n\n \n if (strength <= 0.0 && uSpringDamping <= 0.0) continue;\n\n float spring_dist = uSpringLength;\r\n \n\n \n float otherIndexRoundedF = floor(otherIndexF + 0.5);\n\n if (otherIndexRoundedF < 0.0) continue;\n\n \n float ox = mod(otherIndexRoundedF, uTextureSize);\r\n float oy = floor(otherIndexRoundedF / uTextureSize);\r\n vec2 otherUV = vec2((ox + 0.5) / uTextureSize, (oy + 0.5) / uTextureSize);\n\n vec4 otherPosTex = texture2D(uPositionsTexture, otherUV);\r\n vec3 otherPos = otherPosTex.xyz;\n\n \n if (otherPosTex.w < 1.5) continue;\n\n vec3 otherVel = texture2D(uVelocityTexture, otherUV).xyz;\n\n vec3 delta = otherPos - pos;\r\n vec3 force = vec3(0.0);\r\n \r\n float currentLength = length(delta);\r\n if (currentLength > 0.0) {\r\n float displacement = currentLength - spring_dist;\r\n \r\n \n vec3 direction = delta / currentLength;\r\n float springForce = displacement * strength;\r\n \r\n \n vec3 relVel = velocity - otherVel;\r\n float dampingForce = dot(relVel, direction) * uSpringDamping;\n\n \n force = direction * (springForce - dampingForce);\r\n }\n\n \n velocityDelta += force * uAlpha;\r\n }\n\n vec3 newVel = velocity + velocityDelta;\r\n gl_FragColor = vec4(newVel, 0.0);\r\n}";
|
|
2840
|
+
|
|
2841
|
+
//#endregion
|
|
2842
|
+
//#region simulation/passes/LinkPass.ts
|
|
2843
|
+
/**
|
|
2844
|
+
* Link force pass - spring forces between connected nodes
|
|
2845
|
+
* Reads configuration directly from context.config
|
|
2846
|
+
*/
|
|
2847
|
+
var LinkPass = class extends BasePass {
|
|
2848
|
+
getName() {
|
|
2849
|
+
return "link";
|
|
2850
|
+
}
|
|
2851
|
+
initMaterial(context) {
|
|
2852
|
+
const linksTexture = context.assets.getLinkIndicesTexture();
|
|
2853
|
+
const linksTextureSize = linksTexture?.image?.data ? Math.ceil(Math.sqrt(linksTexture.image.data.length / 4)) : 0;
|
|
2854
|
+
this.material = this.createMaterial(compute_default, link_default, {
|
|
2855
|
+
uPositionsTexture: { value: null },
|
|
2856
|
+
uVelocityTexture: { value: null },
|
|
2857
|
+
uLinksTexture: { value: context.assets.getLinkIndicesTexture() },
|
|
2858
|
+
uLinksMapTexture: { value: context.assets.getLinkMapTexture() },
|
|
2859
|
+
uLinkPropertiesTexture: { value: context.assets.getLinkPropertiesTexture() },
|
|
2860
|
+
uTextureSize: { value: context.textureSize },
|
|
2861
|
+
uLinksTextureSize: { value: linksTextureSize },
|
|
2862
|
+
uSpringStrength: { value: context.config.springStrength },
|
|
2863
|
+
uSpringLength: { value: context.config.springLength },
|
|
2864
|
+
uSpringDamping: { value: context.config.springDamping },
|
|
2865
|
+
uAlpha: { value: context.config.alpha },
|
|
2866
|
+
uMaxLinks: { value: context.config.maxLinks }
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
updateUniforms(context) {
|
|
2870
|
+
const c = context.config;
|
|
2871
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2872
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2873
|
+
this.setUniform("uSpringStrength", c.springStrength);
|
|
2874
|
+
this.setUniform("uSpringLength", c.springLength);
|
|
2875
|
+
this.setUniform("uSpringDamping", c.springDamping);
|
|
2876
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2877
|
+
this.setUniform("uMaxLinks", c.maxLinks);
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
|
|
2881
|
+
//#endregion
|
|
2882
|
+
//#region assets/glsl/force-sim/manybody.frag
|
|
2883
|
+
var manybody_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPositionsTexture;\r\nuniform sampler2D uVelocityTexture;\r\nuniform float uTextureSize;\r\nuniform float uManyBodyStrength; \nuniform float uMinDistance; \nuniform float uMaxDistance; \nuniform float uAlpha; \nuniform sampler2D uRadiiTexture;\r\nuniform bool uIs3D; \n\nvarying vec2 vUv;\n\nfloat rand(vec2 co){\r\n return fract(sin(dot(co, vec2(12.9898,78.233))) * 43758.5453);\r\n}\n\nvoid main() {\r\n vec4 currentPosition = texture2D(uPositionsTexture, vUv);\r\n vec3 velocity = texture2D(uVelocityTexture, vUv).xyz;\r\n float particleIndex = floor(vUv.y * uTextureSize) * uTextureSize + floor(vUv.x * uTextureSize);\n\n float texStep = 1.0 / uTextureSize;\r\n float halfTex = 0.5 * texStep;\r\n float eps = 1e-6;\n\n float r1 = texture2D(uRadiiTexture, vUv).r;\r\n vec3 force = vec3(0.0);\n\n \n for (float y = 0.0; y < 1.0; y += texStep) {\r\n for (float x = 0.0; x < 1.0; x += texStep) {\r\n vec2 otherUV = vec2(x + halfTex, y + halfTex);\r\n float otherIndex = floor(y * uTextureSize) * uTextureSize + floor(x * uTextureSize);\r\n if (abs(otherIndex - particleIndex) < 0.5) continue; \n\n vec4 otherPosition = texture2D(uPositionsTexture, otherUV);\r\n \r\n \n \n if (otherPosition.w < 1.5) continue;\n\n vec3 delta;\r\n if (uIs3D) {\r\n delta = currentPosition.xyz - otherPosition.xyz;\r\n } else {\r\n delta = vec3(currentPosition.xy - otherPosition.xy, 0.0);\r\n }\n\n float dist = length(delta);\n\n if (dist < uMinDistance || dist > uMaxDistance) continue;\n\n float r2 = texture2D(uRadiiTexture, otherUV).r;\r\n float distSq = max(dist * dist, eps); \r\n float forceMagnitude = (uManyBodyStrength * r1 * r2) / distSq;\n\n vec3 direction = length(delta) > eps ? normalize(delta) : vec3(0.0);\r\n \r\n force += direction * forceMagnitude * r1;\r\n }\r\n }\r\n \r\n \n velocity += force * uAlpha;\r\n \r\n gl_FragColor = vec4(velocity, 0.0);\r\n}";
|
|
2884
|
+
|
|
2885
|
+
//#endregion
|
|
2886
|
+
//#region simulation/passes/ManyBodyPass.ts
|
|
2887
|
+
/**
|
|
2888
|
+
* Many-body force pass - charge/repulsion between all nodes
|
|
2889
|
+
* Reads configuration directly from context.config
|
|
2890
|
+
*/
|
|
2891
|
+
var ManyBodyPass = class extends BasePass {
|
|
2892
|
+
getName() {
|
|
2893
|
+
return "manybody";
|
|
2894
|
+
}
|
|
2895
|
+
initMaterial(context) {
|
|
2896
|
+
this.material = this.createMaterial(compute_default, manybody_default, {
|
|
2897
|
+
uPositionsTexture: { value: null },
|
|
2898
|
+
uVelocityTexture: { value: null },
|
|
2899
|
+
uTextureSize: { value: context.textureSize },
|
|
2900
|
+
uRadiiTexture: { value: context.assets.getNodeRadiiTexture() },
|
|
2901
|
+
uManyBodyStrength: { value: 0 },
|
|
2902
|
+
uMinDistance: { value: 0 },
|
|
2903
|
+
uMaxDistance: { value: 0 },
|
|
2904
|
+
uAlpha: { value: 0 },
|
|
2905
|
+
uIs3D: { value: true }
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
updateUniforms(context) {
|
|
2909
|
+
const c = context.config;
|
|
2910
|
+
this.setUniform("uPositionsTexture", context.simBuffers.getCurrentPositionTexture());
|
|
2911
|
+
this.setUniform("uVelocityTexture", context.simBuffers.getCurrentVelocityTexture());
|
|
2912
|
+
this.setUniform("uManyBodyStrength", c.manyBodyStrength);
|
|
2913
|
+
this.setUniform("uMinDistance", c.manyBodyMinDistance);
|
|
2914
|
+
this.setUniform("uMaxDistance", c.manyBodyMaxDistance);
|
|
2915
|
+
this.setUniform("uAlpha", c.alpha);
|
|
2916
|
+
this.setUniform("uIs3D", c.is3D);
|
|
2917
|
+
}
|
|
2918
|
+
};
|
|
2919
|
+
|
|
2920
|
+
//#endregion
|
|
2921
|
+
//#region assets/glsl/force-sim/velocity_carry.frag
|
|
2922
|
+
var velocity_carry_default = "#ifdef GL_ES\r\nprecision highp float;\r\n#endif\n\nuniform sampler2D uPrevVelocity;\r\nuniform float uDamping;\n\nvarying vec2 vUv;\n\nvoid main() {\r\n vec3 prev = texture2D(uPrevVelocity, vUv).rgb;\r\n vec3 carried = prev * uDamping;\r\n gl_FragColor = vec4(carried, 1.0);\r\n}";
|
|
2923
|
+
|
|
2924
|
+
//#endregion
|
|
2925
|
+
//#region simulation/passes/VelocityCarryPass.ts
|
|
2926
|
+
/**
|
|
2927
|
+
* Velocity carry pass - applies damping to previous velocity
|
|
2928
|
+
* Reads configuration directly from context.config
|
|
2929
|
+
*/
|
|
2930
|
+
var VelocityCarryPass = class extends BasePass {
|
|
2931
|
+
getName() {
|
|
2932
|
+
return "velocityCarry";
|
|
2933
|
+
}
|
|
2934
|
+
initMaterial(context) {
|
|
2935
|
+
this.material = this.createMaterial(compute_default, velocity_carry_default, {
|
|
2936
|
+
uPrevVelocity: { value: null },
|
|
2937
|
+
uDamping: { value: 0 }
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
updateUniforms(context) {
|
|
2941
|
+
this.setUniform("uPrevVelocity", context.simBuffers.getPreviousVelocityTexture());
|
|
2942
|
+
this.setUniform("uDamping", context.config.damping);
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
|
|
2946
|
+
//#endregion
|
|
2947
|
+
//#region simulation/ForceSimulation.ts
|
|
2948
|
+
/**
|
|
2949
|
+
* ForceSimulation - GPU-based force simulation
|
|
2950
|
+
*
|
|
2951
|
+
* Simple architecture:
|
|
2952
|
+
* - Single shared config object (ForceConfig)
|
|
2953
|
+
* - All passes read directly from config via PassContext
|
|
2954
|
+
* - Enable flags control which passes execute
|
|
2955
|
+
* - No manual syncing required
|
|
2956
|
+
*/
|
|
2957
|
+
var ForceSimulation = class {
|
|
2958
|
+
renderer;
|
|
2959
|
+
simulationBuffers = null;
|
|
2960
|
+
computeScene;
|
|
2961
|
+
computeCamera;
|
|
2962
|
+
computeQuad;
|
|
2963
|
+
velocityCarryPass;
|
|
2964
|
+
collisionPass;
|
|
2965
|
+
manyBodyPass;
|
|
2966
|
+
gravityPass;
|
|
2967
|
+
linkPass;
|
|
2968
|
+
elasticPass;
|
|
2969
|
+
attractorPass;
|
|
2970
|
+
dragPass;
|
|
2971
|
+
integratePass;
|
|
2972
|
+
forcePasses;
|
|
2973
|
+
config;
|
|
2974
|
+
isInitialized = false;
|
|
2975
|
+
iterationCount = 0;
|
|
2976
|
+
constructor(renderer) {
|
|
2977
|
+
this.renderer = renderer;
|
|
2978
|
+
this.computeScene = new THREE.Scene();
|
|
2979
|
+
this.computeCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
2980
|
+
this.computeQuad = this.createComputeQuad();
|
|
2981
|
+
this.config = {
|
|
2982
|
+
manyBodyStrength: 3.5,
|
|
2983
|
+
manyBodyMinDistance: .01,
|
|
2984
|
+
manyBodyMaxDistance: .3,
|
|
2985
|
+
enableManyBody: false,
|
|
2986
|
+
collisionStrength: .33,
|
|
2987
|
+
collisionRadius: .33,
|
|
2988
|
+
collisionMaxDistance: .3,
|
|
2989
|
+
enableCollision: true,
|
|
2990
|
+
elasticStrength: .1,
|
|
2991
|
+
enableElastic: false,
|
|
2992
|
+
springStrength: 10,
|
|
2993
|
+
springLength: .1,
|
|
2994
|
+
springDamping: .99,
|
|
2995
|
+
maxLinks: 512,
|
|
2996
|
+
enableLinks: false,
|
|
2997
|
+
gravity: .9,
|
|
2998
|
+
enableGravity: false,
|
|
2999
|
+
attractorStrength: .1,
|
|
3000
|
+
enableAttractors: false,
|
|
3001
|
+
dragStrength: .2,
|
|
3002
|
+
damping: .75,
|
|
3003
|
+
alpha: 1,
|
|
3004
|
+
alphaDecay: .01,
|
|
3005
|
+
deltaTime: .016,
|
|
3006
|
+
spaceSize: 1e3,
|
|
3007
|
+
is3D: true
|
|
3008
|
+
};
|
|
3009
|
+
this.velocityCarryPass = new VelocityCarryPass();
|
|
3010
|
+
this.collisionPass = new CollisionPass();
|
|
3011
|
+
this.manyBodyPass = new ManyBodyPass();
|
|
3012
|
+
this.gravityPass = new GravityPass();
|
|
3013
|
+
this.linkPass = new LinkPass();
|
|
3014
|
+
this.elasticPass = new ElasticPass();
|
|
3015
|
+
this.attractorPass = new AttractorPass();
|
|
3016
|
+
this.dragPass = new DragPass();
|
|
3017
|
+
this.integratePass = new IntegratePass();
|
|
3018
|
+
this.forcePasses = [
|
|
3019
|
+
this.collisionPass,
|
|
3020
|
+
this.manyBodyPass,
|
|
3021
|
+
this.gravityPass,
|
|
3022
|
+
this.attractorPass,
|
|
3023
|
+
this.linkPass,
|
|
3024
|
+
this.elasticPass,
|
|
3025
|
+
this.dragPass
|
|
3026
|
+
];
|
|
3027
|
+
}
|
|
3028
|
+
createComputeQuad() {
|
|
3029
|
+
const quad = new THREE.BufferGeometry();
|
|
3030
|
+
const vertices = new Float32Array([
|
|
3031
|
+
-1,
|
|
3032
|
+
-1,
|
|
3033
|
+
0,
|
|
3034
|
+
1,
|
|
3035
|
+
-1,
|
|
3036
|
+
0,
|
|
3037
|
+
1,
|
|
3038
|
+
1,
|
|
3039
|
+
0,
|
|
3040
|
+
-1,
|
|
3041
|
+
1,
|
|
3042
|
+
0
|
|
3043
|
+
]);
|
|
3044
|
+
const uvs = new Float32Array([
|
|
3045
|
+
0,
|
|
3046
|
+
0,
|
|
3047
|
+
1,
|
|
3048
|
+
0,
|
|
3049
|
+
1,
|
|
3050
|
+
1,
|
|
3051
|
+
0,
|
|
3052
|
+
1
|
|
3053
|
+
]);
|
|
3054
|
+
const indices = new Uint16Array([
|
|
3055
|
+
0,
|
|
3056
|
+
1,
|
|
3057
|
+
2,
|
|
3058
|
+
0,
|
|
3059
|
+
2,
|
|
3060
|
+
3
|
|
3061
|
+
]);
|
|
3062
|
+
quad.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
|
|
3063
|
+
quad.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
|
|
3064
|
+
quad.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
3065
|
+
return quad;
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Initialize simulation with buffers
|
|
3069
|
+
*/
|
|
3070
|
+
initialize(simulationBuffers) {
|
|
3071
|
+
this.simulationBuffers = simulationBuffers;
|
|
3072
|
+
if (!this.simulationBuffers.isReady()) {
|
|
3073
|
+
console.warn("[ForceSimulation] SimulationBuffers not ready");
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
const context = this.createContext();
|
|
3077
|
+
this.velocityCarryPass.initMaterial(context);
|
|
3078
|
+
this.integratePass.initMaterial(context);
|
|
3079
|
+
for (const pass of this.forcePasses) pass.initMaterial(context);
|
|
3080
|
+
this.isInitialized = true;
|
|
3081
|
+
}
|
|
3082
|
+
createContext() {
|
|
3083
|
+
if (!this.simulationBuffers) throw new Error("[ForceSimulation] Cannot create context without buffers");
|
|
3084
|
+
return {
|
|
3085
|
+
scene: this.computeScene,
|
|
3086
|
+
camera: this.computeCamera,
|
|
3087
|
+
quad: this.computeQuad,
|
|
3088
|
+
simBuffers: this.simulationBuffers,
|
|
3089
|
+
assets: staticAssets,
|
|
3090
|
+
renderer: this.renderer,
|
|
3091
|
+
config: this.config,
|
|
3092
|
+
textureSize: this.simulationBuffers.getTextureSize()
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Check if a pass should execute based on config
|
|
3097
|
+
*/
|
|
3098
|
+
shouldExecutePass(pass) {
|
|
3099
|
+
switch (pass.getName()) {
|
|
3100
|
+
case "collision": return this.config.enableCollision;
|
|
3101
|
+
case "manybody": return this.config.enableManyBody;
|
|
3102
|
+
case "gravity": return this.config.enableGravity;
|
|
3103
|
+
case "attractor": return this.config.enableAttractors && this.attractorPass.getAttractors().length > 0;
|
|
3104
|
+
case "link": return this.config.enableLinks;
|
|
3105
|
+
case "elastic": return this.config.enableElastic;
|
|
3106
|
+
case "drag": return this.dragPass.isDragging();
|
|
3107
|
+
default: return true;
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Run one simulation step
|
|
3112
|
+
*/
|
|
3113
|
+
step(deltaTime) {
|
|
3114
|
+
if (!this.isInitialized || !this.simulationBuffers) return;
|
|
3115
|
+
if (deltaTime !== void 0) this.config.deltaTime = deltaTime;
|
|
3116
|
+
const context = this.createContext();
|
|
3117
|
+
const currentVelocityTarget = this.simulationBuffers.getCurrentVelocityTarget();
|
|
3118
|
+
if (currentVelocityTarget) {
|
|
3119
|
+
this.renderer.setRenderTarget(currentVelocityTarget);
|
|
3120
|
+
this.velocityCarryPass.execute(context);
|
|
3121
|
+
}
|
|
3122
|
+
for (const pass of this.forcePasses) {
|
|
3123
|
+
if (!this.shouldExecutePass(pass)) continue;
|
|
3124
|
+
const prevVelocityTarget = this.simulationBuffers.getPreviousVelocityTarget();
|
|
3125
|
+
if (prevVelocityTarget) {
|
|
3126
|
+
this.renderer.setRenderTarget(prevVelocityTarget);
|
|
3127
|
+
pass.execute(context);
|
|
3128
|
+
this.simulationBuffers.swapVelocities();
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
this.simulationBuffers.swapPositions();
|
|
3132
|
+
const newPositionTarget = this.simulationBuffers.getCurrentPositionTarget();
|
|
3133
|
+
if (newPositionTarget) {
|
|
3134
|
+
this.renderer.setRenderTarget(newPositionTarget);
|
|
3135
|
+
this.integratePass.execute(context);
|
|
3136
|
+
}
|
|
3137
|
+
const timeScale = (this.config.deltaTime || .016) * 60;
|
|
3138
|
+
const decayFactor = Math.pow(1 - this.config.alphaDecay, timeScale);
|
|
3139
|
+
this.config.alpha *= decayFactor;
|
|
3140
|
+
this.config.alpha = Math.max(this.config.alpha, 0);
|
|
3141
|
+
this.renderer.setRenderTarget(null);
|
|
3142
|
+
this.iterationCount++;
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Get config for GUI binding
|
|
3146
|
+
* Modify this object directly - changes take effect on next step()
|
|
3147
|
+
*/
|
|
3148
|
+
getConfig() {
|
|
3149
|
+
return this.config;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Re-heat the simulation (set alpha to 1)
|
|
3153
|
+
*/
|
|
3154
|
+
reheat(amt = 1) {
|
|
3155
|
+
this.config.alpha = amt;
|
|
3156
|
+
}
|
|
3157
|
+
startDrag(index, targetWorldPos) {
|
|
3158
|
+
if (!this.simulationBuffers) return;
|
|
3159
|
+
this.dragPass.startDrag(index, targetWorldPos, this.simulationBuffers.getTextureSize());
|
|
3160
|
+
this.reheat(.1);
|
|
3161
|
+
}
|
|
3162
|
+
updateDrag(targetWorldPos) {
|
|
3163
|
+
this.dragPass.updateDrag(targetWorldPos);
|
|
3164
|
+
this.reheat(.01);
|
|
3165
|
+
}
|
|
3166
|
+
endDrag() {
|
|
3167
|
+
this.dragPass.endDrag();
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* Set attractors for category-based attraction
|
|
3171
|
+
* @param attractors Array of attractor definitions
|
|
3172
|
+
*/
|
|
3173
|
+
setAttractors(attractors) {
|
|
3174
|
+
this.attractorPass.setAttractors(attractors);
|
|
3175
|
+
this.config.enableAttractors = attractors.length > 0;
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Add a single attractor
|
|
3179
|
+
*/
|
|
3180
|
+
addAttractor(attractor) {
|
|
3181
|
+
this.attractorPass.addAttractor(attractor);
|
|
3182
|
+
this.config.enableAttractors = true;
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* Remove an attractor by ID
|
|
3186
|
+
*/
|
|
3187
|
+
removeAttractor(id) {
|
|
3188
|
+
this.attractorPass.removeAttractor(id);
|
|
3189
|
+
if (this.attractorPass.getAttractors().length === 0) this.config.enableAttractors = false;
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* Update an attractor's position (useful for animated attractors)
|
|
3193
|
+
*/
|
|
3194
|
+
updateAttractorPosition(id, position) {
|
|
3195
|
+
this.attractorPass.updateAttractorPosition(id, position);
|
|
3196
|
+
}
|
|
3197
|
+
/**
|
|
3198
|
+
* Get current attractors
|
|
3199
|
+
*/
|
|
3200
|
+
getAttractors() {
|
|
3201
|
+
return this.attractorPass.getAttractors();
|
|
3202
|
+
}
|
|
3203
|
+
/**
|
|
3204
|
+
* Set node logic for attractor matching
|
|
3205
|
+
* Call this when graph data changes
|
|
3206
|
+
*/
|
|
3207
|
+
setNodeGroups(groups) {
|
|
3208
|
+
if (!this.simulationBuffers) return;
|
|
3209
|
+
const textureSize = this.simulationBuffers.getTextureSize();
|
|
3210
|
+
const normalizedGroups = groups.map((g) => Array.isArray(g) ? g : g ? [g] : []);
|
|
3211
|
+
this.attractorPass.setNodeGroups(normalizedGroups, textureSize);
|
|
3212
|
+
}
|
|
3213
|
+
setNodeGroupsFromData(data, accessor) {
|
|
3214
|
+
const groups = data.map((item, i) => accessor(item, i) || []);
|
|
3215
|
+
this.setNodeGroups(groups);
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Get the attractor pass for direct access
|
|
3219
|
+
*/
|
|
3220
|
+
getAttractorPass() {
|
|
3221
|
+
return this.attractorPass;
|
|
3222
|
+
}
|
|
3223
|
+
getAlpha() {
|
|
3224
|
+
return this.config.alpha;
|
|
3225
|
+
}
|
|
3226
|
+
getIterationCount() {
|
|
3227
|
+
return this.iterationCount;
|
|
3228
|
+
}
|
|
3229
|
+
reset() {
|
|
3230
|
+
if (!this.isInitialized || !this.simulationBuffers) return;
|
|
3231
|
+
this.config.alpha = 1;
|
|
3232
|
+
this.iterationCount = 0;
|
|
3233
|
+
this.simulationBuffers.resetPositions();
|
|
3234
|
+
this.simulationBuffers.initVelocities();
|
|
3235
|
+
}
|
|
3236
|
+
dispose() {
|
|
3237
|
+
this.velocityCarryPass.dispose();
|
|
3238
|
+
this.integratePass.dispose();
|
|
3239
|
+
for (const pass of this.forcePasses) pass.dispose();
|
|
3240
|
+
this.computeQuad.dispose();
|
|
3241
|
+
this.isInitialized = false;
|
|
3242
|
+
}
|
|
3243
|
+
};
|
|
3244
|
+
|
|
3245
|
+
//#endregion
|
|
3246
|
+
//#region textures/PickBuffer.ts
|
|
3247
|
+
/**
|
|
3248
|
+
* Manages GPU picking buffer for mouse interaction
|
|
3249
|
+
* Separate from simulation buffers as it has different lifecycle
|
|
3250
|
+
*/
|
|
3251
|
+
var PickBuffer = class {
|
|
3252
|
+
renderer;
|
|
3253
|
+
pickTarget = null;
|
|
3254
|
+
constructor(renderer) {
|
|
3255
|
+
this.renderer = renderer;
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Ensure pick buffer matches viewport size
|
|
3259
|
+
*/
|
|
3260
|
+
resize(width, height) {
|
|
3261
|
+
if (!this.pickTarget || this.pickTarget.width !== width || this.pickTarget.height !== height) {
|
|
3262
|
+
this.pickTarget?.dispose();
|
|
3263
|
+
this.pickTarget = this.createPickBuffer(width, height);
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
createPickBuffer(width, height) {
|
|
3267
|
+
return new THREE.WebGLRenderTarget(width, height, {
|
|
3268
|
+
minFilter: THREE.NearestFilter,
|
|
3269
|
+
magFilter: THREE.NearestFilter,
|
|
3270
|
+
format: THREE.RGBAFormat,
|
|
3271
|
+
type: THREE.UnsignedByteType,
|
|
3272
|
+
stencilBuffer: false,
|
|
3273
|
+
depthBuffer: true,
|
|
3274
|
+
generateMipmaps: false
|
|
3275
|
+
});
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Read node ID at pixel coordinates
|
|
3279
|
+
*/
|
|
3280
|
+
readIdAt(x, y) {
|
|
3281
|
+
if (!this.pickTarget) return -1;
|
|
3282
|
+
this.pickTarget.width;
|
|
3283
|
+
const height = this.pickTarget.height;
|
|
3284
|
+
const buffer = new Uint8Array(4);
|
|
3285
|
+
const readY = height - y - 1;
|
|
3286
|
+
try {
|
|
3287
|
+
this.renderer.readRenderTargetPixels(this.pickTarget, Math.floor(x), Math.floor(readY), 1, 1, buffer);
|
|
3288
|
+
} catch (e) {
|
|
3289
|
+
return -1;
|
|
3290
|
+
}
|
|
3291
|
+
const r = buffer[0] || 0;
|
|
3292
|
+
const g = buffer[1] || 0;
|
|
3293
|
+
const b = buffer[2] || 0;
|
|
3294
|
+
const id = r + (g << 8) + (b << 16) - 1;
|
|
3295
|
+
return id < 0 ? -1 : id;
|
|
3296
|
+
}
|
|
3297
|
+
/**
|
|
3298
|
+
* Render scene to pick buffer
|
|
3299
|
+
*/
|
|
3300
|
+
render(scene, camera) {
|
|
3301
|
+
if (!this.pickTarget) return;
|
|
3302
|
+
const currentRenderTarget = this.renderer.getRenderTarget();
|
|
3303
|
+
this.renderer.setRenderTarget(this.pickTarget);
|
|
3304
|
+
this.renderer.render(scene, camera);
|
|
3305
|
+
this.renderer.setRenderTarget(currentRenderTarget);
|
|
3306
|
+
}
|
|
3307
|
+
getTarget() {
|
|
3308
|
+
return this.pickTarget;
|
|
3309
|
+
}
|
|
3310
|
+
dispose() {
|
|
3311
|
+
this.pickTarget?.dispose();
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
|
|
3315
|
+
//#endregion
|
|
3316
|
+
//#region textures/SimulationBuffers.ts
|
|
3317
|
+
/**
|
|
3318
|
+
* Manages dynamic render targets updated by force simulation
|
|
3319
|
+
* Uses ping-pong technique for GPU computation
|
|
3320
|
+
* Buffers are created lazily when data is initialized
|
|
3321
|
+
*/
|
|
3322
|
+
var SimulationBuffers = class {
|
|
3323
|
+
renderer;
|
|
3324
|
+
textureSize = 0;
|
|
3325
|
+
positionBuffers = {
|
|
3326
|
+
current: null,
|
|
3327
|
+
previous: null,
|
|
3328
|
+
original: null
|
|
3329
|
+
};
|
|
3330
|
+
velocityBuffers = {
|
|
3331
|
+
current: null,
|
|
3332
|
+
previous: null
|
|
3333
|
+
};
|
|
3334
|
+
isInitialized = false;
|
|
3335
|
+
constructor(renderer) {
|
|
3336
|
+
this.renderer = renderer;
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Check if buffers are initialized
|
|
3340
|
+
*/
|
|
3341
|
+
isReady() {
|
|
3342
|
+
return this.isInitialized;
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Initialize buffers with point count and initial positions
|
|
3346
|
+
*/
|
|
3347
|
+
init(pointsCount, initialPositions) {
|
|
3348
|
+
if (this.isInitialized) this.dispose();
|
|
3349
|
+
this.textureSize = this.calculateTextureSize(pointsCount);
|
|
3350
|
+
if (this.textureSize === 0 || !Number.isFinite(this.textureSize)) {
|
|
3351
|
+
console.error(`[SimulationBuffers] Invalid texture size calculated: ${this.textureSize}`);
|
|
3352
|
+
throw new Error(`Invalid texture size: ${this.textureSize}`);
|
|
3353
|
+
}
|
|
3354
|
+
this.positionBuffers = {
|
|
3355
|
+
current: this.createDynamicBuffer(),
|
|
3356
|
+
previous: this.createDynamicBuffer(),
|
|
3357
|
+
original: this.createDynamicBuffer()
|
|
3358
|
+
};
|
|
3359
|
+
this.velocityBuffers = {
|
|
3360
|
+
current: this.createDynamicBuffer(),
|
|
3361
|
+
previous: this.createDynamicBuffer()
|
|
3362
|
+
};
|
|
3363
|
+
this.isInitialized = true;
|
|
3364
|
+
if (initialPositions) this.setInitialPositions(initialPositions);
|
|
3365
|
+
else this.setInitialPositions(new Float32Array(pointsCount * 3));
|
|
3366
|
+
this.initVelocities();
|
|
3367
|
+
}
|
|
3368
|
+
/**
|
|
3369
|
+
* Resize buffers (re-initializes with new size)
|
|
3370
|
+
*/
|
|
3371
|
+
resize(pointsCount, preserveData = false) {
|
|
3372
|
+
if (this.calculateTextureSize(pointsCount) === this.textureSize) return;
|
|
3373
|
+
let savedPositions;
|
|
3374
|
+
if (preserveData && this.isInitialized) savedPositions = this.readPositions();
|
|
3375
|
+
this.init(pointsCount, savedPositions);
|
|
3376
|
+
}
|
|
3377
|
+
calculateTextureSize(count) {
|
|
3378
|
+
const size = Math.ceil(Math.sqrt(count));
|
|
3379
|
+
return 2 ** Math.ceil(Math.log2(size));
|
|
3380
|
+
}
|
|
3381
|
+
createDynamicBuffer() {
|
|
3382
|
+
return new THREE.WebGLRenderTarget(this.textureSize, this.textureSize, {
|
|
3383
|
+
minFilter: THREE.NearestFilter,
|
|
3384
|
+
magFilter: THREE.NearestFilter,
|
|
3385
|
+
format: THREE.RGBAFormat,
|
|
3386
|
+
type: THREE.FloatType,
|
|
3387
|
+
stencilBuffer: false,
|
|
3388
|
+
depthBuffer: false,
|
|
3389
|
+
generateMipmaps: false
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Initialize position buffers from initial data
|
|
3394
|
+
*/
|
|
3395
|
+
setInitialPositions(positionArray) {
|
|
3396
|
+
if (!this.isInitialized) throw new Error("SimulationBuffers must be initialized before setting positions");
|
|
3397
|
+
const textureData = this.arrayToTextureData(positionArray, 4);
|
|
3398
|
+
const texture = this.createDataTexture(textureData);
|
|
3399
|
+
this.copyTextureToBuffer(texture, this.positionBuffers.current);
|
|
3400
|
+
this.copyTextureToBuffer(texture, this.positionBuffers.previous);
|
|
3401
|
+
this.copyTextureToBuffer(texture, this.positionBuffers.original);
|
|
3402
|
+
texture.dispose();
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Update current and previous positions, but keep original positions intact
|
|
3406
|
+
*/
|
|
3407
|
+
updateCurrentPositions(positionArray) {
|
|
3408
|
+
if (!this.isInitialized) throw new Error("SimulationBuffers must be initialized before updating positions");
|
|
3409
|
+
const textureData = this.arrayToTextureData(positionArray, 4);
|
|
3410
|
+
const texture = this.createDataTexture(textureData);
|
|
3411
|
+
this.copyTextureToBuffer(texture, this.positionBuffers.current);
|
|
3412
|
+
this.copyTextureToBuffer(texture, this.positionBuffers.previous);
|
|
3413
|
+
texture.dispose();
|
|
3414
|
+
}
|
|
3415
|
+
/**
|
|
3416
|
+
* Set original positions (anchors) separately from current positions
|
|
3417
|
+
*/
|
|
3418
|
+
setOriginalPositions(positionArray) {
|
|
3419
|
+
if (!this.isInitialized) throw new Error("SimulationBuffers must be initialized before setting original positions");
|
|
3420
|
+
const textureData = this.arrayToTextureData(positionArray, 4);
|
|
3421
|
+
const texture = this.createDataTexture(textureData);
|
|
3422
|
+
this.copyTextureToBuffer(texture, this.positionBuffers.original);
|
|
3423
|
+
texture.dispose();
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* Initialize velocity buffers (usually to zero)
|
|
3427
|
+
*/
|
|
3428
|
+
initVelocities() {
|
|
3429
|
+
if (!this.isInitialized) throw new Error("SimulationBuffers must be initialized before setting velocities");
|
|
3430
|
+
const size = this.textureSize * this.textureSize * 4;
|
|
3431
|
+
const textureData = new Float32Array(size).fill(0);
|
|
3432
|
+
const texture = this.createDataTexture(textureData);
|
|
3433
|
+
this.copyTextureToBuffer(texture, this.velocityBuffers.current);
|
|
3434
|
+
this.copyTextureToBuffer(texture, this.velocityBuffers.previous);
|
|
3435
|
+
texture.dispose();
|
|
3436
|
+
}
|
|
3437
|
+
/**
|
|
3438
|
+
* Swap position buffers (ping-pong)
|
|
3439
|
+
*/
|
|
3440
|
+
swapPositions() {
|
|
3441
|
+
if (!this.isInitialized) return;
|
|
3442
|
+
const temp = this.positionBuffers.current;
|
|
3443
|
+
this.positionBuffers.current = this.positionBuffers.previous;
|
|
3444
|
+
this.positionBuffers.previous = temp;
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* Swap velocity buffers (ping-pong)
|
|
3448
|
+
*/
|
|
3449
|
+
swapVelocities() {
|
|
3450
|
+
if (!this.isInitialized) return;
|
|
3451
|
+
const temp = this.velocityBuffers.current;
|
|
3452
|
+
this.velocityBuffers.current = this.velocityBuffers.previous;
|
|
3453
|
+
this.velocityBuffers.previous = temp;
|
|
3454
|
+
}
|
|
3455
|
+
/**
|
|
3456
|
+
* Reset positions to original state
|
|
3457
|
+
*/
|
|
3458
|
+
resetPositions() {
|
|
3459
|
+
if (!this.isInitialized) return;
|
|
3460
|
+
this.copyBufferToBuffer(this.positionBuffers.original, this.positionBuffers.current);
|
|
3461
|
+
this.copyBufferToBuffer(this.positionBuffers.original, this.positionBuffers.previous);
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Read current positions back from GPU (expensive operation)
|
|
3465
|
+
* Returns RGBA float array (x, y, z, state)
|
|
3466
|
+
*/
|
|
3467
|
+
readPositions() {
|
|
3468
|
+
if (!this.isInitialized || !this.positionBuffers.current) return new Float32Array(0);
|
|
3469
|
+
const width = this.positionBuffers.current.width;
|
|
3470
|
+
const height = this.positionBuffers.current.height;
|
|
3471
|
+
const pixels = new Float32Array(width * height * 4);
|
|
3472
|
+
this.renderer.readRenderTargetPixels(this.positionBuffers.current, 0, 0, width, height, pixels);
|
|
3473
|
+
return pixels;
|
|
3474
|
+
}
|
|
3475
|
+
getCurrentPositionTarget() {
|
|
3476
|
+
return this.positionBuffers.current;
|
|
3477
|
+
}
|
|
3478
|
+
getPreviousPositionTarget() {
|
|
3479
|
+
return this.positionBuffers.previous;
|
|
3480
|
+
}
|
|
3481
|
+
getCurrentPositionTexture() {
|
|
3482
|
+
return this.positionBuffers.current.texture;
|
|
3483
|
+
}
|
|
3484
|
+
getPreviousPositionTexture() {
|
|
3485
|
+
return this.positionBuffers.previous?.texture ?? null;
|
|
3486
|
+
}
|
|
3487
|
+
getOriginalPositionTexture() {
|
|
3488
|
+
return this.positionBuffers.original?.texture ?? null;
|
|
3489
|
+
}
|
|
3490
|
+
getCurrentVelocityTarget() {
|
|
3491
|
+
return this.velocityBuffers.current;
|
|
3492
|
+
}
|
|
3493
|
+
getPreviousVelocityTarget() {
|
|
3494
|
+
return this.velocityBuffers.previous;
|
|
3495
|
+
}
|
|
3496
|
+
getCurrentVelocityTexture() {
|
|
3497
|
+
return this.velocityBuffers.current?.texture ?? null;
|
|
3498
|
+
}
|
|
3499
|
+
getPreviousVelocityTexture() {
|
|
3500
|
+
return this.velocityBuffers.previous?.texture ?? null;
|
|
3501
|
+
}
|
|
3502
|
+
getTextureSize() {
|
|
3503
|
+
return this.textureSize;
|
|
3504
|
+
}
|
|
3505
|
+
arrayToTextureData(array, componentsPerItem) {
|
|
3506
|
+
const totalTexels = this.textureSize * this.textureSize;
|
|
3507
|
+
const data = new Float32Array(totalTexels * 4);
|
|
3508
|
+
const itemCount = array.length / componentsPerItem;
|
|
3509
|
+
for (let i = 0; i < itemCount; i++) {
|
|
3510
|
+
const texIndex = i * 4;
|
|
3511
|
+
const arrayIndex = i * componentsPerItem;
|
|
3512
|
+
data[texIndex] = array[arrayIndex] || 0;
|
|
3513
|
+
data[texIndex + 1] = array[arrayIndex + 1] || 0;
|
|
3514
|
+
data[texIndex + 2] = array[arrayIndex + 2] || 0;
|
|
3515
|
+
data[texIndex + 3] = array[arrayIndex + 3] || 0;
|
|
3516
|
+
}
|
|
3517
|
+
return data;
|
|
3518
|
+
}
|
|
3519
|
+
createDataTexture(data) {
|
|
3520
|
+
const expectedSize = this.textureSize * this.textureSize * 4;
|
|
3521
|
+
if (data.length !== expectedSize) {
|
|
3522
|
+
console.error(`[SimulationBuffers] DataTexture size mismatch!`);
|
|
3523
|
+
console.error(` Expected: ${expectedSize} (${this.textureSize}x${this.textureSize}x4)`);
|
|
3524
|
+
console.error(` Got: ${data.length}`);
|
|
3525
|
+
console.error(` Stack trace:`, (/* @__PURE__ */ new Error()).stack);
|
|
3526
|
+
const correctData = new Float32Array(expectedSize);
|
|
3527
|
+
correctData.set(data.slice(0, Math.min(data.length, expectedSize)));
|
|
3528
|
+
data = correctData;
|
|
3529
|
+
}
|
|
3530
|
+
const texture = new THREE.DataTexture(data, this.textureSize, this.textureSize, THREE.RGBAFormat, THREE.FloatType);
|
|
3531
|
+
texture.needsUpdate = true;
|
|
3532
|
+
texture.minFilter = THREE.NearestFilter;
|
|
3533
|
+
texture.magFilter = THREE.NearestFilter;
|
|
3534
|
+
texture.generateMipmaps = false;
|
|
3535
|
+
return texture;
|
|
3536
|
+
}
|
|
3537
|
+
copyTextureToBuffer(source, target) {
|
|
3538
|
+
const copyMaterial = new THREE.ShaderMaterial({
|
|
3539
|
+
uniforms: { tSource: { value: source } },
|
|
3540
|
+
vertexShader: `
|
|
3541
|
+
varying vec2 vUv;
|
|
3542
|
+
void main() {
|
|
3543
|
+
vUv = uv;
|
|
3544
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
3545
|
+
}
|
|
3546
|
+
`,
|
|
3547
|
+
fragmentShader: `
|
|
3548
|
+
uniform sampler2D tSource;
|
|
3549
|
+
varying vec2 vUv;
|
|
3550
|
+
void main() {
|
|
3551
|
+
gl_FragColor = texture2D(tSource, vUv);
|
|
3552
|
+
}
|
|
3553
|
+
`
|
|
3554
|
+
});
|
|
3555
|
+
const quad = new THREE.PlaneGeometry(2, 2);
|
|
3556
|
+
const mesh = new THREE.Mesh(quad, copyMaterial);
|
|
3557
|
+
const scene = new THREE.Scene();
|
|
3558
|
+
scene.add(mesh);
|
|
3559
|
+
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
3560
|
+
this.renderer.setRenderTarget(target);
|
|
3561
|
+
this.renderer.render(scene, camera);
|
|
3562
|
+
this.renderer.setRenderTarget(null);
|
|
3563
|
+
copyMaterial.dispose();
|
|
3564
|
+
quad.dispose();
|
|
3565
|
+
}
|
|
3566
|
+
copyBufferToBuffer(source, target) {
|
|
3567
|
+
this.copyTextureToBuffer(source.texture, target);
|
|
3568
|
+
}
|
|
3569
|
+
dispose() {
|
|
3570
|
+
this.positionBuffers.current?.dispose();
|
|
3571
|
+
this.positionBuffers.previous?.dispose();
|
|
3572
|
+
this.positionBuffers.original?.dispose();
|
|
3573
|
+
this.velocityBuffers.current?.dispose();
|
|
3574
|
+
this.velocityBuffers.previous?.dispose();
|
|
3575
|
+
this.positionBuffers = {
|
|
3576
|
+
current: null,
|
|
3577
|
+
previous: null,
|
|
3578
|
+
original: null
|
|
3579
|
+
};
|
|
3580
|
+
this.velocityBuffers = {
|
|
3581
|
+
current: null,
|
|
3582
|
+
previous: null
|
|
3583
|
+
};
|
|
3584
|
+
this.textureSize = 0;
|
|
3585
|
+
this.isInitialized = false;
|
|
3586
|
+
}
|
|
3587
|
+
};
|
|
3588
|
+
|
|
3589
|
+
//#endregion
|
|
3590
|
+
//#region core/Clock.ts
|
|
3591
|
+
/**
|
|
3592
|
+
* Clock - Unified timing for the force graph package
|
|
3593
|
+
*/
|
|
3594
|
+
var Clock = class {
|
|
3595
|
+
previousTime = 0;
|
|
3596
|
+
elapsedTime = 0;
|
|
3597
|
+
deltaTime = 0;
|
|
3598
|
+
isRunning = false;
|
|
3599
|
+
maxDeltaTime = .1;
|
|
3600
|
+
constructor() {}
|
|
3601
|
+
/**
|
|
3602
|
+
* Start the clock
|
|
3603
|
+
*/
|
|
3604
|
+
start() {
|
|
3605
|
+
if (this.isRunning) return;
|
|
3606
|
+
this.previousTime = performance.now() / 1e3;
|
|
3607
|
+
this.isRunning = true;
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Stop the clock
|
|
3611
|
+
*/
|
|
3612
|
+
stop() {
|
|
3613
|
+
this.isRunning = false;
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Update the clock - call once per frame
|
|
3617
|
+
* @returns delta time in seconds
|
|
3618
|
+
*/
|
|
3619
|
+
update() {
|
|
3620
|
+
if (!this.isRunning) this.start();
|
|
3621
|
+
const now = performance.now() / 1e3;
|
|
3622
|
+
let rawDelta = now - this.previousTime;
|
|
3623
|
+
this.previousTime = now;
|
|
3624
|
+
this.deltaTime = Math.min(rawDelta, this.maxDeltaTime);
|
|
3625
|
+
this.elapsedTime += this.deltaTime;
|
|
3626
|
+
return this.deltaTime;
|
|
3627
|
+
}
|
|
3628
|
+
/**
|
|
3629
|
+
* Get delta time in seconds
|
|
3630
|
+
*/
|
|
3631
|
+
getDeltaTime() {
|
|
3632
|
+
return this.deltaTime;
|
|
3633
|
+
}
|
|
3634
|
+
/**
|
|
3635
|
+
* Get total elapsed time in seconds
|
|
3636
|
+
*/
|
|
3637
|
+
getElapsedTime() {
|
|
3638
|
+
return this.elapsedTime;
|
|
3639
|
+
}
|
|
3640
|
+
/**
|
|
3641
|
+
* Check if clock is running
|
|
3642
|
+
*/
|
|
3643
|
+
getIsRunning() {
|
|
3644
|
+
return this.isRunning;
|
|
3645
|
+
}
|
|
3646
|
+
};
|
|
3647
|
+
|
|
3648
|
+
//#endregion
|
|
3649
|
+
//#region core/GraphStore.ts
|
|
3650
|
+
/**
|
|
3651
|
+
* GraphStore - Central data management
|
|
3652
|
+
* Responsibilities:
|
|
3653
|
+
* - Store raw graph data (nodes, links)
|
|
3654
|
+
* - Node/Link CRUD operations
|
|
3655
|
+
* - ID mapping and lookups
|
|
3656
|
+
* - Data validation
|
|
3657
|
+
* - Change notifications
|
|
3658
|
+
*/
|
|
3659
|
+
var GraphStore = class {
|
|
3660
|
+
nodes = /* @__PURE__ */ new Map();
|
|
3661
|
+
links = /* @__PURE__ */ new Map();
|
|
3662
|
+
nodeArray = [];
|
|
3663
|
+
linkArray = [];
|
|
3664
|
+
nodeIdToIndex = /* @__PURE__ */ new Map();
|
|
3665
|
+
linkIdToIndex = /* @__PURE__ */ new Map();
|
|
3666
|
+
nodeToLinks = /* @__PURE__ */ new Map();
|
|
3667
|
+
constructor() {}
|
|
3668
|
+
/**
|
|
3669
|
+
* Set graph data (replaces all)
|
|
3670
|
+
*/
|
|
3671
|
+
setData(data) {
|
|
3672
|
+
this.clear();
|
|
3673
|
+
data.nodes.forEach((node, index) => {
|
|
3674
|
+
this.nodes.set(node.id, node);
|
|
3675
|
+
this.nodeIdToIndex.set(node.id, index);
|
|
3676
|
+
this.nodeArray.push(node);
|
|
3677
|
+
});
|
|
3678
|
+
data.links.forEach((link, index) => {
|
|
3679
|
+
const linkId = this.getLinkId(link);
|
|
3680
|
+
this.links.set(linkId, link);
|
|
3681
|
+
this.linkIdToIndex.set(linkId, index);
|
|
3682
|
+
this.linkArray.push(link);
|
|
3683
|
+
this.addLinkConnectivity(link.source, linkId);
|
|
3684
|
+
this.addLinkConnectivity(link.target, linkId);
|
|
3685
|
+
});
|
|
3686
|
+
}
|
|
3687
|
+
/**
|
|
3688
|
+
* Add nodes
|
|
3689
|
+
*/
|
|
3690
|
+
addNodes(nodes) {
|
|
3691
|
+
nodes.forEach((node) => {
|
|
3692
|
+
if (!this.nodes.has(node.id)) {
|
|
3693
|
+
const index = this.nodeArray.length;
|
|
3694
|
+
this.nodes.set(node.id, node);
|
|
3695
|
+
this.nodeIdToIndex.set(node.id, index);
|
|
3696
|
+
this.nodeArray.push(node);
|
|
3697
|
+
}
|
|
3698
|
+
});
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Remove nodes (and connected links)
|
|
3702
|
+
*/
|
|
3703
|
+
removeNodes(nodeIds) {
|
|
3704
|
+
nodeIds.forEach((id) => {
|
|
3705
|
+
const connectedLinks = this.nodeToLinks.get(id);
|
|
3706
|
+
if (connectedLinks) this.removeLinks(Array.from(connectedLinks));
|
|
3707
|
+
this.nodes.delete(id);
|
|
3708
|
+
this.nodeIdToIndex.delete(id);
|
|
3709
|
+
this.nodeToLinks.delete(id);
|
|
3710
|
+
});
|
|
3711
|
+
this.rebuildNodeArrays();
|
|
3712
|
+
}
|
|
3713
|
+
/**
|
|
3714
|
+
* Add links
|
|
3715
|
+
*/
|
|
3716
|
+
addLinks(links) {
|
|
3717
|
+
links.forEach((link) => {
|
|
3718
|
+
const linkId = this.getLinkId(link);
|
|
3719
|
+
if (!this.links.has(linkId)) {
|
|
3720
|
+
const index = this.linkArray.length;
|
|
3721
|
+
this.links.set(linkId, link);
|
|
3722
|
+
this.linkIdToIndex.set(linkId, index);
|
|
3723
|
+
this.linkArray.push(link);
|
|
3724
|
+
this.addLinkConnectivity(link.source, linkId);
|
|
3725
|
+
this.addLinkConnectivity(link.target, linkId);
|
|
3726
|
+
}
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3729
|
+
/**
|
|
3730
|
+
* Remove links
|
|
3731
|
+
*/
|
|
3732
|
+
removeLinks(linkIds) {
|
|
3733
|
+
linkIds.forEach((id) => {
|
|
3734
|
+
const link = this.links.get(id);
|
|
3735
|
+
if (link) {
|
|
3736
|
+
this.removeLinkConnectivity(link.source, id);
|
|
3737
|
+
this.removeLinkConnectivity(link.target, id);
|
|
3738
|
+
this.links.delete(id);
|
|
3739
|
+
this.linkIdToIndex.delete(id);
|
|
3740
|
+
}
|
|
3741
|
+
});
|
|
3742
|
+
this.rebuildLinkArrays();
|
|
3743
|
+
}
|
|
3744
|
+
/**
|
|
3745
|
+
* Get all nodes as array
|
|
3746
|
+
*/
|
|
3747
|
+
getNodes() {
|
|
3748
|
+
return this.nodeArray;
|
|
3749
|
+
}
|
|
3750
|
+
/**
|
|
3751
|
+
* Get all links as array
|
|
3752
|
+
*/
|
|
3753
|
+
getLinks() {
|
|
3754
|
+
return this.linkArray;
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* Get connected node IDs for a given node
|
|
3758
|
+
*/
|
|
3759
|
+
getConnectedNodeIds(nodeId) {
|
|
3760
|
+
const connectedLinks = this.nodeToLinks.get(nodeId);
|
|
3761
|
+
if (!connectedLinks) return [];
|
|
3762
|
+
const connectedNodes = /* @__PURE__ */ new Set();
|
|
3763
|
+
connectedLinks.forEach((linkId) => {
|
|
3764
|
+
const link = this.links.get(linkId);
|
|
3765
|
+
if (link) {
|
|
3766
|
+
connectedNodes.add(link.source);
|
|
3767
|
+
connectedNodes.add(link.target);
|
|
3768
|
+
}
|
|
3769
|
+
});
|
|
3770
|
+
connectedNodes.delete(nodeId);
|
|
3771
|
+
return Array.from(connectedNodes);
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* Get node by ID
|
|
3775
|
+
*/
|
|
3776
|
+
getNodeById(id) {
|
|
3777
|
+
return this.nodes.get(id) ?? null;
|
|
3778
|
+
}
|
|
3779
|
+
/**
|
|
3780
|
+
* Get node by index
|
|
3781
|
+
*/
|
|
3782
|
+
getNodeByIndex(index) {
|
|
3783
|
+
return this.nodeArray[index] ?? null;
|
|
3784
|
+
}
|
|
3785
|
+
/**
|
|
3786
|
+
* Get node index
|
|
3787
|
+
*/
|
|
3788
|
+
getNodeIndex(id) {
|
|
3789
|
+
return this.nodeIdToIndex.get(id) ?? -1;
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* Get link by ID
|
|
3793
|
+
*/
|
|
3794
|
+
getLinkById(id) {
|
|
3795
|
+
return this.links.get(id) ?? null;
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* Get links connected to node
|
|
3799
|
+
*/
|
|
3800
|
+
getNodeLinks(nodeId) {
|
|
3801
|
+
const linkIds = this.nodeToLinks.get(nodeId);
|
|
3802
|
+
if (!linkIds) return [];
|
|
3803
|
+
return Array.from(linkIds).map((id) => this.links.get(id)).filter((link) => link !== void 0);
|
|
3804
|
+
}
|
|
3805
|
+
/**
|
|
3806
|
+
* Get node count
|
|
3807
|
+
*/
|
|
3808
|
+
getNodeCount() {
|
|
3809
|
+
return this.nodeArray.length;
|
|
3810
|
+
}
|
|
3811
|
+
/**
|
|
3812
|
+
* Get link count
|
|
3813
|
+
*/
|
|
3814
|
+
getLinkCount() {
|
|
3815
|
+
return this.linkArray.length;
|
|
3816
|
+
}
|
|
3817
|
+
/**
|
|
3818
|
+
* Clear all data
|
|
3819
|
+
*/
|
|
3820
|
+
clear() {
|
|
3821
|
+
this.nodes.clear();
|
|
3822
|
+
this.links.clear();
|
|
3823
|
+
this.nodeArray = [];
|
|
3824
|
+
this.linkArray = [];
|
|
3825
|
+
this.nodeIdToIndex.clear();
|
|
3826
|
+
this.linkIdToIndex.clear();
|
|
3827
|
+
this.nodeToLinks.clear();
|
|
3828
|
+
}
|
|
3829
|
+
getLinkId(link) {
|
|
3830
|
+
return `${link.source}-${link.target}`;
|
|
3831
|
+
}
|
|
3832
|
+
addLinkConnectivity(nodeId, linkId) {
|
|
3833
|
+
if (!this.nodeToLinks.has(nodeId)) this.nodeToLinks.set(nodeId, /* @__PURE__ */ new Set());
|
|
3834
|
+
this.nodeToLinks.get(nodeId).add(linkId);
|
|
3835
|
+
}
|
|
3836
|
+
removeLinkConnectivity(nodeId, linkId) {
|
|
3837
|
+
const links = this.nodeToLinks.get(nodeId);
|
|
3838
|
+
if (links) {
|
|
3839
|
+
links.delete(linkId);
|
|
3840
|
+
if (links.size === 0) this.nodeToLinks.delete(nodeId);
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
rebuildNodeArrays() {
|
|
3844
|
+
this.nodeArray = Array.from(this.nodes.values());
|
|
3845
|
+
this.nodeIdToIndex.clear();
|
|
3846
|
+
this.nodeArray.forEach((node, index) => {
|
|
3847
|
+
this.nodeIdToIndex.set(node.id, index);
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
rebuildLinkArrays() {
|
|
3851
|
+
this.linkArray = Array.from(this.links.values());
|
|
3852
|
+
this.linkIdToIndex.clear();
|
|
3853
|
+
this.linkArray.forEach((link, index) => {
|
|
3854
|
+
const linkId = this.getLinkId(link);
|
|
3855
|
+
this.linkIdToIndex.set(linkId, index);
|
|
3856
|
+
});
|
|
3857
|
+
}
|
|
3858
|
+
};
|
|
3859
|
+
|
|
3860
|
+
//#endregion
|
|
3861
|
+
//#region core/Engine.ts
|
|
3862
|
+
CameraControls.install({ THREE });
|
|
3863
|
+
/**
|
|
3864
|
+
* Engine - Main orchestrator
|
|
3865
|
+
* Responsibilities:
|
|
3866
|
+
* - Owns all shared buffers (StaticAssets, SimulationBuffers, PickBuffer)
|
|
3867
|
+
* - Coordinates GraphStore, GraphScene, ForceSimulation
|
|
3868
|
+
* - Manages render loop
|
|
3869
|
+
* - Handles user interaction
|
|
3870
|
+
*/
|
|
3871
|
+
var Engine = class {
|
|
3872
|
+
graphStore;
|
|
3873
|
+
graphScene;
|
|
3874
|
+
forceSimulation;
|
|
3875
|
+
interactionManager;
|
|
3876
|
+
clock;
|
|
3877
|
+
simulationBuffers;
|
|
3878
|
+
pickBuffer;
|
|
3879
|
+
animationFrameId = null;
|
|
3880
|
+
isRunning = false;
|
|
3881
|
+
boundResizeHandler = null;
|
|
3882
|
+
groupOrder;
|
|
3883
|
+
constructor(canvas, options = {}) {
|
|
3884
|
+
this.canvas = canvas;
|
|
3885
|
+
this.groupOrder = options.groupOrder;
|
|
3886
|
+
const width = options.width ?? window.innerWidth;
|
|
3887
|
+
const height = options.height ?? window.innerHeight;
|
|
3888
|
+
this.clock = new Clock();
|
|
3889
|
+
this.graphStore = new GraphStore();
|
|
3890
|
+
this.graphScene = new GraphScene(canvas, options.cameraConfig);
|
|
3891
|
+
this.graphScene.resize(width, height);
|
|
3892
|
+
this.simulationBuffers = new SimulationBuffers(this.graphScene.renderer);
|
|
3893
|
+
this.pickBuffer = new PickBuffer(this.graphScene.renderer);
|
|
3894
|
+
this.pickBuffer.resize(width, height);
|
|
3895
|
+
this.forceSimulation = new ForceSimulation(this.graphScene.renderer);
|
|
3896
|
+
this.interactionManager = new InteractionManager((x, y) => this.graphScene.pick(x, y), this.graphScene.renderer.domElement, {
|
|
3897
|
+
width,
|
|
3898
|
+
height
|
|
3899
|
+
}, (index) => this.graphStore.getNodeByIndex(index), this.graphScene.camera, this.forceSimulation, options.tooltipConfig, this.graphScene, (nodeId) => this.graphStore.getConnectedNodeIds(nodeId));
|
|
3900
|
+
this.boundResizeHandler = this.handleWindowResize.bind(this);
|
|
3901
|
+
window.addEventListener("resize", this.boundResizeHandler);
|
|
3902
|
+
}
|
|
3903
|
+
/**
|
|
3904
|
+
* Handle window resize event
|
|
3905
|
+
*/
|
|
3906
|
+
handleWindowResize() {
|
|
3907
|
+
const width = window.innerWidth;
|
|
3908
|
+
const height = window.innerHeight;
|
|
3909
|
+
this.resize(width, height);
|
|
3910
|
+
}
|
|
3911
|
+
/**
|
|
3912
|
+
* Set graph data
|
|
3913
|
+
*/
|
|
3914
|
+
setData(data) {
|
|
3915
|
+
this.graphStore.setData(data);
|
|
3916
|
+
const nodes = this.graphStore.getNodes();
|
|
3917
|
+
const links = this.graphStore.getLinks();
|
|
3918
|
+
const startPositions = new Float32Array(nodes.length * 4);
|
|
3919
|
+
const originalPositions = new Float32Array(nodes.length * 4);
|
|
3920
|
+
nodes.forEach((node, i) => {
|
|
3921
|
+
let state = node.state ?? 3;
|
|
3922
|
+
startPositions[i * 4] = node.x ?? node.fx ?? 0;
|
|
3923
|
+
startPositions[i * 4 + 1] = node.y ?? node.fy ?? 0;
|
|
3924
|
+
startPositions[i * 4 + 2] = node.z ?? node.fz ?? 0;
|
|
3925
|
+
startPositions[i * 4 + 3] = state;
|
|
3926
|
+
originalPositions[i * 4] = node.fx ?? node.x ?? 0;
|
|
3927
|
+
originalPositions[i * 4 + 1] = node.fy ?? node.y ?? 0;
|
|
3928
|
+
originalPositions[i * 4 + 2] = node.fz ?? node.z ?? 0;
|
|
3929
|
+
originalPositions[i * 4 + 3] = state;
|
|
3930
|
+
});
|
|
3931
|
+
this.simulationBuffers.init(nodes.length, startPositions);
|
|
3932
|
+
this.simulationBuffers.setOriginalPositions(originalPositions);
|
|
3933
|
+
this.graphScene.create(nodes, links, this.simulationBuffers, this.pickBuffer, this.groupOrder);
|
|
3934
|
+
this.forceSimulation.initialize(this.simulationBuffers);
|
|
3935
|
+
}
|
|
3936
|
+
/**
|
|
3937
|
+
* Update node states based on a callback
|
|
3938
|
+
* This allows changing visibility/behavior without resetting the simulation
|
|
3939
|
+
*/
|
|
3940
|
+
updateNodeStates(callback) {
|
|
3941
|
+
if (!this.simulationBuffers.isReady()) return;
|
|
3942
|
+
const nodes = this.graphStore.getNodes();
|
|
3943
|
+
const currentPositions = this.simulationBuffers.readPositions();
|
|
3944
|
+
const newCurrentPositions = new Float32Array(nodes.length * 4);
|
|
3945
|
+
const newOriginalPositions = new Float32Array(nodes.length * 4);
|
|
3946
|
+
nodes.forEach((node, i) => {
|
|
3947
|
+
const newState = callback(node);
|
|
3948
|
+
node.state = newState;
|
|
3949
|
+
newCurrentPositions[i * 4] = currentPositions[i * 4];
|
|
3950
|
+
newCurrentPositions[i * 4 + 1] = currentPositions[i * 4 + 1];
|
|
3951
|
+
newCurrentPositions[i * 4 + 2] = currentPositions[i * 4 + 2];
|
|
3952
|
+
newCurrentPositions[i * 4 + 3] = newState;
|
|
3953
|
+
newOriginalPositions[i * 4] = node.fx ?? node.x ?? 0;
|
|
3954
|
+
newOriginalPositions[i * 4 + 1] = node.fy ?? node.y ?? 0;
|
|
3955
|
+
newOriginalPositions[i * 4 + 2] = node.fz ?? node.z ?? 0;
|
|
3956
|
+
newOriginalPositions[i * 4 + 3] = newState;
|
|
3957
|
+
});
|
|
3958
|
+
this.simulationBuffers.updateCurrentPositions(newCurrentPositions);
|
|
3959
|
+
this.simulationBuffers.setOriginalPositions(newOriginalPositions);
|
|
3960
|
+
}
|
|
3961
|
+
/**
|
|
3962
|
+
* Reheat the simulation
|
|
3963
|
+
*/
|
|
3964
|
+
reheat(alpha = 1) {
|
|
3965
|
+
this.forceSimulation.reheat(alpha);
|
|
3966
|
+
}
|
|
3967
|
+
/**
|
|
3968
|
+
* Set alpha decay
|
|
3969
|
+
*/
|
|
3970
|
+
setAlphaDecay(decay) {
|
|
3971
|
+
this.forceSimulation.config.alphaDecay = decay;
|
|
3972
|
+
}
|
|
3973
|
+
/**
|
|
3974
|
+
* Get current alpha
|
|
3975
|
+
*/
|
|
3976
|
+
getAlpha() {
|
|
3977
|
+
return this.forceSimulation.getAlpha();
|
|
3978
|
+
}
|
|
3979
|
+
/**Apply a preset to the graph
|
|
3980
|
+
*/
|
|
3981
|
+
applyPreset(preset) {
|
|
3982
|
+
if (preset.force) {
|
|
3983
|
+
Object.assign(this.forceSimulation.config, preset.force);
|
|
3984
|
+
this.forceSimulation.reheat();
|
|
3985
|
+
}
|
|
3986
|
+
if (preset.camera) {
|
|
3987
|
+
const { position, target, zoom, transitionDuration, mode, minDistance, maxDistance, boundary } = preset.camera;
|
|
3988
|
+
if (mode) this.graphScene.camera.setMode(mode);
|
|
3989
|
+
if (transitionDuration !== void 0) this.graphScene.camera.configureControls({ smoothTime: transitionDuration });
|
|
3990
|
+
if (minDistance !== void 0 || maxDistance !== void 0) this.graphScene.camera.configureControls({
|
|
3991
|
+
minDistance,
|
|
3992
|
+
maxDistance
|
|
3993
|
+
});
|
|
3994
|
+
if (boundary) {
|
|
3995
|
+
const box = new THREE.Box3(new THREE.Vector3(boundary.min.x, boundary.min.y, boundary.min.z), new THREE.Vector3(boundary.max.x, boundary.max.y, boundary.max.z));
|
|
3996
|
+
this.graphScene.camera.setBoundary(box);
|
|
3997
|
+
} else if (boundary === null) this.graphScene.camera.setBoundary(null);
|
|
3998
|
+
if (position && target) {
|
|
3999
|
+
const posVec = new THREE.Vector3(position.x, position.y, position.z);
|
|
4000
|
+
const targetVec = new THREE.Vector3(target.x, target.y, target.z);
|
|
4001
|
+
this.graphScene.camera.setLookAt(posVec, targetVec, true);
|
|
4002
|
+
} else {
|
|
4003
|
+
if (position) this.graphScene.camera.controls.setPosition(position.x, position.y, position.z, true);
|
|
4004
|
+
if (target) this.graphScene.camera.controls.setTarget(target.x, target.y, target.z, true);
|
|
4005
|
+
}
|
|
4006
|
+
if (zoom !== void 0) this.graphScene.camera.controls.zoomTo(zoom, true);
|
|
4007
|
+
}
|
|
4008
|
+
if (preset.nodes) {
|
|
4009
|
+
if (preset.nodes.state) {
|
|
4010
|
+
this.updateNodeStates(preset.nodes.state);
|
|
4011
|
+
this.forceSimulation.reheat();
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
if (preset.links) {
|
|
4015
|
+
const linkRenderer = this.graphScene.getLinkRenderer();
|
|
4016
|
+
if (linkRenderer) linkRenderer.setOptions({ alpha: { default: preset.links.opacity } });
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
/**
|
|
4020
|
+
*
|
|
4021
|
+
* Start render loop
|
|
4022
|
+
*/
|
|
4023
|
+
start() {
|
|
4024
|
+
if (this.isRunning) return;
|
|
4025
|
+
this.isRunning = true;
|
|
4026
|
+
this.clock.start();
|
|
4027
|
+
this.animate();
|
|
4028
|
+
}
|
|
4029
|
+
/**
|
|
4030
|
+
* Stop render loop
|
|
4031
|
+
*/
|
|
4032
|
+
stop() {
|
|
4033
|
+
this.isRunning = false;
|
|
4034
|
+
this.clock.stop();
|
|
4035
|
+
if (this.animationFrameId !== null) {
|
|
4036
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
4037
|
+
this.animationFrameId = null;
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
/**
|
|
4041
|
+
* Render loop
|
|
4042
|
+
*/
|
|
4043
|
+
animate = () => {
|
|
4044
|
+
if (!this.isRunning) return;
|
|
4045
|
+
const deltaTime = this.clock.update();
|
|
4046
|
+
const elapsedTime = this.clock.getElapsedTime();
|
|
4047
|
+
this.forceSimulation.step(deltaTime);
|
|
4048
|
+
const positionTexture = this.simulationBuffers.getCurrentPositionTexture();
|
|
4049
|
+
this.graphScene.updatePositions(positionTexture);
|
|
4050
|
+
this.graphScene.update(elapsedTime);
|
|
4051
|
+
this.interactionManager.getPointerInput().update();
|
|
4052
|
+
this.graphScene.render();
|
|
4053
|
+
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
4054
|
+
};
|
|
4055
|
+
/**
|
|
4056
|
+
* GPU pick node at canvas coordinates
|
|
4057
|
+
*/
|
|
4058
|
+
pickNode(x, y) {
|
|
4059
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
4060
|
+
if (!nodeRenderer) return null;
|
|
4061
|
+
const camera = this.graphScene.getCamera();
|
|
4062
|
+
const nodeIndex = nodeRenderer.pick(x, y, camera.camera);
|
|
4063
|
+
if (nodeIndex < 0) return null;
|
|
4064
|
+
return this.graphStore.getNodeByIndex(nodeIndex)?.id ?? null;
|
|
4065
|
+
}
|
|
4066
|
+
/**
|
|
4067
|
+
* Get node by ID
|
|
4068
|
+
*/
|
|
4069
|
+
getNodeById(id) {
|
|
4070
|
+
return this.graphStore.getNodeById(id);
|
|
4071
|
+
}
|
|
4072
|
+
/**
|
|
4073
|
+
* Highlight nodes
|
|
4074
|
+
*/
|
|
4075
|
+
highlightNodes(nodeIds) {
|
|
4076
|
+
this.interactionManager.searchHighlightIds = nodeIds;
|
|
4077
|
+
this.graphScene.getNodeRenderer()?.highlight(nodeIds);
|
|
4078
|
+
}
|
|
4079
|
+
/**
|
|
4080
|
+
* Clear highlights
|
|
4081
|
+
*/
|
|
4082
|
+
clearHighlights() {
|
|
4083
|
+
this.interactionManager.searchHighlightIds = [];
|
|
4084
|
+
if (this.interactionManager.isTooltipSticky && this.interactionManager.stickyNodeId) {
|
|
4085
|
+
const node = this.graphStore.getNodeById(this.interactionManager.stickyNodeId);
|
|
4086
|
+
if (node) {
|
|
4087
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
4088
|
+
const connectedIds = this.graphStore.getConnectedNodeIds(node.id);
|
|
4089
|
+
connectedIds.push(node.id);
|
|
4090
|
+
nodeRenderer?.highlight(connectedIds);
|
|
4091
|
+
return;
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
this.graphScene.getNodeRenderer()?.clearHighlights();
|
|
4095
|
+
}
|
|
4096
|
+
/**
|
|
4097
|
+
* Get node position in 3D space
|
|
4098
|
+
*/
|
|
4099
|
+
getNodePosition(nodeId) {
|
|
4100
|
+
if (!this.graphStore.getNodeById(nodeId)) return null;
|
|
4101
|
+
const index = this.graphStore.getNodes().findIndex((n) => n.id === nodeId);
|
|
4102
|
+
if (index === -1) return null;
|
|
4103
|
+
const positions = this.simulationBuffers.readPositions();
|
|
4104
|
+
return new THREE.Vector3(positions[index * 4], positions[index * 4 + 1], positions[index * 4 + 2]);
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* Programmatically select a node (highlight + tooltip)
|
|
4108
|
+
*/
|
|
4109
|
+
selectNode(nodeId) {
|
|
4110
|
+
const node = this.graphStore.getNodeById(nodeId);
|
|
4111
|
+
if (!node) return;
|
|
4112
|
+
this.interactionManager.isTooltipSticky = true;
|
|
4113
|
+
this.interactionManager.stickyNodeId = nodeId;
|
|
4114
|
+
this.graphScene.getLinkRenderer()?.highlightConnectedLinks(node.id);
|
|
4115
|
+
const nodeRenderer = this.graphScene.getNodeRenderer();
|
|
4116
|
+
const connectedIds = this.graphStore.getConnectedNodeIds(node.id);
|
|
4117
|
+
connectedIds.push(node.id);
|
|
4118
|
+
nodeRenderer?.highlight(connectedIds);
|
|
4119
|
+
const pos = this.getNodePosition(nodeId);
|
|
4120
|
+
if (pos) {
|
|
4121
|
+
const screenPos = pos.clone().project(this.graphScene.camera.camera);
|
|
4122
|
+
const size = new THREE.Vector2();
|
|
4123
|
+
this.graphScene.renderer.getSize(size);
|
|
4124
|
+
const rect = this.graphScene.renderer.domElement.getBoundingClientRect();
|
|
4125
|
+
const x = (screenPos.x + 1) / 2 * size.x + rect.left;
|
|
4126
|
+
const y = -(screenPos.y - 1) / 2 * size.y + rect.top;
|
|
4127
|
+
this.interactionManager.tooltipManager.showFull(node, x, y);
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
/**
|
|
4131
|
+
* Resize canvas and all dependent components
|
|
4132
|
+
*/
|
|
4133
|
+
resize(width, height) {
|
|
4134
|
+
this.graphScene.resize(width, height);
|
|
4135
|
+
this.pickBuffer.resize(width, height);
|
|
4136
|
+
this.interactionManager.resize(width, height);
|
|
4137
|
+
}
|
|
4138
|
+
/**
|
|
4139
|
+
* Get the unified clock for timing information
|
|
4140
|
+
*/
|
|
4141
|
+
getClock() {
|
|
4142
|
+
return this.clock;
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* Get all nodes
|
|
4146
|
+
*/
|
|
4147
|
+
get nodes() {
|
|
4148
|
+
return this.graphStore.getNodes();
|
|
4149
|
+
}
|
|
4150
|
+
/**
|
|
4151
|
+
* Get all links
|
|
4152
|
+
*/
|
|
4153
|
+
get links() {
|
|
4154
|
+
return this.graphStore.getLinks();
|
|
4155
|
+
}
|
|
4156
|
+
/**
|
|
4157
|
+
* Get force simulation for external configuration (GUI binding)
|
|
4158
|
+
*/
|
|
4159
|
+
get simulation() {
|
|
4160
|
+
return this.forceSimulation;
|
|
4161
|
+
}
|
|
4162
|
+
/**
|
|
4163
|
+
* Get camera controller for external configuration
|
|
4164
|
+
*/
|
|
4165
|
+
get camera() {
|
|
4166
|
+
return this.graphScene.camera;
|
|
4167
|
+
}
|
|
4168
|
+
/**
|
|
4169
|
+
* Set camera mode (Orbit, Map, Fly)
|
|
4170
|
+
*/
|
|
4171
|
+
setCameraMode(mode) {
|
|
4172
|
+
this.graphScene.camera.setMode(mode);
|
|
4173
|
+
}
|
|
4174
|
+
/**
|
|
4175
|
+
* Animate camera to look at a specific target from a specific position
|
|
4176
|
+
*/
|
|
4177
|
+
async setCameraLookAt(position, target, transitionDuration) {
|
|
4178
|
+
if (transitionDuration !== void 0) this.graphScene.camera.configureControls({ smoothTime: transitionDuration });
|
|
4179
|
+
const posVec = new THREE.Vector3(position.x, position.y, position.z);
|
|
4180
|
+
const targetVec = new THREE.Vector3(target.x, target.y, target.z);
|
|
4181
|
+
await this.graphScene.camera.setLookAt(posVec, targetVec, true);
|
|
4182
|
+
}
|
|
4183
|
+
/**
|
|
4184
|
+
* Focus camera on a specific target node
|
|
4185
|
+
*/
|
|
4186
|
+
async setCameraFocus(target, distance, transitionDuration) {
|
|
4187
|
+
const camPos = this.graphScene.camera.camera.position;
|
|
4188
|
+
const targetVec = new THREE.Vector3(target.x, target.y, target.z);
|
|
4189
|
+
const direction = new THREE.Vector3().subVectors(camPos, targetVec).normalize();
|
|
4190
|
+
if (direction.lengthSq() < .001) direction.set(0, 0, 1);
|
|
4191
|
+
const newPos = targetVec.clone().add(direction.multiplyScalar(distance));
|
|
4192
|
+
await this.setCameraLookAt(newPos, targetVec, transitionDuration);
|
|
4193
|
+
}
|
|
4194
|
+
/**
|
|
4195
|
+
* Cleanup
|
|
4196
|
+
*/
|
|
4197
|
+
dispose() {
|
|
4198
|
+
this.stop();
|
|
4199
|
+
this.graphScene.dispose();
|
|
4200
|
+
this.interactionManager.dispose();
|
|
4201
|
+
staticAssets.dispose();
|
|
4202
|
+
this.simulationBuffers.dispose();
|
|
4203
|
+
this.pickBuffer.dispose();
|
|
4204
|
+
this.graphStore.clear();
|
|
4205
|
+
}
|
|
4206
|
+
calculateTextureSize(nodeCount) {
|
|
4207
|
+
const size = Math.ceil(Math.sqrt(nodeCount));
|
|
4208
|
+
return 2 ** Math.ceil(Math.log2(size));
|
|
4209
|
+
}
|
|
4210
|
+
};
|
|
4211
|
+
|
|
4212
|
+
//#endregion
|
|
4213
|
+
export { CameraMode, Clock, Engine, ForceSimulation, GraphScene, GraphStore, NodeState, PickBuffer, SimulationBuffers, StaticAssets, staticAssets, styleRegistry };
|