@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
// DedicatedWorkerGlobalScope is available at runtime in Web Workers but
|
|
2
|
-
// the 'webworker' lib conflicts with 'DOM' in the main tsconfig. Declaring
|
|
3
|
-
// a minimal interface here avoids adding a separate tsconfig for one file.
|
|
4
|
-
interface WorkerSelf {
|
|
5
|
-
postMessage(msg: unknown): void;
|
|
6
|
-
onmessage: ((event: MessageEvent) => void) | null;
|
|
7
|
-
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
|
|
8
|
-
}
|
|
9
|
-
declare const self: WorkerSelf;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Force-directed graph simulation Web Worker.
|
|
13
|
-
*
|
|
14
|
-
* BUNDLING: Built separately via `bun build` (see package.json "build" script)
|
|
15
|
-
* into dist/simulation-worker.js as a self-contained IIFE. d3-force and all
|
|
16
|
-
* its transitive deps are inlined -- no external imports in the output.
|
|
17
|
-
*
|
|
18
|
-
* IMPORTANT: This file cannot import from workspace packages (@opendata-ai/*).
|
|
19
|
-
* All needed types are defined inline or duplicated from worker-protocol.ts.
|
|
20
|
-
* The bun build step bundles this as an isolated IIFE.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
forceCenter,
|
|
25
|
-
forceCollide,
|
|
26
|
-
forceLink,
|
|
27
|
-
forceManyBody,
|
|
28
|
-
forceSimulation,
|
|
29
|
-
forceX,
|
|
30
|
-
forceY,
|
|
31
|
-
type Simulation,
|
|
32
|
-
type SimulationNodeDatum,
|
|
33
|
-
} from 'd3-force';
|
|
34
|
-
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// Inline types (duplicated from worker-protocol.ts for bundle isolation)
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
interface SimNode {
|
|
40
|
-
id: string;
|
|
41
|
-
x?: number;
|
|
42
|
-
y?: number;
|
|
43
|
-
radius: number;
|
|
44
|
-
community?: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface SimEdge {
|
|
48
|
-
source: string;
|
|
49
|
-
target: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface SimConfig {
|
|
53
|
-
chargeStrength: number;
|
|
54
|
-
linkDistance: number;
|
|
55
|
-
clustering: { field: string; strength: number } | null;
|
|
56
|
-
alphaDecay: number;
|
|
57
|
-
velocityDecay: number;
|
|
58
|
-
collisionRadius: number;
|
|
59
|
-
collisionPadding?: number;
|
|
60
|
-
linkStrength?: number;
|
|
61
|
-
centerForce?: boolean;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
type InMessage =
|
|
65
|
-
| { type: 'init'; nodes: SimNode[]; edges: SimEdge[]; config: SimConfig }
|
|
66
|
-
| { type: 'reheat'; alpha?: number }
|
|
67
|
-
| { type: 'pin'; nodeId: string; x: number; y: number }
|
|
68
|
-
| { type: 'unpin'; nodeId: string }
|
|
69
|
-
| { type: 'drag'; nodeId: string; x: number; y: number }
|
|
70
|
-
| { type: 'stop' };
|
|
71
|
-
|
|
72
|
-
type OutMessage =
|
|
73
|
-
| { type: 'positions'; nodes: Array<{ id: string; x: number; y: number }>; alpha: number }
|
|
74
|
-
| { type: 'settled' }
|
|
75
|
-
| { type: 'error'; message: string };
|
|
76
|
-
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// Internal simulation node (extends d3 SimulationNodeDatum)
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
interface InternalNode extends SimulationNodeDatum {
|
|
82
|
-
id: string;
|
|
83
|
-
radius: number;
|
|
84
|
-
community?: string;
|
|
85
|
-
fx?: number | null;
|
|
86
|
-
fy?: number | null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
// Custom cluster force
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Pulls nodes toward the centroid of their community group.
|
|
95
|
-
* Strength controls how aggressively nodes cluster (0-1, default 0.3).
|
|
96
|
-
*
|
|
97
|
-
* NOTE: Duplicated in simulation.ts (sync fallback path). This file can't
|
|
98
|
-
* import from workspace packages since it's built as a standalone IIFE.
|
|
99
|
-
* Keep both copies in sync.
|
|
100
|
-
*/
|
|
101
|
-
function forceCluster(nodes: InternalNode[], strength: number) {
|
|
102
|
-
return (alpha: number) => {
|
|
103
|
-
// Compute per-community centroid
|
|
104
|
-
const cx = new Map<string, number>();
|
|
105
|
-
const cy = new Map<string, number>();
|
|
106
|
-
const count = new Map<string, number>();
|
|
107
|
-
|
|
108
|
-
for (const node of nodes) {
|
|
109
|
-
if (!node.community) continue;
|
|
110
|
-
const c = node.community;
|
|
111
|
-
cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
|
|
112
|
-
cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
|
|
113
|
-
count.set(c, (count.get(c) ?? 0) + 1);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Normalize to get mean positions
|
|
117
|
-
for (const [c, n] of count) {
|
|
118
|
-
cx.set(c, cx.get(c)! / n);
|
|
119
|
-
cy.set(c, cy.get(c)! / n);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Pull each node toward its community centroid
|
|
123
|
-
const k = strength * alpha;
|
|
124
|
-
for (const node of nodes) {
|
|
125
|
-
if (!node.community) continue;
|
|
126
|
-
const targetX = cx.get(node.community)!;
|
|
127
|
-
const targetY = cy.get(node.community)!;
|
|
128
|
-
node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
|
|
129
|
-
node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ---------------------------------------------------------------------------
|
|
135
|
-
// Worker entry point
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
const ctx = self;
|
|
139
|
-
let simulation: Simulation<InternalNode, undefined> | null = null;
|
|
140
|
-
let nodeMap: Map<string, InternalNode> = new Map();
|
|
141
|
-
|
|
142
|
-
function post(msg: OutMessage): void {
|
|
143
|
-
ctx.postMessage(msg);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function packPositions(nodes: InternalNode[]): Array<{ id: string; x: number; y: number }> {
|
|
147
|
-
return nodes.map((n) => ({
|
|
148
|
-
id: n.id,
|
|
149
|
-
x: n.x ?? 0,
|
|
150
|
-
y: n.y ?? 0,
|
|
151
|
-
}));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
|
|
155
|
-
const msg = event.data;
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
switch (msg.type) {
|
|
159
|
-
case 'init': {
|
|
160
|
-
// Stop any existing simulation
|
|
161
|
-
if (simulation) simulation.stop();
|
|
162
|
-
|
|
163
|
-
const internalNodes: InternalNode[] = msg.nodes.map((n) => ({
|
|
164
|
-
id: n.id,
|
|
165
|
-
x: n.x,
|
|
166
|
-
y: n.y,
|
|
167
|
-
radius: n.radius,
|
|
168
|
-
community: n.community,
|
|
169
|
-
}));
|
|
170
|
-
|
|
171
|
-
nodeMap = new Map(internalNodes.map((n) => [n.id, n]));
|
|
172
|
-
|
|
173
|
-
const { config } = msg;
|
|
174
|
-
|
|
175
|
-
const linkForce = forceLink(msg.edges.map((e) => ({ ...e })))
|
|
176
|
-
.id((d) => (d as InternalNode).id)
|
|
177
|
-
.distance(config.linkDistance);
|
|
178
|
-
if (config.linkStrength != null) {
|
|
179
|
-
linkForce.strength(config.linkStrength);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const padding = config.collisionPadding ?? 2;
|
|
183
|
-
|
|
184
|
-
simulation = forceSimulation<InternalNode>(internalNodes)
|
|
185
|
-
.force('link', linkForce)
|
|
186
|
-
.force('charge', forceManyBody().strength(config.chargeStrength))
|
|
187
|
-
.force(
|
|
188
|
-
'collide',
|
|
189
|
-
forceCollide<InternalNode>().radius((d) => d.radius + padding),
|
|
190
|
-
)
|
|
191
|
-
// Weak gravity keeps disconnected nodes from drifting far from center
|
|
192
|
-
.force('gravityX', forceX<InternalNode>(0).strength(0.05))
|
|
193
|
-
.force('gravityY', forceY<InternalNode>(0).strength(0.05))
|
|
194
|
-
.alphaDecay(config.alphaDecay)
|
|
195
|
-
.velocityDecay(config.velocityDecay);
|
|
196
|
-
|
|
197
|
-
// Center force (default true)
|
|
198
|
-
if (config.centerForce !== false) {
|
|
199
|
-
simulation.force('center', forceCenter(0, 0));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Add clustering force if configured
|
|
203
|
-
if (config.clustering) {
|
|
204
|
-
const clusterFn = forceCluster(internalNodes, config.clustering.strength);
|
|
205
|
-
// d3 calls force functions with (alpha) on each tick
|
|
206
|
-
simulation.force('cluster', clusterFn as unknown as ReturnType<typeof forceCenter>);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
simulation.on('tick', () => {
|
|
210
|
-
post({
|
|
211
|
-
type: 'positions',
|
|
212
|
-
nodes: packPositions(internalNodes),
|
|
213
|
-
alpha: simulation!.alpha(),
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
simulation.on('end', () => {
|
|
218
|
-
post({ type: 'settled' });
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
case 'reheat': {
|
|
225
|
-
if (!simulation) break;
|
|
226
|
-
simulation.alpha(msg.alpha ?? 0.3).restart();
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
case 'pin': {
|
|
231
|
-
const node = nodeMap.get(msg.nodeId);
|
|
232
|
-
if (node) {
|
|
233
|
-
node.fx = msg.x;
|
|
234
|
-
node.fy = msg.y;
|
|
235
|
-
}
|
|
236
|
-
break;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
case 'unpin': {
|
|
240
|
-
const node = nodeMap.get(msg.nodeId);
|
|
241
|
-
if (node) {
|
|
242
|
-
node.fx = null;
|
|
243
|
-
node.fy = null;
|
|
244
|
-
}
|
|
245
|
-
// Gentle reheat so the released node settles into equilibrium
|
|
246
|
-
// without destabilizing the whole graph. 0.1 is enough for
|
|
247
|
-
// local settling without triggering large-scale reorganization.
|
|
248
|
-
if (simulation && simulation.alpha() < 0.1) {
|
|
249
|
-
simulation.alpha(0.1).restart();
|
|
250
|
-
}
|
|
251
|
-
break;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
case 'drag': {
|
|
255
|
-
const node = nodeMap.get(msg.nodeId);
|
|
256
|
-
if (node) {
|
|
257
|
-
node.fx = msg.x;
|
|
258
|
-
node.fy = msg.y;
|
|
259
|
-
}
|
|
260
|
-
// Reheat slightly for responsive dragging
|
|
261
|
-
if (simulation && simulation.alpha() < 0.1) {
|
|
262
|
-
simulation.alpha(0.1).restart();
|
|
263
|
-
}
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
case 'stop': {
|
|
268
|
-
if (simulation) simulation.stop();
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
} catch (err) {
|
|
273
|
-
post({
|
|
274
|
-
type: 'error',
|
|
275
|
-
message: err instanceof Error ? err.message : String(err),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}) as EventListener);
|
package/src/graph/simulation.ts
DELETED
|
@@ -1,422 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SimulationManager: spawns a Web Worker for the force simulation,
|
|
3
|
-
* or falls back to synchronous d3-force on the main thread.
|
|
4
|
-
*
|
|
5
|
-
* The worker is always preferred when available. Synchronous fallback
|
|
6
|
-
* is only used when Web Workers are unavailable (SSR, test environments).
|
|
7
|
-
* The sync path batches ticks via requestAnimationFrame to avoid
|
|
8
|
-
* blocking the main thread.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
forceCenter,
|
|
13
|
-
forceCollide,
|
|
14
|
-
forceLink,
|
|
15
|
-
forceManyBody,
|
|
16
|
-
forceSimulation,
|
|
17
|
-
forceX,
|
|
18
|
-
forceY,
|
|
19
|
-
type Simulation,
|
|
20
|
-
type SimulationNodeDatum,
|
|
21
|
-
} from 'd3-force';
|
|
22
|
-
|
|
23
|
-
import type { SimEdge, SimNode, WorkerOutMessage, WorkerSimulationConfig } from './worker-protocol';
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Constants
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
const SYNC_MAX_TICKS = 300;
|
|
30
|
-
const SYNC_TICKS_PER_BATCH = 15;
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Internal node shape for sync simulation
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
interface SyncNode extends SimulationNodeDatum {
|
|
37
|
-
id: string;
|
|
38
|
-
radius: number;
|
|
39
|
-
community?: string;
|
|
40
|
-
fx?: number | null;
|
|
41
|
-
fy?: number | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// Cluster force (duplicated in simulation-worker.ts for the Web Worker path.
|
|
46
|
-
// Worker can't import from workspace packages, so both copies must stay in sync.)
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
function forceCluster(nodes: SyncNode[], strength: number) {
|
|
50
|
-
return (alpha: number) => {
|
|
51
|
-
const cx = new Map<string, number>();
|
|
52
|
-
const cy = new Map<string, number>();
|
|
53
|
-
const count = new Map<string, number>();
|
|
54
|
-
|
|
55
|
-
for (const node of nodes) {
|
|
56
|
-
if (!node.community) continue;
|
|
57
|
-
const c = node.community;
|
|
58
|
-
cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
|
|
59
|
-
cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
|
|
60
|
-
count.set(c, (count.get(c) ?? 0) + 1);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
for (const [c, n] of count) {
|
|
64
|
-
cx.set(c, cx.get(c)! / n);
|
|
65
|
-
cy.set(c, cy.get(c)! / n);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const k = strength * alpha;
|
|
69
|
-
for (const node of nodes) {
|
|
70
|
-
if (!node.community) continue;
|
|
71
|
-
const targetX = cx.get(node.community)!;
|
|
72
|
-
const targetY = cy.get(node.community)!;
|
|
73
|
-
node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
|
|
74
|
-
node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// SimulationManager
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
type TickCallback = (positions: Array<{ id: string; x: number; y: number }>, alpha: number) => void;
|
|
84
|
-
|
|
85
|
-
type SettledCallback = () => void;
|
|
86
|
-
|
|
87
|
-
export class SimulationManager {
|
|
88
|
-
private worker: Worker | null = null;
|
|
89
|
-
private syncSim: Simulation<SyncNode, undefined> | null = null;
|
|
90
|
-
private syncNodes: SyncNode[] = [];
|
|
91
|
-
private syncNodeMap: Map<string, SyncNode> = new Map();
|
|
92
|
-
private tickCb: TickCallback | null = null;
|
|
93
|
-
private settledCb: SettledCallback | null = null;
|
|
94
|
-
private destroyed = false;
|
|
95
|
-
private syncRafId: number | null = null;
|
|
96
|
-
|
|
97
|
-
// Stored for worker->sync fallback
|
|
98
|
-
private initNodes: SimNode[] = [];
|
|
99
|
-
private initEdges: SimEdge[] = [];
|
|
100
|
-
private initConfig: WorkerSimulationConfig | null = null;
|
|
101
|
-
|
|
102
|
-
private constructor() {}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Create a SimulationManager. Uses Web Worker for large graphs,
|
|
106
|
-
* synchronous fallback for small graphs or when Worker unavailable.
|
|
107
|
-
*/
|
|
108
|
-
static create(
|
|
109
|
-
nodes: SimNode[],
|
|
110
|
-
edges: SimEdge[],
|
|
111
|
-
config: WorkerSimulationConfig,
|
|
112
|
-
): SimulationManager {
|
|
113
|
-
const mgr = new SimulationManager();
|
|
114
|
-
|
|
115
|
-
const useWorker = typeof Worker !== 'undefined';
|
|
116
|
-
|
|
117
|
-
if (useWorker) {
|
|
118
|
-
mgr.initWorker(nodes, edges, config);
|
|
119
|
-
} else {
|
|
120
|
-
mgr.initSync(nodes, edges, config);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return mgr;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Register a callback for position updates. */
|
|
127
|
-
onTick(cb: TickCallback): void {
|
|
128
|
-
this.tickCb = cb;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Register a callback for when the simulation has settled. */
|
|
132
|
-
onSettled(cb: SettledCallback): void {
|
|
133
|
-
this.settledCb = cb;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Reheat the simulation. */
|
|
137
|
-
reheat(alpha?: number): void {
|
|
138
|
-
if (this.destroyed) return;
|
|
139
|
-
|
|
140
|
-
if (this.worker) {
|
|
141
|
-
this.worker.postMessage({ type: 'reheat', alpha });
|
|
142
|
-
} else if (this.syncSim) {
|
|
143
|
-
this.syncSim.alpha(alpha ?? 0.3).restart();
|
|
144
|
-
this.runSyncTicks();
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Pin a node to fixed x/y coordinates. */
|
|
149
|
-
pinNode(id: string, x: number, y: number): void {
|
|
150
|
-
if (this.destroyed) return;
|
|
151
|
-
|
|
152
|
-
if (this.worker) {
|
|
153
|
-
this.worker.postMessage({ type: 'pin', nodeId: id, x, y });
|
|
154
|
-
} else {
|
|
155
|
-
const node = this.syncNodeMap.get(id);
|
|
156
|
-
if (node) {
|
|
157
|
-
node.fx = x;
|
|
158
|
-
node.fy = y;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Unpin a node and reheat so forces settle it into equilibrium. */
|
|
164
|
-
unpinNode(id: string): void {
|
|
165
|
-
if (this.destroyed) return;
|
|
166
|
-
|
|
167
|
-
if (this.worker) {
|
|
168
|
-
this.worker.postMessage({ type: 'unpin', nodeId: id });
|
|
169
|
-
} else {
|
|
170
|
-
const node = this.syncNodeMap.get(id);
|
|
171
|
-
if (node) {
|
|
172
|
-
node.fx = null;
|
|
173
|
-
node.fy = null;
|
|
174
|
-
}
|
|
175
|
-
if (this.syncSim && this.syncSim.alpha() < 0.1) {
|
|
176
|
-
this.syncSim.alpha(0.1).restart();
|
|
177
|
-
this.runSyncTicks();
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Drag a node (pins it and reheats slightly). */
|
|
183
|
-
dragNode(id: string, x: number, y: number): void {
|
|
184
|
-
if (this.destroyed) return;
|
|
185
|
-
|
|
186
|
-
if (this.worker) {
|
|
187
|
-
this.worker.postMessage({ type: 'drag', nodeId: id, x, y });
|
|
188
|
-
} else {
|
|
189
|
-
const node = this.syncNodeMap.get(id);
|
|
190
|
-
if (node) {
|
|
191
|
-
node.fx = x;
|
|
192
|
-
node.fy = y;
|
|
193
|
-
}
|
|
194
|
-
if (this.syncSim && this.syncSim.alpha() < 0.1) {
|
|
195
|
-
this.syncSim.alpha(0.1).restart();
|
|
196
|
-
this.runSyncTicks();
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Tear down the simulation and release resources. */
|
|
202
|
-
destroy(): void {
|
|
203
|
-
this.destroyed = true;
|
|
204
|
-
|
|
205
|
-
if (this.syncRafId !== null) {
|
|
206
|
-
cancelAnimationFrame(this.syncRafId);
|
|
207
|
-
this.syncRafId = null;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (this.worker) {
|
|
211
|
-
this.worker.postMessage({ type: 'stop' });
|
|
212
|
-
this.worker.terminate();
|
|
213
|
-
this.worker = null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (this.syncSim) {
|
|
217
|
-
this.syncSim.stop();
|
|
218
|
-
this.syncSim = null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
this.tickCb = null;
|
|
222
|
-
this.settledCb = null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// -------------------------------------------------------------------------
|
|
226
|
-
// Worker path
|
|
227
|
-
// -------------------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
private initWorker(nodes: SimNode[], edges: SimEdge[], config: WorkerSimulationConfig): void {
|
|
230
|
-
// Store for fallback if worker fails to load
|
|
231
|
-
this.initNodes = nodes;
|
|
232
|
-
this.initEdges = edges;
|
|
233
|
-
this.initConfig = config;
|
|
234
|
-
|
|
235
|
-
// Worker URL resolution:
|
|
236
|
-
// - Built dist/ consumers: import.meta.url points at dist/index.js,
|
|
237
|
-
// so ./simulation-worker.js resolves to dist/simulation-worker.js.
|
|
238
|
-
// - Vite dev with source aliases (Ladle): import.meta.url points at
|
|
239
|
-
// src/graph/simulation.ts, so ./simulation-worker.js doesn't exist.
|
|
240
|
-
// The .js worker fails to load, and the onerror handler retries
|
|
241
|
-
// with .ts which Vite transforms on the fly.
|
|
242
|
-
// - Vite production build: detects `new Worker(new URL(...))` and
|
|
243
|
-
// bundles the worker as a hashed .js asset.
|
|
244
|
-
const initMsg = { type: 'init' as const, nodes, edges, config };
|
|
245
|
-
const wireWorker = (worker: Worker) => {
|
|
246
|
-
this.worker = worker;
|
|
247
|
-
|
|
248
|
-
worker.onmessage = (event: MessageEvent<WorkerOutMessage>) => {
|
|
249
|
-
if (this.destroyed) return;
|
|
250
|
-
const msg = event.data;
|
|
251
|
-
|
|
252
|
-
switch (msg.type) {
|
|
253
|
-
case 'positions':
|
|
254
|
-
this.tickCb?.(msg.nodes, msg.alpha);
|
|
255
|
-
break;
|
|
256
|
-
case 'settled':
|
|
257
|
-
this.settledCb?.();
|
|
258
|
-
break;
|
|
259
|
-
case 'error':
|
|
260
|
-
console.error('[SimulationManager] Worker error:', msg.message);
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
worker.postMessage(initMsg);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
const w = new Worker(new URL('./simulation-worker.js', import.meta.url), {
|
|
270
|
-
type: 'module',
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
w.onerror = () => {
|
|
274
|
-
// .js failed (likely Vite dev with source aliases). Try .ts.
|
|
275
|
-
// The URL is constructed dynamically to prevent bundlers (Rollup)
|
|
276
|
-
// from statically analyzing it and trying to resolve the .ts file
|
|
277
|
-
// as an asset entry point in production builds.
|
|
278
|
-
if (this.destroyed) return;
|
|
279
|
-
w.terminate();
|
|
280
|
-
this.worker = null;
|
|
281
|
-
|
|
282
|
-
try {
|
|
283
|
-
const tsUrl = new URL(import.meta.url.replace(/\/[^/]+$/, '/simulation-worker.ts'));
|
|
284
|
-
const w2 = new Worker(tsUrl, { type: 'module' });
|
|
285
|
-
|
|
286
|
-
w2.onerror = () => {
|
|
287
|
-
// Both .js and .ts failed - fall back to sync.
|
|
288
|
-
if (this.destroyed) return;
|
|
289
|
-
console.warn('[SimulationManager] Worker failed to load, falling back to sync');
|
|
290
|
-
w2.terminate();
|
|
291
|
-
this.worker = null;
|
|
292
|
-
this.initSync(this.initNodes, this.initEdges, this.initConfig!);
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
wireWorker(w2);
|
|
296
|
-
} catch {
|
|
297
|
-
console.warn('[SimulationManager] Worker creation failed, using sync fallback');
|
|
298
|
-
this.initSync(this.initNodes, this.initEdges, this.initConfig!);
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
wireWorker(w);
|
|
303
|
-
} catch {
|
|
304
|
-
// Worker construction failed (e.g. SSR or restrictive CSP)
|
|
305
|
-
console.warn('[SimulationManager] Worker creation failed, using sync fallback');
|
|
306
|
-
this.initSync(nodes, edges, config);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// -------------------------------------------------------------------------
|
|
311
|
-
// Synchronous fallback
|
|
312
|
-
// -------------------------------------------------------------------------
|
|
313
|
-
|
|
314
|
-
private initSync(nodes: SimNode[], edges: SimEdge[], config: WorkerSimulationConfig): void {
|
|
315
|
-
this.syncNodes = nodes.map((n) => ({
|
|
316
|
-
id: n.id,
|
|
317
|
-
x: n.x,
|
|
318
|
-
y: n.y,
|
|
319
|
-
radius: n.radius,
|
|
320
|
-
community: n.community,
|
|
321
|
-
}));
|
|
322
|
-
|
|
323
|
-
this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
|
|
324
|
-
|
|
325
|
-
const linkForce = forceLink(edges.map((e) => ({ ...e })))
|
|
326
|
-
.id((d) => (d as SyncNode).id)
|
|
327
|
-
.distance(config.linkDistance);
|
|
328
|
-
if (config.linkStrength != null) {
|
|
329
|
-
linkForce.strength(config.linkStrength);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const padding = config.collisionPadding ?? 2;
|
|
333
|
-
|
|
334
|
-
this.syncSim = forceSimulation<SyncNode>(this.syncNodes)
|
|
335
|
-
.force('link', linkForce)
|
|
336
|
-
.force('charge', forceManyBody().strength(config.chargeStrength))
|
|
337
|
-
.force(
|
|
338
|
-
'collide',
|
|
339
|
-
forceCollide<SyncNode>().radius((d) => d.radius + padding),
|
|
340
|
-
)
|
|
341
|
-
// Weak gravity keeps disconnected nodes from drifting far from center
|
|
342
|
-
.force('gravityX', forceX<SyncNode>(0).strength(0.05))
|
|
343
|
-
.force('gravityY', forceY<SyncNode>(0).strength(0.05))
|
|
344
|
-
.alphaDecay(config.alphaDecay)
|
|
345
|
-
.velocityDecay(config.velocityDecay)
|
|
346
|
-
.stop(); // Don't auto-run; we tick manually
|
|
347
|
-
|
|
348
|
-
// Center force (default true)
|
|
349
|
-
if (config.centerForce !== false) {
|
|
350
|
-
this.syncSim.force('center', forceCenter(0, 0));
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Add clustering force if configured
|
|
354
|
-
if (config.clustering) {
|
|
355
|
-
const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
|
|
356
|
-
// d3 calls force functions with (alpha) on each tick
|
|
357
|
-
this.syncSim.force('cluster', clusterFn as unknown as ReturnType<typeof forceCenter>);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Defer initial delivery: callbacks aren't wired yet at create() time
|
|
361
|
-
this.runSyncTicks(true);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Run simulation ticks in batches, yielding to the main thread between
|
|
366
|
-
* batches via requestAnimationFrame. This prevents a multi-second freeze
|
|
367
|
-
* when the sync fallback handles large graphs (1k+ nodes).
|
|
368
|
-
*
|
|
369
|
-
* Each batch runs SYNC_TICKS_PER_BATCH ticks, emits positions for
|
|
370
|
-
* progressive rendering, then schedules the next batch.
|
|
371
|
-
*
|
|
372
|
-
* @param deferred - When true, start via microtask (initial run where
|
|
373
|
-
* callbacks aren't wired yet). Otherwise start immediately.
|
|
374
|
-
*/
|
|
375
|
-
private runSyncTicks(deferred = false): void {
|
|
376
|
-
if (!this.syncSim || this.destroyed) return;
|
|
377
|
-
|
|
378
|
-
// Cancel any in-flight batched run (e.g. from a previous reheat)
|
|
379
|
-
if (this.syncRafId !== null) {
|
|
380
|
-
cancelAnimationFrame(this.syncRafId);
|
|
381
|
-
this.syncRafId = null;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const sim = this.syncSim;
|
|
385
|
-
let tickCount = 0;
|
|
386
|
-
|
|
387
|
-
const runBatch = () => {
|
|
388
|
-
if (this.destroyed || !this.syncSim) return;
|
|
389
|
-
this.syncRafId = null;
|
|
390
|
-
|
|
391
|
-
for (let i = 0; i < SYNC_TICKS_PER_BATCH && tickCount < SYNC_MAX_TICKS; i++, tickCount++) {
|
|
392
|
-
sim.tick();
|
|
393
|
-
if (sim.alpha() < 0.001) {
|
|
394
|
-
tickCount = SYNC_MAX_TICKS;
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const positions = this.syncNodes.map((n) => ({
|
|
400
|
-
id: n.id,
|
|
401
|
-
x: n.x ?? 0,
|
|
402
|
-
y: n.y ?? 0,
|
|
403
|
-
}));
|
|
404
|
-
const alpha = sim.alpha();
|
|
405
|
-
const settled = alpha < 0.001 || tickCount >= SYNC_MAX_TICKS;
|
|
406
|
-
|
|
407
|
-
this.tickCb?.(positions, alpha);
|
|
408
|
-
|
|
409
|
-
if (settled) {
|
|
410
|
-
this.settledCb?.();
|
|
411
|
-
} else {
|
|
412
|
-
this.syncRafId = requestAnimationFrame(runBatch);
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
if (deferred) {
|
|
417
|
-
queueMicrotask(runBatch);
|
|
418
|
-
} else {
|
|
419
|
-
runBatch();
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|