@opendata-ai/openchart-vanilla 8.0.0-rc.8 → 8.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 (147) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/{chunk-UZ7J3NOF.js → chunk-6BUCYCLD.js} +7314 -5605
  3. package/dist/chunk-6BUCYCLD.js.map +1 -0
  4. package/dist/chunk-DCODCLWI.js +234 -0
  5. package/dist/chunk-DCODCLWI.js.map +1 -0
  6. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  7. package/dist/chunk-EKAUQAAX.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +309 -27
  16. package/dist/index.js +2020 -836
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-CL-nA0f-.d.ts} +85 -2
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.d.ts +2 -0
  21. package/dist/static.js +8 -69
  22. package/dist/static.js.map +1 -1
  23. package/dist/story/index.d.ts +25 -9
  24. package/dist/story/index.js +9 -7
  25. package/dist/story/index.js.map +1 -1
  26. package/dist/styles.css +1 -1
  27. package/dist/styles.css.map +1 -1
  28. package/package.json +14 -6
  29. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  30. package/dist/chunk-UZ7J3NOF.js.map +0 -1
  31. package/src/__test-fixtures__/dom.ts +0 -70
  32. package/src/__test-fixtures__/specs.ts +0 -214
  33. package/src/__tests__/animation.test.ts +0 -358
  34. package/src/__tests__/annotations-render.test.ts +0 -201
  35. package/src/__tests__/compound-labels.test.ts +0 -122
  36. package/src/__tests__/continuous-legend.test.ts +0 -91
  37. package/src/__tests__/crosshair.test.ts +0 -130
  38. package/src/__tests__/edit-events.test.ts +0 -950
  39. package/src/__tests__/events.test.ts +0 -387
  40. package/src/__tests__/export.test.ts +0 -241
  41. package/src/__tests__/font-race.test.ts +0 -199
  42. package/src/__tests__/gradient-ids.test.ts +0 -159
  43. package/src/__tests__/gradient-utils.test.ts +0 -125
  44. package/src/__tests__/known-bugs-render.test.ts +0 -183
  45. package/src/__tests__/map.test.ts +0 -636
  46. package/src/__tests__/mount.test.ts +0 -357
  47. package/src/__tests__/pattern-fills.test.ts +0 -105
  48. package/src/__tests__/resize-timing.test.ts +0 -218
  49. package/src/__tests__/sankey.test.ts +0 -133
  50. package/src/__tests__/selection-events.test.ts +0 -869
  51. package/src/__tests__/series-search.test.ts +0 -311
  52. package/src/__tests__/static.test.ts +0 -141
  53. package/src/__tests__/svg-renderer.test.ts +0 -895
  54. package/src/__tests__/table-keyboard.test.ts +0 -361
  55. package/src/__tests__/table-mount.test.ts +0 -639
  56. package/src/__tests__/tilemap.test.ts +0 -158
  57. package/src/__tests__/tooltip.test.ts +0 -337
  58. package/src/__tests__/transition.test.ts +0 -1792
  59. package/src/__tests__/update-scrolly-matrix.test.ts +0 -563
  60. package/src/__tests__/you-draw-it.test.ts +0 -273
  61. package/src/animation-vars.ts +0 -42
  62. package/src/animation.ts +0 -76
  63. package/src/barlist-mount.ts +0 -383
  64. package/src/barlist-renderer.ts +0 -267
  65. package/src/dom-helpers.ts +0 -17
  66. package/src/export.ts +0 -482
  67. package/src/gradient-utils.ts +0 -138
  68. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  69. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  70. package/src/graph/__tests__/interaction.test.ts +0 -205
  71. package/src/graph/__tests__/keyboard.test.ts +0 -653
  72. package/src/graph/__tests__/search.test.ts +0 -88
  73. package/src/graph/__tests__/simulation.test.ts +0 -233
  74. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  75. package/src/graph/__tests__/zoom.test.ts +0 -203
  76. package/src/graph/canvas-renderer.ts +0 -747
  77. package/src/graph/interaction.ts +0 -367
  78. package/src/graph/keyboard.ts +0 -208
  79. package/src/graph/search.ts +0 -50
  80. package/src/graph/simulation-worker-url.ts +0 -12
  81. package/src/graph/simulation-worker.ts +0 -278
  82. package/src/graph/simulation.ts +0 -422
  83. package/src/graph/spatial-index.ts +0 -121
  84. package/src/graph/types.ts +0 -47
  85. package/src/graph/worker-protocol.ts +0 -70
  86. package/src/graph/zoom.ts +0 -134
  87. package/src/graph-mount.ts +0 -888
  88. package/src/index.ts +0 -76
  89. package/src/interactions/chart-events.ts +0 -139
  90. package/src/interactions/crosshair.ts +0 -233
  91. package/src/interactions/drag-handler.ts +0 -175
  92. package/src/interactions/editing-drags.ts +0 -515
  93. package/src/interactions/index.ts +0 -25
  94. package/src/interactions/keyboard-nav.ts +0 -111
  95. package/src/interactions/legend-interaction.ts +0 -46
  96. package/src/interactions/selection.ts +0 -280
  97. package/src/interactions/tooltip-events.ts +0 -72
  98. package/src/map-camera.ts +0 -111
  99. package/src/map-mount.ts +0 -792
  100. package/src/map-renderer.ts +0 -460
  101. package/src/map-transition.ts +0 -99
  102. package/src/measure-text.ts +0 -134
  103. package/src/mount.ts +0 -1382
  104. package/src/pattern-utils.ts +0 -110
  105. package/src/renderers/annotations.ts +0 -388
  106. package/src/renderers/axes.ts +0 -351
  107. package/src/renderers/brand.ts +0 -87
  108. package/src/renderers/chrome.ts +0 -196
  109. package/src/renderers/endpoint-labels.ts +0 -150
  110. package/src/renderers/legend.ts +0 -311
  111. package/src/renderers/marks.ts +0 -561
  112. package/src/renderers/metrics.ts +0 -63
  113. package/src/renderers/svg-dom.ts +0 -40
  114. package/src/renderers/table-cells.ts +0 -446
  115. package/src/resize-observer.ts +0 -46
  116. package/src/sankey-mount.ts +0 -622
  117. package/src/sankey-renderer.ts +0 -614
  118. package/src/series-search.ts +0 -392
  119. package/src/static.ts +0 -251
  120. package/src/story/__tests__/camera-math.test.ts +0 -145
  121. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  122. package/src/story/__tests__/progress-math.test.ts +0 -95
  123. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  124. package/src/story/__tests__/tween.test.ts +0 -159
  125. package/src/story/camera-math.ts +0 -125
  126. package/src/story/create-chart-story.ts +0 -353
  127. package/src/story/crossfade.ts +0 -70
  128. package/src/story/index.ts +0 -39
  129. package/src/story/progress-math.ts +0 -85
  130. package/src/story/resolve-camera-target.ts +0 -119
  131. package/src/story/scroll-driver.ts +0 -206
  132. package/src/story/story-camera.ts +0 -49
  133. package/src/story/tween.ts +0 -112
  134. package/src/story/types.ts +0 -76
  135. package/src/svg-ids.ts +0 -23
  136. package/src/svg-renderer.ts +0 -383
  137. package/src/table-keyboard.ts +0 -266
  138. package/src/table-mount.ts +0 -567
  139. package/src/table-renderer.ts +0 -414
  140. package/src/text-edit-overlay.ts +0 -255
  141. package/src/theme-tokens.ts +0 -51
  142. package/src/tilemap-mount.ts +0 -511
  143. package/src/tilemap-renderer.ts +0 -396
  144. package/src/tooltip.ts +0 -177
  145. package/src/transition.ts +0 -2816
  146. package/src/you-draw-it.ts +0 -480
  147. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,31 +1,59 @@
1
+ import {
2
+ isModuleNotFound,
3
+ paletteFromCanvas,
4
+ readCanvasSRGB,
5
+ resolveRepeat
6
+ } from "./chunk-DCODCLWI.js";
1
7
  import {
2
8
  renderTileMapSVG
3
- } from "./chunk-W34KN7FD.js";
9
+ } from "./chunk-2NAQRP66.js";
4
10
  import {
11
+ AnimationScheduler,
12
+ CANVAS_DEFAULT_UPDATE_MAX_MARKS,
13
+ DEFAULT_UPDATE_MAX_MARKS,
14
+ SpatialIndex,
15
+ VECTOR_EXPORT_MAX_POINTS,
5
16
  cancelAnimations,
6
17
  createChart,
7
- createMap,
18
+ createGeoMap,
8
19
  createMeasureText,
9
20
  createTextEditOverlay,
10
21
  createTooltipManager,
22
+ createTween,
11
23
  createYouDrawIt,
24
+ materializeCanvasModeSVG,
25
+ observeResize,
26
+ prefersReducedMotion,
27
+ quadtree,
28
+ resolveDarkMode,
29
+ resolveEase,
30
+ resolveFontFamily,
31
+ scheduleFontReload,
32
+ setupAnimationCleanup,
33
+ setupTableAnimationCleanup,
34
+ zoom_default
35
+ } from "./chunk-6BUCYCLD.js";
36
+ import {
37
+ embedFonts,
38
+ ensureSVGDimensions,
12
39
  exportCSV,
13
40
  exportJPG,
14
41
  exportPNG,
15
42
  exportSVG,
16
43
  exportSVGWithFonts,
17
- observeResize,
18
- resolveFontFamily,
19
- scheduleFontReload,
20
- setupAnimationCleanup,
21
- setupTableAnimationCleanup
22
- } from "./chunk-UZ7J3NOF.js";
44
+ getSVGBackgroundColor,
45
+ getSVGDimensions,
46
+ rasterizeSVGToCanvas
47
+ } from "./chunk-T32UHMZA.js";
23
48
  import {
24
49
  rectPathWithCorners,
25
50
  registerMarkRenderer,
26
51
  renderChartSVG,
27
52
  stampAnimationVars
28
- } from "./chunk-5EOV5JQZ.js";
53
+ } from "./chunk-EKAUQAAX.js";
54
+ import {
55
+ injectThemeStyleBlock
56
+ } from "./chunk-RM5XS6NR.js";
29
57
 
30
58
  // src/barlist-mount.ts
31
59
  import { compileBarList } from "@opendata-ai/openchart-engine";
@@ -222,14 +250,6 @@ function renderBarListSVG(layout, opts) {
222
250
  }
223
251
 
224
252
  // src/barlist-mount.ts
