@opendata-ai/openchart-vanilla 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. package/src/tooltip.ts +120 -0
@@ -0,0 +1,265 @@
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
+ * The companion simulation-worker-url.ts provides createSimulationWorker()
23
+ * which uses `new URL('./simulation-worker.ts', import.meta.url)` for Vite dev,
24
+ * while production consumers load the pre-built dist/simulation-worker.js.
25
+ */
26
+
27
+ import {
28
+ forceCenter,
29
+ forceCollide,
30
+ forceLink,
31
+ forceManyBody,
32
+ forceSimulation,
33
+ forceX,
34
+ forceY,
35
+ type Simulation,
36
+ type SimulationNodeDatum,
37
+ } from 'd3-force';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Inline types (duplicated from worker-protocol.ts for bundle isolation)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ interface SimNode {
44
+ id: string;
45
+ x?: number;
46
+ y?: number;
47
+ radius: number;
48
+ community?: string;
49
+ }
50
+
51
+ interface SimEdge {
52
+ source: string;
53
+ target: string;
54
+ }
55
+
56
+ interface SimConfig {
57
+ chargeStrength: number;
58
+ linkDistance: number;
59
+ clustering: { field: string; strength: number } | null;
60
+ alphaDecay: number;
61
+ velocityDecay: number;
62
+ collisionRadius: number;
63
+ }
64
+
65
+ type InMessage =
66
+ | { type: 'init'; nodes: SimNode[]; edges: SimEdge[]; config: SimConfig }
67
+ | { type: 'reheat'; alpha?: number }
68
+ | { type: 'pin'; nodeId: string; x: number; y: number }
69
+ | { type: 'unpin'; nodeId: string }
70
+ | { type: 'drag'; nodeId: string; x: number; y: number }
71
+ | { type: 'stop' };
72
+
73
+ type OutMessage =
74
+ | { type: 'positions'; nodes: Array<{ id: string; x: number; y: number }>; alpha: number }
75
+ | { type: 'settled' }
76
+ | { type: 'error'; message: string };
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Internal simulation node (extends d3 SimulationNodeDatum)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ interface InternalNode extends SimulationNodeDatum {
83
+ id: string;
84
+ radius: number;
85
+ community?: string;
86
+ fx?: number | null;
87
+ fy?: number | null;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Custom cluster force
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Pulls nodes toward the centroid of their community group.
96
+ * Strength controls how aggressively nodes cluster (0-1, default 0.3).
97
+ *
98
+ * NOTE: Duplicated in simulation.ts (sync fallback path). This file can't
99
+ * import from workspace packages since it's built as a standalone IIFE.
100
+ * Keep both copies in sync.
101
+ */
102
+ function forceCluster(nodes: InternalNode[], strength: number) {
103
+ return (alpha: number) => {
104
+ // Compute per-community centroid
105
+ const cx = new Map<string, number>();
106
+ const cy = new Map<string, number>();
107
+ const count = new Map<string, number>();
108
+
109
+ for (const node of nodes) {
110
+ if (!node.community) continue;
111
+ const c = node.community;
112
+ cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
113
+ cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
114
+ count.set(c, (count.get(c) ?? 0) + 1);
115
+ }
116
+
117
+ // Normalize to get mean positions
118
+ for (const [c, n] of count) {
119
+ cx.set(c, cx.get(c)! / n);
120
+ cy.set(c, cy.get(c)! / n);
121
+ }
122
+
123
+ // Pull each node toward its community centroid
124
+ const k = strength * alpha;
125
+ for (const node of nodes) {
126
+ if (!node.community) continue;
127
+ const targetX = cx.get(node.community)!;
128
+ const targetY = cy.get(node.community)!;
129
+ node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
130
+ node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
131
+ }
132
+ };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Worker entry point
137
+ // ---------------------------------------------------------------------------
138
+
139
+ const ctx = self;
140
+ let simulation: Simulation<InternalNode, undefined> | null = null;
141
+ let nodeMap: Map<string, InternalNode> = new Map();
142
+
143
+ function post(msg: OutMessage): void {
144
+ ctx.postMessage(msg);
145
+ }
146
+
147
+ function packPositions(nodes: InternalNode[]): Array<{ id: string; x: number; y: number }> {
148
+ return nodes.map((n) => ({
149
+ id: n.id,
150
+ x: n.x ?? 0,
151
+ y: n.y ?? 0,
152
+ }));
153
+ }
154
+
155
+ ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
156
+ const msg = event.data;
157
+
158
+ try {
159
+ switch (msg.type) {
160
+ case 'init': {
161
+ // Stop any existing simulation
162
+ if (simulation) simulation.stop();
163
+
164
+ const internalNodes: InternalNode[] = msg.nodes.map((n) => ({
165
+ id: n.id,
166
+ x: n.x,
167
+ y: n.y,
168
+ radius: n.radius,
169
+ community: n.community,
170
+ }));
171
+
172
+ nodeMap = new Map(internalNodes.map((n) => [n.id, n]));
173
+
174
+ const { config } = msg;
175
+
176
+ simulation = forceSimulation<InternalNode>(internalNodes)
177
+ .force(
178
+ 'link',
179
+ forceLink(msg.edges.map((e) => ({ ...e })))
180
+ .id((d) => (d as InternalNode).id)
181
+ .distance(config.linkDistance),
182
+ )
183
+ .force('charge', forceManyBody().strength(config.chargeStrength))
184
+ .force('center', forceCenter(0, 0))
185
+ .force(
186
+ 'collide',
187
+ forceCollide<InternalNode>().radius((d) => d.radius + 1),
188
+ )
189
+ // Weak gravity keeps disconnected nodes from drifting far from center
190
+ .force('gravityX', forceX<InternalNode>(0).strength(0.05))
191
+ .force('gravityY', forceY<InternalNode>(0).strength(0.05))
192
+ .alphaDecay(config.alphaDecay)
193
+ .velocityDecay(config.velocityDecay);
194
+
195
+ // Add clustering force if configured
196
+ if (config.clustering) {
197
+ const clusterFn = forceCluster(internalNodes, config.clustering.strength);
198
+ // d3 calls force functions with (alpha) on each tick
199
+ simulation.force('cluster', clusterFn as unknown as ReturnType<typeof forceCenter>);
200
+ }
201
+
202
+ simulation.on('tick', () => {
203
+ post({
204
+ type: 'positions',
205
+ nodes: packPositions(internalNodes),
206
+ alpha: simulation!.alpha(),
207
+ });
208
+ });
209
+
210
+ simulation.on('end', () => {
211
+ post({ type: 'settled' });
212
+ });
213
+
214
+ break;
215
+ }
216
+
217
+ case 'reheat': {
218
+ if (!simulation) break;
219
+ simulation.alpha(msg.alpha ?? 0.3).restart();
220
+ break;
221
+ }
222
+
223
+ case 'pin': {
224
+ const node = nodeMap.get(msg.nodeId);
225
+ if (node) {
226
+ node.fx = msg.x;
227
+ node.fy = msg.y;
228
+ }
229
+ break;
230
+ }
231
+
232
+ case 'unpin': {
233
+ const node = nodeMap.get(msg.nodeId);
234
+ if (node) {
235
+ node.fx = null;
236
+ node.fy = null;
237
+ }
238
+ break;
239
+ }
240
+
241
+ case 'drag': {
242
+ const node = nodeMap.get(msg.nodeId);
243
+ if (node) {
244
+ node.fx = msg.x;
245
+ node.fy = msg.y;
246
+ }
247
+ // Reheat slightly for responsive dragging
248
+ if (simulation && simulation.alpha() < 0.1) {
249
+ simulation.alpha(0.1).restart();
250
+ }
251
+ break;
252
+ }
253
+
254
+ case 'stop': {
255
+ if (simulation) simulation.stop();
256
+ break;
257
+ }
258
+ }
259
+ } catch (err) {
260
+ post({
261
+ type: 'error',
262
+ message: err instanceof Error ? err.message : String(err),
263
+ });
264
+ }
265
+ }) as EventListener);
@@ -0,0 +1,350 @@
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
+ * Synchronous fallback is used when:
6
+ * - Web Workers are unavailable (SSR, test environments)
7
+ * - Node count is below 200 (worker overhead not worth it)
8
+ *
9
+ * The sync path caps at 300 ticks to prevent blocking the main thread.
10
+ */
11
+
12
+ import {
13
+ forceCenter,
14
+ forceCollide,
15
+ forceLink,
16
+ forceManyBody,
17
+ forceSimulation,
18
+ forceX,
19
+ forceY,
20
+ type Simulation,
21
+ type SimulationNodeDatum,
22
+ } from 'd3-force';
23
+
24
+ import type { SimEdge, SimNode, WorkerOutMessage, WorkerSimulationConfig } from './worker-protocol';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const SYNC_THRESHOLD = 200;
31
+ const SYNC_MAX_TICKS = 300;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Internal node shape for sync simulation
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface SyncNode extends SimulationNodeDatum {
38
+ id: string;
39
+ radius: number;
40
+ community?: string;
41
+ fx?: number | null;
42
+ fy?: number | null;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Cluster force (duplicated in simulation-worker.ts for the Web Worker path.
47
+ // Worker can't import from workspace packages, so both copies must stay in sync.)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function forceCluster(nodes: SyncNode[], strength: number) {
51
+ return (alpha: number) => {
52
+ const cx = new Map<string, number>();
53
+ const cy = new Map<string, number>();
54
+ const count = new Map<string, number>();
55
+
56
+ for (const node of nodes) {
57
+ if (!node.community) continue;
58
+ const c = node.community;
59
+ cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
60
+ cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
61
+ count.set(c, (count.get(c) ?? 0) + 1);
62
+ }
63
+
64
+ for (const [c, n] of count) {
65
+ cx.set(c, cx.get(c)! / n);
66
+ cy.set(c, cy.get(c)! / n);
67
+ }
68
+
69
+ const k = strength * alpha;
70
+ for (const node of nodes) {
71
+ if (!node.community) continue;
72
+ const targetX = cx.get(node.community)!;
73
+ const targetY = cy.get(node.community)!;
74
+ node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
75
+ node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
76
+ }
77
+ };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // SimulationManager
82
+ // ---------------------------------------------------------------------------
83
+
84
+ type TickCallback = (positions: Array<{ id: string; x: number; y: number }>, alpha: number) => void;
85
+
86
+ type SettledCallback = () => void;
87
+
88
+ export class SimulationManager {
89
+ private worker: Worker | null = null;
90
+ private syncSim: Simulation<SyncNode, undefined> | null = null;
91
+ private syncNodes: SyncNode[] = [];
92
+ private syncNodeMap: Map<string, SyncNode> = new Map();
93
+ private tickCb: TickCallback | null = null;
94
+ private settledCb: SettledCallback | null = null;
95
+ private destroyed = false;
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' && nodes.length >= SYNC_THRESHOLD;
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 (free it from fixed position). */
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
+ }
176
+ }
177
+
178
+ /** Drag a node (pins it and reheats slightly). */
179
+ dragNode(id: string, x: number, y: number): void {
180
+ if (this.destroyed) return;
181
+
182
+ if (this.worker) {
183
+ this.worker.postMessage({ type: 'drag', nodeId: id, x, y });
184
+ } else {
185
+ const node = this.syncNodeMap.get(id);
186
+ if (node) {
187
+ node.fx = x;
188
+ node.fy = y;
189
+ }
190
+ if (this.syncSim && this.syncSim.alpha() < 0.1) {
191
+ this.syncSim.alpha(0.1).restart();
192
+ this.runSyncTicks();
193
+ }
194
+ }
195
+ }
196
+
197
+ /** Tear down the simulation and release resources. */
198
+ destroy(): void {
199
+ this.destroyed = true;
200
+
201
+ if (this.worker) {
202
+ this.worker.postMessage({ type: 'stop' });
203
+ this.worker.terminate();
204
+ this.worker = null;
205
+ }
206
+
207
+ if (this.syncSim) {
208
+ this.syncSim.stop();
209
+ this.syncSim = null;
210
+ }
211
+
212
+ this.tickCb = null;
213
+ this.settledCb = null;
214
+ }
215
+
216
+ // -------------------------------------------------------------------------
217
+ // Worker path
218
+ // -------------------------------------------------------------------------
219
+
220
+ private initWorker(nodes: SimNode[], edges: SimEdge[], config: WorkerSimulationConfig): void {
221
+ // Store for fallback if worker fails to load
222
+ this.initNodes = nodes;
223
+ this.initEdges = edges;
224
+ this.initConfig = config;
225
+
226
+ try {
227
+ const workerUrl = new URL('./simulation-worker.ts', import.meta.url);
228
+ this.worker = new Worker(workerUrl, { type: 'module' });
229
+ } catch {
230
+ // Worker construction failed (e.g. SSR or restrictive CSP)
231
+ console.warn('[SimulationManager] Worker creation failed, using sync fallback');
232
+ this.initSync(nodes, edges, config);
233
+ return;
234
+ }
235
+
236
+ this.worker.onmessage = (event: MessageEvent<WorkerOutMessage>) => {
237
+ if (this.destroyed) return;
238
+ const msg = event.data;
239
+
240
+ switch (msg.type) {
241
+ case 'positions':
242
+ this.tickCb?.(msg.nodes, msg.alpha);
243
+ break;
244
+ case 'settled':
245
+ this.settledCb?.();
246
+ break;
247
+ case 'error':
248
+ console.error('[SimulationManager] Worker error:', msg.message);
249
+ break;
250
+ }
251
+ };
252
+
253
+ this.worker.onerror = () => {
254
+ // Worker failed to load (e.g. MIME type error in dev, missing file).
255
+ // Terminate and fall back to synchronous simulation.
256
+ if (this.destroyed) return;
257
+ console.warn('[SimulationManager] Worker failed to load, falling back to sync');
258
+ this.worker?.terminate();
259
+ this.worker = null;
260
+ this.initSync(this.initNodes, this.initEdges, this.initConfig!);
261
+ };
262
+
263
+ this.worker.postMessage({ type: 'init', nodes, edges, config });
264
+ }
265
+
266
+ // -------------------------------------------------------------------------
267
+ // Synchronous fallback
268
+ // -------------------------------------------------------------------------
269
+
270
+ private initSync(nodes: SimNode[], edges: SimEdge[], config: WorkerSimulationConfig): void {
271
+ this.syncNodes = nodes.map((n) => ({
272
+ id: n.id,
273
+ x: n.x,
274
+ y: n.y,
275
+ radius: n.radius,
276
+ community: n.community,
277
+ }));
278
+
279
+ this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
280
+
281
+ this.syncSim = forceSimulation<SyncNode>(this.syncNodes)
282
+ .force(
283
+ 'link',
284
+ forceLink(edges.map((e) => ({ ...e })))
285
+ .id((d) => (d as SyncNode).id)
286
+ .distance(config.linkDistance),
287
+ )
288
+ .force('charge', forceManyBody().strength(config.chargeStrength))
289
+ .force('center', forceCenter(0, 0))
290
+ .force(
291
+ 'collide',
292
+ forceCollide<SyncNode>().radius((d) => d.radius + 1),
293
+ )
294
+ // Weak gravity keeps disconnected nodes from drifting far from center
295
+ .force('gravityX', forceX<SyncNode>(0).strength(0.05))
296
+ .force('gravityY', forceY<SyncNode>(0).strength(0.05))
297
+ .alphaDecay(config.alphaDecay)
298
+ .velocityDecay(config.velocityDecay)
299
+ .stop(); // Don't auto-run; we tick manually
300
+
301
+ // Add clustering force if configured
302
+ if (config.clustering) {
303
+ const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
304
+ // d3 calls force functions with (alpha) on each tick
305
+ this.syncSim.force('cluster', clusterFn as unknown as ReturnType<typeof forceCenter>);
306
+ }
307
+
308
+ // Defer initial delivery: callbacks aren't wired yet at create() time
309
+ this.runSyncTicks(true);
310
+ }
311
+
312
+ /**
313
+ * Run ticks synchronously and fire callbacks.
314
+ * @param deferred - When true, deliver results via microtask. Used for the
315
+ * initial run where callbacks haven't been registered yet. Subsequent
316
+ * calls (reheat, drag) fire synchronously since callbacks are already set.
317
+ */
318
+ private runSyncTicks(deferred = false): void {
319
+ if (!this.syncSim || this.destroyed) return;
320
+
321
+ const sim = this.syncSim;
322
+ for (let i = 0; i < SYNC_MAX_TICKS; i++) {
323
+ sim.tick();
324
+ if (sim.alpha() < 0.001) break;
325
+ }
326
+
327
+ const positions = this.syncNodes.map((n) => ({
328
+ id: n.id,
329
+ x: n.x ?? 0,
330
+ y: n.y ?? 0,
331
+ }));
332
+ const alpha = sim.alpha();
333
+ const settled = alpha < 0.001;
334
+
335
+ const deliver = () => {
336
+ if (this.destroyed) return;
337
+ this.tickCb?.(positions, alpha);
338
+ if (settled) {
339
+ this.settledCb?.();
340
+ }
341
+ };
342
+
343
+ if (deferred) {
344
+ // Initial run: callbacks not registered yet, defer to microtask
345
+ queueMicrotask(deliver);
346
+ } else {
347
+ deliver();
348
+ }
349
+ }
350
+ }