@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/README.md
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# Fuse Three Force Graph
|
|
2
|
+
|
|
3
|
+
A high-performance GPU-accelerated force-directed graph visualization library built with Three.js. Features a modular pass-based architecture for flexible and extensible force simulations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **GPU-Accelerated**: All force calculations run on the GPU using WebGL compute shaders
|
|
8
|
+
- **Modular Pass Architecture**: Flexible system for composing and customizing force behaviors
|
|
9
|
+
- **Ping-Pong Rendering**: Efficient double-buffering for position and velocity updates
|
|
10
|
+
- **Interactive Controls**: Built-in camera controls, node dragging, and hover interactions
|
|
11
|
+
- **Extensible**: Easy to add custom force passes and visual effects
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
### Core Components
|
|
16
|
+
|
|
17
|
+
#### Engine (`core/Engine.ts`)
|
|
18
|
+
The main orchestrator that owns and coordinates all components:
|
|
19
|
+
- Manages shared GPU buffers (SimulationBuffers, StaticAssets, PickBuffer)
|
|
20
|
+
- Coordinates GraphStore, GraphScene, and ForceSimulation
|
|
21
|
+
- Handles the render loop and user interactions
|
|
22
|
+
|
|
23
|
+
#### SimulationBuffers (`textures/SimulationBuffers.ts`)
|
|
24
|
+
Manages dynamic render targets updated by force simulation:
|
|
25
|
+
- Position buffers (current, previous, original) for ping-pong rendering
|
|
26
|
+
- Velocity buffers for force accumulation
|
|
27
|
+
- Automatically sizes textures based on node count
|
|
28
|
+
|
|
29
|
+
#### StaticAssets (`textures/StaticAssets.ts`)
|
|
30
|
+
Manages read-only GPU textures:
|
|
31
|
+
- Node radii and colors
|
|
32
|
+
- Link indices and properties
|
|
33
|
+
- Created once at initialization, updated only on mode changes
|
|
34
|
+
|
|
35
|
+
#### GraphScene (`rendering/GraphScene.ts`)
|
|
36
|
+
Manages the 3D scene and visual rendering:
|
|
37
|
+
- Node and link renderers
|
|
38
|
+
- Camera controls
|
|
39
|
+
- Visual mode application
|
|
40
|
+
|
|
41
|
+
### Force Simulation
|
|
42
|
+
|
|
43
|
+
The simulation uses a **pass-based architecture** where each force type is implemented as an independent pass:
|
|
44
|
+
|
|
45
|
+
#### BasePass (`simulation/BasePass.ts`)
|
|
46
|
+
Abstract base class for all force passes. Provides:
|
|
47
|
+
- Material management
|
|
48
|
+
- Uniform updates
|
|
49
|
+
- Enable/disable control
|
|
50
|
+
- Render execution
|
|
51
|
+
|
|
52
|
+
#### Built-in Force Passes
|
|
53
|
+
|
|
54
|
+
Located in `simulation/passes/`:
|
|
55
|
+
|
|
56
|
+
1. **VelocityCarryPass** - Applies damping to previous velocity
|
|
57
|
+
2. **CollisionPass** - Prevents node overlap
|
|
58
|
+
3. **ManyBodyPass** - Charge repulsion between all nodes
|
|
59
|
+
4. **GravityPass** - Pulls nodes toward center
|
|
60
|
+
5. **LinkPass** - Spring forces between connected nodes
|
|
61
|
+
6. **EmbeddingsPass** - Elastic pull toward original positions
|
|
62
|
+
7. **DragPass** - Interactive node dragging
|
|
63
|
+
8. **IntegratePass** - Updates positions from velocities
|
|
64
|
+
|
|
65
|
+
#### ForceSimulation (`simulation/ForceSimulation.ts`)
|
|
66
|
+
Manages and executes force passes:
|
|
67
|
+
```typescript
|
|
68
|
+
// Add custom force pass
|
|
69
|
+
simulation.addPass('myForce', new MyCustomPass(config))
|
|
70
|
+
|
|
71
|
+
// Remove a pass
|
|
72
|
+
simulation.removePass('collision')
|
|
73
|
+
|
|
74
|
+
// Enable/disable a pass
|
|
75
|
+
simulation.setPassEnabled('gravity', false)
|
|
76
|
+
|
|
77
|
+
// Get a pass for configuration
|
|
78
|
+
const gravityPass = simulation.getPass('gravity')
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Execution Pipeline
|
|
82
|
+
|
|
83
|
+
Each simulation step follows this sequence:
|
|
84
|
+
|
|
85
|
+
1. **Velocity Carry** - Initialize velocity buffer with damped previous velocity
|
|
86
|
+
2. **Force Accumulation** - Each enabled pass accumulates forces into velocity
|
|
87
|
+
- Read current velocity
|
|
88
|
+
- Compute force contribution
|
|
89
|
+
- Write to previous velocity buffer
|
|
90
|
+
- Swap buffers (ping-pong)
|
|
91
|
+
3. **Integration** - Update positions using accumulated velocities
|
|
92
|
+
4. **Alpha Decay** - Reduce simulation heat over time
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
### Basic Setup
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { Engine } from './core/Engine'
|
|
100
|
+
|
|
101
|
+
// Create engine with a canvas element
|
|
102
|
+
const engine = new Engine(canvas, {
|
|
103
|
+
width: window.innerWidth,
|
|
104
|
+
height: window.innerHeight,
|
|
105
|
+
backgroundColor: '#000000'
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Load graph data
|
|
109
|
+
const graphData = {
|
|
110
|
+
nodes: [
|
|
111
|
+
{ id: '1', x: 0, y: 0, z: 0 },
|
|
112
|
+
{ id: '2', x: 10, y: 10, z: 0 }
|
|
113
|
+
],
|
|
114
|
+
links: [
|
|
115
|
+
{ source: '1', target: '2' }
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
engine.setData(graphData)
|
|
120
|
+
|
|
121
|
+
// Start simulation and rendering
|
|
122
|
+
engine.start()
|
|
123
|
+
|
|
124
|
+
GRAPH.styleRegistry.setNodeStyles({
|
|
125
|
+
'root': { color: 0xE53E3E, size: 55 },
|
|
126
|
+
'series': { color: 0x38A169, size: 33 },
|
|
127
|
+
'artwork': { color: 0x3182CE, size: 22 },
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Get simulation config - direct access, changes take effect immediately
|
|
131
|
+
const simulation = engine.getSimulation()
|
|
132
|
+
const config = simulation.config
|
|
133
|
+
|
|
134
|
+
// Create Tweakpane
|
|
135
|
+
pane = new Pane()
|
|
136
|
+
|
|
137
|
+
// Bind simulation parameters - changes to config work directly, no sync needed
|
|
138
|
+
const simFolder = pane.addFolder({ title: 'Simulation' })
|
|
139
|
+
simFolder.addBinding(config, 'alpha', { min: 0, max: 1 })
|
|
140
|
+
simFolder.addBinding(config, 'alphaDecay', { min: 0, max: 0.1 })
|
|
141
|
+
simFolder.addBinding(config, 'damping', { min: 0, max: 1 })
|
|
142
|
+
|
|
143
|
+
// Many-body force, check uniforms that can be added in binding...
|
|
144
|
+
const manyBodyFolder = pane.addFolder({ title: 'Many-Body Force' })
|
|
145
|
+
manyBodyFolder.addBinding(config, 'enableManyBody')
|
|
146
|
+
manyBodyFolder.addBinding(config, 'manyBodyStrength', { min: 0, max: 100 })
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
// Set up attractors - each pulls specific categories
|
|
150
|
+
simulation.setAttractors([
|
|
151
|
+
{
|
|
152
|
+
id: 'center',
|
|
153
|
+
position: { x: 0, y: 0.0, z: 0 },
|
|
154
|
+
categories: ['root'],
|
|
155
|
+
strength: 55.
|
|
156
|
+
},])
|
|
157
|
+
|
|
158
|
+
// Adjust global attractor strength
|
|
159
|
+
simulation.config.attractorStrength = 0.03
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Generating Random Data
|
|
165
|
+
|
|
166
|
+
For testing and prototyping, you can generate random graph data:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// Utility function to create random nodes and links
|
|
170
|
+
const createRandomData = (nodeCount: number = 10, linkCount: number = 15) => {
|
|
171
|
+
const groups = ['root', 'series', 'artwork', 'character', 'location']
|
|
172
|
+
|
|
173
|
+
// Generate random nodes
|
|
174
|
+
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
|
|
175
|
+
id: (i + 1).toString(),
|
|
176
|
+
group: groups[Math.floor(Math.random() * groups.length)],
|
|
177
|
+
x: (Math.random() - 0.5) * 2,
|
|
178
|
+
y: (Math.random() - 0.5) * 2,
|
|
179
|
+
z: (Math.random() - 0.5) * 2
|
|
180
|
+
}))
|
|
181
|
+
|
|
182
|
+
// Generate random links
|
|
183
|
+
const links = Array.from({ length: linkCount }, () => {
|
|
184
|
+
const sourceId = Math.floor(Math.random() * nodeCount) + 1
|
|
185
|
+
let targetId = Math.floor(Math.random() * nodeCount) + 1
|
|
186
|
+
|
|
187
|
+
// Ensure source and target are different
|
|
188
|
+
while (targetId === sourceId) {
|
|
189
|
+
targetId = Math.floor(Math.random() * nodeCount) + 1
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
source: sourceId.toString(),
|
|
194
|
+
target: targetId.toString()
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return { nodes, links }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Use random data
|
|
202
|
+
engine.setData(createRandomData(100, 70))
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Custom Force Pass
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { BasePass, type PassContext } from './simulation/BasePass'
|
|
209
|
+
|
|
210
|
+
class CustomForcePass extends BasePass {
|
|
211
|
+
private strength: number = 1.0
|
|
212
|
+
|
|
213
|
+
getName(): string {
|
|
214
|
+
return 'CustomForce'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
initMaterial(context: PassContext): void {
|
|
218
|
+
this.material = this.createMaterial(
|
|
219
|
+
vertexShader,
|
|
220
|
+
fragmentShader,
|
|
221
|
+
{
|
|
222
|
+
uPositionsTexture: { value: null },
|
|
223
|
+
uVelocityTexture: { value: null },
|
|
224
|
+
uStrength: { value: this.strength },
|
|
225
|
+
uAlpha: { value: 1.0 }
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
updateUniforms(context: PassContext): void {
|
|
231
|
+
if (!this.material) return
|
|
232
|
+
|
|
233
|
+
this.material.uniforms.uPositionsTexture.value =
|
|
234
|
+
context.simBuffers.getCurrentPositionTexture()
|
|
235
|
+
this.material.uniforms.uVelocityTexture.value =
|
|
236
|
+
context.simBuffers.getCurrentVelocityTexture()
|
|
237
|
+
this.material.uniforms.uAlpha.value = context.alpha
|
|
238
|
+
this.material.uniforms.uStrength.value = this.strength
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setStrength(strength: number): void {
|
|
242
|
+
this.strength = strength
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Add to simulation
|
|
247
|
+
const customPass = new CustomForcePass()
|
|
248
|
+
customPass.initMaterial(context)
|
|
249
|
+
forceSimulation.addPass('custom', customPass, 2) // position 2
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Configuration
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// Update force configuration
|
|
256
|
+
engine.getSimulation().updateConfig({
|
|
257
|
+
manyBodyStrength: 100,
|
|
258
|
+
enableCollision: true,
|
|
259
|
+
collisionRadius: 8.0,
|
|
260
|
+
gravity: 1.2,
|
|
261
|
+
damping: 0.95,
|
|
262
|
+
alpha: 1.0
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Interactive node dragging
|
|
266
|
+
engine.getInteractionManager().on('dragStart', ({ nodeId }) => {
|
|
267
|
+
console.log('Dragging node:', nodeId)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Node picking
|
|
271
|
+
const nodeId = engine.pickNode(mouseX, mouseY)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Project Structure
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
fuse-three-forcegraph/
|
|
278
|
+
├── assets/
|
|
279
|
+
│ └── glsl/ # GLSL shaders for force simulation
|
|
280
|
+
│ ├── force-sim/ # Force compute shaders
|
|
281
|
+
│ ├── lines/ # Link rendering shaders
|
|
282
|
+
│ └── points/ # Node rendering shaders
|
|
283
|
+
├── audio/ # Audio integration (RNBO)
|
|
284
|
+
├── controls/ # Input handling and interactions
|
|
285
|
+
│ ├── InteractionManager.ts
|
|
286
|
+
│ ├── InputProcessor.ts
|
|
287
|
+
│ └── handlers/ # Click, drag, hover handlers
|
|
288
|
+
├── core/ # Core engine components
|
|
289
|
+
│ ├── Engine.ts # Main orchestrator
|
|
290
|
+
│ ├── EventEmitter.ts # Event system
|
|
291
|
+
│ └── GraphStore.ts # Graph data management
|
|
292
|
+
├── rendering/ # Visual rendering
|
|
293
|
+
│ ├── GraphScene.ts # Scene management
|
|
294
|
+
│ ├── CameraController.ts
|
|
295
|
+
│ ├── nodes/ # Node rendering
|
|
296
|
+
│ └── links/ # Link rendering
|
|
297
|
+
├── simulation/ # Force simulation
|
|
298
|
+
│ ├── BasePass.ts # Pass base class
|
|
299
|
+
│ ├── ForceSimulation.ts # Pass manager
|
|
300
|
+
│ └── passes/ # Individual force passes
|
|
301
|
+
├── textures/ # GPU buffer management
|
|
302
|
+
│ ├── SimulationBuffers.ts # Dynamic buffers
|
|
303
|
+
│ ├── StaticAssets.ts # Static textures
|
|
304
|
+
│ └── PickBuffer.ts # GPU picking
|
|
305
|
+
├── types/ # TypeScript definitions
|
|
306
|
+
└── ui/ # UI components (tooltips, etc.)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## GPU Texture Layout
|
|
310
|
+
|
|
311
|
+
### Position/Velocity Buffers
|
|
312
|
+
- Format: `RGBA Float`
|
|
313
|
+
- Layout: Grid where each pixel = one node
|
|
314
|
+
- Channels: `(x, y, z, unused)`
|
|
315
|
+
- Size: Next power-of-2 square ≥ √nodeCount
|
|
316
|
+
|
|
317
|
+
### Static Assets
|
|
318
|
+
- **Radii**: `Red Float` (1 channel)
|
|
319
|
+
- **Colors**: `RGBA Float` (4 channels)
|
|
320
|
+
- **Link Indices**: `RGBA Float` (source_x, source_y, target_x, target_y)
|
|
321
|
+
|
|
322
|
+
## Performance Considerations
|
|
323
|
+
|
|
324
|
+
- All position/velocity data stays on GPU
|
|
325
|
+
- Force computations use fragment shaders (parallel)
|
|
326
|
+
- Ping-pong rendering avoids read-after-write hazards
|
|
327
|
+
- Static assets minimize data transfer
|
|
328
|
+
- Geometry uses instancing for efficient rendering
|
|
329
|
+
|
|
330
|
+
## Interaction Model
|
|
331
|
+
|
|
332
|
+
The library implements a three-tiered interaction system for progressive engagement:
|
|
333
|
+
|
|
334
|
+
### 1. Hover
|
|
335
|
+
- **Trigger**: Mouse cursor enters node boundary
|
|
336
|
+
- **Purpose**: Lightweight preview and visual feedback
|
|
337
|
+
- **Response**:
|
|
338
|
+
- Node highlight/glow effect
|
|
339
|
+
- Cursor change
|
|
340
|
+
- Optional tooltip display
|
|
341
|
+
- No layout disruption
|
|
342
|
+
- **Use Case**: Quick scanning and exploration
|
|
343
|
+
|
|
344
|
+
### 2. Pop (Dwell/Long Hover)
|
|
345
|
+
- **Trigger**: Cursor remains over node for defined duration (e.g., 500ms)
|
|
346
|
+
- **Purpose**: Detailed information display without commitment
|
|
347
|
+
- **Response**:
|
|
348
|
+
- Expanded tooltip/info card
|
|
349
|
+
- Highlight connected nodes and edges
|
|
350
|
+
- Subtle camera focus adjustment
|
|
351
|
+
- Audio feedback (optional)
|
|
352
|
+
- **Use Case**: Examining node details and immediate connections
|
|
353
|
+
|
|
354
|
+
### 3. Click
|
|
355
|
+
- **Trigger**: Primary mouse button click on node
|
|
356
|
+
- **Purpose**: Full interaction and state change
|
|
357
|
+
- **Response**:
|
|
358
|
+
- Node selection/deselection
|
|
359
|
+
- Full graph filtering (show only connected components)
|
|
360
|
+
- Panel/sidebar updates
|
|
361
|
+
- Deep-dive views
|
|
362
|
+
- State persistence
|
|
363
|
+
- **Use Case**: Focused analysis and permanent selection
|
|
364
|
+
|
|
365
|
+
### Interaction Pipeline
|
|
366
|
+
```typescript
|
|
367
|
+
// Hover
|
|
368
|
+
interactionManager.on('hover', ({ node, position }) => {
|
|
369
|
+
// Immediate visual feedback
|
|
370
|
+
graphScene.highlightNode(node.id, 'hover')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// Pop (triggered after dwell time)
|
|
374
|
+
interactionManager.on('pop', ({ node, dwellTime }) => {
|
|
375
|
+
// Show detailed tooltip
|
|
376
|
+
tooltipManager.showExpanded(node)
|
|
377
|
+
// Highlight neighborhood
|
|
378
|
+
graphScene.highlightNeighborhood(node.id)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Click
|
|
382
|
+
interactionManager.on('click', ({ node }) => {
|
|
383
|
+
// Full selection
|
|
384
|
+
graphStore.selectNode(node.id)
|
|
385
|
+
// Filter graph
|
|
386
|
+
graphScene.filterToConnected(node.id)
|
|
387
|
+
})
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
This progressive disclosure pattern prevents overwhelming users while enabling deep exploration when needed.
|
|
391
|
+
|
|
392
|
+
## Dependencies
|
|
393
|
+
|
|
394
|
+
- **three.js** - 3D rendering engine
|
|
395
|
+
- **camera-controls** - Camera manipulation
|
|
396
|
+
- **gsap** - Animation and transitions
|
|
397
|
+
|