225
- function resolveDarkMode(mode) {
226
- if (mode === "force") return true;
227
- if (mode === "off" || mode === void 0) return false;
228
- if (typeof window !== "undefined" && window.matchMedia) {
229
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
230
- }
231
- return false;
232
- }
233
253
  function createBarList(container, spec, options) {
234
254
  let currentSpec = spec;
235
255
  let currentLayout;
@@ -361,6 +381,7 @@ function createBarList(container, spec, options) {
361
381
  }
362
382
  if (currentLayout.animation?.enter) {
363
383
  animationCleanup = setupAnimationCleanup(newSvg, () => {
384
+ animationCleanup = null;
364
385
  if (pendingResize && !destroyed) {
365
386
  pendingResize = false;
366
387
  resize();
@@ -462,22 +483,488 @@ function createBarList(container, spec, options) {
462
483
  };
463
484
  }
464
485
 
486
+ // src/export-sequence.ts
487
+ function settleAnimation(spec) {
488
+ const authored = spec.animation;
489
+ const update = typeof authored === "object" && typeof authored.update === "object" ? { ...authored.update } : true;
490
+ return {
491
+ ...spec,
492
+ animation: { enter: false, update }
493
+ };
494
+ }
495
+ function mountOffscreen(spec, width, height, darkMode, themeConfig, renderer) {
496
+ const container = document.createElement("div");
497
+ container.style.cssText = `position:absolute;left:-99999px;top:0;width:${width}px;height:${height}px;visibility:hidden;`;
498
+ document.body.appendChild(container);
499
+ const settledSpec = settleAnimation(spec);
500
+ let instance;
501
+ try {
502
+ instance = createChart(container, settledSpec, {
503
+ darkMode,
504
+ theme: themeConfig,
505
+ renderer,
506
+ responsive: false
507
+ });
508
+ } catch (err) {
509
+ container.remove();
510
+ throw err;
511
+ }
512
+ const svg = container.querySelector("svg");
513
+ if (!svg) {
514
+ instance.destroy();
515
+ container.remove();
516
+ throw new Error("exportSpecSequence: chart produced no SVG");
517
+ }
518
+ return { instance, svg, container };
519
+ }
520
+ async function exportSpecSequence(specs, options) {
521
+ if (specs.length === 0) {
522
+ throw new Error("exportSpecSequence requires at least one spec");
523
+ }
524
+ let gifenc;
525
+ try {
526
+ gifenc = await import("gifenc");
527
+ } catch (err) {
528
+ if (isModuleNotFound(err)) {
529
+ throw new Error(
530
+ "GIF export requires the optional 'gifenc' peer dependency. Install it: npm install gifenc"
531
+ );
532
+ }
533
+ throw new Error("GIF export failed to load the gifenc encoder", {
534
+ cause: err
535
+ });
536
+ }
537
+ const { GIFEncoder, quantize, applyPalette } = gifenc;
538
+ const dpi = options.dpi ?? 2;
539
+ const fps = options.fps ?? 25;
540
+ const dwellMs = options.dwellMs ?? 1200;
541
+ const frameDelay = Math.round(1e3 / fps);
542
+ const { width, height } = options;
543
+ const darkMode = options.darkMode ?? "off";
544
+ const themeConfig = options.theme;
545
+ const shouldEmbed = options.embedFonts ?? true;
546
+ const repeat = resolveRepeat(options.loop);
547
+ const { instance, svg, container } = mountOffscreen(
548
+ specs[0],
549
+ width,
550
+ height,
551
+ darkMode,
552
+ themeConfig,
553
+ options.renderer
554
+ );
555
+ try {
556
+ const w = getSVGDimensions(svg).width || width;
557
+ const h = getSVGDimensions(svg).height || height;
558
+ const theme = () => {
559
+ const layout = instance.layout;
560
+ return layout && "theme" in layout ? layout.theme : void 0;
561
+ };
562
+ const bgColor = options.backgroundColor ?? getSVGBackgroundColor(svg, theme()?.colors.background);
563
+ const fillBackground = (ctx, cv) => {
564
+ ctx.fillStyle = bgColor;
565
+ ctx.fillRect(0, 0, cv.width, cv.height);
566
+ };
567
+ const prepareLiveSvg = async () => {
568
+ const live2 = container.querySelector("svg");
569
+ if (!live2) throw new Error("exportSpecSequence: live SVG vanished mid-sequence");
570
+ injectThemeStyleBlock(live2, theme());
571
+ if (shouldEmbed) await embedFonts(live2);
572
+ return live2;
573
+ };
574
+ const grab = async (live2) => {
575
+ const svgString = ensureSVGDimensions(exportSVG(live2), w, h);
576
+ return rasterizeSVGToCanvas(svgString, w, h, dpi, fillBackground);
577
+ };
578
+ const frames = [];
579
+ let live = await prepareLiveSvg();
580
+ frames.push({ canvas: await grab(live), delayMs: dwellMs });
581
+ for (let i = 1; i < specs.length; i++) {
582
+ const nextSpec = settleAnimation(specs[i]);
583
+ const handle = instance.beginManualUpdate(nextSpec);
584
+ live = await prepareLiveSvg();
585
+ if (handle) {
586
+ const totalMs = handle.totalMs;
587
+ for (let t = frameDelay; t < totalMs; t += frameDelay) {
588
+ handle.step(t);
589
+ frames.push({ canvas: await grab(live), delayMs: frameDelay });
590
+ }
591
+ handle.step(totalMs);
592
+ handle.cancel();
593
+ }
594
+ frames.push({ canvas: await grab(live), delayMs: dwellMs });
595
+ }
596
+ const globalPalette = paletteFromCanvas(frames[frames.length - 1].canvas, quantize);
597
+ const gif = GIFEncoder();
598
+ frames.forEach(({ canvas, delayMs }, i) => {
599
+ const index2 = applyPalette(readCanvasSRGB(canvas), globalPalette);
600
+ gif.writeFrame(index2, canvas.width, canvas.height, {
601
+ palette: globalPalette,
602
+ delay: delayMs,
603
+ // `repeat` writes the loop extension; only meaningful on the first frame.
604
+ ...i === 0 ? { repeat } : {}
605
+ });
606
+ });
607
+ gif.finish();
608
+ return new Blob([gif.bytes()], { type: "image/gif" });
609
+ } finally {
610
+ instance.destroy();
611
+ container.remove();
612
+ }
613
+ }
614
+
465
615
  // src/graph/simulation-worker-url.ts
466
616
  function createSimulationWorker() {
467
617
  return new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
468
618
  }
469
619
 
470
620
  // src/graph-mount.ts
471
- import { compileGraph } from "@opendata-ai/openchart-engine";
621
+ import { buildEdgeTooltip, compileGraph } from "@opendata-ai/openchart-engine";
622
+
623
+ // src/graph/zoom.ts
624
+ var ZoomTransform = class _ZoomTransform {
625
+ constructor(x3, y3, k) {
626
+ this.x = x3;
627
+ this.y = y3;
628
+ this.k = k;
629
+ }
630
+ /** Convert screen coordinates to graph coordinates. */
631
+ screenToGraph(sx, sy) {
632
+ return {
633
+ x: (sx - this.x) / this.k,
634
+ y: (sy - this.y) / this.k
635
+ };
636
+ }
637
+ /** Convert graph coordinates to screen coordinates. */
638
+ graphToScreen(gx, gy) {
639
+ return {
640
+ x: gx * this.k + this.x,
641
+ y: gy * this.k + this.y
642
+ };
643
+ }
644
+ /**
645
+ * Zoom to a target scale, keeping the given screen-space pivot
646
+ * point fixed (content under the cursor stays under the cursor).
647
+ */
648
+ zoomAt(targetK, pivotX, pivotY) {
649
+ const gx = (pivotX - this.x) / this.k;
650
+ const gy = (pivotY - this.y) / this.k;
651
+ return new _ZoomTransform(pivotX - gx * targetK, pivotY - gy * targetK, targetK);
652
+ }
653
+ /** Pan by a screen-space delta. */
654
+ pan(dx, dy) {
655
+ return new _ZoomTransform(this.x + dx, this.y + dy, this.k);
656
+ }
657
+ /**
658
+ * Compute a transform that fits all nodes within the given canvas
659
+ * dimensions with the specified padding.
660
+ *
661
+ * Returns the transform and the ideal content height (in screen pixels)
662
+ * so callers can shrink the canvas to eliminate dead space.
663
+ */
664
+ static fitBounds(nodes, canvasW, canvasH, padding = 40, opts) {
665
+ if (nodes.length === 0) {
666
+ return { transform: _ZoomTransform.identity(), contentHeight: canvasH };
667
+ }
668
+ const insetTop = Math.min(Math.max(0, opts?.insetTop ?? 0), canvasH * 0.4);
669
+ let minX = Infinity;
670
+ let minY = Infinity;
671
+ let maxX = -Infinity;
672
+ let maxY = -Infinity;
673
+ for (const n of nodes) {
674
+ const r = n.radius;
675
+ if (n.x - r < minX) minX = n.x - r;
676
+ if (n.y - r < minY) minY = n.y - r;
677
+ if (n.x + r > maxX) maxX = n.x + r;
678
+ if (n.y + r > maxY) maxY = n.y + r;
679
+ }
680
+ let graphW = maxX - minX;
681
+ let graphH = maxY - minY;
682
+ if (graphW === 0 && graphH === 0) {
683
+ return {
684
+ transform: new _ZoomTransform(
685
+ canvasW / 2 - minX,
686
+ insetTop + (canvasH - insetTop) / 2 - minY,
687
+ 1
688
+ ),
689
+ contentHeight: padding * 2 + insetTop
690
+ };
691
+ }
692
+ if (opts?.spread !== false && nodes.length > 50) {
693
+ const spread = 1 + Math.sqrt(nodes.length) / 120;
694
+ const cx2 = (minX + maxX) / 2;
695
+ const cy2 = (minY + maxY) / 2;
696
+ graphW *= spread;
697
+ graphH *= spread;
698
+ minX = cx2 - graphW / 2;
699
+ maxX = cx2 + graphW / 2;
700
+ minY = cy2 - graphH / 2;
701
+ maxY = cy2 + graphH / 2;
702
+ }
703
+ const availW = canvasW - padding * 2;
704
+ const availH = canvasH - insetTop - padding * 2;
705
+ const k = Math.min(1, availW / graphW, availH / graphH);
706
+ const cx = (minX + maxX) / 2;
707
+ const cy = (minY + maxY) / 2;
708
+ const tx = canvasW / 2 - cx * k;
709
+ const ty = insetTop + (canvasH - insetTop) / 2 - cy * k;
710
+ const contentHeight = graphH * k + padding * 2 + insetTop;
711
+ return {
712
+ transform: new _ZoomTransform(tx, ty, k),
713
+ contentHeight
714
+ };
715
+ }
716
+ /** Identity transform (no pan, no zoom). */
717
+ static identity() {
718
+ return new _ZoomTransform(0, 0, 1);
719
+ }
720
+ };
721
+
722
+ // src/graph/camera.ts
723
+ var K_MIN = 0.05;
724
+ var K_MAX = 15;
725
+ var AUTO_MIN_MS = 300;
726
+ var AUTO_MAX_MS = 1200;
727
+ var AUTO_SCALE = 0.6;
728
+ function transformToView(t, viewport) {
729
+ const cx = (viewport.width / 2 - t.x) / t.k;
730
+ const cy = (viewport.height / 2 - t.y) / t.k;
731
+ const width = viewport.width / t.k;
732
+ return [cx, cy, width];
733
+ }
734
+ function viewToTransform(view, viewport) {
735
+ const [cx, cy, width] = view;
736
+ const k = clampK(viewport.width / width);
737
+ const x3 = viewport.width / 2 - cx * k;
738
+ const y3 = viewport.height / 2 - cy * k;
739
+ return new ZoomTransform(x3, y3, k);
740
+ }
741
+ function clampK(k) {
742
+ if (!Number.isFinite(k) || k <= 0) return K_MIN;
743
+ return Math.min(K_MAX, Math.max(K_MIN, k));
744
+ }
745
+ function createCameraFlight(inputs) {
746
+ const { from, viewport, apply, onDone, opts } = inputs;
747
+ const ease = resolveEase(opts?.ease ?? "smooth");
748
+ const isProvider = typeof inputs.to === "function";
749
+ const fromView = transformToView(from, viewport);
750
+ function targetView() {
751
+ const to = isProvider ? inputs.to() : inputs.to;
752
+ return transformToView(to, viewport);
753
+ }
754
+ let interp = zoom_default(fromView, targetView());
755
+ const resolvedDuration = resolveDuration(opts?.duration, interp.duration);
756
+ let startTime = null;
757
+ let finished = false;
758
+ function applyAt(t) {
759
+ if (isProvider) interp = zoom_default(fromView, targetView());
760
+ const view = interp(t);
761
+ if (view.some((v) => !Number.isFinite(v))) {
762
+ apply(viewToTransform(targetView(), viewport));
763
+ return;
764
+ }
765
+ apply(viewToTransform(view, viewport));
766
+ }
767
+ return {
768
+ tick(now2) {
769
+ if (finished) return false;
770
+ if (startTime === null) startTime = now2;
771
+ const raw = resolvedDuration <= 0 ? 1 : Math.min(1, (now2 - startTime) / resolvedDuration);
772
+ applyAt(ease(raw));
773
+ if (raw >= 1) {
774
+ finished = true;
775
+ onDone?.();
776
+ return false;
777
+ }
778
+ return true;
779
+ },
780
+ finish() {
781
+ if (finished) return;
782
+ finished = true;
783
+ applyAt(1);
784
+ onDone?.();
785
+ },
786
+ cancel() {
787
+ finished = true;
788
+ }
789
+ };
790
+ }
791
+ function createCameraFollow(inputs) {
792
+ let finished = false;
793
+ return {
794
+ tick() {
795
+ if (finished) return false;
796
+ if (!inputs.isActive()) {
797
+ finished = true;
798
+ return false;
799
+ }
800
+ inputs.apply(inputs.target());
801
+ return true;
802
+ },
803
+ finish() {
804
+ finished = true;
805
+ },
806
+ cancel() {
807
+ finished = true;
808
+ }
809
+ };
810
+ }
811
+ function resolveDuration(duration, interpDuration) {
812
+ if (typeof duration === "number") return Math.max(1, duration);
813
+ const scaled = interpDuration * AUTO_SCALE;
814
+ return Math.max(AUTO_MIN_MS, Math.min(AUTO_MAX_MS, Math.max(1, scaled)));
815
+ }
472
816
 
473
817
  // src/graph/canvas-renderer.ts
474
818
  import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from "@opendata-ai/openchart-core";
819
+
820
+ // src/graph/entrance.ts
821
+ function nodeEnterProgress(globalT, index2, total, buckets = 8) {
822
+ const g = globalT <= 0 ? 0 : globalT >= 1 ? 1 : globalT;
823
+ const start = total > 0 ? index2 / total * 0.4 : 0;
824
+ const local = (g - start) / 0.6;
825
+ const clamped = local <= 0 ? 0 : local >= 1 ? 1 : local;
826
+ const b = Math.max(1, Math.floor(buckets));
827
+ return Math.round(clamped * b) / b;
828
+ }
829
+ var ENTRANCE_STAGGER_MAX_NODES = 3e3;
830
+ var ENTRANCE_DRIFT_PX = 16;
831
+ function centroid(nodes) {
832
+ let cx = 0;
833
+ let cy = 0;
834
+ for (const n of nodes) {
835
+ cx += n.x;
836
+ cy += n.y;
837
+ }
838
+ cx /= nodes.length;
839
+ cy /= nodes.length;
840
+ return { cx, cy };
841
+ }
842
+ function hashId(id) {
843
+ let h = 2166136261;
844
+ for (let i = 0; i < id.length; i++) {
845
+ h ^= id.charCodeAt(i);
846
+ h = Math.imul(h, 16777619);
847
+ }
848
+ return h >>> 0;
849
+ }
850
+ function entranceOrder(nodes) {
851
+ const rank = /* @__PURE__ */ new Map();
852
+ if (nodes.length === 0) return rank;
853
+ const sorted = [...nodes].sort((a2, b) => {
854
+ const ha = hashId(a2.id);
855
+ const hb = hashId(b.id);
856
+ return ha - hb || (a2.id < b.id ? -1 : a2.id > b.id ? 1 : 0);
857
+ });
858
+ for (let i = 0; i < sorted.length; i++) rank.set(sorted[i].id, i);
859
+ return rank;
860
+ }
861
+ function entranceOffsets(nodes, dist = ENTRANCE_DRIFT_PX) {
862
+ const offsets = /* @__PURE__ */ new Map();
863
+ if (nodes.length === 0) return offsets;
864
+ const { cx, cy } = centroid(nodes);
865
+ for (const n of nodes) {
866
+ const dx = n.x - cx;
867
+ const dy = n.y - cy;
868
+ const len = Math.sqrt(dx * dx + dy * dy);
869
+ if (len < 1e-6) {
870
+ offsets.set(n.id, { x: 0, y: -dist });
871
+ } else {
872
+ offsets.set(n.id, { x: dx / len * dist, y: dy / len * dist });
873
+ }
874
+ }
875
+ return offsets;
876
+ }
877
+ function popScale(t) {
878
+ if (t <= 0) return 0;
879
+ if (t >= 1) return 1;
880
+ const c1 = 1.70158;
881
+ const c3 = c1 + 1;
882
+ const u = t - 1;
883
+ return 1 + c3 * u * u * u + c1 * u * u;
884
+ }
885
+ function popAlpha(t) {
886
+ const a2 = t / 0.6;
887
+ return a2 >= 1 ? 1 : a2 <= 0 ? 0 : a2;
888
+ }
889
+ function driftFactor(t) {
890
+ const u = 1 - (t <= 0 ? 0 : t >= 1 ? 1 : t);
891
+ return u * u;
892
+ }
893
+
894
+ // src/graph/canvas-renderer.ts
475
895
  var LABEL_FONT_MIN = 8;
476
896
  var LABEL_FONT_MAX = 12;
477
897
  var EDGE_ALPHA_DEFAULT = 0.35;
478
898
  var EDGE_ALPHA_CONNECTED = 1;
479
- var EDGE_ALPHA_DIMMED = 0.05;
480
899
  var SEARCH_NON_MATCH_ALPHA = 0.15;
900
+ var DEFAULT_DIM_OPACITY = 0.15;
901
+ var CROSSFADE_MAX_EDGES = 2e4;
902
+ function edgeTier(edge, focus) {
903
+ if (!focus.hasActive) return "default";
904
+ return focus.connected.has(edge.source) && focus.connected.has(edge.target) ? "connected" : "dimmed";
905
+ }
906
+ function nodeTier(node, focus) {
907
+ if (!focus.hasActive) return "default";
908
+ return focus.connected.has(node.id) ? "connected" : "dimmed";
909
+ }
910
+ function edgeTierAlpha(tier, dimOpacity) {
911
+ switch (tier) {
912
+ case "connected":
913
+ return EDGE_ALPHA_CONNECTED;
914
+ case "dimmed":
915
+ return dimOpacity / 3;
916
+ default:
917
+ return EDGE_ALPHA_DEFAULT;
918
+ }
919
+ }
920
+ function nodeTierAlpha(tier, dimOpacity) {
921
+ switch (tier) {
922
+ case "dimmed":
923
+ return dimOpacity;
924
+ default:
925
+ return 1;
926
+ }
927
+ }
928
+ function lerp(a2, b, t) {
929
+ return a2 + (b - a2) * t;
930
+ }
931
+ var ZERO_SHIFT = { x: 0, y: 0 };
932
+ function makeEntranceReveal(entrance, total) {
933
+ const g = entrance.t;
934
+ const { stagger, order, offsets } = entrance;
935
+ const rankOf = (node) => order?.get(node.id) ?? node.index;
936
+ const nodeT = (node) => stagger ? nodeEnterProgress(g, rankOf(node), total) : g;
937
+ const edgeAlpha = Math.max(0, (g - 0.3) / 0.7);
938
+ const globalRamp = 0.6 + 0.4 * g;
939
+ return {
940
+ nodeAlpha: (node) => stagger ? popAlpha(nodeT(node)) : globalRamp,
941
+ nodeScale: (node) => stagger ? popScale(nodeT(node)) : globalRamp,
942
+ shift: (id) => {
943
+ if (!stagger || !offsets || !order) return ZERO_SHIFT;
944
+ const off = offsets.get(id);
945
+ if (!off) return ZERO_SHIFT;
946
+ const f = driftFactor(nodeEnterProgress(g, order.get(id) ?? 0, total));
947
+ return f > 0 ? { x: off.x * f, y: off.y * f } : ZERO_SHIFT;
948
+ },
949
+ edgeAlpha,
950
+ labelAlpha: g
951
+ };
952
+ }
953
+ function deriveFocus(hoveredNodeId, selectedNodeIds, adjacencyMap) {
954
+ const hasActive = hoveredNodeId !== null || selectedNodeIds.size > 0;
955
+ const connected = /* @__PURE__ */ new Set();
956
+ if (hasActive) {
957
+ const active = /* @__PURE__ */ new Set();
958
+ if (hoveredNodeId) active.add(hoveredNodeId);
959
+ for (const id of selectedNodeIds) active.add(id);
960
+ for (const id of active) {
961
+ connected.add(id);
962
+ const neighbors = adjacencyMap.get(id);
963
+ if (neighbors) for (const nid of neighbors) connected.add(nid);
964
+ }
965
+ }
966
+ return { hasActive, connected, searchMatches: null, selected: selectedNodeIds };
967
+ }
481
968
  var GLOW_NODE_THRESHOLD = 2e3;
482
969
  var GLOW_RADIUS_MULTIPLIER = 1.3;
483
970
  var GLOW_ALPHA = 0.15;
@@ -544,18 +1031,12 @@ var GraphCanvasRenderer = class {
544
1031
  searchMatches,
545
1032
  isGesturing
546
1033
  } = state;
547
- const hasActiveNode = hoveredNodeId !== null || selectedNodeIds.size > 0;
548
- const activeNodeIds = /* @__PURE__ */ new Set();
549
- if (hoveredNodeId) activeNodeIds.add(hoveredNodeId);
550
- for (const id of selectedNodeIds) activeNodeIds.add(id);
551
- const connectedNodeIds = /* @__PURE__ */ new Set();
552
- for (const id of activeNodeIds) {
553
- connectedNodeIds.add(id);
554
- const neighbors = adjacencyMap.get(id);
555
- if (neighbors) {
556
- for (const nid of neighbors) connectedNodeIds.add(nid);
557
- }
558
- }
1034
+ const dimOpacity = state.dimOpacity ?? DEFAULT_DIM_OPACITY;
1035
+ const nextFocus = state.focus?.next ?? deriveFocus(hoveredNodeId, selectedNodeIds, adjacencyMap);
1036
+ const crossfade = state.focus && state.focus.t < 1 && !isGesturing ? state.focus : null;
1037
+ const entrance = state.entrance && state.entrance.t < 1 ? makeEntranceReveal(state.entrance, nodes.length) : null;
1038
+ const enterAlpha = state.enterAlpha ?? null;
1039
+ const enterAlphaFor = (id) => enterAlpha?.get(id) ?? 1;
559
1040
  const rect = visibleRect(cssWidth, cssHeight, transform);
560
1041
  const visibleNodes = nodes.filter((n) => nodeInView(n, rect));
561
1042
  const visibleEdges = edges.filter((e) => edgeInView(e, rect));
@@ -572,14 +1053,34 @@ var GraphCanvasRenderer = class {
572
1053
  }
573
1054
  ctx.translate(transform.x, transform.y);
574
1055
  ctx.scale(transform.k, transform.k);
575
- this.drawEdgesBatched(
576
- ctx,
577
- visibleEdges,
578
- hasActiveNode,
579
- connectedNodeIds,
580
- isGesturing ? null : searchMatches,
581
- hoveredEdgeId
582
- );
1056
+ if (state.exiting && state.exiting.alpha > 0) {
1057
+ this.drawGhosts(ctx, state.exiting, rect);
1058
+ }
1059
+ if (crossfade && visibleEdges.length <= CROSSFADE_MAX_EDGES) {
1060
+ this.drawEdgesCrossfade(
1061
+ ctx,
1062
+ visibleEdges,
1063
+ crossfade.prev,
1064
+ crossfade.next,
1065
+ crossfade.t,
1066
+ dimOpacity,
1067
+ isGesturing ? null : searchMatches,
1068
+ hoveredEdgeId,
1069
+ entrance,
1070
+ enterAlphaFor
1071
+ );
1072
+ } else {
1073
+ this.drawEdgesBatched(
1074
+ ctx,
1075
+ visibleEdges,
1076
+ nextFocus,
1077
+ dimOpacity,
1078
+ isGesturing ? null : searchMatches,
1079
+ hoveredEdgeId,
1080
+ entrance,
1081
+ enterAlphaFor
1082
+ );
1083
+ }
583
1084
  this.drawNodesBatched(
584
1085
  ctx,
585
1086
  visibleNodes,
@@ -588,7 +1089,13 @@ var GraphCanvasRenderer = class {
588
1089
  isGesturing ? null : searchMatches,
589
1090
  showGlow,
590
1091
  theme,
591
- minRadius
1092
+ minRadius,
1093
+ nextFocus,
1094
+ dimOpacity,
1095
+ crossfade,
1096
+ state.hoverRadiusScale,
1097
+ entrance,
1098
+ enterAlphaFor
592
1099
  );
593
1100
  if (!isGesturing) {
594
1101
  this.drawLabels(
@@ -599,7 +1106,9 @@ var GraphCanvasRenderer = class {
599
1106
  selectedNodeIds,
600
1107
  searchMatches,
601
1108
  transform.k,
602
- theme
1109
+ theme,
1110
+ entrance,
1111
+ enterAlphaFor
603
1112
  );
604
1113
  }
605
1114
  ctx.restore();
@@ -629,10 +1138,12 @@ var GraphCanvasRenderer = class {
629
1138
  // -------------------------------------------------------------------------
630
1139
  // Batched edge drawing
631
1140
  // -------------------------------------------------------------------------
632
- drawEdgesBatched(ctx, edges, hasActiveNode, connectedNodeIds, searchMatches, hoveredEdgeId) {
633
- const dimmedEdges = [];
634
- const defaultEdges = [];
635
- const connectedEdges = [];
1141
+ drawEdgesBatched(ctx, edges, focus, dimOpacity, searchMatches, hoveredEdgeId, entrance, enterAlphaFor) {
1142
+ const buckets = {
1143
+ dimmed: [],
1144
+ default: [],
1145
+ connected: []
1146
+ };
636
1147
  let hoveredEdge = null;
637
1148
  for (const edge of edges) {
638
1149
  const edgeId = `${edge.source}->${edge.target}`;
@@ -640,58 +1151,109 @@ var GraphCanvasRenderer = class {
640
1151
  hoveredEdge = edge;
641
1152
  continue;
642
1153
  }
643
- const isConnected = hasActiveNode && connectedNodeIds.has(edge.source) && connectedNodeIds.has(edge.target);
644
- const isDimmed = hasActiveNode && !isConnected;
645
- if (isConnected) {
646
- connectedEdges.push(edge);
647
- } else if (isDimmed) {
648
- dimmedEdges.push(edge);
649
- } else {
650
- defaultEdges.push(edge);
651
- }
652
- }
653
- this.drawEdgeGroupBatched(ctx, dimmedEdges, EDGE_ALPHA_DIMMED, searchMatches);
654
- this.drawEdgeGroupBatched(ctx, defaultEdges, EDGE_ALPHA_DEFAULT, searchMatches);
655
- this.drawEdgeGroupBatched(ctx, connectedEdges, EDGE_ALPHA_CONNECTED, searchMatches);
656
- if (hoveredEdge) {
657
- const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
658
- ctx.setLineDash(dash);
659
- ctx.strokeStyle = hoveredEdge.stroke;
660
- ctx.lineWidth = hoveredEdge.strokeWidth * 2;
661
- ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
662
- ctx.beginPath();
663
- ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
664
- ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
665
- ctx.stroke();
666
- ctx.setLineDash([]);
667
- ctx.globalAlpha = 1;
1154
+ buckets[edgeTier(edge, focus)].push(edge);
668
1155
  }
1156
+ const ea = entrance ? entrance.edgeAlpha : 1;
1157
+ this.drawEdgeGroupBatched(
1158
+ ctx,
1159
+ buckets.dimmed,
1160
+ edgeTierAlpha("dimmed", dimOpacity) * ea,
1161
+ searchMatches,
1162
+ enterAlphaFor
1163
+ );
1164
+ this.drawEdgeGroupBatched(
1165
+ ctx,
1166
+ buckets.default,
1167
+ EDGE_ALPHA_DEFAULT * ea,
1168
+ searchMatches,
1169
+ enterAlphaFor
1170
+ );
1171
+ this.drawEdgeGroupBatched(
1172
+ ctx,
1173
+ buckets.connected,
1174
+ EDGE_ALPHA_CONNECTED * ea,
1175
+ searchMatches,
1176
+ enterAlphaFor
1177
+ );
1178
+ this.drawHoveredEdge(ctx, hoveredEdge);
1179
+ }
1180
+ /**
1181
+ * Crossfade edges between a prev and next focus state. Each edge is classified
1182
+ * under BOTH snapshots → at most 9 (prevTier × nextTier) buckets, each drawn
1183
+ * batched at `lerp(edgeTierAlpha[prev], edgeTierAlpha[next], t)`. Preserves the
1184
+ * per-group style batching within each bucket.
1185
+ */
1186
+ drawEdgesCrossfade(ctx, edges, prev, next, t, dimOpacity, searchMatches, hoveredEdgeId, entrance, enterAlphaFor) {
1187
+ const ea = entrance ? entrance.edgeAlpha : 1;
1188
+ const buckets = /* @__PURE__ */ new Map();
1189
+ let hoveredEdge = null;
1190
+ for (const edge of edges) {
1191
+ const edgeId = `${edge.source}->${edge.target}`;
1192
+ if (edgeId === hoveredEdgeId) {
1193
+ hoveredEdge = edge;
1194
+ continue;
1195
+ }
1196
+ const key = `${edgeTier(edge, prev)}|${edgeTier(edge, next)}`;
1197
+ let bucket = buckets.get(key);
1198
+ if (!bucket) {
1199
+ bucket = [];
1200
+ buckets.set(key, bucket);
1201
+ }
1202
+ bucket.push(edge);
1203
+ }
1204
+ const ordered = [...buckets.entries()].map(([key, bucket]) => {
1205
+ const [prevTier, nextTier] = key.split("|");
1206
+ const alpha = lerp(edgeTierAlpha(prevTier, dimOpacity), edgeTierAlpha(nextTier, dimOpacity), t) * ea;
1207
+ return { alpha, bucket };
1208
+ }).sort((a2, b) => a2.alpha - b.alpha);
1209
+ for (const { alpha, bucket } of ordered) {
1210
+ this.drawEdgeGroupBatched(ctx, bucket, alpha, searchMatches, enterAlphaFor);
1211
+ }
1212
+ this.drawHoveredEdge(ctx, hoveredEdge);
1213
+ }
1214
+ /** Draw the hovered edge on top with a thickened highlight stroke. */
1215
+ drawHoveredEdge(ctx, hoveredEdge) {
1216
+ if (!hoveredEdge) return;
1217
+ const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
1218
+ ctx.setLineDash(dash);
1219
+ ctx.strokeStyle = hoveredEdge.stroke;
1220
+ ctx.lineWidth = hoveredEdge.strokeWidth * 2;
1221
+ ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
1222
+ ctx.beginPath();
1223
+ ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
1224
+ ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
1225
+ ctx.stroke();
1226
+ ctx.setLineDash([]);
1227
+ ctx.globalAlpha = 1;
669
1228
  }
670
1229
  /**
671
1230
  * Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
672
1231
  * When search is inactive, all edges of the same style are drawn in a single path.
673
1232
  * When search is active, edges split by search-match status for alpha dimming.
674
1233
  */
675
- drawEdgeGroupBatched(ctx, edges, alpha, searchMatches) {
1234
+ drawEdgeGroupBatched(ctx, edges, alpha, searchMatches, enterAlphaFor) {
676
1235
  if (edges.length === 0) return;
677
1236
  const groups = /* @__PURE__ */ new Map();
678
1237
  for (const edge of edges) {
679
- const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
1238
+ const rawEnter = Math.min(enterAlphaFor(edge.source), enterAlphaFor(edge.target));
1239
+ const enter = Math.round(rawEnter * 8) / 8;
1240
+ const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}|${enter}`;
680
1241
  let group = groups.get(key);
681
1242
  if (!group) {
682
- group = [];
1243
+ group = { edges: [], enter };
683
1244
  groups.set(key, group);
684
1245
  }
685
- group.push(edge);
1246
+ group.edges.push(edge);
686
1247
  }
687
- for (const [, group] of groups) {
1248
+ for (const [, { edges: group, enter }] of groups) {
688
1249
  const sample = group[0];
689
1250
  const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
690
1251
  ctx.setLineDash(dash);
691
1252
  ctx.strokeStyle = sample.stroke;
692
1253
  ctx.lineWidth = sample.strokeWidth;
1254
+ const groupAlpha = alpha * enter;
693
1255
  if (!searchMatches) {
694
- ctx.globalAlpha = alpha;
1256
+ ctx.globalAlpha = groupAlpha;
695
1257
  ctx.beginPath();
696
1258
  for (const edge of group) {
697
1259
  ctx.moveTo(edge.sourceX, edge.sourceY);
@@ -699,7 +1261,7 @@ var GraphCanvasRenderer = class {
699
1261
  }
700
1262
  ctx.stroke();
701
1263
  } else {
702
- ctx.globalAlpha = alpha;
1264
+ ctx.globalAlpha = groupAlpha;
703
1265
  ctx.beginPath();
704
1266
  let hasMatched = false;
705
1267
  const nonMatchPath = [];
@@ -716,7 +1278,7 @@ var GraphCanvasRenderer = class {
716
1278
  }
717
1279
  if (hasMatched) ctx.stroke();
718
1280
  if (nonMatchPath.length > 0) {
719
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * alpha;
1281
+ ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * groupAlpha;
720
1282
  ctx.beginPath();
721
1283
  for (const edge of nonMatchPath) {
722
1284
  ctx.moveTo(edge.sourceX, edge.sourceY);
@@ -732,147 +1294,114 @@ var GraphCanvasRenderer = class {
732
1294
  // -------------------------------------------------------------------------
733
1295
  // Batched node drawing
734
1296
  // -------------------------------------------------------------------------
735
- drawNodesBatched(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, showGlow, theme, minRadius) {
1297
+ drawNodesBatched(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, showGlow, theme, minRadius, nextFocus, dimOpacity, crossfade, hoverRadiusScale, entrance, enterAlphaFor) {
1298
+ const focusAlpha = (node) => {
1299
+ if (crossfade) {
1300
+ return lerp(
1301
+ nodeTierAlpha(nodeTier(node, crossfade.prev), dimOpacity),
1302
+ nodeTierAlpha(nodeTier(node, crossfade.next), dimOpacity),
1303
+ crossfade.t
1304
+ );
1305
+ }
1306
+ return nodeTierAlpha(nodeTier(node, nextFocus), dimOpacity);
1307
+ };
1308
+ const searchAlpha = (node) => searchMatches !== null && !searchMatches.has(node.id) ? SEARCH_NON_MATCH_ALPHA : 1;
1309
+ const entranceAlpha = (node) => entrance ? entrance.nodeAlpha(node) : 1;
1310
+ const entranceScale = (node) => entrance ? entrance.nodeScale(node) : 1;
1311
+ const entranceShift = (node) => entrance ? entrance.shift(node.id) : ZERO_SHIFT;
1312
+ const effectiveAlpha = (node) => focusAlpha(node) * searchAlpha(node) * entranceAlpha(node) * enterAlphaFor(node.id);
736
1313
  const bulkNodes = [];
737
1314
  const specialNodes = [];
738
1315
  for (const node of nodes) {
739
- if (node.id === hoveredNodeId || selectedNodeIds.has(node.id)) {
1316
+ if (node.id === hoveredNodeId || selectedNodeIds.has(node.id) || (hoverRadiusScale?.has(node.id) ?? false)) {
740
1317
  specialNodes.push(node);
741
1318
  } else {
742
1319
  bulkNodes.push(node);
743
1320
  }
744
1321
  }
745
- const r = (node) => Math.max(node.radius, minRadius);
746
- if (showGlow) {
1322
+ const r = (node) => Math.max(node.radius, minRadius) * entranceScale(node);
1323
+ if (showGlow && !entrance) {
747
1324
  this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
748
1325
  }
749
1326
  const fillGroups = /* @__PURE__ */ new Map();
750
1327
  for (const node of bulkNodes) {
751
- let group = fillGroups.get(node.fill);
1328
+ const alpha = effectiveAlpha(node);
1329
+ const key = `${node.fill}|${alpha.toFixed(3)}`;
1330
+ let group = fillGroups.get(key);
752
1331
  if (!group) {
753
- group = [];
754
- fillGroups.set(node.fill, group);
1332
+ group = { fill: node.fill, alpha, nodes: [] };
1333
+ fillGroups.set(key, group);
755
1334
  }
756
- group.push(node);
1335
+ group.nodes.push(node);
757
1336
  }
758
- if (!searchMatches) {
759
- ctx.globalAlpha = 1;
760
- for (const [fill, group] of fillGroups) {
761
- ctx.fillStyle = fill;
762
- ctx.beginPath();
763
- for (const node of group) {
764
- const nr = r(node);
765
- ctx.moveTo(node.x + nr, node.y);
766
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
767
- }
768
- ctx.fill();
769
- }
770
- } else {
771
- for (const [fill, group] of fillGroups) {
772
- ctx.fillStyle = fill;
773
- ctx.globalAlpha = 1;
774
- ctx.beginPath();
775
- let hasMatched = false;
776
- const dimmedNodes = [];
777
- for (const node of group) {
778
- if (searchMatches.has(node.id)) {
779
- const nr = r(node);
780
- ctx.moveTo(node.x + nr, node.y);
781
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
782
- hasMatched = true;
783
- } else {
784
- dimmedNodes.push(node);
785
- }
786
- }
787
- if (hasMatched) ctx.fill();
788
- if (dimmedNodes.length > 0) {
789
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
790
- ctx.beginPath();
791
- for (const node of dimmedNodes) {
792
- const nr = r(node);
793
- ctx.moveTo(node.x + nr, node.y);
794
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
795
- }
796
- ctx.fill();
797
- }
1337
+ for (const { fill, alpha, nodes: group } of fillGroups.values()) {
1338
+ ctx.fillStyle = fill;
1339
+ ctx.globalAlpha = alpha;
1340
+ ctx.beginPath();
1341
+ for (const node of group) {
1342
+ const nr = r(node);
1343
+ if (nr <= 0) continue;
1344
+ const s = entranceShift(node);
1345
+ ctx.moveTo(node.x + s.x + nr, node.y + s.y);
1346
+ ctx.arc(node.x + s.x, node.y + s.y, nr, 0, TWO_PI);
798
1347
  }
1348
+ ctx.fill();
799
1349
  }
800
1350
  const strokeGroups = /* @__PURE__ */ new Map();
801
1351
  for (const node of bulkNodes) {
802
- const key = `${node.stroke}|${node.strokeWidth}`;
1352
+ const alpha = effectiveAlpha(node);
1353
+ const key = `${node.stroke}|${node.strokeWidth}|${alpha.toFixed(3)}`;
803
1354
  let group = strokeGroups.get(key);
804
1355
  if (!group) {
805
- group = [];
1356
+ group = { stroke: node.stroke, width: node.strokeWidth, alpha, nodes: [] };
806
1357
  strokeGroups.set(key, group);
807
1358
  }
808
- group.push(node);
1359
+ group.nodes.push(node);
809
1360
  }
810
- for (const [key, group] of strokeGroups) {
811
- const [stroke, widthStr] = key.split("|");
1361
+ for (const { stroke, width, alpha, nodes: group } of strokeGroups.values()) {
812
1362
  ctx.strokeStyle = stroke;
813
- ctx.lineWidth = parseFloat(widthStr);
814
- if (!searchMatches) {
815
- ctx.globalAlpha = 1;
816
- ctx.beginPath();
817
- for (const node of group) {
818
- const nr = r(node);
819
- ctx.moveTo(node.x + nr, node.y);
820
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
821
- }
822
- ctx.stroke();
823
- } else {
824
- ctx.globalAlpha = 1;
825
- ctx.beginPath();
826
- let hasMatched = false;
827
- const dimmedNodes = [];
828
- for (const node of group) {
829
- if (searchMatches.has(node.id)) {
830
- const nr = r(node);
831
- ctx.moveTo(node.x + nr, node.y);
832
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
833
- hasMatched = true;
834
- } else {
835
- dimmedNodes.push(node);
836
- }
837
- }
838
- if (hasMatched) ctx.stroke();
839
- if (dimmedNodes.length > 0) {
840
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA;
841
- ctx.beginPath();
842
- for (const node of dimmedNodes) {
843
- const nr = r(node);
844
- ctx.moveTo(node.x + nr, node.y);
845
- ctx.arc(node.x, node.y, nr, 0, TWO_PI);
846
- }
847
- ctx.stroke();
848
- }
1363
+ ctx.lineWidth = width;
1364
+ ctx.globalAlpha = alpha;
1365
+ ctx.beginPath();
1366
+ for (const node of group) {
1367
+ const nr = r(node);
1368
+ if (nr <= 0) continue;
1369
+ const s = entranceShift(node);
1370
+ ctx.moveTo(node.x + s.x + nr, node.y + s.y);
1371
+ ctx.arc(node.x + s.x, node.y + s.y, nr, 0, TWO_PI);
849
1372
  }
1373
+ ctx.stroke();
850
1374
  }
851
1375
  for (const node of specialNodes) {
852
1376
  const isHovered = node.id === hoveredNodeId;
853
1377
  const isSelected = selectedNodeIds.has(node.id);
854
1378
  const dimmed = searchMatches !== null && !searchMatches.has(node.id);
855
1379
  const baseRadius = Math.max(node.radius, minRadius);
856
- const radius = isHovered ? baseRadius * 1.15 : baseRadius;
1380
+ const hoverScale = hoverRadiusScale?.get(node.id) ?? (isHovered ? 1.15 : 1);
1381
+ const radius = baseRadius * hoverScale;
1382
+ const brightened = isHovered && hoverScale >= 1.075;
1383
+ const s = entranceShift(node);
1384
+ const nx = node.x + s.x;
1385
+ const ny = node.y + s.y;
857
1386
  ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
858
1387
  if (showGlow && !dimmed) {
859
1388
  ctx.beginPath();
860
- ctx.arc(node.x, node.y, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
1389
+ ctx.arc(nx, ny, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
861
1390
  ctx.fillStyle = node.fill;
862
1391
  ctx.globalAlpha = GLOW_ALPHA;
863
1392
  ctx.fill();
864
1393
  ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
865
1394
  }
866
1395
  ctx.beginPath();
867
- ctx.arc(node.x, node.y, radius, 0, TWO_PI);
868
- ctx.fillStyle = isHovered ? brighten(node.fill) : node.fill;
1396
+ ctx.arc(nx, ny, radius, 0, TWO_PI);
1397
+ ctx.fillStyle = brightened ? brighten(node.fill) : node.fill;
869
1398
  ctx.fill();
870
1399
  ctx.strokeStyle = node.stroke;
871
1400
  ctx.lineWidth = node.strokeWidth;
872
1401
  ctx.stroke();
873
1402
  if (isSelected) {
874
1403
  ctx.beginPath();
875
- ctx.arc(node.x, node.y, radius + 3, 0, TWO_PI);
1404
+ ctx.arc(nx, ny, radius + 3, 0, TWO_PI);
876
1405
  ctx.strokeStyle = theme.colors.categorical[0] ?? "#3b82f6";
877
1406
  ctx.lineWidth = 2;
878
1407
  ctx.stroke();
@@ -908,7 +1437,8 @@ var GraphCanvasRenderer = class {
908
1437
  // -------------------------------------------------------------------------
909
1438
  // Labels (drawn individually, skipped during gestures)
910
1439
  // -------------------------------------------------------------------------
911
- drawLabels(ctx, nodes, threshold, hoveredNodeId, selectedNodeIds, searchMatches, zoom, theme) {
1440
+ drawLabels(ctx, nodes, threshold, hoveredNodeId, selectedNodeIds, searchMatches, zoom, theme, entrance, enterAlphaFor) {
1441
+ const la = entrance ? entrance.labelAlpha : 1;
912
1442
  const rawSize = 10 / zoom;
913
1443
  const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
914
1444
  ctx.font = `${fontSize}px ${theme.fonts.family}`;
@@ -921,12 +1451,12 @@ var GraphCanvasRenderer = class {
921
1451
  const forced = isHovered || isSelected;
922
1452
  const dimmed = searchMatches !== null && !searchMatches.has(node.id);
923
1453
  if (!forced && node.labelPriority < threshold) continue;
924
- ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
1454
+ ctx.globalAlpha = (dimmed ? SEARCH_NON_MATCH_ALPHA : 1) * la * enterAlphaFor(node.id);
925
1455
  const labelY = node.y + node.radius + 3;
926
1456
  if (theme.colors.background !== "transparent") {
927
1457
  ctx.strokeStyle = theme.colors.background;
928
1458
  } else {
929
- ctx.strokeStyle = isLightColor(theme.colors.text) ? "rgba(0, 0, 0, 0.7)" : "rgba(255, 255, 255, 0.85)";
1459
+ ctx.strokeStyle = theme.isDark ? "rgba(0, 0, 0, 0.7)" : "rgba(255, 255, 255, 0.85)";
930
1460
  }
931
1461
  ctx.lineWidth = 3;
932
1462
  ctx.lineJoin = "round";
@@ -937,6 +1467,59 @@ var GraphCanvasRenderer = class {
937
1467
  }
938
1468
  ctx.globalAlpha = 1;
939
1469
  }
1470
+ // -------------------------------------------------------------------------
1471
+ // Exit ghosts (Phase 7)
1472
+ // -------------------------------------------------------------------------
1473
+ /**
1474
+ * Draw exit ghosts (removed nodes/edges) UNDER the live marks at a global fade
1475
+ * alpha. Neutral rendering: no focus dim, no search dim, no selection ring —
1476
+ * they're on their way out. Batched by (stroke/style) for edges and (fill) for
1477
+ * nodes, culled to the visible rect.
1478
+ */
1479
+ drawGhosts(ctx, exiting, rect) {
1480
+ const alpha = exiting.alpha;
1481
+ const edgeGroups = /* @__PURE__ */ new Map();
1482
+ for (const edge of exiting.edges) {
1483
+ if (!edgeInView(edge, rect)) continue;
1484
+ const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
1485
+ const group = edgeGroups.get(key);
1486
+ if (group) group.push(edge);
1487
+ else edgeGroups.set(key, [edge]);
1488
+ }
1489
+ for (const [, group] of edgeGroups) {
1490
+ const sample = group[0];
1491
+ const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
1492
+ ctx.setLineDash(dash);
1493
+ ctx.strokeStyle = sample.stroke;
1494
+ ctx.lineWidth = sample.strokeWidth;
1495
+ ctx.globalAlpha = EDGE_ALPHA_DEFAULT * alpha;
1496
+ ctx.beginPath();
1497
+ for (const edge of group) {
1498
+ ctx.moveTo(edge.sourceX, edge.sourceY);
1499
+ ctx.lineTo(edge.targetX, edge.targetY);
1500
+ }
1501
+ ctx.stroke();
1502
+ }
1503
+ ctx.setLineDash([]);
1504
+ const nodeGroups = /* @__PURE__ */ new Map();
1505
+ for (const node of exiting.nodes) {
1506
+ if (!nodeInView(node, rect)) continue;
1507
+ const group = nodeGroups.get(node.fill);
1508
+ if (group) group.push(node);
1509
+ else nodeGroups.set(node.fill, [node]);
1510
+ }
1511
+ for (const [fill, group] of nodeGroups) {
1512
+ ctx.fillStyle = fill;
1513
+ ctx.globalAlpha = alpha;
1514
+ ctx.beginPath();
1515
+ for (const node of group) {
1516
+ ctx.moveTo(node.x + node.radius, node.y);
1517
+ ctx.arc(node.x, node.y, node.radius, 0, TWO_PI);
1518
+ }
1519
+ ctx.fill();
1520
+ }
1521
+ ctx.globalAlpha = 1;
1522
+ }
940
1523
  };
941
1524
  function brighten(color) {
942
1525
  const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
@@ -956,110 +1539,102 @@ function brighten(color) {
956
1539
  }
957
1540
  return color;
958
1541
  }
959
- function isLightColor(color) {
960
- const hex = color.replace("#", "");
961
- const full = hex.length === 3 ? hex.split("").map((c2) => c2 + c2).join("") : hex;
962
- if (full.length !== 6) return false;
963
- const r = parseInt(full.slice(0, 2), 16) / 255;
964
- const g = parseInt(full.slice(2, 4), 16) / 255;
965
- const b = parseInt(full.slice(4, 6), 16) / 255;
966
- const toLinear = (c2) => c2 <= 0.03928 ? c2 / 12.92 : ((c2 + 0.055) / 1.055) ** 2.4;
967
- return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) > 0.5;
968
- }
969
1542
 
970
- // src/graph/zoom.ts
971
- var ZoomTransform = class _ZoomTransform {
972
- constructor(x3, y3, k) {
973
- this.x = x3;
974
- this.y = y3;
975
- this.k = k;
976
- }
977
- /** Convert screen coordinates to graph coordinates. */
978
- screenToGraph(sx, sy) {
979
- return {
980
- x: (sx - this.x) / this.k,
981
- y: (sy - this.y) / this.k
982
- };
983
- }
984
- /** Convert graph coordinates to screen coordinates. */
985
- graphToScreen(gx, gy) {
986
- return {
987
- x: gx * this.k + this.x,
988
- y: gy * this.k + this.y
989
- };
1543
+ // src/graph/focus-transition.ts
1544
+ function focusSnapshotsEqual(a2, b) {
1545
+ return a2.hasActive === b.hasActive && setsEqual(a2.connected, b.connected) && nullableSetsEqual(a2.searchMatches, b.searchMatches) && setsEqual(a2.selected, b.selected);
1546
+ }
1547
+ function setsEqual(a2, b) {
1548
+ if (a2.size !== b.size) return false;
1549
+ for (const v of a2) if (!b.has(v)) return false;
1550
+ return true;
1551
+ }
1552
+ function nullableSetsEqual(a2, b) {
1553
+ if (a2 === null || b === null) return a2 === b;
1554
+ return setsEqual(a2, b);
1555
+ }
1556
+ var FocusTransition = class {
1557
+ prev;
1558
+ next;
1559
+ startTime;
1560
+ duration;
1561
+ ease;
1562
+ constructor(initial, duration, ease, now2) {
1563
+ this.prev = initial;
1564
+ this.next = initial;
1565
+ this.startTime = now2;
1566
+ this.duration = Math.max(0, duration);
1567
+ this.ease = ease;
990
1568
  }
991
1569
  /**
992
- * Zoom to a target scale, keeping the given screen-space pivot
993
- * point fixed (content under the cursor stays under the cursor).
1570
+ * Point a fresh transition at `target`, capturing the endpoint closest to the
1571
+ * current display as the new `prev` (the `p < 0.5` rule). A no-op when
1572
+ * `target` already equals `next`.
994
1573
  */
995
- zoomAt(targetK, pivotX, pivotY) {
996
- const gx = (pivotX - this.x) / this.k;
997
- const gy = (pivotY - this.y) / this.k;
998
- return new _ZoomTransform(pivotX - gx * targetK, pivotY - gy * targetK, targetK);
999
- }
1000
- /** Pan by a screen-space delta. */
1001
- pan(dx, dy) {
1002
- return new _ZoomTransform(this.x + dx, this.y + dy, this.k);
1003
- }
1004
- /**
1005
- * Compute a transform that fits all nodes within the given canvas
1006
- * dimensions with the specified padding.
1007
- *
1008
- * Returns the transform and the ideal content height (in screen pixels)
1009
- * so callers can shrink the canvas to eliminate dead space.
1010
- */
1011
- static fitBounds(nodes, canvasW, canvasH, padding = 40) {
1012
- if (nodes.length === 0) {
1013
- return { transform: _ZoomTransform.identity(), contentHeight: canvasH };
1014
- }
1015
- let minX = Infinity;
1016
- let minY = Infinity;
1017
- let maxX = -Infinity;
1018
- let maxY = -Infinity;
1019
- for (const n of nodes) {
1020
- const r = n.radius;
1021
- if (n.x - r < minX) minX = n.x - r;
1022
- if (n.y - r < minY) minY = n.y - r;
1023
- if (n.x + r > maxX) maxX = n.x + r;
1024
- if (n.y + r > maxY) maxY = n.y + r;
1025
- }
1026
- let graphW = maxX - minX;
1027
- let graphH = maxY - minY;
1028
- if (graphW === 0 && graphH === 0) {
1029
- return {
1030
- transform: new _ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1),
1031
- contentHeight: padding * 2
1032
- };
1033
- }
1034
- if (nodes.length > 50) {
1035
- const spread = 1 + Math.sqrt(nodes.length) / 120;
1036
- const cx2 = (minX + maxX) / 2;
1037
- const cy2 = (minY + maxY) / 2;
1038
- graphW *= spread;
1039
- graphH *= spread;
1040
- minX = cx2 - graphW / 2;
1041
- maxX = cx2 + graphW / 2;
1042
- minY = cy2 - graphH / 2;
1043
- maxY = cy2 + graphH / 2;
1044
- }
1045
- const availW = canvasW - padding * 2;
1046
- const availH = canvasH - padding * 2;
1047
- const k = Math.min(1, availW / graphW, availH / graphH);
1048
- const cx = (minX + maxX) / 2;
1049
- const cy = (minY + maxY) / 2;
1050
- const tx = canvasW / 2 - cx * k;
1051
- const ty = canvasH / 2 - cy * k;
1052
- const contentHeight = graphH * k + padding * 2;
1053
- return {
1054
- transform: new _ZoomTransform(tx, ty, k),
1055
- contentHeight
1056
- };
1057
- }
1058
- /** Identity transform (no pan, no zoom). */
1059
- static identity() {
1060
- return new _ZoomTransform(0, 0, 1);
1574
+ retarget(target, now2) {
1575
+ if (focusSnapshotsEqual(target, this.next)) return;
1576
+ const p = this.rawProgress(now2);
1577
+ this.prev = p < 0.5 ? this.prev : this.next;
1578
+ this.next = target;
1579
+ this.startTime = now2;
1580
+ }
1581
+ /** Raw (un-eased) 0..1 progress. */
1582
+ rawProgress(now2) {
1583
+ if (this.duration <= 0) return 1;
1584
+ return Math.min(1, Math.max(0, (now2 - this.startTime) / this.duration));
1585
+ }
1586
+ /** Eased 0..1 progress toward `next`. */
1587
+ progress(now2) {
1588
+ return this.ease(this.rawProgress(now2));
1589
+ }
1590
+ /** True once the transition has fully settled onto `next`. */
1591
+ isSettled(now2) {
1592
+ return this.rawProgress(now2) >= 1;
1061
1593
  }
1062
1594
  };
1595
+ function composeStandingFocus(highlight, searchMatches, selected, adjacency) {
1596
+ const hasHighlight = highlight !== null && highlight.size > 0;
1597
+ const hasSearch = searchMatches !== null && searchMatches.size > 0;
1598
+ let core = null;
1599
+ if (highlight !== null && hasHighlight && searchMatches !== null && hasSearch) {
1600
+ const inter = intersect(highlight, searchMatches);
1601
+ core = inter.size > 0 ? inter : searchMatches;
1602
+ } else if (hasHighlight) {
1603
+ core = highlight;
1604
+ } else if (hasSearch) {
1605
+ core = null;
1606
+ }
1607
+ const connected = /* @__PURE__ */ new Set();
1608
+ if (core) {
1609
+ for (const id of core) {
1610
+ connected.add(id);
1611
+ const neighbors = adjacency.get(id);
1612
+ if (neighbors) for (const nid of neighbors) connected.add(nid);
1613
+ }
1614
+ }
1615
+ const hasActive = core !== null && core.size > 0 || hasSearch || selected.size > 0;
1616
+ return {
1617
+ hasActive,
1618
+ connected,
1619
+ searchMatches: hasSearch ? searchMatches : null,
1620
+ selected
1621
+ };
1622
+ }
1623
+ function layerHoverFocus(standing, hoveredId, hoverConnected) {
1624
+ if (hoveredId === null || hoverConnected === null) return standing;
1625
+ return {
1626
+ hasActive: true,
1627
+ connected: hoverConnected,
1628
+ searchMatches: standing.searchMatches,
1629
+ selected: standing.selected
1630
+ };
1631
+ }
1632
+ function intersect(a2, b) {
1633
+ const [small, large] = a2.size <= b.size ? [a2, b] : [b, a2];
1634
+ const out = /* @__PURE__ */ new Set();
1635
+ for (const v of small) if (large.has(v)) out.add(v);
1636
+ return out;
1637
+ }
1063
1638
 
1064
1639
  // src/graph/interaction.ts
1065
1640
  var ZOOM_MIN = 0.05;
@@ -1121,6 +1696,15 @@ var GraphInteractionManager = class {
1121
1696
  getTransform() {
1122
1697
  return this.transform;
1123
1698
  }
1699
+ /**
1700
+ * Replace the internal selection set. Used by the mount to prune deleted ids
1701
+ * after a data update so a later shift-click can't resurrect them through
1702
+ * `onSelectionChange`. Does NOT fire `onSelectionChange` — the caller owns any
1703
+ * downstream sync (the mount already holds the pruned set).
1704
+ */
1705
+ setSelection(ids) {
1706
+ this.selectedIds = new Set(ids);
1707
+ }
1124
1708
  destroy() {
1125
1709
  this.canvas.removeEventListener("wheel", this.boundWheel);
1126
1710
  this.canvas.removeEventListener("mousedown", this.boundMouseDown);
@@ -1165,6 +1749,10 @@ var GraphInteractionManager = class {
1165
1749
  }
1166
1750
  onMouseMove(e) {
1167
1751
  const { x: x3, y: y3 } = this.canvasXY(e);
1752
+ if (this.callbacks.onPointerMove) {
1753
+ const gp = this.transform.screenToGraph(x3, y3);
1754
+ this.callbacks.onPointerMove(gp.x, gp.y);
1755
+ }
1168
1756
  if (this.dragState) {
1169
1757
  const graph = this.transform.screenToGraph(x3, y3);
1170
1758
  if (!this.dragState.started) {
@@ -1223,6 +1811,7 @@ var GraphInteractionManager = class {
1223
1811
  onMouseLeave(_e) {
1224
1812
  this.callbacks.onHoverChange(null);
1225
1813
  this.canvas.style.cursor = "default";
1814
+ this.callbacks.onPointerLeave?.();
1226
1815
  if (this.panState) {
1227
1816
  this.panState = null;
1228
1817
  }
@@ -1455,9 +2044,76 @@ function attachGraphKeyboardNav(options) {
1455
2044
  };
1456
2045
  }
1457
2046
 
2047
+ // src/graph/legend.ts
2048
+ function escapeHtml(str) {
2049
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
2050
+ }
2051
+ function createGraphLegend(host, data, callbacks) {
2052
+ const listeners = [];
2053
+ function render(view) {
2054
+ teardownListeners();
2055
+ host.replaceChildren();
2056
+ if (view.nodes.length === 0 && view.edges.length === 0) {
2057
+ host.style.display = "none";
2058
+ return;
2059
+ }
2060
+ host.style.display = "";
2061
+ for (const entry of view.nodes) {
2062
+ host.appendChild(nodeRow(entry));
2063
+ }
2064
+ for (const entry of view.edges) {
2065
+ host.appendChild(edgeRow(entry));
2066
+ }
2067
+ }
2068
+ function nodeRow(entry) {
2069
+ const interactive = callbacks.interactive;
2070
+ const el = document.createElement(interactive ? "button" : "div");
2071
+ el.className = "oc-graph-legend-item";
2072
+ if (!entry.active) el.classList.add("oc-graph-legend-item--inactive");
2073
+ if (interactive) {
2074
+ const btn = el;
2075
+ btn.type = "button";
2076
+ btn.setAttribute("aria-pressed", String(entry.active));
2077
+ const onClick = () => callbacks.onToggle(entry.label);
2078
+ const onEnter = () => callbacks.onHover(entry.label);
2079
+ const onLeave = () => callbacks.onHover(null);
2080
+ btn.addEventListener("click", onClick);
2081
+ btn.addEventListener("mouseenter", onEnter);
2082
+ btn.addEventListener("mouseleave", onLeave);
2083
+ listeners.push(() => {
2084
+ btn.removeEventListener("click", onClick);
2085
+ btn.removeEventListener("mouseenter", onEnter);
2086
+ btn.removeEventListener("mouseleave", onLeave);
2087
+ });
2088
+ }
2089
+ el.innerHTML = `<span class="oc-graph-legend-swatch" style="background:${escapeHtml(entry.color)}"></span><span class="oc-graph-legend-label">${escapeHtml(entry.label)}</span>` + (callbacks.counts && entry.count != null ? `<span class="oc-graph-legend-count">${entry.count.toLocaleString()}</span>` : "");
2090
+ return el;
2091
+ }
2092
+ function edgeRow(entry) {
2093
+ const el = document.createElement("div");
2094
+ el.className = "oc-graph-legend-item oc-graph-legend-item--edge";
2095
+ el.innerHTML = `<span class="oc-graph-legend-swatch oc-graph-legend-swatch--line" style="background:${escapeHtml(entry.color)}"></span><span class="oc-graph-legend-label">${escapeHtml(entry.label)}</span>` + (callbacks.counts && entry.count != null ? `<span class="oc-graph-legend-count">${entry.count.toLocaleString()}</span>` : "");
2096
+ return el;
2097
+ }
2098
+ function teardownListeners() {
2099
+ for (const off of listeners) off();
2100
+ listeners.length = 0;
2101
+ }
2102
+ render(data);
2103
+ return {
2104
+ update: render,
2105
+ destroy() {
2106
+ teardownListeners();
2107
+ host.replaceChildren();
2108
+ }
2109
+ };
2110
+ }
2111
+
1458
2112
  // src/graph/search.ts
1459
2113
  var GraphSearchManager = class {
1460
2114
  matchedIds = null;
2115
+ /** The last active (non-empty) query, so a data update can re-run it. */
2116
+ query = null;
1461
2117
  /**
1462
2118
  * Search for nodes matching the query string.
1463
2119
  * Returns a Set of matching node ids, or an empty set if nothing matches.
@@ -1466,8 +2122,10 @@ var GraphSearchManager = class {
1466
2122
  const q = query.toLowerCase().trim();
1467
2123
  if (q === "") {
1468
2124
  this.matchedIds = null;
2125
+ this.query = null;
1469
2126
  return /* @__PURE__ */ new Set();
1470
2127
  }
2128
+ this.query = query;
1471
2129
  const matches = /* @__PURE__ */ new Set();
1472
2130
  for (const node of nodes) {
1473
2131
  const label = (node.label ?? "").toLowerCase();
@@ -1485,14 +2143,53 @@ var GraphSearchManager = class {
1485
2143
  */
1486
2144
  clearSearch() {
1487
2145
  this.matchedIds = null;
2146
+ this.query = null;
1488
2147
  return null;
1489
2148
  }
1490
2149
  /** Get the current set of matched ids, or null if no search is active. */
1491
2150
  getMatches() {
1492
2151
  return this.matchedIds;
1493
2152
  }
2153
+ /** The last active query string, or null when no search is active. */
2154
+ getQuery() {
2155
+ return this.query;
2156
+ }
1494
2157
  };
1495
2158
 
2159
+ // src/graph/seed.ts
2160
+ function hash32(str) {
2161
+ let h = 2166136261;
2162
+ for (let i = 0; i < str.length; i++) {
2163
+ h ^= str.charCodeAt(i);
2164
+ h = Math.imul(h, 16777619);
2165
+ }
2166
+ return h >>> 0;
2167
+ }
2168
+ function unit(h) {
2169
+ return (h >>> 0) / 4294967296;
2170
+ }
2171
+ var R0 = 300;
2172
+ function seedNodePositions(nodes, seed) {
2173
+ const n = nodes.length;
2174
+ if (n === 0) return;
2175
+ for (const node of nodes) {
2176
+ const u1 = unit(hash32(`${node.id}|${seed}|r`));
2177
+ const u2 = unit(hash32(`${node.id}|${seed}|t`));
2178
+ const r = R0 * Math.sqrt(u1);
2179
+ const theta = u2 * Math.PI * 2;
2180
+ let cx = 0;
2181
+ let cy = 0;
2182
+ if (node.community) {
2183
+ const ca = unit(hash32(`${node.community}|${seed}`)) * Math.PI * 2;
2184
+ const bias = 0.6 * R0;
2185
+ cx = Math.cos(ca) * bias;
2186
+ cy = Math.sin(ca) * bias;
2187
+ }
2188
+ node.x = cx + r * Math.cos(theta);
2189
+ node.y = cy + r * Math.sin(theta);
2190
+ }
2191
+ }
2192
+
1496
2193
  // ../../node_modules/.bun/d3-force@3.0.0/node_modules/d3-force/src/center.js
1497
2194
  function center_default(x3, y3) {
1498
2195
  var nodes, strength = 1;
@@ -1522,301 +2219,6 @@ function center_default(x3, y3) {
1522
2219
  return force;
1523
2220
  }
1524
2221
 
1525
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/add.js
1526
- function add_default(d) {
1527
- const x3 = +this._x.call(null, d), y3 = +this._y.call(null, d);
1528
- return add(this.cover(x3, y3), x3, y3, d);
1529
- }
1530
- function add(tree, x3, y3, d) {
1531
- if (isNaN(x3) || isNaN(y3)) return tree;
1532
- var parent, node = tree._root, leaf = { data: d }, x0 = tree._x0, y0 = tree._y0, x1 = tree._x1, y1 = tree._y1, xm, ym, xp, yp, right, bottom, i, j;
1533
- if (!node) return tree._root = leaf, tree;
1534
- while (node.length) {
1535
- if (right = x3 >= (xm = (x0 + x1) / 2)) x0 = xm;
1536
- else x1 = xm;
1537
- if (bottom = y3 >= (ym = (y0 + y1) / 2)) y0 = ym;
1538
- else y1 = ym;
1539
- if (parent = node, !(node = node[i = bottom << 1 | right])) return parent[i] = leaf, tree;
1540
- }
1541
- xp = +tree._x.call(null, node.data);
1542
- yp = +tree._y.call(null, node.data);
1543
- if (x3 === xp && y3 === yp) return leaf.next = node, parent ? parent[i] = leaf : tree._root = leaf, tree;
1544
- do {
1545
- parent = parent ? parent[i] = new Array(4) : tree._root = new Array(4);
1546
- if (right = x3 >= (xm = (x0 + x1) / 2)) x0 = xm;
1547
- else x1 = xm;
1548
- if (bottom = y3 >= (ym = (y0 + y1) / 2)) y0 = ym;
1549
- else y1 = ym;
1550
- } while ((i = bottom << 1 | right) === (j = (yp >= ym) << 1 | xp >= xm));
1551
- return parent[j] = node, parent[i] = leaf, tree;
1552
- }
1553
- function addAll(data) {
1554
- var d, i, n = data.length, x3, y3, xz = new Array(n), yz = new Array(n), x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1555
- for (i = 0; i < n; ++i) {
1556
- if (isNaN(x3 = +this._x.call(null, d = data[i])) || isNaN(y3 = +this._y.call(null, d))) continue;
1557
- xz[i] = x3;
1558
- yz[i] = y3;
1559
- if (x3 < x0) x0 = x3;
1560
- if (x3 > x1) x1 = x3;
1561
- if (y3 < y0) y0 = y3;
1562
- if (y3 > y1) y1 = y3;
1563
- }
1564
- if (x0 > x1 || y0 > y1) return this;
1565
- this.cover(x0, y0).cover(x1, y1);
1566
- for (i = 0; i < n; ++i) {
1567
- add(this, xz[i], yz[i], data[i]);
1568
- }
1569
- return this;
1570
- }
1571
-
1572
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/cover.js
1573
- function cover_default(x3, y3) {
1574
- if (isNaN(x3 = +x3) || isNaN(y3 = +y3)) return this;
1575
- var x0 = this._x0, y0 = this._y0, x1 = this._x1, y1 = this._y1;
1576
- if (isNaN(x0)) {
1577
- x1 = (x0 = Math.floor(x3)) + 1;
1578
- y1 = (y0 = Math.floor(y3)) + 1;
1579
- } else {
1580
- var z = x1 - x0 || 1, node = this._root, parent, i;
1581
- while (x0 > x3 || x3 >= x1 || y0 > y3 || y3 >= y1) {
1582
- i = (y3 < y0) << 1 | x3 < x0;
1583
- parent = new Array(4), parent[i] = node, node = parent, z *= 2;
1584
- switch (i) {
1585
- case 0:
1586
- x1 = x0 + z, y1 = y0 + z;
1587
- break;
1588
- case 1:
1589
- x0 = x1 - z, y1 = y0 + z;
1590
- break;
1591
- case 2:
1592
- x1 = x0 + z, y0 = y1 - z;
1593
- break;
1594
- case 3:
1595
- x0 = x1 - z, y0 = y1 - z;
1596
- break;
1597
- }
1598
- }
1599
- if (this._root && this._root.length) this._root = node;
1600
- }
1601
- this._x0 = x0;
1602
- this._y0 = y0;
1603
- this._x1 = x1;
1604
- this._y1 = y1;
1605
- return this;
1606
- }
1607
-
1608
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/data.js
1609
- function data_default() {
1610
- var data = [];
1611
- this.visit(function(node) {
1612
- if (!node.length) do
1613
- data.push(node.data);
1614
- while (node = node.next);
1615
- });
1616
- return data;
1617
- }
1618
-
1619
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/extent.js
1620
- function extent_default(_) {
1621
- return arguments.length ? this.cover(+_[0][0], +_[0][1]).cover(+_[1][0], +_[1][1]) : isNaN(this._x0) ? void 0 : [[this._x0, this._y0], [this._x1, this._y1]];
1622
- }
1623
-
1624
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/quad.js
1625
- function quad_default(node, x0, y0, x1, y1) {
1626
- this.node = node;
1627
- this.x0 = x0;
1628
- this.y0 = y0;
1629
- this.x1 = x1;
1630
- this.y1 = y1;
1631
- }
1632
-
1633
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/find.js
1634
- function find_default(x3, y3, radius) {
1635
- var data, x0 = this._x0, y0 = this._y0, x1, y1, x22, y22, x32 = this._x1, y32 = this._y1, quads = [], node = this._root, q, i;
1636
- if (node) quads.push(new quad_default(node, x0, y0, x32, y32));
1637
- if (radius == null) radius = Infinity;
1638
- else {
1639
- x0 = x3 - radius, y0 = y3 - radius;
1640
- x32 = x3 + radius, y32 = y3 + radius;
1641
- radius *= radius;
1642
- }
1643
- while (q = quads.pop()) {
1644
- if (!(node = q.node) || (x1 = q.x0) > x32 || (y1 = q.y0) > y32 || (x22 = q.x1) < x0 || (y22 = q.y1) < y0) continue;
1645
- if (node.length) {
1646
- var xm = (x1 + x22) / 2, ym = (y1 + y22) / 2;
1647
- quads.push(
1648
- new quad_default(node[3], xm, ym, x22, y22),
1649
- new quad_default(node[2], x1, ym, xm, y22),
1650
- new quad_default(node[1], xm, y1, x22, ym),
1651
- new quad_default(node[0], x1, y1, xm, ym)
1652
- );
1653
- if (i = (y3 >= ym) << 1 | x3 >= xm) {
1654
- q = quads[quads.length - 1];
1655
- quads[quads.length - 1] = quads[quads.length - 1 - i];
1656
- quads[quads.length - 1 - i] = q;
1657
- }
1658
- } else {
1659
- var dx = x3 - +this._x.call(null, node.data), dy = y3 - +this._y.call(null, node.data), d2 = dx * dx + dy * dy;
1660
- if (d2 < radius) {
1661
- var d = Math.sqrt(radius = d2);
1662
- x0 = x3 - d, y0 = y3 - d;
1663
- x32 = x3 + d, y32 = y3 + d;
1664
- data = node.data;
1665
- }
1666
- }
1667
- }
1668
- return data;
1669
- }
1670
-
1671
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/remove.js
1672
- function remove_default(d) {
1673
- if (isNaN(x3 = +this._x.call(null, d)) || isNaN(y3 = +this._y.call(null, d))) return this;
1674
- var parent, node = this._root, retainer, previous, next, x0 = this._x0, y0 = this._y0, x1 = this._x1, y1 = this._y1, x3, y3, xm, ym, right, bottom, i, j;
1675
- if (!node) return this;
1676
- if (node.length) while (true) {
1677
- if (right = x3 >= (xm = (x0 + x1) / 2)) x0 = xm;
1678
- else x1 = xm;
1679
- if (bottom = y3 >= (ym = (y0 + y1) / 2)) y0 = ym;
1680
- else y1 = ym;
1681
- if (!(parent = node, node = node[i = bottom << 1 | right])) return this;
1682
- if (!node.length) break;
1683
- if (parent[i + 1 & 3] || parent[i + 2 & 3] || parent[i + 3 & 3]) retainer = parent, j = i;
1684
- }
1685
- while (node.data !== d) if (!(previous = node, node = node.next)) return this;
1686
- if (next = node.next) delete node.next;
1687
- if (previous) return next ? previous.next = next : delete previous.next, this;
1688
- if (!parent) return this._root = next, this;
1689
- next ? parent[i] = next : delete parent[i];
1690
- if ((node = parent[0] || parent[1] || parent[2] || parent[3]) && node === (parent[3] || parent[2] || parent[1] || parent[0]) && !node.length) {
1691
- if (retainer) retainer[j] = node;
1692
- else this._root = node;
1693
- }
1694
- return this;
1695
- }
1696
- function removeAll(data) {
1697
- for (var i = 0, n = data.length; i < n; ++i) this.remove(data[i]);
1698
- return this;
1699
- }
1700
-
1701
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/root.js
1702
- function root_default() {
1703
- return this._root;
1704
- }
1705
-
1706
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/size.js
1707
- function size_default() {
1708
- var size = 0;
1709
- this.visit(function(node) {
1710
- if (!node.length) do
1711
- ++size;
1712
- while (node = node.next);
1713
- });
1714
- return size;
1715
- }
1716
-
1717
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/visit.js
1718
- function visit_default(callback) {
1719
- var quads = [], q, node = this._root, child, x0, y0, x1, y1;
1720
- if (node) quads.push(new quad_default(node, this._x0, this._y0, this._x1, this._y1));
1721
- while (q = quads.pop()) {
1722
- if (!callback(node = q.node, x0 = q.x0, y0 = q.y0, x1 = q.x1, y1 = q.y1) && node.length) {
1723
- var xm = (x0 + x1) / 2, ym = (y0 + y1) / 2;
1724
- if (child = node[3]) quads.push(new quad_default(child, xm, ym, x1, y1));
1725
- if (child = node[2]) quads.push(new quad_default(child, x0, ym, xm, y1));
1726
- if (child = node[1]) quads.push(new quad_default(child, xm, y0, x1, ym));
1727
- if (child = node[0]) quads.push(new quad_default(child, x0, y0, xm, ym));
1728
- }
1729
- }
1730
- return this;
1731
- }
1732
-
1733
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/visitAfter.js
1734
- function visitAfter_default(callback) {
1735
- var quads = [], next = [], q;
1736
- if (this._root) quads.push(new quad_default(this._root, this._x0, this._y0, this._x1, this._y1));
1737
- while (q = quads.pop()) {
1738
- var node = q.node;
1739
- if (node.length) {
1740
- var child, x0 = q.x0, y0 = q.y0, x1 = q.x1, y1 = q.y1, xm = (x0 + x1) / 2, ym = (y0 + y1) / 2;
1741
- if (child = node[0]) quads.push(new quad_default(child, x0, y0, xm, ym));
1742
- if (child = node[1]) quads.push(new quad_default(child, xm, y0, x1, ym));
1743
- if (child = node[2]) quads.push(new quad_default(child, x0, ym, xm, y1));
1744
- if (child = node[3]) quads.push(new quad_default(child, xm, ym, x1, y1));
1745
- }
1746
- next.push(q);
1747
- }
1748
- while (q = next.pop()) {
1749
- callback(q.node, q.x0, q.y0, q.x1, q.y1);
1750
- }
1751
- return this;
1752
- }
1753
-
1754
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/x.js
1755
- function defaultX(d) {
1756
- return d[0];
1757
- }
1758
- function x_default(_) {
1759
- return arguments.length ? (this._x = _, this) : this._x;
1760
- }
1761
-
1762
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/y.js
1763
- function defaultY(d) {
1764
- return d[1];
1765
- }
1766
- function y_default(_) {
1767
- return arguments.length ? (this._y = _, this) : this._y;
1768
- }
1769
-
1770
- // ../../node_modules/.bun/d3-quadtree@3.0.1/node_modules/d3-quadtree/src/quadtree.js
1771
- function quadtree(nodes, x3, y3) {
1772
- var tree = new Quadtree(x3 == null ? defaultX : x3, y3 == null ? defaultY : y3, NaN, NaN, NaN, NaN);
1773
- return nodes == null ? tree : tree.addAll(nodes);
1774
- }
1775
- function Quadtree(x3, y3, x0, y0, x1, y1) {
1776
- this._x = x3;
1777
- this._y = y3;
1778
- this._x0 = x0;
1779
- this._y0 = y0;
1780
- this._x1 = x1;
1781
- this._y1 = y1;
1782
- this._root = void 0;
1783
- }
1784
- function leaf_copy(leaf) {
1785
- var copy = { data: leaf.data }, next = copy;
1786
- while (leaf = leaf.next) next = next.next = { data: leaf.data };
1787
- return copy;
1788
- }
1789
- var treeProto = quadtree.prototype = Quadtree.prototype;
1790
- treeProto.copy = function() {
1791
- var copy = new Quadtree(this._x, this._y, this._x0, this._y0, this._x1, this._y1), node = this._root, nodes, child;
1792
- if (!node) return copy;
1793
- if (!node.length) return copy._root = leaf_copy(node), copy;
1794
- nodes = [{ source: node, target: copy._root = new Array(4) }];
1795
- while (node = nodes.pop()) {
1796
- for (var i = 0; i < 4; ++i) {
1797
- if (child = node.source[i]) {
1798
- if (child.length) nodes.push({ source: child, target: node.target[i] = new Array(4) });
1799
- else node.target[i] = leaf_copy(child);
1800
- }
1801
- }
1802
- }
1803
- return copy;
1804
- };
1805
- treeProto.add = add_default;
1806
- treeProto.addAll = addAll;
1807
- treeProto.cover = cover_default;
1808
- treeProto.data = data_default;
1809
- treeProto.extent = extent_default;
1810
- treeProto.find = find_default;
1811
- treeProto.remove = remove_default;
1812
- treeProto.removeAll = removeAll;
1813
- treeProto.root = root_default;
1814
- treeProto.size = size_default;
1815
- treeProto.visit = visit_default;
1816
- treeProto.visitAfter = visitAfter_default;
1817
- treeProto.x = x_default;
1818
- treeProto.y = y_default;
1819
-
1820
2222
  // ../../node_modules/.bun/d3-force@3.0.0/node_modules/d3-force/src/constant.js
1821
2223
  function constant_default(x3) {
1822
2224
  return function() {
@@ -2354,7 +2756,7 @@ function manyBody_default() {
2354
2756
  }
2355
2757
 
2356
2758
  // ../../node_modules/.bun/d3-force@3.0.0/node_modules/d3-force/src/x.js
2357
- function x_default2(x3) {
2759
+ function x_default(x3) {
2358
2760
  var strength = constant_default(0.1), nodes, strengths, xz;
2359
2761
  if (typeof x3 !== "function") x3 = constant_default(x3 == null ? 0 : +x3);
2360
2762
  function force(alpha) {
@@ -2385,7 +2787,7 @@ function x_default2(x3) {
2385
2787
  }
2386
2788
 
2387
2789
  // ../../node_modules/.bun/d3-force@3.0.0/node_modules/d3-force/src/y.js
2388
- function y_default2(y3) {
2790
+ function y_default(y3) {
2389
2791
  var strength = constant_default(0.1), nodes, strengths, yz;
2390
2792
  if (typeof y3 !== "function") y3 = constant_default(y3 == null ? 0 : +y3);
2391
2793
  function force(alpha) {
@@ -2416,8 +2818,15 @@ function y_default2(y3) {
2416
2818
  }
2417
2819
 
2418
2820
  // src/graph/simulation.ts
2419
- var SYNC_MAX_TICKS = 300;
2420
2821
  var SYNC_TICKS_PER_BATCH = 15;
2822
+ var SYNC_MAX_TICKS_CEIL = 800;
2823
+ var DEFAULT_ALPHA_MIN = 1e-3;
2824
+ var DEFAULT_WARMUP_BUDGET_MS = 250;
2825
+ function ticksToAlphaMin(alphaDecay, alphaMin = DEFAULT_ALPHA_MIN) {
2826
+ if (!(alphaDecay > 0) || alphaDecay >= 1) return SYNC_MAX_TICKS_CEIL;
2827
+ const n = Math.ceil(Math.log(alphaMin) / Math.log(1 - alphaDecay));
2828
+ return Math.min(SYNC_MAX_TICKS_CEIL, Math.max(1, n));
2829
+ }
2421
2830
  function forceCluster(nodes, strength) {
2422
2831
  return (alpha) => {
2423
2832
  const cx = /* @__PURE__ */ new Map();
@@ -2444,6 +2853,24 @@ function forceCluster(nodes, strength) {
2444
2853
  }
2445
2854
  };
2446
2855
  }
2856
+ function forceCursor(nodes, pointer) {
2857
+ return (alpha) => {
2858
+ if (!pointer.active || pointer.radius <= 0) return;
2859
+ const r2 = pointer.radius * pointer.radius;
2860
+ const k = pointer.strength * alpha;
2861
+ for (const node of nodes) {
2862
+ const dx = (node.x ?? 0) - pointer.x;
2863
+ const dy = (node.y ?? 0) - pointer.y;
2864
+ const dist2 = dx * dx + dy * dy;
2865
+ if (dist2 >= r2) continue;
2866
+ const dist = Math.sqrt(dist2) || 1e-6;
2867
+ const falloff = (pointer.radius - dist) / pointer.radius;
2868
+ const push = k * falloff / dist;
2869
+ node.vx = (node.vx ?? 0) + dx * push;
2870
+ node.vy = (node.vy ?? 0) + dy * push;
2871
+ }
2872
+ };
2873
+ }
2447
2874
  var SimulationManager = class _SimulationManager {
2448
2875
  worker = null;
2449
2876
  syncSim = null;
@@ -2453,18 +2880,36 @@ var SimulationManager = class _SimulationManager {
2453
2880
  settledCb = null;
2454
2881
  destroyed = false;
2455
2882
  syncRafId = null;
2883
+ /** Derived per-graph cap on sync ticks, from alphaDecay via ticksToAlphaMin. */
2884
+ syncMaxTicks = SYNC_MAX_TICKS_CEIL;
2885
+ /** True until the sync warmup loop has completed (nothing renders before then). */
2886
+ syncWarmupPending = false;
2887
+ /** Remaining warmup ticks and the ms budget, consumed by the pre-reveal loop. */
2888
+ syncWarmupTicks = 0;
2889
+ syncWarmupBudgetMs = DEFAULT_WARMUP_BUDGET_MS;
2890
+ /** Injectable clock for the warmup ms budget (deterministic in tests). */
2891
+ now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
2456
2892
  // Stored for worker->sync fallback
2457
2893
  initNodes = [];
2458
2894
  initEdges = [];
2459
2895
  initConfig = null;
2896
+ // Cursor-repulsion pointer state (sync path). radius=0 keeps the force inert.
2897
+ pointer = { x: 0, y: 0, active: false, radius: 0, strength: 0 };
2898
+ // Separately tracked alpha-target intents so drag and cursor don't stomp each
2899
+ // other. The higher intent wins; releasing one falls back to the other.
2900
+ dragAlphaTarget = 0;
2901
+ cursorAlphaTarget = 0;
2460
2902
  constructor() {
2461
2903
  }
2462
2904
  /**
2463
2905
  * Create a SimulationManager. Uses Web Worker for large graphs,
2464
2906
  * synchronous fallback for small graphs or when Worker unavailable.
2907
+ *
2908
+ * `opts.now` injects a clock for the warmup ms budget (tests pass a fake one).
2465
2909
  */
2466
- static create(nodes, edges, config) {
2910
+ static create(nodes, edges, config, opts) {
2467
2911
  const mgr = new _SimulationManager();
2912
+ if (opts?.now) mgr.now = opts.now;
2468
2913
  const useWorker = typeof Worker !== "undefined";
2469
2914
  if (useWorker) {
2470
2915
  mgr.initWorker(nodes, edges, config);
@@ -2491,36 +2936,90 @@ var SimulationManager = class _SimulationManager {
2491
2936
  this.runSyncTicks();
2492
2937
  }
2493
2938
  }
2494
- /** Pin a node to fixed x/y coordinates. */
2495
- pinNode(id, x3, y3) {
2939
+ /**
2940
+ * Pin a node to fixed x/y coordinates.
2941
+ *
2942
+ * When `alphaTarget` is provided (springy drag), the sim is held warm so the
2943
+ * pinned node's neighbors follow springily. Omitting it posts a byte-identical
2944
+ * legacy message and leaves alpha untouched.
2945
+ */
2946
+ pinNode(id, x3, y3, alphaTarget) {
2496
2947
  if (this.destroyed) return;
2497
2948
  if (this.worker) {
2498
- this.worker.postMessage({ type: "pin", nodeId: id, x: x3, y: y3 });
2949
+ this.worker.postMessage(
2950
+ alphaTarget != null ? { type: "pin", nodeId: id, x: x3, y: y3, alphaTarget } : { type: "pin", nodeId: id, x: x3, y: y3 }
2951
+ );
2499
2952
  } else {
2500
2953
  const node = this.syncNodeMap.get(id);
2501
2954
  if (node) {
2502
2955
  node.fx = x3;
2503
2956
  node.fy = y3;
2504
2957
  }
2958
+ if (alphaTarget != null && this.syncSim) {
2959
+ this.dragAlphaTarget = alphaTarget;
2960
+ this.syncAlphaTarget();
2961
+ this.syncSim.restart();
2962
+ this.runSyncTicks();
2963
+ }
2505
2964
  }
2506
2965
  }
2507
- /** Unpin a node and reheat so forces settle it into equilibrium. */
2508
- unpinNode(id) {
2966
+ /**
2967
+ * Unpin a node and reheat so forces settle it into equilibrium.
2968
+ *
2969
+ * When `alphaTarget` is provided (springy release), the sim cools back toward
2970
+ * that target instead of the legacy gentle reheat. Omitting it preserves the
2971
+ * exact legacy reheat behavior (and posts a byte-identical message).
2972
+ */
2973
+ unpinNode(id, alphaTarget) {
2509
2974
  if (this.destroyed) return;
2510
2975
  if (this.worker) {
2511
- this.worker.postMessage({ type: "unpin", nodeId: id });
2976
+ this.worker.postMessage(
2977
+ alphaTarget != null ? { type: "unpin", nodeId: id, alphaTarget } : { type: "unpin", nodeId: id }
2978
+ );
2512
2979
  } else {
2513
2980
  const node = this.syncNodeMap.get(id);
2514
2981
  if (node) {
2515
2982
  node.fx = null;
2516
2983
  node.fy = null;
2517
2984
  }
2518
- if (this.syncSim && this.syncSim.alpha() < 0.1) {
2985
+ if (alphaTarget != null) {
2986
+ this.dragAlphaTarget = alphaTarget;
2987
+ this.syncAlphaTarget();
2988
+ this.runSyncTicks();
2989
+ } else if (this.syncSim && this.syncSim.alpha() < 0.1) {
2519
2990
  this.syncSim.alpha(0.1).restart();
2520
2991
  this.runSyncTicks();
2521
2992
  }
2522
2993
  }
2523
2994
  }
2995
+ /**
2996
+ * Feed the cursor-repulsion force a pointer position. `active: false` clears
2997
+ * the force. No-op when the graph has no cursor force configured (radius 0).
2998
+ */
2999
+ setPointer(x3, y3, active) {
3000
+ if (this.destroyed) return;
3001
+ if (this.worker) {
3002
+ this.worker.postMessage({ type: "pointer", x: x3, y: y3, active });
3003
+ } else {
3004
+ if (this.pointer.radius <= 0) return;
3005
+ this.pointer.x = x3;
3006
+ this.pointer.y = y3;
3007
+ this.pointer.active = active;
3008
+ this.cursorAlphaTarget = active ? 0.03 : 0;
3009
+ if (this.syncSim) {
3010
+ this.syncAlphaTarget();
3011
+ if (active) {
3012
+ this.syncSim.restart();
3013
+ this.runSyncTicks();
3014
+ }
3015
+ }
3016
+ }
3017
+ }
3018
+ /** Apply the max of the tracked alpha-target intents to the sync sim. */
3019
+ syncAlphaTarget() {
3020
+ if (!this.syncSim) return;
3021
+ this.syncSim.alphaTarget(Math.max(this.dragAlphaTarget, this.cursorAlphaTarget));
3022
+ }
2524
3023
  /** Drag a node (pins it and reheats slightly). */
2525
3024
  dragNode(id, x3, y3) {
2526
3025
  if (this.destroyed) return;
@@ -2634,7 +3133,7 @@ var SimulationManager = class _SimulationManager {
2634
3133
  this.syncSim = simulation_default(this.syncNodes).force("link", linkForce).force("charge", manyBody_default().strength(config.chargeStrength)).force(
2635
3134
  "collide",
2636
3135
  collide_default().radius((d) => d.radius + padding)
2637
- ).force("gravityX", x_default2(0).strength(0.05)).force("gravityY", y_default2(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
3136
+ ).force("gravityX", x_default(0).strength(0.05)).force("gravityY", y_default(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
2638
3137
  if (config.centerForce !== false) {
2639
3138
  this.syncSim.force("center", center_default(0, 0));
2640
3139
  }
@@ -2642,6 +3141,20 @@ var SimulationManager = class _SimulationManager {
2642
3141
  const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
2643
3142
  this.syncSim.force("cluster", clusterFn);
2644
3143
  }
3144
+ this.pointer.active = false;
3145
+ this.pointer.radius = config.cursorRepulsion?.radius ?? 0;
3146
+ this.pointer.strength = config.cursorRepulsion?.strength ?? 0;
3147
+ this.dragAlphaTarget = 0;
3148
+ this.cursorAlphaTarget = 0;
3149
+ const cursorFn = forceCursor(this.syncNodes, this.pointer);
3150
+ this.syncSim.force("cursor", cursorFn);
3151
+ if (config.initialAlpha != null) {
3152
+ this.syncSim.alpha(config.initialAlpha);
3153
+ }
3154
+ this.syncMaxTicks = ticksToAlphaMin(config.alphaDecay);
3155
+ this.syncWarmupTicks = config.warmupTicks ?? 0;
3156
+ this.syncWarmupBudgetMs = config.warmupBudgetMs ?? DEFAULT_WARMUP_BUDGET_MS;
3157
+ this.syncWarmupPending = this.syncWarmupTicks > 0;
2645
3158
  this.runSyncTicks(true);
2646
3159
  }
2647
3160
  /**
@@ -2662,14 +3175,36 @@ var SimulationManager = class _SimulationManager {
2662
3175
  this.syncRafId = null;
2663
3176
  }
2664
3177
  const sim = this.syncSim;
3178
+ const maxTicks = this.syncMaxTicks;
2665
3179
  let tickCount = 0;
3180
+ const runWarmup = () => {
3181
+ if (this.destroyed || !this.syncSim) return;
3182
+ this.syncRafId = null;
3183
+ const start2 = this.now();
3184
+ while (this.syncWarmupTicks > 0) {
3185
+ for (let i = 0; i < SYNC_TICKS_PER_BATCH && this.syncWarmupTicks > 0; i++) {
3186
+ sim.tick();
3187
+ this.syncWarmupTicks--;
3188
+ if (sim.alpha() < DEFAULT_ALPHA_MIN) {
3189
+ this.syncWarmupTicks = 0;
3190
+ break;
3191
+ }
3192
+ }
3193
+ if (this.syncWarmupTicks > 0 && this.now() - start2 >= this.syncWarmupBudgetMs) {
3194
+ this.syncWarmupTicks = 0;
3195
+ break;
3196
+ }
3197
+ }
3198
+ this.syncWarmupPending = false;
3199
+ runBatch();
3200
+ };
2666
3201
  const runBatch = () => {
2667
3202
  if (this.destroyed || !this.syncSim) return;
2668
3203
  this.syncRafId = null;
2669
- for (let i = 0; i < SYNC_TICKS_PER_BATCH && tickCount < SYNC_MAX_TICKS; i++, tickCount++) {
3204
+ for (let i = 0; i < SYNC_TICKS_PER_BATCH && tickCount < maxTicks; i++, tickCount++) {
2670
3205
  sim.tick();
2671
- if (sim.alpha() < 1e-3) {
2672
- tickCount = SYNC_MAX_TICKS;
3206
+ if (sim.alpha() < DEFAULT_ALPHA_MIN) {
3207
+ tickCount = maxTicks;
2673
3208
  break;
2674
3209
  }
2675
3210
  }
@@ -2679,7 +3214,7 @@ var SimulationManager = class _SimulationManager {
2679
3214
  y: n.y ?? 0
2680
3215
  }));
2681
3216
  const alpha = sim.alpha();
2682
- const settled = alpha < 1e-3 || tickCount >= SYNC_MAX_TICKS;
3217
+ const settled = alpha < DEFAULT_ALPHA_MIN || tickCount >= maxTicks;
2683
3218
  this.tickCb?.(positions, alpha);
2684
3219
  if (settled) {
2685
3220
  this.settledCb?.();
@@ -2687,104 +3222,163 @@ var SimulationManager = class _SimulationManager {
2687
3222
  this.syncRafId = requestAnimationFrame(runBatch);
2688
3223
  }
2689
3224
  };
3225
+ const start = this.syncWarmupPending ? runWarmup : runBatch;
2690
3226
  if (deferred) {
2691
- queueMicrotask(runBatch);
3227
+ queueMicrotask(start);
2692
3228
  } else {
2693
- runBatch();
3229
+ start();
2694
3230
  }
2695
3231
  }
2696
3232
  };
2697
3233
 
2698
3234
  // src/graph/spatial-index.ts
2699
- var SpatialIndex = class {
2700
- tree = null;
2701
- nodes = [];
2702
- maxRadius = 0;
2703
- generation = 0;
2704
- /** Rebuild the quadtree from the current set of positioned nodes. */
2705
- rebuild(nodes) {
2706
- this.nodes = nodes;
2707
- this.maxRadius = 0;
2708
- for (const n of nodes) {
2709
- if (n.radius > this.maxRadius) this.maxRadius = n.radius;
3235
+ var SpatialIndex2 = class extends SpatialIndex {
3236
+ };
3237
+
3238
+ // src/graph/update-diff-config.ts
3239
+ function stableKey(c2) {
3240
+ return JSON.stringify([
3241
+ c2.chargeStrength,
3242
+ c2.linkDistance,
3243
+ c2.clustering ? [c2.clustering.field, c2.clustering.strength] : null,
3244
+ c2.alphaDecay,
3245
+ c2.velocityDecay,
3246
+ c2.collisionRadius,
3247
+ c2.collisionPadding ?? null,
3248
+ c2.linkStrength ?? null,
3249
+ c2.centerForce ?? null,
3250
+ c2.seed ?? null,
3251
+ c2.warmupTicks ?? null,
3252
+ c2.warmupBudgetMs ?? null
3253
+ ]);
3254
+ }
3255
+ function simulationConfigEqual(a2, b) {
3256
+ return stableKey(a2) === stableKey(b);
3257
+ }
3258
+
3259
+ // src/graph/update-diff.ts
3260
+ var JITTER = 8;
3261
+ function jitter(id, seed) {
3262
+ const jx = hash32(`${id}|${seed}|jx`) / 4294967296 * 2 - 1;
3263
+ const jy = hash32(`${id}|${seed}|jy`) / 4294967296 * 2 - 1;
3264
+ return { x: jx * JITTER, y: jy * JITTER };
3265
+ }
3266
+ var FALLBACK_R0 = 300;
3267
+ function seededDisc(id, seed) {
3268
+ const u1 = hash32(`${id}|${seed}|r`) / 4294967296;
3269
+ const u2 = hash32(`${id}|${seed}|t`) / 4294967296;
3270
+ const r = FALLBACK_R0 * Math.sqrt(u1);
3271
+ const theta = u2 * Math.PI * 2;
3272
+ return { x: r * Math.cos(theta), y: r * Math.sin(theta) };
3273
+ }
3274
+ function diffGraphUpdate(prevNodes, prevEdges, next, prevConfig, seed) {
3275
+ const prevIds = new Set(prevNodes.map((n) => n.id));
3276
+ const nextIds = new Set(next.nodes.map((n) => n.id));
3277
+ const survivingPositions = /* @__PURE__ */ new Map();
3278
+ const enteringIds = [];
3279
+ for (const n of prevNodes) {
3280
+ if (nextIds.has(n.id)) survivingPositions.set(n.id, { x: n.x, y: n.y });
3281
+ }
3282
+ for (const n of next.nodes) {
3283
+ if (!prevIds.has(n.id)) enteringIds.push(n.id);
3284
+ }
3285
+ const exitingNodes = prevNodes.filter((n) => !nextIds.has(n.id));
3286
+ const nextEdgeKeys = new Set(next.edges.map((e) => `${e.source} ${e.target}`));
3287
+ const exitingEdges = prevEdges.filter((e) => !nextEdgeKeys.has(`${e.source} ${e.target}`));
3288
+ const prevEdgeCounts = /* @__PURE__ */ new Map();
3289
+ for (const e of prevEdges) {
3290
+ const k = `${e.source} ${e.target}`;
3291
+ prevEdgeCounts.set(k, (prevEdgeCounts.get(k) ?? 0) + 1);
3292
+ }
3293
+ let enteringEdgeCount = 0;
3294
+ const remainingPrev = new Map(prevEdgeCounts);
3295
+ for (const e of next.edges) {
3296
+ const k = `${e.source} ${e.target}`;
3297
+ const c2 = remainingPrev.get(k);
3298
+ if (c2 && c2 > 0) {
3299
+ remainingPrev.set(k, c2 - 1);
3300
+ } else {
3301
+ enteringEdgeCount++;
3302
+ }
3303
+ }
3304
+ const sameNodes = prevIds.size === nextIds.size && enteringIds.length === 0;
3305
+ const sameEdges = edgeSetsEqual(prevEdges, next.edges);
3306
+ const visualOnly = sameNodes && sameEdges && simulationConfigEqual(prevConfig, next.simulationConfig);
3307
+ const spawnPositions = /* @__PURE__ */ new Map();
3308
+ if (enteringIds.length > 0) {
3309
+ const enteringSet = new Set(enteringIds);
3310
+ const nextAdjacency = buildAdjacency(next.edges);
3311
+ for (const id of enteringIds) {
3312
+ const neighborPos = firstSurvivingNeighborPos(
3313
+ id,
3314
+ nextAdjacency,
3315
+ survivingPositions,
3316
+ enteringSet
3317
+ );
3318
+ const j = jitter(id, seed);
3319
+ if (neighborPos) {
3320
+ spawnPositions.set(id, { x: neighborPos.x + j.x, y: neighborPos.y + j.y });
3321
+ } else {
3322
+ const disc = seededDisc(id, seed);
3323
+ spawnPositions.set(id, { x: disc.x + j.x, y: disc.y + j.y });
3324
+ }
2710
3325
  }
2711
- this.tree = quadtree().x((d) => d.x).y((d) => d.y).addAll(nodes);
2712
- this.generation++;
2713
3326
  }
2714
- /** Current generation counter. Increments on each rebuild. */
2715
- getGeneration() {
2716
- return this.generation;
3327
+ return {
3328
+ visualOnly,
3329
+ enteringIds,
3330
+ survivingPositions,
3331
+ spawnPositions,
3332
+ exitingNodes,
3333
+ exitingEdges,
3334
+ enteringEdgeCount
3335
+ };
3336
+ }
3337
+ function buildAdjacency(edges) {
3338
+ const map = /* @__PURE__ */ new Map();
3339
+ const push = (a2, b) => {
3340
+ const list = map.get(a2);
3341
+ if (list) list.push(b);
3342
+ else map.set(a2, [b]);
3343
+ };
3344
+ for (const e of edges) {
3345
+ push(e.source, e.target);
3346
+ push(e.target, e.source);
2717
3347
  }
2718
- /**
2719
- * Find the nearest node to (x, y) within maxDistance.
2720
- * Accounts for node radius: a hit occurs if the point is inside
2721
- * the node circle (distance to center < node.radius), or if the
2722
- * edge-to-edge distance is within maxDistance.
2723
- */
2724
- findNearest(x3, y3, maxDistance = Infinity) {
2725
- if (!this.tree || this.nodes.length === 0) return null;
2726
- const searchRadius = maxDistance + this.maxRadius;
2727
- let best = null;
2728
- let bestEffectiveDist = maxDistance + this.maxRadius + 1;
2729
- this.tree.visit((node, x0, y0, x1, y1) => {
2730
- const closestX = Math.max(x0, Math.min(x3, x1));
2731
- const closestY = Math.max(y0, Math.min(y3, y1));
2732
- const quadDist = Math.hypot(closestX - x3, closestY - y3);
2733
- if (quadDist > searchRadius) return true;
2734
- if (!node.length) {
2735
- let current = node;
2736
- do {
2737
- const d = current.data;
2738
- if (d) {
2739
- const dist = Math.hypot(d.x - x3, d.y - y3);
2740
- const effectiveDist = Math.max(0, dist - d.radius);
2741
- if (effectiveDist <= maxDistance && effectiveDist < bestEffectiveDist) {
2742
- bestEffectiveDist = effectiveDist;
2743
- best = d;
2744
- }
2745
- }
2746
- } while (current = current.next);
2747
- }
2748
- return false;
2749
- });
2750
- return best;
2751
- }
2752
- /** Find all nodes whose centers fall within the given rectangle. */
2753
- findInRect(x1, y1, x22, y22) {
2754
- if (!this.tree) return [];
2755
- const minX = Math.min(x1, x22);
2756
- const minY = Math.min(y1, y22);
2757
- const maxX = Math.max(x1, x22);
2758
- const maxY = Math.max(y1, y22);
2759
- const results = [];
2760
- this.tree.visit((node, qx0, qy0, qx1, qy1) => {
2761
- if (qx0 > maxX || qx1 < minX || qy0 > maxY || qy1 < minY) {
2762
- return true;
2763
- }
2764
- if (!node.length) {
2765
- let current = node;
2766
- do {
2767
- const d = current.data;
2768
- if (d && d.x >= minX && d.x <= maxX && d.y >= minY && d.y <= maxY) {
2769
- results.push(d);
2770
- }
2771
- } while (current = current.next);
2772
- }
2773
- return false;
2774
- });
2775
- return results;
3348
+ return map;
3349
+ }
3350
+ function firstSurvivingNeighborPos(id, adjacency, survivingPositions, enteringSet) {
3351
+ const neighbors = adjacency.get(id);
3352
+ if (!neighbors) return null;
3353
+ for (const nid of neighbors) {
3354
+ if (enteringSet.has(nid)) continue;
3355
+ const pos = survivingPositions.get(nid);
3356
+ if (pos) return pos;
2776
3357
  }
2777
- };
3358
+ return null;
3359
+ }
3360
+ function edgeSetsEqual(prev, next) {
3361
+ if (prev.length !== next.length) return false;
3362
+ const key = (e) => `${e.source}->${e.target}`;
3363
+ const counts = /* @__PURE__ */ new Map();
3364
+ for (const e of prev) {
3365
+ const k = key(e);
3366
+ counts.set(k, (counts.get(k) ?? 0) + 1);
3367
+ }
3368
+ for (const e of next) {
3369
+ const k = key(e);
3370
+ const c2 = counts.get(k);
3371
+ if (!c2) return false;
3372
+ counts.set(k, c2 - 1);
3373
+ }
3374
+ return true;
3375
+ }
2778
3376
 
2779
3377
  // src/graph-mount.ts
2780
- function resolveDarkMode2(mode) {
2781
- if (mode === "force") return true;
2782
- if (mode === "off" || mode === void 0) return false;
2783
- if (typeof window !== "undefined" && window.matchMedia) {
2784
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
2785
- }
2786
- return false;
2787
- }
3378
+ var SPRINGY_DRAG_MAX_NODES = 5e3;
3379
+ var CURSOR_FORCE_MAX_NODES = 2e3;
3380
+ var CURSOR_POINTER_THROTTLE_MS = 33;
3381
+ var FOLLOW_SETTLE_ALPHA = 0.05;
2788
3382
  function createGraph(container, spec, options) {
2789
3383
  let currentSpec = spec;
2790
3384
  let compilation;
@@ -2793,9 +3387,10 @@ function createGraph(container, spec, options) {
2793
3387
  let canvas = null;
2794
3388
  let chromeEl = null;
2795
3389
  let legendEl = null;
3390
+ let legendController = null;
2796
3391
  let renderer = null;
2797
3392
  let simulation = null;
2798
- const spatialIndex = new SpatialIndex();
3393
+ const spatialIndex = new SpatialIndex2();
2799
3394
  let interactionManager = null;
2800
3395
  const searchManager = new GraphSearchManager();
2801
3396
  let tooltipManager = null;
@@ -2812,8 +3407,31 @@ function createGraph(container, spec, options) {
2812
3407
  let animFrameId = null;
2813
3408
  let needsRender = false;
2814
3409
  let isGesturing = false;
3410
+ const scheduler = new AnimationScheduler(() => scheduleRender());
2815
3411
  let gestureTimeout = null;
2816
3412
  let lastEdgeHitTime = 0;
3413
+ let lastPointerFeedTime = 0;
3414
+ let activeFlight = null;
3415
+ let activeFollow = null;
3416
+ let lastAlpha = 1;
3417
+ let cameraChangePending = false;
3418
+ let highlightSet = null;
3419
+ let highlightDimOpacity = null;
3420
+ let activeCategories = /* @__PURE__ */ new Set();
3421
+ let nodeCategory = /* @__PURE__ */ new Map();
3422
+ let focusTransition = null;
3423
+ let focusAnim = null;
3424
+ let hoverRadiusTween = null;
3425
+ let entranceProgress = 1;
3426
+ let entranceActive = false;
3427
+ let entranceStagger = false;
3428
+ let entranceOrderMap = null;
3429
+ let entranceOffsetMap = null;
3430
+ let entranceFitInFlight = false;
3431
+ let entranceReveal = null;
3432
+ let suppressEntranceOnce = options?.suppressEntrance ?? false;
3433
+ let enterAlphaMap = null;
3434
+ let exitingGhosts = null;
2817
3435
  function markGesture() {
2818
3436
  isGesturing = true;
2819
3437
  if (gestureTimeout !== null) clearTimeout(gestureTimeout);
@@ -2833,7 +3451,7 @@ function createGraph(container, spec, options) {
2833
3451
  }
2834
3452
  function compile() {
2835
3453
  const { width, height } = getContainerDimensions();
2836
- const darkMode = resolveDarkMode2(options?.darkMode);
3454
+ const darkMode = resolveDarkMode(options?.darkMode);
2837
3455
  const compileOpts = {
2838
3456
  width,
2839
3457
  height,
@@ -2846,6 +3464,14 @@ function createGraph(container, spec, options) {
2846
3464
  function buildDataMaps() {
2847
3465
  nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
2848
3466
  edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
3467
+ nodeCategory = /* @__PURE__ */ new Map();
3468
+ const field = compilation.legendField;
3469
+ if (field) {
3470
+ for (const n of compilation.nodes) {
3471
+ const v = n.data?.[field];
3472
+ if (v != null) nodeCategory.set(n.id, String(v));
3473
+ }
3474
+ }
2849
3475
  }
2850
3476
  function buildAdjacencyMap(edges) {
2851
3477
  const map = /* @__PURE__ */ new Map();
@@ -2905,7 +3531,7 @@ function createGraph(container, spec, options) {
2905
3531
  }
2906
3532
  function createDOM() {
2907
3533
  const { width, height } = getContainerDimensions();
2908
- const isDark = resolveDarkMode2(options?.darkMode);
3534
+ const isDark = resolveDarkMode(options?.darkMode);
2909
3535
  wrapper = document.createElement("div");
2910
3536
  wrapper.className = isDark ? "oc-graph-wrapper oc-dark" : "oc-graph-wrapper";
2911
3537
  if (isDark) {
@@ -2940,6 +3566,7 @@ function createGraph(container, spec, options) {
2940
3566
  wrapper.appendChild(legendEl);
2941
3567
  }
2942
3568
  container.appendChild(wrapper);
3569
+ syncChromeInset();
2943
3570
  const canvasHeight = Math.max(height, 200);
2944
3571
  renderer = new GraphCanvasRenderer(canvas);
2945
3572
  renderer.resize(width, canvasHeight);
@@ -2948,10 +3575,10 @@ function createGraph(container, spec, options) {
2948
3575
  if (!chromeEl) return;
2949
3576
  let html = "";
2950
3577
  if (compilation.chrome.title) {
2951
- html += `<h2 class="oc-title">${escapeHtml(compilation.chrome.title.text)}</h2>`;
3578
+ html += `<h2 class="oc-title">${escapeHtml2(compilation.chrome.title.text)}</h2>`;
2952
3579
  }
2953
3580
  if (compilation.chrome.subtitle) {
2954
- html += `<p class="oc-subtitle">${escapeHtml(compilation.chrome.subtitle.text)}</p>`;
3581
+ html += `<p class="oc-subtitle">${escapeHtml2(compilation.chrome.subtitle.text)}</p>`;
2955
3582
  }
2956
3583
  chromeEl.innerHTML = html;
2957
3584
  if (!html) {
@@ -2960,27 +3587,68 @@ function createGraph(container, spec, options) {
2960
3587
  chromeEl.style.display = "";
2961
3588
  }
2962
3589
  }
3590
+ function legendConfig() {
3591
+ const l = options?.legend;
3592
+ if (l && typeof l === "object") {
3593
+ return { interactive: l.interactive ?? true, counts: l.counts ?? true };
3594
+ }
3595
+ return { interactive: true, counts: true };
3596
+ }
3597
+ function legendViewData() {
3598
+ return { nodes: getLegend().nodes, edges: getLegend().edges };
3599
+ }
2963
3600
  function renderLegend2() {
2964
3601
  if (!legendEl) return;
2965
- const entries = "entries" in compilation.legend ? compilation.legend.entries : [];
2966
- if (entries.length === 0) {
2967
- legendEl.style.display = "none";
2968
- return;
2969
- }
2970
- legendEl.style.display = "";
2971
- let html = "";
2972
- for (const entry of entries) {
2973
- html += '<div class="oc-graph-legend-item">';
2974
- html += `<span class="oc-graph-legend-swatch" style="background:${escapeHtml(entry.color)}"></span>`;
2975
- html += `<span>${escapeHtml(entry.label)}</span>`;
2976
- html += "</div>";
3602
+ const cfg = legendConfig();
3603
+ if (!legendController) {
3604
+ legendController = createGraphLegend(legendEl, legendViewData(), {
3605
+ interactive: cfg.interactive,
3606
+ counts: cfg.counts,
3607
+ onToggle: (value) => toggleLegendCategory(value),
3608
+ onHover: (value) => {
3609
+ const field = compilation.legendField;
3610
+ options?.onLegendHover?.(value !== null && field ? { field, value } : null);
3611
+ }
3612
+ });
3613
+ } else {
3614
+ legendController.update(legendViewData());
2977
3615
  }
2978
- legendEl.innerHTML = html;
2979
3616
  }
2980
- function initSimulation() {
3617
+ function syncLegendActiveState() {
3618
+ if (legendController) legendController.update(legendViewData());
3619
+ syncChromeInset();
3620
+ }
3621
+ function syncChromeInset() {
3622
+ if (!chromeEl) return;
3623
+ const legendW = legendEl?.offsetWidth ?? 0;
3624
+ chromeEl.style.right = legendW > 0 ? `${legendW + 24}px` : "";
3625
+ }
3626
+ function chromeInsetTop() {
3627
+ if (!chromeEl || chromeEl.style.display === "none") return 0;
3628
+ return chromeEl.offsetHeight;
3629
+ }
3630
+ function springyDragEnabled() {
3631
+ return compilation.interaction.springyDrag && compilation.nodes.length <= SPRINGY_DRAG_MAX_NODES;
3632
+ }
3633
+ function cursorForceEnabled() {
3634
+ return compilation.interaction.cursorRepulsion !== null && compilation.nodes.length <= CURSOR_FORCE_MAX_NODES && !prefersReducedMotion();
3635
+ }
3636
+ function initSimulation(opts) {
2981
3637
  const simNodes = toSimNodes(compilation.nodes);
2982
3638
  const simEdges = toSimEdges(compilation.edges);
2983
3639
  const config = compilation.simulationConfig;
3640
+ if (opts?.positions) {
3641
+ seedNodePositions(simNodes, config.seed ?? 0);
3642
+ for (const n of simNodes) {
3643
+ const p = opts.positions.get(n.id);
3644
+ if (p) {
3645
+ n.x = p.x;
3646
+ n.y = p.y;
3647
+ }
3648
+ }
3649
+ } else {
3650
+ seedNodePositions(simNodes, config.seed ?? 0);
3651
+ }
2984
3652
  simulation = SimulationManager.create(simNodes, simEdges, {
2985
3653
  chargeStrength: config.chargeStrength,
2986
3654
  linkDistance: config.linkDistance,
@@ -2990,19 +3658,30 @@ function createGraph(container, spec, options) {
2990
3658
  collisionRadius: config.collisionRadius,
2991
3659
  collisionPadding: config.collisionPadding,
2992
3660
  linkStrength: config.linkStrength,
2993
- centerForce: config.centerForce
3661
+ // Update sims suppress the (non-alpha-scaled) center force to avoid a
3662
+ // global jump on tick 1; the alpha-scaled forceX/forceY gravity still
3663
+ // holds the layout centered.
3664
+ centerForce: opts?.suppressCenter ? false : config.centerForce,
3665
+ warmupTicks: opts?.skipWarmup ? 0 : config.warmupTicks,
3666
+ warmupBudgetMs: config.warmupBudgetMs,
3667
+ initialAlpha: opts?.initialAlpha ?? config.initialAlpha,
3668
+ // Cursor force radius/strength (null when disabled or gated off by node
3669
+ // count). The mount only feeds pointer positions when the same gate holds.
3670
+ cursorRepulsion: cursorForceEnabled() ? compilation.interaction.cursorRepulsion : null
2994
3671
  });
3672
+ lastAlpha = opts?.initialAlpha ?? 1;
2995
3673
  let initialSettleDone = false;
2996
- let initialFitDone = false;
2997
- simulation.onTick((positions, _alpha) => {
3674
+ let initialFitDone = opts?.skipEntrance ?? false;
3675
+ simulation.onTick((positions, alpha) => {
2998
3676
  if (destroyed) return;
3677
+ lastAlpha = alpha;
2999
3678
  const posMap = /* @__PURE__ */ new Map();
3000
3679
  for (const p of positions) {
3001
3680
  posMap.set(p.id, { x: p.x, y: p.y });
3002
3681
  }
3003
- positionedNodes = compilation.nodes.map((node) => {
3682
+ positionedNodes = compilation.nodes.map((node, index2) => {
3004
3683
  const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
3005
- return { ...node, x: pos.x, y: pos.y };
3684
+ return { ...node, x: pos.x, y: pos.y, index: index2 };
3006
3685
  });
3007
3686
  positionedEdges = compilation.edges.map((edge) => {
3008
3687
  const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
@@ -3016,11 +3695,11 @@ function createGraph(container, spec, options) {
3016
3695
  };
3017
3696
  });
3018
3697
  spatialIndex.rebuild(positionedNodes);
3019
- if (!initialFitDone && positionedNodes.length > 0 && interactionManager) {
3698
+ if (!initialFitDone && positionedNodes.length > 0 && interactionManager && options?.fitOnLoad !== false) {
3699
+ initialFitDone = true;
3700
+ startEntrance();
3701
+ } else if (!initialFitDone && options?.fitOnLoad === false) {
3020
3702
  initialFitDone = true;
3021
- const { width: cw, height: ch } = getCanvasDimensions();
3022
- const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
3023
- interactionManager.setTransform(fitTransform);
3024
3703
  }
3025
3704
  needsRender = true;
3026
3705
  scheduleRender();
@@ -3030,6 +3709,65 @@ function createGraph(container, spec, options) {
3030
3709
  initialSettleDone = true;
3031
3710
  });
3032
3711
  }
3712
+ function computeInitialFit() {
3713
+ const { width: cw, height: ch } = getCanvasDimensions();
3714
+ const warmed = (compilation.simulationConfig.warmupTicks ?? 0) > 0;
3715
+ const { transform } = ZoomTransform.fitBounds(positionedNodes, cw, ch, void 0, {
3716
+ spread: !warmed,
3717
+ insetTop: chromeInsetTop()
3718
+ });
3719
+ return transform;
3720
+ }
3721
+ function startEntrance() {
3722
+ if (!interactionManager) return;
3723
+ const fit = computeInitialFit();
3724
+ const enter = compilation.animation?.enter ?? null;
3725
+ const suppressed = suppressEntranceOnce;
3726
+ suppressEntranceOnce = false;
3727
+ if (suppressed || !enter || prefersReducedMotion()) {
3728
+ interactionManager.setTransform(fit);
3729
+ cameraChangePending = true;
3730
+ entranceActive = false;
3731
+ entranceProgress = 1;
3732
+ return;
3733
+ }
3734
+ const { width: cw, height: ch } = getCanvasDimensions();
3735
+ const pulledBack = fit.zoomAt(fit.k * 0.7, cw / 2, ch / 2);
3736
+ interactionManager.setTransform(pulledBack);
3737
+ cameraChangePending = true;
3738
+ entranceActive = true;
3739
+ entranceProgress = 0;
3740
+ entranceStagger = enter.stagger && positionedNodes.length <= ENTRANCE_STAGGER_MAX_NODES;
3741
+ if (entranceStagger) {
3742
+ entranceOrderMap = entranceOrder(positionedNodes);
3743
+ entranceOffsetMap = entranceOffsets(positionedNodes);
3744
+ } else {
3745
+ entranceOrderMap = null;
3746
+ entranceOffsetMap = null;
3747
+ }
3748
+ if (enter.cameraFit) {
3749
+ entranceFitInFlight = true;
3750
+ flyCamera(fit, { duration: enter.duration + 100 }, () => {
3751
+ entranceFitInFlight = false;
3752
+ });
3753
+ }
3754
+ const ease = resolveEase(enter.ease);
3755
+ entranceReveal = createTween({
3756
+ duration: enter.duration,
3757
+ ease,
3758
+ apply: (t) => {
3759
+ entranceProgress = t;
3760
+ needsRender = true;
3761
+ },
3762
+ onDone: () => {
3763
+ entranceProgress = 1;
3764
+ entranceActive = false;
3765
+ entranceReveal = null;
3766
+ needsRender = true;
3767
+ }
3768
+ });
3769
+ scheduler.add(entranceReveal);
3770
+ }
3033
3771
  function getCanvasDimensions() {
3034
3772
  if (!canvas) return { width: 600, height: 400 };
3035
3773
  const rect = canvas.getBoundingClientRect();
@@ -3042,26 +3780,274 @@ function createGraph(container, spec, options) {
3042
3780
  if (animFrameId !== null || destroyed) return;
3043
3781
  animFrameId = requestAnimationFrame(renderFrame);
3044
3782
  }
3045
- function renderFrame() {
3783
+ function hoverConnectedSet(nodeId) {
3784
+ if (nodeId === null) return null;
3785
+ if (compilation.interaction.hoverMode === "category") {
3786
+ const cat = nodeCategory.get(nodeId);
3787
+ const set3 = /* @__PURE__ */ new Set([nodeId]);
3788
+ if (cat !== void 0) {
3789
+ for (const [id, c2] of nodeCategory) if (c2 === cat) set3.add(id);
3790
+ }
3791
+ return set3;
3792
+ }
3793
+ const set2 = /* @__PURE__ */ new Set([nodeId]);
3794
+ const neighbors = adjacencyMap.get(nodeId);
3795
+ if (neighbors) for (const nid of neighbors) set2.add(nid);
3796
+ return set2;
3797
+ }
3798
+ function standingSnapshot() {
3799
+ return composeStandingFocus(
3800
+ highlightSet,
3801
+ searchManager.getMatches(),
3802
+ selectedNodeIds,
3803
+ adjacencyMap
3804
+ );
3805
+ }
3806
+ function targetSnapshot() {
3807
+ return layerHoverFocus(standingSnapshot(), hoveredNodeId, hoverConnectedSet(hoveredNodeId));
3808
+ }
3809
+ function armFocus(now2) {
3810
+ const target = targetSnapshot();
3811
+ const hoverCfg = compilation.animation?.hover ?? null;
3812
+ const duration = hoverCfg && !prefersReducedMotion() ? hoverCfg.duration : 0;
3813
+ const ease = resolveEase(hoverCfg?.ease ?? "smooth");
3814
+ if (!focusTransition) {
3815
+ focusTransition = new FocusTransition(target, duration, ease, now2);
3816
+ return;
3817
+ }
3818
+ focusTransition.retarget(target, now2);
3819
+ if (focusAnim) scheduler.remove(focusAnim);
3820
+ focusAnim = {
3821
+ tick: (t) => {
3822
+ const running = focusTransition !== null && !focusTransition.isSettled(t);
3823
+ if (!running) focusAnim = null;
3824
+ return running;
3825
+ },
3826
+ finish: () => {
3827
+ focusAnim = null;
3828
+ },
3829
+ cancel: () => {
3830
+ focusAnim = null;
3831
+ }
3832
+ };
3833
+ scheduler.add(focusAnim);
3834
+ }
3835
+ function refreshFocus() {
3836
+ armFocus(performance.now());
3837
+ needsRender = true;
3838
+ scheduleRender();
3839
+ }
3840
+ function resolveHighlightTarget(target) {
3841
+ if ("nodeIds" in target) return new Set(target.nodeIds);
3842
+ if ("neighborsOf" in target) {
3843
+ const set3 = /* @__PURE__ */ new Set();
3844
+ if (target.includeSelf !== false) set3.add(target.neighborsOf);
3845
+ const neighbors = adjacencyMap.get(target.neighborsOf);
3846
+ if (neighbors) for (const nid of neighbors) set3.add(nid);
3847
+ return set3;
3848
+ }
3849
+ const values = new Set(
3850
+ Array.isArray(target.category.value) ? target.category.value : [target.category.value]
3851
+ );
3852
+ const field = target.category.field;
3853
+ const set2 = /* @__PURE__ */ new Set();
3854
+ for (const n of compilation.nodes) {
3855
+ const v = n.data?.[field];
3856
+ if (v != null && values.has(String(v))) set2.add(n.id);
3857
+ }
3858
+ return set2;
3859
+ }
3860
+ function categoryHighlightSet() {
3861
+ if (activeCategories.size === 0) return null;
3862
+ const set2 = /* @__PURE__ */ new Set();
3863
+ for (const [id, cat] of nodeCategory) if (activeCategories.has(cat)) set2.add(id);
3864
+ return set2;
3865
+ }
3866
+ function emitHighlightChange() {
3867
+ options?.onHighlightChange?.(highlightSet ? [...highlightSet] : null);
3868
+ }
3869
+ function applyInitialHighlight() {
3870
+ const init = compilation.initialHighlight;
3871
+ if (!init) return;
3872
+ activeCategories = new Set(init.values);
3873
+ highlightSet = categoryHighlightSet();
3874
+ highlightDimOpacity = null;
3875
+ }
3876
+ function startHoverRadiusTween(nodeId) {
3877
+ const hoverCfg = compilation.animation?.hover ?? null;
3878
+ if (nodeId === null || !hoverCfg || prefersReducedMotion()) {
3879
+ hoverRadiusTween = nodeId ? { nodeId, scale: 1.15 } : null;
3880
+ return;
3881
+ }
3882
+ hoverRadiusTween = { nodeId, scale: 1 };
3883
+ const ease = resolveEase(hoverCfg.ease);
3884
+ const tween = createTween({
3885
+ duration: hoverCfg.duration,
3886
+ ease,
3887
+ apply: (t) => {
3888
+ if (hoverRadiusTween?.nodeId === nodeId) hoverRadiusTween.scale = 1 + 0.15 * t;
3889
+ needsRender = true;
3890
+ },
3891
+ onDone: () => {
3892
+ if (hoverRadiusTween?.nodeId === nodeId) hoverRadiusTween.scale = 1.15;
3893
+ }
3894
+ });
3895
+ scheduler.add(tween);
3896
+ }
3897
+ function showNodeTooltip(nodeId) {
3898
+ if (!tooltipManager || !interactionManager) return;
3899
+ const defaults = compilation.tooltipDescriptors.get(nodeId);
3900
+ if (!defaults) return;
3901
+ const node = positionedNodes.find((n) => n.id === nodeId);
3902
+ if (!node) return;
3903
+ const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
3904
+ const formatter = tooltipFormatter();
3905
+ if (!formatter) {
3906
+ tooltipManager.show(defaults, screen.x, screen.y);
3907
+ return;
3908
+ }
3909
+ const result = formatter({ kind: "node", data: nodeDataById(nodeId) }, defaults);
3910
+ applyFormatterResult(result, screen.x, screen.y);
3911
+ }
3912
+ function showEdgeTooltip(edgeId, data, screenX, screenY) {
3913
+ if (!tooltipManager) return;
3914
+ const edge = compilation.edges.find((e) => `${e.source}->${e.target}` === edgeId);
3915
+ const defaults = edge ? buildEdgeTooltip(edge) : { title: edgeId, fields: [] };
3916
+ const formatter = tooltipFormatter();
3917
+ if (!formatter) {
3918
+ tooltipManager.show(defaults, screenX, screenY);
3919
+ return;
3920
+ }
3921
+ const result = formatter({ kind: "edge", data }, defaults);
3922
+ applyFormatterResult(result, screenX, screenY);
3923
+ }
3924
+ function tooltipFormatter() {
3925
+ const t = options?.tooltip;
3926
+ return t && typeof t === "object" ? t.formatter ?? null : null;
3927
+ }
3928
+ function applyFormatterResult(result, x3, y3) {
3929
+ if (!tooltipManager) return;
3930
+ if (result === null) {
3931
+ tooltipManager.hide();
3932
+ } else if (typeof result === "string") {
3933
+ tooltipManager.show({ text: result }, x3, y3);
3934
+ } else if (result instanceof HTMLElement) {
3935
+ tooltipManager.show({ element: result }, x3, y3);
3936
+ } else {
3937
+ tooltipManager.show(result, x3, y3);
3938
+ }
3939
+ }
3940
+ function buildRenderState(now2) {
3941
+ const transform = interactionManager.getTransform();
3942
+ if (!focusTransition) armFocus(now2);
3943
+ const ft = focusTransition;
3944
+ const t = ft.progress(now2);
3945
+ const focus = t < 1 ? { t, prev: ft.prev, next: ft.next } : void 0;
3946
+ const settledNext = ft.next;
3947
+ const hoverRadiusScale = hoverRadiusTween ? /* @__PURE__ */ new Map([[hoverRadiusTween.nodeId, hoverRadiusTween.scale]]) : void 0;
3948
+ return {
3949
+ nodes: positionedNodes,
3950
+ edges: positionedEdges,
3951
+ transform: { x: transform.x, y: transform.y, k: transform.k },
3952
+ hoveredNodeId,
3953
+ hoveredEdgeId,
3954
+ selectedNodeIds,
3955
+ adjacencyMap,
3956
+ theme: compilation.theme,
3957
+ searchMatches: searchManager.getMatches(),
3958
+ isGesturing,
3959
+ watermark: compilation.watermark,
3960
+ focus: focus ?? { t: 1, prev: settledNext, next: settledNext },
3961
+ hoverRadiusScale,
3962
+ dimOpacity: highlightDimOpacity ?? compilation.interaction.dimOpacity,
3963
+ entrance: entranceActive && entranceProgress < 1 ? {
3964
+ t: entranceProgress,
3965
+ stagger: entranceStagger,
3966
+ order: entranceOrderMap ?? void 0,
3967
+ offsets: entranceOffsetMap ?? void 0
3968
+ } : void 0,
3969
+ enterAlpha: enterAlphaMap ?? void 0,
3970
+ exiting: exitingGhosts ?? void 0
3971
+ };
3972
+ }
3973
+ function renderFrame(now2) {
3046
3974
  animFrameId = null;
3047
3975
  if (destroyed || !renderer || !interactionManager) return;
3976
+ if (scheduler.tick(now2)) needsRender = true;
3048
3977
  if (needsRender) {
3049
3978
  needsRender = false;
3050
- const transform = interactionManager.getTransform();
3051
- const state = {
3052
- nodes: positionedNodes,
3053
- edges: positionedEdges,
3054
- transform: { x: transform.x, y: transform.y, k: transform.k },
3055
- hoveredNodeId,
3056
- hoveredEdgeId,
3057
- selectedNodeIds,
3058
- adjacencyMap,
3059
- theme: compilation.theme,
3060
- searchMatches: searchManager.getMatches(),
3061
- isGesturing,
3062
- watermark: compilation.watermark
3063
- };
3064
- renderer.render(state);
3979
+ renderer.render(buildRenderState(now2));
3980
+ }
3981
+ if (scheduler.active) scheduleRender();
3982
+ if (cameraChangePending) {
3983
+ cameraChangePending = false;
3984
+ const t = interactionManager.getTransform();
3985
+ options?.onCameraChange?.({ x: t.x, y: t.y, k: t.k });
3986
+ }
3987
+ }
3988
+ function flyCamera(to, opts, onDone) {
3989
+ if (destroyed || !interactionManager) return;
3990
+ cancelFlight();
3991
+ const cameraCfg = compilation.animation?.camera ?? null;
3992
+ const resolveTarget = () => typeof to === "function" ? to() : to;
3993
+ const snap = cameraCfg === null || prefersReducedMotion() || opts?.duration === 0;
3994
+ if (snap) {
3995
+ interactionManager.setTransform(resolveTarget());
3996
+ cameraChangePending = true;
3997
+ needsRender = true;
3998
+ scheduleRender();
3999
+ onDone?.();
4000
+ return;
4001
+ }
4002
+ const { width, height } = getCanvasDimensions();
4003
+ const duration = opts?.duration ?? cameraCfg?.duration ?? "auto";
4004
+ const ease = opts?.ease ?? cameraCfg?.ease ?? "smooth";
4005
+ const heavy = positionedNodes.length > 1e3;
4006
+ const flight = createCameraFlight({
4007
+ from: interactionManager.getTransform(),
4008
+ to,
4009
+ viewport: { width, height },
4010
+ apply: (t) => {
4011
+ interactionManager.setTransform(t);
4012
+ isGesturing = heavy;
4013
+ cameraChangePending = true;
4014
+ needsRender = true;
4015
+ },
4016
+ onDone: () => {
4017
+ activeFlight = null;
4018
+ isGesturing = false;
4019
+ needsRender = true;
4020
+ if (typeof to === "function") startFollow(to);
4021
+ scheduleRender();
4022
+ onDone?.();
4023
+ },
4024
+ opts: { duration, ease }
4025
+ });
4026
+ activeFlight = flight;
4027
+ scheduler.add(flight);
4028
+ }
4029
+ function startFollow(target) {
4030
+ const follow = createCameraFollow({
4031
+ target,
4032
+ apply: (t) => {
4033
+ interactionManager.setTransform(t);
4034
+ cameraChangePending = true;
4035
+ needsRender = true;
4036
+ },
4037
+ isActive: () => !destroyed && lastAlpha >= FOLLOW_SETTLE_ALPHA
4038
+ });
4039
+ activeFollow = follow;
4040
+ scheduler.add(follow);
4041
+ }
4042
+ function cancelFlight() {
4043
+ if (activeFlight) {
4044
+ scheduler.remove(activeFlight);
4045
+ activeFlight = null;
4046
+ isGesturing = false;
4047
+ }
4048
+ if (activeFollow) {
4049
+ scheduler.remove(activeFollow);
4050
+ activeFollow = null;
3065
4051
  }
3066
4052
  }
3067
4053
  function initInteraction() {
@@ -3071,37 +4057,33 @@ function createGraph(container, spec, options) {
3071
4057
  }
3072
4058
  interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
3073
4059
  onTransformChange(_transform) {
4060
+ cancelFlight();
3074
4061
  markGesture();
4062
+ cameraChangePending = true;
3075
4063
  needsRender = true;
3076
4064
  scheduleRender();
3077
4065
  },
3078
4066
  onHoverChange(nodeId) {
4067
+ if (nodeId === hoveredNodeId) return;
3079
4068
  hoveredNodeId = nodeId;
4069
+ armFocus(performance.now());
4070
+ startHoverRadiusTween(nodeId);
3080
4071
  needsRender = true;
3081
4072
  scheduleRender();
3082
- if (nodeId) {
3083
- options?.onNodeHover?.(nodeDataById(nodeId));
3084
- } else {
3085
- options?.onNodeHover?.(null);
4073
+ if (nodeId && hoveredEdgeId) {
4074
+ hoveredEdgeId = null;
4075
+ options?.onEdgeHover?.(null);
4076
+ tooltipManager?.hide();
3086
4077
  }
4078
+ options?.onNodeHover?.(nodeId ? nodeDataById(nodeId) : null);
3087
4079
  if (nodeId && tooltipManager) {
3088
- if (hoveredEdgeId) {
3089
- hoveredEdgeId = null;
3090
- options?.onEdgeHover?.(null);
3091
- }
3092
- const content = compilation.tooltipDescriptors.get(nodeId);
3093
- if (content) {
3094
- const node = positionedNodes.find((n) => n.id === nodeId);
3095
- if (node && interactionManager) {
3096
- const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
3097
- tooltipManager.show(content, screen.x, screen.y);
3098
- }
3099
- }
4080
+ showNodeTooltip(nodeId);
3100
4081
  } else if (!nodeId) {
3101
4082
  tooltipManager?.hide();
3102
4083
  }
3103
4084
  },
3104
4085
  onBackgroundHover(graphX, graphY, screenX, screenY) {
4086
+ if (hoveredNodeId) return;
3105
4087
  const now2 = performance.now();
3106
4088
  if (now2 - lastEdgeHitTime < 32) {
3107
4089
  if (hoveredEdgeId) {
@@ -3124,14 +4106,7 @@ function createGraph(container, spec, options) {
3124
4106
  if (edgeId) {
3125
4107
  const data = edgeDataById(edgeId);
3126
4108
  options?.onEdgeHover?.(data);
3127
- if (tooltipManager && data) {
3128
- const fields = Object.entries(data).filter(([key]) => key !== "source" && key !== "target").filter(([, value]) => value != null).map(([key, value]) => ({
3129
- label: key,
3130
- value: typeof value === "number" ? value.toLocaleString() : String(value)
3131
- }));
3132
- const [source, target] = edgeId.split("->");
3133
- tooltipManager.show({ title: `${source} \u2192 ${target}`, fields }, screenX, screenY);
3134
- }
4109
+ if (tooltipManager && data) showEdgeTooltip(edgeId, data, screenX, screenY);
3135
4110
  } else {
3136
4111
  options?.onEdgeHover?.(null);
3137
4112
  tooltipManager?.hide();
@@ -3140,6 +4115,7 @@ function createGraph(container, spec, options) {
3140
4115
  },
3141
4116
  onSelectionChange(nodeIds) {
3142
4117
  selectedNodeIds = new Set(nodeIds);
4118
+ armFocus(performance.now());
3143
4119
  needsRender = true;
3144
4120
  scheduleRender();
3145
4121
  options?.onSelectionChange?.(nodeIds);
@@ -3152,16 +4128,27 @@ function createGraph(container, spec, options) {
3152
4128
  const node = positionedNodes.find((n) => n.id === nodeId);
3153
4129
  const x3 = node?.x ?? 0;
3154
4130
  const y3 = node?.y ?? 0;
3155
- simulation?.pinNode(nodeId, x3, y3);
4131
+ simulation?.pinNode(nodeId, x3, y3, springyDragEnabled() ? 0.3 : void 0);
3156
4132
  canvas?.classList.add("oc-graph-canvas--dragging");
3157
4133
  },
3158
4134
  onNodeDrag(nodeId, x3, y3) {
3159
4135
  simulation?.dragNode(nodeId, x3, y3);
3160
4136
  },
3161
4137
  onNodeDragEnd(nodeId) {
3162
- simulation?.unpinNode(nodeId);
4138
+ simulation?.unpinNode(nodeId, springyDragEnabled() ? 0 : void 0);
3163
4139
  canvas?.classList.remove("oc-graph-canvas--dragging");
3164
4140
  },
4141
+ onPointerMove(graphX, graphY) {
4142
+ if (!cursorForceEnabled()) return;
4143
+ const now2 = performance.now();
4144
+ if (now2 - lastPointerFeedTime < CURSOR_POINTER_THROTTLE_MS) return;
4145
+ lastPointerFeedTime = now2;
4146
+ simulation?.setPointer(graphX, graphY, true);
4147
+ },
4148
+ onPointerLeave() {
4149
+ if (!cursorForceEnabled()) return;
4150
+ simulation?.setPointer(0, 0, false);
4151
+ },
3165
4152
  onDoubleClick(nodeId) {
3166
4153
  options?.onNodeDoubleClick?.(nodeDataById(nodeId));
3167
4154
  }
@@ -3191,9 +4178,7 @@ function createGraph(container, spec, options) {
3191
4178
  const factor = direction === "in" ? 1.2 : 0.8;
3192
4179
  const newK = t.k * factor;
3193
4180
  const newTransform = t.zoomAt(newK, cw / 2, ch / 2);
3194
- interactionManager.setTransform(newTransform);
3195
- needsRender = true;
3196
- scheduleRender();
4181
+ flyCamera(newTransform, { duration: 200 });
3197
4182
  },
3198
4183
  onFitAll() {
3199
4184
  zoomToFit();
@@ -3212,93 +4197,318 @@ function createGraph(container, spec, options) {
3212
4197
  needsRender = true;
3213
4198
  scheduleRender();
3214
4199
  }
3215
- function zoomToFit() {
4200
+ function zoomToFit(opts) {
3216
4201
  if (destroyed || !interactionManager || positionedNodes.length === 0) return;
3217
4202
  const { width: cw, height: ch } = getCanvasDimensions();
3218
- const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
3219
- interactionManager.setTransform(fitTransform);
3220
- needsRender = true;
3221
- scheduleRender();
4203
+ const { transform: fitTransform } = ZoomTransform.fitBounds(
4204
+ positionedNodes,
4205
+ cw,
4206
+ ch,
4207
+ opts?.padding,
4208
+ {
4209
+ insetTop: chromeInsetTop()
4210
+ }
4211
+ );
4212
+ flyCamera(fitTransform, opts);
3222
4213
  }
3223
- function zoomToNode(nodeId) {
4214
+ function zoomToNode(nodeId, opts) {
3224
4215
  if (destroyed || !interactionManager || !canvas) return;
3225
4216
  const node = positionedNodes.find((n) => n.id === nodeId);
3226
4217
  if (!node) return;
3227
4218
  const { width: cw, height: ch } = getCanvasDimensions();
3228
- const k = 2;
3229
- const tx = cw / 2 - node.x * k;
3230
- const ty = ch / 2 - node.y * k;
3231
- const newTransform = new ZoomTransform(tx, ty, k);
3232
- interactionManager.setTransform(newTransform);
3233
- needsRender = true;
3234
- scheduleRender();
4219
+ const k = clampK(opts?.scale ?? 2);
4220
+ const provider = () => {
4221
+ const live = positionedNodes.find((n) => n.id === nodeId) ?? node;
4222
+ return new ZoomTransform(cw / 2 - live.x * k, ch / 2 - live.y * k, k);
4223
+ };
4224
+ flyCamera(provider, opts);
4225
+ }
4226
+ function flyTo(target, opts) {
4227
+ if (destroyed || !interactionManager) return;
4228
+ const { width: cw, height: ch } = getCanvasDimensions();
4229
+ const k = clampK(target.k ?? interactionManager.getTransform().k);
4230
+ flyCamera(new ZoomTransform(cw / 2 - target.x * k, ch / 2 - target.y * k, k), opts);
4231
+ }
4232
+ function centerAt(x3, y3, opts) {
4233
+ flyTo({ x: x3, y: y3 }, opts);
3235
4234
  }
3236
- function selectNode(nodeId) {
4235
+ function getCamera() {
4236
+ const t = interactionManager?.getTransform() ?? ZoomTransform.identity();
4237
+ return { x: t.x, y: t.y, k: t.k };
4238
+ }
4239
+ function selectNode(nodeId, opts) {
3237
4240
  if (destroyed) return;
3238
4241
  selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
3239
4242
  needsRender = true;
3240
4243
  scheduleRender();
3241
4244
  options?.onSelectionChange?.([nodeId]);
4245
+ const shouldFly = opts?.fly ?? compilation.interaction.selectFlyTo;
4246
+ if (shouldFly) zoomToNode(nodeId, opts);
3242
4247
  }
3243
4248
  function getSelectedNodes() {
3244
4249
  return [...selectedNodeIds];
3245
4250
  }
4251
+ function getSearchMatches() {
4252
+ return [...searchManager.getMatches() ?? []];
4253
+ }
4254
+ function highlight(target, opts) {
4255
+ activeCategories = /* @__PURE__ */ new Set();
4256
+ highlightSet = resolveHighlightTarget(target);
4257
+ highlightDimOpacity = opts?.dimOpacity ?? null;
4258
+ syncLegendActiveState();
4259
+ refreshFocus();
4260
+ emitHighlightChange();
4261
+ }
4262
+ function clearHighlight() {
4263
+ activeCategories = /* @__PURE__ */ new Set();
4264
+ highlightSet = null;
4265
+ highlightDimOpacity = null;
4266
+ syncLegendActiveState();
4267
+ refreshFocus();
4268
+ emitHighlightChange();
4269
+ }
4270
+ function getHighlight() {
4271
+ return highlightSet ? [...highlightSet] : null;
4272
+ }
4273
+ function getLegend() {
4274
+ const nodeEntries = "entries" in compilation.legend ? compilation.legend.entries : [];
4275
+ return {
4276
+ field: compilation.legendField,
4277
+ nodes: nodeEntries.filter((e) => !e.overflow).map((e) => ({
4278
+ label: e.label,
4279
+ color: e.color,
4280
+ count: e.count,
4281
+ active: activeCategories.size === 0 || activeCategories.has(e.label)
4282
+ })),
4283
+ edges: (compilation.edgeLegend ?? []).map((e) => ({
4284
+ label: e.label,
4285
+ color: e.color,
4286
+ count: e.count
4287
+ }))
4288
+ };
4289
+ }
4290
+ function setActiveCategories(values) {
4291
+ if (destroyed) return;
4292
+ activeCategories = new Set(values);
4293
+ highlightSet = categoryHighlightSet();
4294
+ highlightDimOpacity = null;
4295
+ syncLegendActiveState();
4296
+ refreshFocus();
4297
+ emitHighlightChange();
4298
+ }
4299
+ function getActiveCategories() {
4300
+ return [...activeCategories];
4301
+ }
4302
+ function toggleLegendCategory(value) {
4303
+ if (activeCategories.has(value)) activeCategories.delete(value);
4304
+ else activeCategories.add(value);
4305
+ highlightSet = categoryHighlightSet();
4306
+ highlightDimOpacity = null;
4307
+ syncLegendActiveState();
4308
+ refreshFocus();
4309
+ options?.onLegendToggle?.([...activeCategories]);
4310
+ emitHighlightChange();
4311
+ }
3246
4312
  function doResize() {
3247
4313
  if (destroyed || !canvas || !renderer || !wrapper) return;
3248
4314
  const { width, height } = getContainerDimensions();
3249
4315
  const canvasHeight = Math.max(height, 200);
3250
4316
  renderer.resize(width, canvasHeight);
4317
+ syncChromeInset();
4318
+ if (entranceFitInFlight && interactionManager) {
4319
+ cancelFlight();
4320
+ entranceFitInFlight = false;
4321
+ interactionManager.setTransform(computeInitialFit());
4322
+ cameraChangePending = true;
4323
+ }
3251
4324
  needsRender = true;
3252
4325
  scheduleRender();
3253
4326
  }
3254
4327
  function update(newSpec) {
3255
4328
  if (destroyed) return;
3256
4329
  currentSpec = newSpec;
3257
- teardownSubsystems();
4330
+ scheduler.finishAll();
4331
+ entranceActive = false;
4332
+ entranceProgress = 1;
4333
+ entranceFitInFlight = false;
4334
+ entranceReveal = null;
4335
+ enterAlphaMap = null;
4336
+ exitingGhosts = null;
4337
+ const prevNodes = positionedNodes;
4338
+ const prevEdges = positionedEdges;
4339
+ const prevConfig = compilation.simulationConfig;
3258
4340
  compilation = compile();
4341
+ const diff = diffGraphUpdate(
4342
+ prevNodes,
4343
+ prevEdges,
4344
+ compilation,
4345
+ prevConfig,
4346
+ prevConfig.seed ?? 0
4347
+ );
4348
+ if (diff.visualOnly) {
4349
+ runVisualOnlyUpdate();
4350
+ return;
4351
+ }
4352
+ runStructuralUpdate(diff);
4353
+ }
4354
+ function runVisualOnlyUpdate() {
3259
4355
  adjacencyMap = buildAdjacencyMap(compilation.edges);
3260
4356
  buildDataMaps();
3261
- renderChrome3();
3262
- renderLegend2();
3263
- initSimulation();
3264
- initInteraction();
3265
- hoveredNodeId = null;
3266
- hoveredEdgeId = null;
3267
- selectedNodeIds = /* @__PURE__ */ new Set();
3268
- searchManager.clearSearch();
3269
- }
3270
- function updateVisuals(newSpec) {
3271
- if (destroyed) return;
3272
- currentSpec = newSpec;
3273
4357
  const posMap = /* @__PURE__ */ new Map();
3274
4358
  for (const node of positionedNodes) {
3275
4359
  posMap.set(node.id, { x: node.x, y: node.y });
3276
4360
  }
3277
- compilation = compile();
3278
- adjacencyMap = buildAdjacencyMap(compilation.edges);
3279
- buildDataMaps();
3280
- positionedNodes = compilation.nodes.map((node) => {
4361
+ positionedNodes = compilation.nodes.map((node, index2) => {
3281
4362
  const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
3282
- return { ...node, x: pos.x, y: pos.y };
4363
+ return { ...node, x: pos.x, y: pos.y, index: index2 };
3283
4364
  });
3284
4365
  positionedEdges = compilation.edges.map((edge) => {
3285
4366
  const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
3286
4367
  const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
3287
- return {
3288
- ...edge,
3289
- sourceX: src.x,
3290
- sourceY: src.y,
3291
- targetX: tgt.x,
3292
- targetY: tgt.y
3293
- };
4368
+ return { ...edge, sourceX: src.x, sourceY: src.y, targetX: tgt.x, targetY: tgt.y };
4369
+ });
4370
+ spatialIndex.rebuild(positionedNodes);
4371
+ if (activeCategories.size > 0) {
4372
+ highlightSet = categoryHighlightSet();
4373
+ }
4374
+ reRunSearch();
4375
+ renderChrome3();
4376
+ renderLegend2();
4377
+ syncLegendActiveState();
4378
+ needsRender = true;
4379
+ scheduleRender();
4380
+ }
4381
+ function runStructuralUpdate(diff) {
4382
+ teardownSimOnly();
4383
+ adjacencyMap = buildAdjacencyMap(compilation.edges);
4384
+ buildDataMaps();
4385
+ const positions = /* @__PURE__ */ new Map();
4386
+ for (const [id, p] of diff.survivingPositions) positions.set(id, p);
4387
+ for (const [id, p] of diff.spawnPositions) positions.set(id, p);
4388
+ const prevNodeCount = diff.survivingPositions.size + diff.exitingNodes.length;
4389
+ const nextNodeCount = diff.survivingPositions.size + diff.enteringIds.length;
4390
+ const nodeRatio = ratio(
4391
+ diff.enteringIds.length + diff.exitingNodes.length,
4392
+ Math.max(prevNodeCount, nextNodeCount)
4393
+ );
4394
+ const prevEdgeCount = compilation.edges.length - diff.enteringEdgeCount + diff.exitingEdges.length;
4395
+ const nextEdgeCount = compilation.edges.length;
4396
+ const edgeRatio = ratio(
4397
+ diff.enteringEdgeCount + diff.exitingEdges.length,
4398
+ Math.max(prevEdgeCount, nextEdgeCount)
4399
+ );
4400
+ const changeRatio = Math.max(nodeRatio, edgeRatio);
4401
+ const initialAlpha = Math.min(1, 0.3 + 0.7 * changeRatio);
4402
+ positionedNodes = compilation.nodes.map((node, index2) => {
4403
+ const pos = positions.get(node.id) ?? { x: 0, y: 0 };
4404
+ return { ...node, x: pos.x, y: pos.y, index: index2 };
4405
+ });
4406
+ positionedEdges = compilation.edges.map((edge) => {
4407
+ const src = positions.get(edge.source) ?? { x: 0, y: 0 };
4408
+ const tgt = positions.get(edge.target) ?? { x: 0, y: 0 };
4409
+ return { ...edge, sourceX: src.x, sourceY: src.y, targetX: tgt.x, targetY: tgt.y };
3294
4410
  });
3295
4411
  spatialIndex.rebuild(positionedNodes);
4412
+ initSimulation({
4413
+ positions,
4414
+ suppressCenter: true,
4415
+ initialAlpha,
4416
+ skipWarmup: true,
4417
+ skipEntrance: true
4418
+ });
3296
4419
  renderChrome3();
3297
4420
  renderLegend2();
4421
+ reconcileStateAfterUpdate();
4422
+ startUpdateTransitions(diff);
3298
4423
  needsRender = true;
3299
4424
  scheduleRender();
3300
4425
  }
4426
+ function ratio(numerator, denominator) {
4427
+ return denominator > 0 ? numerator / denominator : 0;
4428
+ }
4429
+ function reconcileStateAfterUpdate() {
4430
+ const nextIds = new Set(compilation.nodes.map((n) => n.id));
4431
+ if (hoveredNodeId && !nextIds.has(hoveredNodeId)) hoveredNodeId = null;
4432
+ if (hoveredEdgeId) {
4433
+ const [src, tgt] = hoveredEdgeId.split("->");
4434
+ if (!nextIds.has(src) || !nextIds.has(tgt)) hoveredEdgeId = null;
4435
+ }
4436
+ const survivingSelection = [...selectedNodeIds].filter((id) => nextIds.has(id));
4437
+ selectedNodeIds = new Set(survivingSelection);
4438
+ interactionManager?.setSelection(survivingSelection);
4439
+ if (activeCategories.size > 0) {
4440
+ highlightSet = categoryHighlightSet();
4441
+ } else if (highlightSet) {
4442
+ highlightSet = new Set([...highlightSet].filter((id) => nextIds.has(id)));
4443
+ if (highlightSet.size === 0) highlightSet = null;
4444
+ }
4445
+ syncLegendActiveState();
4446
+ reRunSearch();
4447
+ focusTransition = null;
4448
+ armFocus(performance.now());
4449
+ }
4450
+ function reRunSearch() {
4451
+ const q = searchManager.getQuery();
4452
+ if (q !== null) searchManager.search(q, positionedNodes);
4453
+ }
4454
+ function startUpdateTransitions(diff) {
4455
+ const updateCfg = compilation.animation?.update ?? null;
4456
+ const exitCfg = compilation.animation?.exit ?? null;
4457
+ const reduced = prefersReducedMotion();
4458
+ if (diff.enteringIds.length > 0 && updateCfg && !reduced) {
4459
+ const entering = diff.enteringIds;
4460
+ enterAlphaMap = new Map(entering.map((id) => [id, 0]));
4461
+ const ease = resolveEase(updateCfg.ease);
4462
+ const tween = createTween({
4463
+ duration: updateCfg.duration,
4464
+ ease,
4465
+ apply: (t) => {
4466
+ const q = Math.round(t * 8) / 8;
4467
+ if (enterAlphaMap) for (const id of entering) enterAlphaMap.set(id, q);
4468
+ needsRender = true;
4469
+ },
4470
+ onDone: () => {
4471
+ enterAlphaMap = null;
4472
+ needsRender = true;
4473
+ }
4474
+ });
4475
+ scheduler.add(tween);
4476
+ }
4477
+ if ((diff.exitingNodes.length > 0 || diff.exitingEdges.length > 0) && exitCfg && !reduced) {
4478
+ exitingGhosts = { nodes: diff.exitingNodes, edges: diff.exitingEdges, alpha: 1 };
4479
+ const ease = resolveEase(exitCfg.ease);
4480
+ const tween = createTween({
4481
+ duration: exitCfg.duration,
4482
+ ease,
4483
+ apply: (t) => {
4484
+ if (exitingGhosts) exitingGhosts.alpha = 1 - t;
4485
+ needsRender = true;
4486
+ },
4487
+ onDone: () => {
4488
+ exitingGhosts = null;
4489
+ needsRender = true;
4490
+ }
4491
+ });
4492
+ scheduler.add(tween);
4493
+ }
4494
+ }
4495
+ function updateVisuals(newSpec) {
4496
+ update(newSpec);
4497
+ }
4498
+ function teardownSimOnly() {
4499
+ simulation?.destroy();
4500
+ simulation = null;
4501
+ }
3301
4502
  function teardownSubsystems() {
4503
+ scheduler.cancelAll();
4504
+ activeFlight = null;
4505
+ activeFollow = null;
4506
+ entranceReveal = null;
4507
+ entranceActive = false;
4508
+ entranceProgress = 1;
4509
+ entranceFitInFlight = false;
4510
+ enterAlphaMap = null;
4511
+ exitingGhosts = null;
3302
4512
  if (animFrameId !== null) {
3303
4513
  cancelAnimationFrame(animFrameId);
3304
4514
  animFrameId = null;
@@ -3326,6 +4536,8 @@ function createGraph(container, spec, options) {
3326
4536
  disconnectResize();
3327
4537
  disconnectResize = null;
3328
4538
  }
4539
+ legendController?.destroy();
4540
+ legendController = null;
3329
4541
  if (wrapper?.parentNode) {
3330
4542
  wrapper.parentNode.removeChild(wrapper);
3331
4543
  }
@@ -3340,32 +4552,13 @@ function createGraph(container, spec, options) {
3340
4552
  compilation = compile();
3341
4553
  adjacencyMap = buildAdjacencyMap(compilation.edges);
3342
4554
  buildDataMaps();
4555
+ applyInitialHighlight();
3343
4556
  createDOM();
3344
4557
  initSimulation();
3345
4558
  initInteraction();
3346
4559
  } catch (err) {
3347
4560
  console.error("[viz] Graph mount failed:", err);
3348
- return {
3349
- update() {
3350
- },
3351
- updateVisuals() {
3352
- },
3353
- search() {
3354
- },
3355
- clearSearch() {
3356
- },
3357
- zoomToFit() {
3358
- },
3359
- zoomToNode() {
3360
- },
3361
- selectNode() {
3362
- },
3363
- getSelectedNodes: () => [],
3364
- resize() {
3365
- },
3366
- destroy() {
3367
- }
3368
- };
4561
+ throw err;
3369
4562
  }
3370
4563
  if (options?.responsive !== false) {
3371
4564
  disconnectResize = observeResize(container, () => {
@@ -3379,13 +4572,23 @@ function createGraph(container, spec, options) {
3379
4572
  clearSearch,
3380
4573
  zoomToFit,
3381
4574
  zoomToNode,
4575
+ flyTo,
4576
+ centerAt,
4577
+ getCamera,
3382
4578
  selectNode,
3383
4579
  getSelectedNodes,
4580
+ getSearchMatches,
4581
+ highlight,
4582
+ clearHighlight,
4583
+ getHighlight,
4584
+ getLegend,
4585
+ setActiveCategories,
4586
+ getActiveCategories,
3384
4587
  resize: doResize,
3385
4588
  destroy
3386
4589
  };
3387
4590
  }
3388
- function escapeHtml(str) {
4591
+ function escapeHtml2(str) {
3389
4592
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3390
4593
  }
3391
4594
 
@@ -4122,14 +5325,6 @@ function renderSankeySVG(layout, animation) {
4122
5325
  }
4123
5326
 
4124
5327
  // src/sankey-mount.ts
4125
- function resolveDarkMode3(mode) {
4126
- if (mode === "force") return true;
4127
- if (mode === "off" || mode === void 0) return false;
4128
- if (typeof window !== "undefined" && window.matchMedia) {
4129
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
4130
- }
4131
- return false;
4132
- }
4133
5328
  var HIGHLIGHT_OPACITY = 0.7;
4134
5329
  var DIM_OPACITY = 0.15;
4135
5330
  var NODE_DIM_OPACITY = 0.2;
@@ -4161,7 +5356,7 @@ function createSankey(container, spec, options) {
4161
5356
  }
4162
5357
  function compile() {
4163
5358
  const { width, height } = getContainerDimensions();
4164
- const darkMode = resolveDarkMode3(options?.darkMode);
5359
+ const darkMode = resolveDarkMode(options?.darkMode);
4165
5360
  const compileOpts = {
4166
5361
  width,
4167
5362
  height,
@@ -4336,7 +5531,7 @@ function createSankey(container, spec, options) {
4336
5531
  const animation = shouldAnimate ? currentLayout.animation : void 0;
4337
5532
  svgElement = renderSankeySVG(currentLayout, animation);
4338
5533
  container.appendChild(svgElement);
4339
- const isDark = resolveDarkMode3(options?.darkMode);
5534
+ const isDark = resolveDarkMode(options?.darkMode);
4340
5535
  if (isDark) {
4341
5536
  container.classList.add("oc-dark");
4342
5537
  } else {
@@ -4437,7 +5632,7 @@ function createSankey(container, spec, options) {
4437
5632
  const animation = shouldAnimate ? currentLayout.animation : void 0;
4438
5633
  svgElement = renderSankeySVG(currentLayout, animation);
4439
5634
  container.appendChild(svgElement);
4440
- const isDark = resolveDarkMode3(options?.darkMode);
5635
+ const isDark = resolveDarkMode(options?.darkMode);
4441
5636
  if (isDark) {
4442
5637
  container.classList.add("oc-dark");
4443
5638
  } else {
@@ -4954,14 +6149,6 @@ function renderTable(layout, container, opts) {
4954
6149
  }
4955
6150
 
4956
6151
  // src/table-mount.ts
4957
- function resolveDarkMode4(mode) {
4958
- if (mode === "force") return true;
4959
- if (mode === "off" || mode === void 0) return false;
4960
- if (typeof window !== "undefined" && window.matchMedia) {
4961
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
4962
- }
4963
- return false;
4964
- }
4965
6152
  function csvEscape(value) {
4966
6153
  if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) {
4967
6154
  return `"${value.replace(/"/g, '""')}"`;
@@ -5022,7 +6209,7 @@ function createTable(container, spec, options) {
5022
6209
  }
5023
6210
  function compile() {
5024
6211
  const state = getState();
5025
- const darkMode = resolveDarkMode4(options?.darkMode);
6212
+ const darkMode = resolveDarkMode(options?.darkMode);
5026
6213
  const { width } = getContainerDimensions();
5027
6214
  const compileOpts = {
5028
6215
  width,
@@ -5084,7 +6271,7 @@ function createTable(container, spec, options) {
5084
6271
  if (isFirstRender) {
5085
6272
  isFirstRender = false;
5086
6273
  }
5087
- const isDark = resolveDarkMode4(options?.darkMode);
6274
+ const isDark = resolveDarkMode(options?.darkMode);
5088
6275
  if (isDark) {
5089
6276
  container.classList.add("oc-dark");
5090
6277
  } else {
@@ -5239,7 +6426,7 @@ function createTable(container, spec, options) {
5239
6426
  throw new Error(`Unsupported export format: ${format}`);
5240
6427
  }
5241
6428
  const state = getState();
5242
- const darkMode = resolveDarkMode4(options?.darkMode);
6429
+ const darkMode = resolveDarkMode(options?.darkMode);
5243
6430
  const { width } = getContainerDimensions();
5244
6431
  const fullLayout = compileTable(currentSpec, {
5245
6432
  width,
@@ -5333,14 +6520,6 @@ function pickTargetTileSize(width) {
5333
6520
  if (width < BREAKPOINT_COMPACT_MAX) return 34;
5334
6521
  return 40;
5335
6522
  }
5336
- function resolveDarkMode5(mode) {
5337
- if (mode === "force") return true;
5338
- if (mode === "off" || mode === void 0) return false;
5339
- if (typeof window !== "undefined" && window.matchMedia) {
5340
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
5341
- }
5342
- return false;
5343
- }
5344
6523
  function createTileMap(container, spec, options) {
5345
6524
  let currentSpec = spec;
5346
6525
  let currentLayout;
@@ -5378,7 +6557,7 @@ function createTileMap(container, spec, options) {
5378
6557
  }
5379
6558
  function compile() {
5380
6559
  const { width, height } = getContainerDimensions();
5381
- const darkMode = resolveDarkMode5(options?.darkMode);
6560
+ const darkMode = resolveDarkMode(options?.darkMode);
5382
6561
  const compileOpts = {
5383
6562
  width,
5384
6563
  height,
@@ -5550,7 +6729,7 @@ function createTileMap(container, spec, options) {
5550
6729
  }
5551
6730
  container.classList.remove("oc-tilemap-root", "oc-dark");
5552
6731
  }
5553
- if (resolveDarkMode5(options?.darkMode)) {
6732
+ if (resolveDarkMode(options?.darkMode)) {
5554
6733
  container.classList.add("oc-dark");
5555
6734
  }
5556
6735
  currentLayout = compile();
@@ -5580,11 +6759,14 @@ function createTileMap(container, spec, options) {
5580
6759
  };
5581
6760
  }
5582
6761
  export {
6762
+ CANVAS_DEFAULT_UPDATE_MAX_MARKS,
6763
+ DEFAULT_UPDATE_MAX_MARKS,
6764
+ VECTOR_EXPORT_MAX_POINTS,
5583
6765
  attachKeyboardNav,
5584
6766
  createBarList,
5585
6767
  createChart,
6768
+ createGeoMap,
5586
6769
  createGraph,
5587
- createMap,
5588
6770
  createSankey,
5589
6771
  createSimulationWorker,
5590
6772
  createTable,
@@ -5597,6 +6779,8 @@ export {
5597
6779
  exportPNG,
5598
6780
  exportSVG,
5599
6781
  exportSVGWithFonts,
6782
+ exportSpecSequence,
6783
+ materializeCanvasModeSVG,
5600
6784
  observeResize,
5601
6785
  rectPathWithCorners,
5602
6786
  registerMarkRenderer,