@opendata-ai/openchart-vanilla 6.1.2 → 6.1.4
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/index.d.ts +5 -15
- package/dist/index.js +100 -57
- package/dist/index.js.map +1 -1
- package/dist/simulation-worker.js +3 -0
- package/package.json +3 -3
- package/src/graph/simulation-worker-url.ts +5 -23
- package/src/graph/simulation-worker.ts +6 -4
- package/src/graph/simulation.ts +124 -60
- package/src/graph-mount.ts +13 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.1.
|
|
3
|
+
"version": "6.1.4",
|
|
4
4
|
"description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@floating-ui/dom": "^1.7.6",
|
|
53
|
-
"@opendata-ai/openchart-core": "6.1.
|
|
54
|
-
"@opendata-ai/openchart-engine": "6.1.
|
|
53
|
+
"@opendata-ai/openchart-core": "6.1.4",
|
|
54
|
+
"@opendata-ai/openchart-engine": "6.1.4",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -1,30 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a Web Worker running the force simulation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and handles the asset accordingly.
|
|
4
|
+
* References the built .js file via `new URL` + `import.meta.url`.
|
|
5
|
+
* The consuming app's bundler resolves the worker path at build time.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - Production (tsup + bun build): dist/simulation-worker.js is a self-contained
|
|
11
|
-
* IIFE produced by `bun build`. The consuming app's bundler copies it as an
|
|
12
|
-
* asset and rewrites the URL.
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* import { createSimulationWorker } from '@opendata-ai/openchart-vanilla';
|
|
16
|
-
* const worker = createSimulationWorker();
|
|
17
|
-
* worker.postMessage({ type: 'init', nodes, links, width: 800, height: 600 });
|
|
18
|
-
* worker.onmessage = (e) => console.log(e.data);
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Path that resolves in Vite dev (workspace source) to the .ts file.
|
|
23
|
-
* In production dist/, the consuming bundler resolves to simulation-worker.js
|
|
24
|
-
* which sits alongside index.js in the dist folder.
|
|
7
|
+
* Note: SimulationManager handles .js/.ts fallback internally. This
|
|
8
|
+
* helper is exported for consumers who want to manage the worker directly.
|
|
25
9
|
*/
|
|
26
|
-
const workerUrl = new URL('./simulation-worker.ts', import.meta.url);
|
|
27
|
-
|
|
28
10
|
export function createSimulationWorker(): Worker {
|
|
29
|
-
return new Worker(
|
|
11
|
+
return new Worker(new URL('./simulation-worker.js', import.meta.url), { type: 'module' });
|
|
30
12
|
}
|
|
@@ -18,10 +18,6 @@ declare const self: WorkerSelf;
|
|
|
18
18
|
* IMPORTANT: This file cannot import from workspace packages (@opendata-ai/*).
|
|
19
19
|
* All needed types are defined inline or duplicated from worker-protocol.ts.
|
|
20
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
21
|
*/
|
|
26
22
|
|
|
27
23
|
import {
|
|
@@ -246,6 +242,12 @@ ctx.addEventListener('message', ((event: MessageEvent<InMessage>) => {
|
|
|
246
242
|
node.fx = null;
|
|
247
243
|
node.fy = null;
|
|
248
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
|
+
}
|
|
249
251
|
break;
|
|
250
252
|
}
|
|
251
253
|
|
package/src/graph/simulation.ts
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
* SimulationManager: spawns a Web Worker for the force simulation,
|
|
3
3
|
* or falls back to synchronous d3-force on the main thread.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* The sync path caps at 300 ticks to prevent blocking the main thread.
|
|
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.
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
import {
|
|
@@ -27,8 +26,8 @@ import type { SimEdge, SimNode, WorkerOutMessage, WorkerSimulationConfig } from
|
|
|
27
26
|
// Constants
|
|
28
27
|
// ---------------------------------------------------------------------------
|
|
29
28
|
|
|
30
|
-
const SYNC_THRESHOLD = 200;
|
|
31
29
|
const SYNC_MAX_TICKS = 300;
|
|
30
|
+
const SYNC_TICKS_PER_BATCH = 15;
|
|
32
31
|
|
|
33
32
|
// ---------------------------------------------------------------------------
|
|
34
33
|
// Internal node shape for sync simulation
|
|
@@ -93,6 +92,7 @@ export class SimulationManager {
|
|
|
93
92
|
private tickCb: TickCallback | null = null;
|
|
94
93
|
private settledCb: SettledCallback | null = null;
|
|
95
94
|
private destroyed = false;
|
|
95
|
+
private syncRafId: number | null = null;
|
|
96
96
|
|
|
97
97
|
// Stored for worker->sync fallback
|
|
98
98
|
private initNodes: SimNode[] = [];
|
|
@@ -112,7 +112,7 @@ export class SimulationManager {
|
|
|
112
112
|
): SimulationManager {
|
|
113
113
|
const mgr = new SimulationManager();
|
|
114
114
|
|
|
115
|
-
const useWorker = typeof Worker !== 'undefined'
|
|
115
|
+
const useWorker = typeof Worker !== 'undefined';
|
|
116
116
|
|
|
117
117
|
if (useWorker) {
|
|
118
118
|
mgr.initWorker(nodes, edges, config);
|
|
@@ -160,7 +160,7 @@ export class SimulationManager {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
/** Unpin a node
|
|
163
|
+
/** Unpin a node and reheat so forces settle it into equilibrium. */
|
|
164
164
|
unpinNode(id: string): void {
|
|
165
165
|
if (this.destroyed) return;
|
|
166
166
|
|
|
@@ -172,6 +172,10 @@ export class SimulationManager {
|
|
|
172
172
|
node.fx = null;
|
|
173
173
|
node.fy = null;
|
|
174
174
|
}
|
|
175
|
+
if (this.syncSim && this.syncSim.alpha() < 0.1) {
|
|
176
|
+
this.syncSim.alpha(0.1).restart();
|
|
177
|
+
this.runSyncTicks();
|
|
178
|
+
}
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
|
|
@@ -198,6 +202,11 @@ export class SimulationManager {
|
|
|
198
202
|
destroy(): void {
|
|
199
203
|
this.destroyed = true;
|
|
200
204
|
|
|
205
|
+
if (this.syncRafId !== null) {
|
|
206
|
+
cancelAnimationFrame(this.syncRafId);
|
|
207
|
+
this.syncRafId = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
201
210
|
if (this.worker) {
|
|
202
211
|
this.worker.postMessage({ type: 'stop' });
|
|
203
212
|
this.worker.terminate();
|
|
@@ -223,44 +232,79 @@ export class SimulationManager {
|
|
|
223
232
|
this.initEdges = edges;
|
|
224
233
|
this.initConfig = config;
|
|
225
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
|
+
|
|
226
268
|
try {
|
|
227
|
-
const
|
|
228
|
-
|
|
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);
|
|
229
303
|
} catch {
|
|
230
304
|
// Worker construction failed (e.g. SSR or restrictive CSP)
|
|
231
305
|
console.warn('[SimulationManager] Worker creation failed, using sync fallback');
|
|
232
306
|
this.initSync(nodes, edges, config);
|
|
233
|
-
return;
|
|
234
307
|
}
|
|
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
308
|
}
|
|
265
309
|
|
|
266
310
|
// -------------------------------------------------------------------------
|
|
@@ -318,41 +362,61 @@ export class SimulationManager {
|
|
|
318
362
|
}
|
|
319
363
|
|
|
320
364
|
/**
|
|
321
|
-
* Run ticks
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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.
|
|
325
374
|
*/
|
|
326
375
|
private runSyncTicks(deferred = false): void {
|
|
327
376
|
if (!this.syncSim || this.destroyed) return;
|
|
328
377
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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;
|
|
333
382
|
}
|
|
334
383
|
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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;
|
|
342
406
|
|
|
343
|
-
const deliver = () => {
|
|
344
|
-
if (this.destroyed) return;
|
|
345
407
|
this.tickCb?.(positions, alpha);
|
|
408
|
+
|
|
346
409
|
if (settled) {
|
|
347
410
|
this.settledCb?.();
|
|
411
|
+
} else {
|
|
412
|
+
this.syncRafId = requestAnimationFrame(runBatch);
|
|
348
413
|
}
|
|
349
414
|
};
|
|
350
415
|
|
|
351
416
|
if (deferred) {
|
|
352
|
-
|
|
353
|
-
queueMicrotask(deliver);
|
|
417
|
+
queueMicrotask(runBatch);
|
|
354
418
|
} else {
|
|
355
|
-
|
|
419
|
+
runBatch();
|
|
356
420
|
}
|
|
357
421
|
}
|
|
358
422
|
}
|
package/src/graph-mount.ts
CHANGED
|
@@ -378,6 +378,8 @@ export function createGraph(
|
|
|
378
378
|
centerForce: config.centerForce,
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
let initialSettleDone = false;
|
|
382
|
+
|
|
381
383
|
simulation.onTick((positions, _alpha) => {
|
|
382
384
|
if (destroyed) return;
|
|
383
385
|
|
|
@@ -409,19 +411,22 @@ export function createGraph(
|
|
|
409
411
|
// Rebuild spatial index
|
|
410
412
|
spatialIndex.rebuild(positionedNodes);
|
|
411
413
|
|
|
414
|
+
// During initial simulation, continuously fit the viewport so the graph
|
|
415
|
+
// is visible and centered as it forms. This replaces the jarring single
|
|
416
|
+
// snap at settle time with a smooth progressive fit.
|
|
417
|
+
if (!initialSettleDone && positionedNodes.length > 0 && interactionManager) {
|
|
418
|
+
const { width: cw, height: ch } = getCanvasDimensions();
|
|
419
|
+
const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
420
|
+
interactionManager.setTransform(fitTransform);
|
|
421
|
+
}
|
|
422
|
+
|
|
412
423
|
needsRender = true;
|
|
413
424
|
scheduleRender();
|
|
414
425
|
});
|
|
415
426
|
|
|
416
427
|
simulation.onSettled(() => {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const { width: cw, height: ch } = getCanvasDimensions();
|
|
420
|
-
const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
421
|
-
interactionManager.setTransform(fitTransform);
|
|
422
|
-
needsRender = true;
|
|
423
|
-
scheduleRender();
|
|
424
|
-
}
|
|
428
|
+
if (initialSettleDone) return;
|
|
429
|
+
initialSettleDone = true;
|
|
425
430
|
});
|
|
426
431
|
}
|
|
427
432
|
|