@opendata-ai/openchart-vanilla 6.1.2 → 6.1.3

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.
@@ -1175,6 +1175,9 @@
1175
1175
  node.fx = null;
1176
1176
  node.fy = null;
1177
1177
  }
1178
+ if (simulation && simulation.alpha() < 0.1) {
1179
+ simulation.alpha(0.1).restart();
1180
+ }
1178
1181
  break;
1179
1182
  }
1180
1183
  case "drag": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "6.1.2",
3
+ "version": "6.1.3",
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.2",
54
- "@opendata-ai/openchart-engine": "6.1.2",
53
+ "@opendata-ai/openchart-core": "6.1.3",
54
+ "@opendata-ai/openchart-engine": "6.1.3",
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
- * Uses the `new URL` + `import.meta.url` pattern recognized by Vite, Webpack 5,
5
- * Parcel, and esbuild. The bundler resolves the worker file path at build time
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
- * - Vite dev (Ladle): resolves src/graph/simulation-worker.ts directly, serves
9
- * it as a native ES module worker with on-the-fly TypeScript transform.
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(workerUrl, { type: 'module' });
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
 
@@ -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
- * 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.
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' && nodes.length >= SYNC_THRESHOLD;
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 (free it from fixed position). */
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,77 @@ 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 workerUrl = new URL('./simulation-worker.ts', import.meta.url);
228
- this.worker = new Worker(workerUrl, { type: 'module' });
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
+ if (this.destroyed) return;
276
+ w.terminate();
277
+ this.worker = null;
278
+
279
+ try {
280
+ const w2 = new Worker(new URL('./simulation-worker.ts', import.meta.url), {
281
+ type: 'module',
282
+ });
283
+
284
+ w2.onerror = () => {
285
+ // Both .js and .ts failed - fall back to sync.
286
+ if (this.destroyed) return;
287
+ console.warn('[SimulationManager] Worker failed to load, falling back to sync');
288
+ w2.terminate();
289
+ this.worker = null;
290
+ this.initSync(this.initNodes, this.initEdges, this.initConfig!);
291
+ };
292
+
293
+ wireWorker(w2);
294
+ } catch {
295
+ console.warn('[SimulationManager] Worker creation failed, using sync fallback');
296
+ this.initSync(this.initNodes, this.initEdges, this.initConfig!);
297
+ }
298
+ };
299
+
300
+ wireWorker(w);
229
301
  } catch {
230
302
  // Worker construction failed (e.g. SSR or restrictive CSP)
231
303
  console.warn('[SimulationManager] Worker creation failed, using sync fallback');
232
304
  this.initSync(nodes, edges, config);
233
- return;
234
305
  }
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
306
  }
265
307
 
266
308
  // -------------------------------------------------------------------------
@@ -318,41 +360,61 @@ export class SimulationManager {
318
360
  }
319
361
 
320
362
  /**
321
- * Run ticks synchronously and fire callbacks.
322
- * @param deferred - When true, deliver results via microtask. Used for the
323
- * initial run where callbacks haven't been registered yet. Subsequent
324
- * calls (reheat, drag) fire synchronously since callbacks are already set.
363
+ * Run simulation ticks in batches, yielding to the main thread between
364
+ * batches via requestAnimationFrame. This prevents a multi-second freeze
365
+ * when the sync fallback handles large graphs (1k+ nodes).
366
+ *
367
+ * Each batch runs SYNC_TICKS_PER_BATCH ticks, emits positions for
368
+ * progressive rendering, then schedules the next batch.
369
+ *
370
+ * @param deferred - When true, start via microtask (initial run where
371
+ * callbacks aren't wired yet). Otherwise start immediately.
325
372
  */
326
373
  private runSyncTicks(deferred = false): void {
327
374
  if (!this.syncSim || this.destroyed) return;
328
375
 
329
- const sim = this.syncSim;
330
- for (let i = 0; i < SYNC_MAX_TICKS; i++) {
331
- sim.tick();
332
- if (sim.alpha() < 0.001) break;
376
+ // Cancel any in-flight batched run (e.g. from a previous reheat)
377
+ if (this.syncRafId !== null) {
378
+ cancelAnimationFrame(this.syncRafId);
379
+ this.syncRafId = null;
333
380
  }
334
381
 
335
- const positions = this.syncNodes.map((n) => ({
336
- id: n.id,
337
- x: n.x ?? 0,
338
- y: n.y ?? 0,
339
- }));
340
- const alpha = sim.alpha();
341
- const settled = alpha < 0.001;
382
+ const sim = this.syncSim;
383
+ let tickCount = 0;
384
+
385
+ const runBatch = () => {
386
+ if (this.destroyed || !this.syncSim) return;
387
+ this.syncRafId = null;
388
+
389
+ for (let i = 0; i < SYNC_TICKS_PER_BATCH && tickCount < SYNC_MAX_TICKS; i++, tickCount++) {
390
+ sim.tick();
391
+ if (sim.alpha() < 0.001) {
392
+ tickCount = SYNC_MAX_TICKS;
393
+ break;
394
+ }
395
+ }
396
+
397
+ const positions = this.syncNodes.map((n) => ({
398
+ id: n.id,
399
+ x: n.x ?? 0,
400
+ y: n.y ?? 0,
401
+ }));
402
+ const alpha = sim.alpha();
403
+ const settled = alpha < 0.001 || tickCount >= SYNC_MAX_TICKS;
342
404
 
343
- const deliver = () => {
344
- if (this.destroyed) return;
345
405
  this.tickCb?.(positions, alpha);
406
+
346
407
  if (settled) {
347
408
  this.settledCb?.();
409
+ } else {
410
+ this.syncRafId = requestAnimationFrame(runBatch);
348
411
  }
349
412
  };
350
413
 
351
414
  if (deferred) {
352
- // Initial run: callbacks not registered yet, defer to microtask
353
- queueMicrotask(deliver);
415
+ queueMicrotask(runBatch);
354
416
  } else {
355
- deliver();
417
+ runBatch();
356
418
  }
357
419
  }
358
420
  }
@@ -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
- // One final fit after simulation settles
418
- if (canvas && positionedNodes.length > 0 && interactionManager && renderer) {
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