@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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