@opendata-ai/openchart-vanilla 8.4.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +59 -0
  2. package/dist/chunk-2OJZBMXM.js +377 -0
  3. package/dist/chunk-2OJZBMXM.js.map +1 -0
  4. package/dist/{chunk-45XZOWYC.js → chunk-5KV3ZBBL.js} +58 -437
  5. package/dist/chunk-5KV3ZBBL.js.map +1 -0
  6. package/dist/{chunk-HGRNXLDF.js → chunk-5RX6ZQGZ.js} +10 -1
  7. package/dist/chunk-5RX6ZQGZ.js.map +1 -0
  8. package/dist/{chunk-UOB422LY.js → chunk-6B5OBGIO.js} +2 -2
  9. package/dist/chunk-NG7CVCI2.js +58 -0
  10. package/dist/chunk-NG7CVCI2.js.map +1 -0
  11. package/dist/{chunk-FETYAVTZ.js → chunk-RYVV3YIG.js} +3 -3
  12. package/dist/{chunk-LNPRDSK3.js → chunk-UAK6HWOC.js} +2 -2
  13. package/dist/chunk-WOLX5EZT.js +3577 -0
  14. package/dist/chunk-WOLX5EZT.js.map +1 -0
  15. package/dist/{chunk-YGYMB7KB.js → chunk-XGII4ZRT.js} +55 -30
  16. package/dist/chunk-XGII4ZRT.js.map +1 -0
  17. package/dist/export-gif.js +3 -3
  18. package/dist/graph-3d/index.d.ts +272 -0
  19. package/dist/graph-3d/index.js +1556 -0
  20. package/dist/graph-3d/index.js.map +1 -0
  21. package/dist/index.d.ts +4 -252
  22. package/dist/index.js +27 -3471
  23. package/dist/index.js.map +1 -1
  24. package/dist/renderer-registry-B5tW6yAZ.d.ts +376 -0
  25. package/dist/static.js +4 -3
  26. package/dist/static.js.map +1 -1
  27. package/dist/story/index.js +10 -7
  28. package/dist/story/index.js.map +1 -1
  29. package/dist/styles.css +1 -1
  30. package/dist/styles.css.map +1 -1
  31. package/package.json +26 -5
  32. package/dist/chunk-45XZOWYC.js.map +0 -1
  33. package/dist/chunk-HGRNXLDF.js.map +0 -1
  34. package/dist/chunk-YGYMB7KB.js.map +0 -1
  35. /package/dist/{chunk-UOB422LY.js.map → chunk-6B5OBGIO.js.map} +0 -0
  36. /package/dist/{chunk-FETYAVTZ.js.map → chunk-RYVV3YIG.js.map} +0 -0
  37. /package/dist/{chunk-LNPRDSK3.js.map → chunk-UAK6HWOC.js.map} +0 -0
package/dist/index.js CHANGED
@@ -3,15 +3,18 @@ import {
3
3
  paletteFromCanvas,
4
4
  readCanvasSRGB,
5
5
  resolveRepeat
6
- } from "./chunk-FETYAVTZ.js";
6
+ } from "./chunk-RYVV3YIG.js";
7
7
  import {
8
8
  renderTileMapSVG
9
- } from "./chunk-LNPRDSK3.js";
9
+ } from "./chunk-UAK6HWOC.js";
10
+ import {
11
+ GRAPH_3D_NOT_REGISTERED_ERROR,
12
+ createGraph,
13
+ registerGraphRenderer
14
+ } from "./chunk-WOLX5EZT.js";
10
15
  import {
11
- AnimationScheduler,
12
16
  CANVAS_DEFAULT_UPDATE_MAX_MARKS,
13
17
  DEFAULT_UPDATE_MAX_MARKS,
14
- SpatialIndex,
15
18
  VECTOR_EXPORT_MAX_POINTS,
16
19
  applySrOnlyStyles,
17
20
  cancelAnimations,
@@ -19,20 +22,13 @@ import {
19
22
  createGeoMap,
20
23
  createMeasureText,
21
24
  createTextEditOverlay,
22
- createTooltipManager,
23
- createTween,
24
25
  createYouDrawIt,
25
26
  materializeCanvasModeSVG,
26
- observeResize,
27
- prefersReducedMotion,
28
- resolveDarkMode,
29
- resolveEase,
30
27
  resolveFontFamily,
31
- resolvedSurface,
32
28
  scheduleFontReload,
33
29
  setupAnimationCleanup,
34
30
  setupTableAnimationCleanup
35
- } from "./chunk-45XZOWYC.js";
31
+ } from "./chunk-5KV3ZBBL.js";
36
32
  import {
37
33
  embedFonts,
38
34
  ensureSVGDimensions,
@@ -44,16 +40,24 @@ import {
44
40
  getSVGBackgroundColor,
45
41
  getSVGDimensions,
46
42
  rasterizeSVGToCanvas
47
- } from "./chunk-UOB422LY.js";
43
+ } from "./chunk-6B5OBGIO.js";
48
44
  import {
49
45
  rectPathWithCorners,
50
46
  registerMarkRenderer,
51
47
  renderChartSVG,
52
48
  stampAnimationVars
53
- } from "./chunk-YGYMB7KB.js";
49
+ } from "./chunk-XGII4ZRT.js";
54
50
  import {
55
51
  injectThemeStyleBlock
56
- } from "./chunk-HGRNXLDF.js";
52
+ } from "./chunk-5RX6ZQGZ.js";
53
+ import {
54
+ createTooltipManager,
55
+ observeResize,
56
+ resolveDarkMode
57
+ } from "./chunk-2OJZBMXM.js";
58
+ import {
59
+ resolvedSurface
60
+ } from "./chunk-NG7CVCI2.js";
57
61
 
58
62
  // src/barlist-mount.ts
59
63
  import { compileBarList } from "@opendata-ai/openchart-engine";
@@ -607,3456 +611,6 @@ function createSimulationWorker() {
607
611
  return new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
608
612
  }
609
613
 
610
- // src/graph-mount.ts
611
- import { buildEdgeTooltip, compileGraph } from "@opendata-ai/openchart-engine";
612
-
613
- // src/graph/camera.ts
614
- import { interpolateZoom } from "d3-interpolate";
615
-
616
- // src/graph/zoom.ts
617
- var ZoomTransform = class _ZoomTransform {
618
- constructor(x, y, k) {
619
- this.x = x;
620
- this.y = y;
621
- this.k = k;
622
- }
623
- /** Convert screen coordinates to graph coordinates. */
624
- screenToGraph(sx, sy) {
625
- return {
626
- x: (sx - this.x) / this.k,
627
- y: (sy - this.y) / this.k
628
- };
629
- }
630
- /** Convert graph coordinates to screen coordinates. */
631
- graphToScreen(gx, gy) {
632
- return {
633
- x: gx * this.k + this.x,
634
- y: gy * this.k + this.y
635
- };
636
- }
637
- /**
638
- * Zoom to a target scale, keeping the given screen-space pivot
639
- * point fixed (content under the cursor stays under the cursor).
640
- */
641
- zoomAt(targetK, pivotX, pivotY) {
642
- const gx = (pivotX - this.x) / this.k;
643
- const gy = (pivotY - this.y) / this.k;
644
- return new _ZoomTransform(pivotX - gx * targetK, pivotY - gy * targetK, targetK);
645
- }
646
- /** Pan by a screen-space delta. */
647
- pan(dx, dy) {
648
- return new _ZoomTransform(this.x + dx, this.y + dy, this.k);
649
- }
650
- /**
651
- * Compute a transform that fits all nodes within the given canvas
652
- * dimensions with the specified padding.
653
- *
654
- * Returns the transform and the ideal content height (in screen pixels)
655
- * so callers can shrink the canvas to eliminate dead space.
656
- */
657
- static fitBounds(nodes, canvasW, canvasH, padding = 40, opts) {
658
- if (nodes.length === 0) {
659
- return { transform: _ZoomTransform.identity(), contentHeight: canvasH };
660
- }
661
- const insetTop = Math.min(Math.max(0, opts?.insetTop ?? 0), canvasH * 0.4);
662
- let minX = Infinity;
663
- let minY = Infinity;
664
- let maxX = -Infinity;
665
- let maxY = -Infinity;
666
- for (const n of nodes) {
667
- const r = n.radius;
668
- if (n.x - r < minX) minX = n.x - r;
669
- if (n.y - r < minY) minY = n.y - r;
670
- if (n.x + r > maxX) maxX = n.x + r;
671
- if (n.y + r > maxY) maxY = n.y + r;
672
- }
673
- let graphW = maxX - minX;
674
- let graphH = maxY - minY;
675
- if (graphW === 0 && graphH === 0) {
676
- return {
677
- transform: new _ZoomTransform(
678
- canvasW / 2 - minX,
679
- insetTop + (canvasH - insetTop) / 2 - minY,
680
- 1
681
- ),
682
- contentHeight: padding * 2 + insetTop
683
- };
684
- }
685
- if (opts?.spread !== false && nodes.length > 50) {
686
- const spread = 1 + Math.sqrt(nodes.length) / 120;
687
- const cx2 = (minX + maxX) / 2;
688
- const cy2 = (minY + maxY) / 2;
689
- graphW *= spread;
690
- graphH *= spread;
691
- minX = cx2 - graphW / 2;
692
- maxX = cx2 + graphW / 2;
693
- minY = cy2 - graphH / 2;
694
- maxY = cy2 + graphH / 2;
695
- }
696
- const availW = canvasW - padding * 2;
697
- const availH = canvasH - insetTop - padding * 2;
698
- const k = Math.min(1, availW / graphW, availH / graphH);
699
- const cx = (minX + maxX) / 2;
700
- const cy = (minY + maxY) / 2;
701
- const tx = canvasW / 2 - cx * k;
702
- const ty = insetTop + (canvasH - insetTop) / 2 - cy * k;
703
- const contentHeight = graphH * k + padding * 2 + insetTop;
704
- return {
705
- transform: new _ZoomTransform(tx, ty, k),
706
- contentHeight
707
- };
708
- }
709
- /** Identity transform (no pan, no zoom). */
710
- static identity() {
711
- return new _ZoomTransform(0, 0, 1);
712
- }
713
- };
714
-
715
- // src/graph/camera.ts
716
- var K_MIN = 0.05;
717
- var K_MAX = 15;
718
- var AUTO_MIN_MS = 400;
719
- var AUTO_MAX_MS = 600;
720
- var AUTO_SCALE = 0.6;
721
- function transformToView(t, viewport) {
722
- const cx = (viewport.width / 2 - t.x) / t.k;
723
- const cy = (viewport.height / 2 - t.y) / t.k;
724
- const width = viewport.width / t.k;
725
- return [cx, cy, width];
726
- }
727
- function viewToTransform(view, viewport) {
728
- const [cx, cy, width] = view;
729
- const k = clampK(viewport.width / width);
730
- const x = viewport.width / 2 - cx * k;
731
- const y = viewport.height / 2 - cy * k;
732
- return new ZoomTransform(x, y, k);
733
- }
734
- function clampK(k) {
735
- if (!Number.isFinite(k) || k <= 0) return K_MIN;
736
- return Math.min(K_MAX, Math.max(K_MIN, k));
737
- }
738
- function createCameraFlight(inputs) {
739
- const { from, viewport, apply, onDone, opts } = inputs;
740
- const ease = resolveEase(opts?.ease ?? "smooth");
741
- const isProvider = typeof inputs.to === "function";
742
- const fromView = transformToView(from, viewport);
743
- function targetView() {
744
- const to = isProvider ? inputs.to() : inputs.to;
745
- return transformToView(to, viewport);
746
- }
747
- let interp = interpolateZoom(fromView, targetView());
748
- const resolvedDuration = resolveDuration(opts?.duration, interp.duration);
749
- let startTime = null;
750
- let finished = false;
751
- function applyAt(t) {
752
- if (isProvider) interp = interpolateZoom(fromView, targetView());
753
- const view = interp(t);
754
- if (view.some((v) => !Number.isFinite(v))) {
755
- apply(viewToTransform(targetView(), viewport));
756
- return;
757
- }
758
- apply(viewToTransform(view, viewport));
759
- }
760
- return {
761
- tick(now) {
762
- if (finished) return false;
763
- if (startTime === null) startTime = now;
764
- const raw = resolvedDuration <= 0 ? 1 : Math.min(1, (now - startTime) / resolvedDuration);
765
- applyAt(ease(raw));
766
- if (raw >= 1) {
767
- finished = true;
768
- onDone?.();
769
- return false;
770
- }
771
- return true;
772
- },
773
- finish() {
774
- if (finished) return;
775
- finished = true;
776
- applyAt(1);
777
- onDone?.();
778
- },
779
- cancel() {
780
- finished = true;
781
- }
782
- };
783
- }
784
- function createCameraFollow(inputs) {
785
- let finished = false;
786
- return {
787
- tick() {
788
- if (finished) return false;
789
- if (!inputs.isActive()) {
790
- finished = true;
791
- return false;
792
- }
793
- inputs.apply(inputs.target());
794
- return true;
795
- },
796
- finish() {
797
- finished = true;
798
- },
799
- cancel() {
800
- finished = true;
801
- }
802
- };
803
- }
804
- function resolveDuration(duration, interpDuration) {
805
- if (typeof duration === "number") return Math.max(1, duration);
806
- const scaled = interpDuration * AUTO_SCALE;
807
- return Math.max(AUTO_MIN_MS, Math.min(AUTO_MAX_MS, Math.max(1, scaled)));
808
- }
809
-
810
- // src/graph/canvas-renderer.ts
811
- import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from "@opendata-ai/openchart-core";
812
-
813
- // src/graph/entrance.ts
814
- function nodeEnterProgress(globalT, index, total, buckets = 8) {
815
- const g = globalT <= 0 ? 0 : globalT >= 1 ? 1 : globalT;
816
- const start = total > 0 ? index / total * 0.4 : 0;
817
- const local = (g - start) / 0.6;
818
- const clamped = local <= 0 ? 0 : local >= 1 ? 1 : local;
819
- const b = Math.max(1, Math.floor(buckets));
820
- return Math.round(clamped * b) / b;
821
- }
822
- var ENTRANCE_STAGGER_MAX_NODES = 3e3;
823
- var ENTRANCE_DRIFT_PX = 16;
824
- function centroid(nodes) {
825
- let cx = 0;
826
- let cy = 0;
827
- for (const n of nodes) {
828
- cx += n.x;
829
- cy += n.y;
830
- }
831
- cx /= nodes.length;
832
- cy /= nodes.length;
833
- return { cx, cy };
834
- }
835
- function hashId(id) {
836
- let h = 2166136261;
837
- for (let i = 0; i < id.length; i++) {
838
- h ^= id.charCodeAt(i);
839
- h = Math.imul(h, 16777619);
840
- }
841
- return h >>> 0;
842
- }
843
- function entranceOrder(nodes) {
844
- const rank = /* @__PURE__ */ new Map();
845
- if (nodes.length === 0) return rank;
846
- const sorted = [...nodes].sort((a, b) => {
847
- const ha = hashId(a.id);
848
- const hb = hashId(b.id);
849
- return ha - hb || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
850
- });
851
- for (let i = 0; i < sorted.length; i++) rank.set(sorted[i].id, i);
852
- return rank;
853
- }
854
- function entranceOffsets(nodes, dist = ENTRANCE_DRIFT_PX) {
855
- const offsets = /* @__PURE__ */ new Map();
856
- if (nodes.length === 0) return offsets;
857
- const { cx, cy } = centroid(nodes);
858
- for (const n of nodes) {
859
- const dx = n.x - cx;
860
- const dy = n.y - cy;
861
- const len = Math.sqrt(dx * dx + dy * dy);
862
- if (len < 1e-6) {
863
- offsets.set(n.id, { x: 0, y: -dist });
864
- } else {
865
- offsets.set(n.id, { x: dx / len * dist, y: dy / len * dist });
866
- }
867
- }
868
- return offsets;
869
- }
870
- function popScale(t) {
871
- if (t <= 0) return 0;
872
- if (t >= 1) return 1;
873
- const c1 = 1.70158;
874
- const c3 = c1 + 1;
875
- const u = t - 1;
876
- return 1 + c3 * u * u * u + c1 * u * u;
877
- }
878
- function popAlpha(t) {
879
- const a = t / 0.6;
880
- return a >= 1 ? 1 : a <= 0 ? 0 : a;
881
- }
882
- function driftFactor(t) {
883
- const u = 1 - (t <= 0 ? 0 : t >= 1 ? 1 : t);
884
- return u * u;
885
- }
886
-
887
- // src/graph/canvas-renderer.ts
888
- var LABEL_FONT_MIN = 9;
889
- var LABEL_FONT_MAX = 12;
890
- var EDGE_ALPHA_DEFAULT_LIGHT = 0.3;
891
- var EDGE_ALPHA_DEFAULT_DARK = 0.25;
892
- var EDGE_ALPHA_CONNECTED = 1;
893
- var SEARCH_NON_MATCH_ALPHA = 0.25;
894
- var DEFAULT_DIM_OPACITY = 0.3;
895
- var LABEL_BUDGET_MIN = 12;
896
- var LABEL_BUDGET_MAX = 80;
897
- var LABEL_BUDGET_PER_ZOOM = 30;
898
- var CROSSFADE_MAX_EDGES = 2e4;
899
- function edgeTier(edge, focus) {
900
- if (!focus.hasActive) return "default";
901
- return focus.connected.has(edge.source) && focus.connected.has(edge.target) ? "connected" : "dimmed";
902
- }
903
- function nodeTier(node, focus, exemptIds) {
904
- if (!focus.hasActive) return "default";
905
- if (exemptIds?.has(node.id)) return "connected";
906
- return focus.connected.has(node.id) ? "connected" : "dimmed";
907
- }
908
- function edgeTierAlpha(tier, dimOpacity, defaultAlpha) {
909
- switch (tier) {
910
- case "connected":
911
- return EDGE_ALPHA_CONNECTED;
912
- case "dimmed":
913
- return dimOpacity / 3;
914
- default:
915
- return defaultAlpha;
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(a, b, t) {
927
- return a + (b - a) * 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
- }
966
- var GLOW_NODE_THRESHOLD = 2e3;
967
- var GLOW_RADIUS_MULTIPLIER = 1.3;
968
- var GLOW_ALPHA = 0.1;
969
- var CULL_MARGIN = 50;
970
- var TWO_PI = Math.PI * 2;
971
- var MIN_SCREEN_RADIUS = 2.5;
972
- function labelBudget(zoom) {
973
- const raw = Math.round(zoom * LABEL_BUDGET_PER_ZOOM);
974
- return Math.max(LABEL_BUDGET_MIN, Math.min(LABEL_BUDGET_MAX, raw));
975
- }
976
- function boxesOverlap(a, b) {
977
- return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
978
- }
979
- function visibleRect(canvasWidth, canvasHeight, transform, margin = CULL_MARGIN) {
980
- const { x, y, k } = transform;
981
- return {
982
- minX: (-x - margin) / k,
983
- minY: (-y - margin) / k,
984
- maxX: (canvasWidth - x + margin) / k,
985
- maxY: (canvasHeight - y + margin) / k
986
- };
987
- }
988
- function nodeInView(node, rect) {
989
- return node.x + node.radius >= rect.minX && node.x - node.radius <= rect.maxX && node.y + node.radius >= rect.minY && node.y - node.radius <= rect.maxY;
990
- }
991
- function edgeInView(edge, rect) {
992
- return edge.sourceX >= rect.minX && edge.sourceX <= rect.maxX && edge.sourceY >= rect.minY && edge.sourceY <= rect.maxY || edge.targetX >= rect.minX && edge.targetX <= rect.maxX && edge.targetY >= rect.minY && edge.targetY <= rect.maxY;
993
- }
994
- var DASH_PATTERNS = {
995
- solid: [],
996
- dashed: [6, 4],
997
- dotted: [2, 3]
998
- };
999
- var GraphCanvasRenderer = class {
1000
- canvas;
1001
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
1002
- ctx;
1003
- dpr;
1004
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
1005
- cssWidth = 0;
1006
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via this-destructuring
1007
- cssHeight = 0;
1008
- constructor(canvas) {
1009
- this.canvas = canvas;
1010
- this.ctx = canvas.getContext("2d");
1011
- this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
1012
- }
1013
- /** Update canvas dimensions with DPR scaling. CSS size stays at css values. */
1014
- resize(width, height) {
1015
- this.cssWidth = width;
1016
- this.cssHeight = height;
1017
- this.canvas.width = width * this.dpr;
1018
- this.canvas.height = height * this.dpr;
1019
- }
1020
- /** Clear canvas and render the full graph state. */
1021
- render(state) {
1022
- const { ctx, dpr, cssWidth, cssHeight } = this;
1023
- const {
1024
- nodes,
1025
- edges,
1026
- transform,
1027
- hoveredNodeId,
1028
- hoveredEdgeId,
1029
- selectedNodeIds,
1030
- adjacencyMap,
1031
- theme,
1032
- searchMatches,
1033
- isGesturing
1034
- } = state;
1035
- const dimOpacity = state.dimOpacity ?? DEFAULT_DIM_OPACITY;
1036
- const nextFocus = state.focus?.next ?? deriveFocus(hoveredNodeId, selectedNodeIds, adjacencyMap);
1037
- const crossfade = state.focus && state.focus.t < 1 && !isGesturing ? state.focus : null;
1038
- const entrance = state.entrance && state.entrance.t < 1 ? makeEntranceReveal(state.entrance, nodes.length) : null;
1039
- const enterAlpha = state.enterAlpha ?? null;
1040
- const enterAlphaFor = (id) => enterAlpha?.get(id) ?? 1;
1041
- const rect = visibleRect(cssWidth, cssHeight, transform);
1042
- const visibleNodes = nodes.filter((n) => nodeInView(n, rect));
1043
- const visibleEdges = edges.filter((e) => edgeInView(e, rect));
1044
- const isDark = theme.isDark;
1045
- const edgeAlphaDefault = isDark ? EDGE_ALPHA_DEFAULT_DARK : EDGE_ALPHA_DEFAULT_LIGHT;
1046
- const showGlow = isDark && !isGesturing && visibleNodes.length < GLOW_NODE_THRESHOLD;
1047
- const minRadius = MIN_SCREEN_RADIUS / transform.k;
1048
- ctx.save();
1049
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1050
- ctx.clearRect(0, 0, cssWidth, cssHeight);
1051
- if (theme.colors.background !== "transparent") {
1052
- ctx.fillStyle = theme.colors.background;
1053
- ctx.fillRect(0, 0, cssWidth, cssHeight);
1054
- }
1055
- ctx.translate(transform.x, transform.y);
1056
- ctx.scale(transform.k, transform.k);
1057
- if (state.exiting && state.exiting.alpha > 0) {
1058
- this.drawGhosts(ctx, state.exiting, rect, edgeAlphaDefault);
1059
- }
1060
- if (crossfade && visibleEdges.length <= CROSSFADE_MAX_EDGES) {
1061
- this.drawEdgesCrossfade(
1062
- ctx,
1063
- visibleEdges,
1064
- crossfade.prev,
1065
- crossfade.next,
1066
- crossfade.t,
1067
- dimOpacity,
1068
- edgeAlphaDefault,
1069
- isGesturing ? null : searchMatches,
1070
- hoveredEdgeId,
1071
- entrance,
1072
- enterAlphaFor
1073
- );
1074
- } else {
1075
- this.drawEdgesBatched(
1076
- ctx,
1077
- visibleEdges,
1078
- nextFocus,
1079
- dimOpacity,
1080
- edgeAlphaDefault,
1081
- isGesturing ? null : searchMatches,
1082
- hoveredEdgeId,
1083
- entrance,
1084
- enterAlphaFor
1085
- );
1086
- }
1087
- this.drawNodesBatched(
1088
- ctx,
1089
- visibleNodes,
1090
- hoveredNodeId,
1091
- selectedNodeIds,
1092
- isGesturing ? null : searchMatches,
1093
- showGlow,
1094
- theme,
1095
- minRadius,
1096
- nextFocus,
1097
- dimOpacity,
1098
- state.exemptIds,
1099
- crossfade,
1100
- state.hoverRadiusScale,
1101
- entrance,
1102
- enterAlphaFor
1103
- );
1104
- if (!isGesturing) {
1105
- this.drawLabels(
1106
- ctx,
1107
- visibleNodes,
1108
- hoveredNodeId,
1109
- selectedNodeIds,
1110
- searchMatches,
1111
- transform.k,
1112
- theme,
1113
- entrance,
1114
- enterAlphaFor
1115
- );
1116
- }
1117
- ctx.restore();
1118
- if (state.watermark) {
1119
- this.drawBrand(ctx, cssWidth, cssHeight, theme);
1120
- }
1121
- }
1122
- // -------------------------------------------------------------------------
1123
- // Brand rendering
1124
- // -------------------------------------------------------------------------
1125
- drawBrand(ctx, w, h, theme) {
1126
- if (w < BRAND_MIN_WIDTH) return;
1127
- const { dpr } = this;
1128
- ctx.save();
1129
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1130
- const padding = theme.spacing.padding;
1131
- const x = w - padding;
1132
- const y = h - padding;
1133
- ctx.font = `600 ${BRAND_FONT_SIZE}px ${theme.fonts.family}`;
1134
- ctx.fillStyle = theme.colors.axis;
1135
- ctx.globalAlpha = 0.55;
1136
- ctx.textAlign = "right";
1137
- ctx.textBaseline = "alphabetic";
1138
- ctx.fillText("OpenData", x, y);
1139
- ctx.restore();
1140
- }
1141
- // -------------------------------------------------------------------------
1142
- // Batched edge drawing
1143
- // -------------------------------------------------------------------------
1144
- drawEdgesBatched(ctx, edges, focus, dimOpacity, edgeAlphaDefault, searchMatches, hoveredEdgeId, entrance, enterAlphaFor) {
1145
- const buckets = {
1146
- dimmed: [],
1147
- default: [],
1148
- connected: []
1149
- };
1150
- let hoveredEdge = null;
1151
- for (const edge of edges) {
1152
- const edgeId = `${edge.source}->${edge.target}`;
1153
- if (edgeId === hoveredEdgeId) {
1154
- hoveredEdge = edge;
1155
- continue;
1156
- }
1157
- buckets[edgeTier(edge, focus)].push(edge);
1158
- }
1159
- const ea = entrance ? entrance.edgeAlpha : 1;
1160
- this.drawEdgeGroupBatched(
1161
- ctx,
1162
- buckets.dimmed,
1163
- edgeTierAlpha("dimmed", dimOpacity, edgeAlphaDefault) * ea,
1164
- searchMatches,
1165
- enterAlphaFor
1166
- );
1167
- this.drawEdgeGroupBatched(
1168
- ctx,
1169
- buckets.default,
1170
- edgeAlphaDefault * ea,
1171
- searchMatches,
1172
- enterAlphaFor
1173
- );
1174
- this.drawEdgeGroupBatched(
1175
- ctx,
1176
- buckets.connected,
1177
- EDGE_ALPHA_CONNECTED * ea,
1178
- searchMatches,
1179
- enterAlphaFor
1180
- );
1181
- this.drawHoveredEdge(ctx, hoveredEdge);
1182
- }
1183
- /**
1184
- * Crossfade edges between a prev and next focus state. Each edge is classified
1185
- * under BOTH snapshots → at most 9 (prevTier × nextTier) buckets, each drawn
1186
- * batched at `lerp(edgeTierAlpha[prev], edgeTierAlpha[next], t)`. Preserves the
1187
- * per-group style batching within each bucket.
1188
- */
1189
- drawEdgesCrossfade(ctx, edges, prev, next, t, dimOpacity, edgeAlphaDefault, searchMatches, hoveredEdgeId, entrance, enterAlphaFor) {
1190
- const ea = entrance ? entrance.edgeAlpha : 1;
1191
- const buckets = /* @__PURE__ */ new Map();
1192
- let hoveredEdge = null;
1193
- for (const edge of edges) {
1194
- const edgeId = `${edge.source}->${edge.target}`;
1195
- if (edgeId === hoveredEdgeId) {
1196
- hoveredEdge = edge;
1197
- continue;
1198
- }
1199
- const key = `${edgeTier(edge, prev)}|${edgeTier(edge, next)}`;
1200
- let bucket = buckets.get(key);
1201
- if (!bucket) {
1202
- bucket = [];
1203
- buckets.set(key, bucket);
1204
- }
1205
- bucket.push(edge);
1206
- }
1207
- const ordered = [...buckets.entries()].map(([key, bucket]) => {
1208
- const [prevTier, nextTier] = key.split("|");
1209
- const alpha = lerp(
1210
- edgeTierAlpha(prevTier, dimOpacity, edgeAlphaDefault),
1211
- edgeTierAlpha(nextTier, dimOpacity, edgeAlphaDefault),
1212
- t
1213
- ) * ea;
1214
- return { alpha, bucket };
1215
- }).sort((a, b) => a.alpha - b.alpha);
1216
- for (const { alpha, bucket } of ordered) {
1217
- this.drawEdgeGroupBatched(ctx, bucket, alpha, searchMatches, enterAlphaFor);
1218
- }
1219
- this.drawHoveredEdge(ctx, hoveredEdge);
1220
- }
1221
- /** Draw the hovered edge on top with a thickened highlight stroke. */
1222
- drawHoveredEdge(ctx, hoveredEdge) {
1223
- if (!hoveredEdge) return;
1224
- const dash = DASH_PATTERNS[hoveredEdge.style] ?? DASH_PATTERNS.solid;
1225
- ctx.setLineDash(dash);
1226
- ctx.strokeStyle = hoveredEdge.stroke;
1227
- ctx.lineWidth = hoveredEdge.strokeWidth * 2;
1228
- ctx.globalAlpha = EDGE_ALPHA_CONNECTED;
1229
- ctx.beginPath();
1230
- ctx.moveTo(hoveredEdge.sourceX, hoveredEdge.sourceY);
1231
- ctx.lineTo(hoveredEdge.targetX, hoveredEdge.targetY);
1232
- ctx.stroke();
1233
- ctx.setLineDash([]);
1234
- ctx.globalAlpha = 1;
1235
- }
1236
- /**
1237
- * Draw a group of edges at a given alpha, batched by (stroke, strokeWidth, style).
1238
- * When search is inactive, all edges of the same style are drawn in a single path.
1239
- * When search is active, edges split by search-match status for alpha dimming.
1240
- */
1241
- drawEdgeGroupBatched(ctx, edges, alpha, searchMatches, enterAlphaFor) {
1242
- if (edges.length === 0) return;
1243
- const groups = /* @__PURE__ */ new Map();
1244
- for (const edge of edges) {
1245
- const rawEnter = Math.min(enterAlphaFor(edge.source), enterAlphaFor(edge.target));
1246
- const enter = Math.round(rawEnter * 8) / 8;
1247
- const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}|${enter}`;
1248
- let group = groups.get(key);
1249
- if (!group) {
1250
- group = { edges: [], enter };
1251
- groups.set(key, group);
1252
- }
1253
- group.edges.push(edge);
1254
- }
1255
- for (const [, { edges: group, enter }] of groups) {
1256
- const sample = group[0];
1257
- const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
1258
- ctx.setLineDash(dash);
1259
- ctx.strokeStyle = sample.stroke;
1260
- ctx.lineWidth = sample.strokeWidth;
1261
- const groupAlpha = alpha * enter;
1262
- if (!searchMatches) {
1263
- ctx.globalAlpha = groupAlpha;
1264
- ctx.beginPath();
1265
- for (const edge of group) {
1266
- ctx.moveTo(edge.sourceX, edge.sourceY);
1267
- ctx.lineTo(edge.targetX, edge.targetY);
1268
- }
1269
- ctx.stroke();
1270
- } else {
1271
- ctx.globalAlpha = groupAlpha;
1272
- ctx.beginPath();
1273
- let hasMatched = false;
1274
- const nonMatchPath = [];
1275
- for (const edge of group) {
1276
- const srcMatch = searchMatches.has(edge.source);
1277
- const tgtMatch = searchMatches.has(edge.target);
1278
- if (srcMatch || tgtMatch) {
1279
- ctx.moveTo(edge.sourceX, edge.sourceY);
1280
- ctx.lineTo(edge.targetX, edge.targetY);
1281
- hasMatched = true;
1282
- } else {
1283
- nonMatchPath.push(edge);
1284
- }
1285
- }
1286
- if (hasMatched) ctx.stroke();
1287
- if (nonMatchPath.length > 0) {
1288
- ctx.globalAlpha = SEARCH_NON_MATCH_ALPHA * groupAlpha;
1289
- ctx.beginPath();
1290
- for (const edge of nonMatchPath) {
1291
- ctx.moveTo(edge.sourceX, edge.sourceY);
1292
- ctx.lineTo(edge.targetX, edge.targetY);
1293
- }
1294
- ctx.stroke();
1295
- }
1296
- }
1297
- }
1298
- ctx.setLineDash([]);
1299
- ctx.globalAlpha = 1;
1300
- }
1301
- // -------------------------------------------------------------------------
1302
- // Batched node drawing
1303
- // -------------------------------------------------------------------------
1304
- drawNodesBatched(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, showGlow, theme, minRadius, nextFocus, dimOpacity, exemptIds, crossfade, hoverRadiusScale, entrance, enterAlphaFor) {
1305
- const focusAlpha = (node) => {
1306
- if (crossfade) {
1307
- return lerp(
1308
- nodeTierAlpha(nodeTier(node, crossfade.prev, exemptIds), dimOpacity),
1309
- nodeTierAlpha(nodeTier(node, crossfade.next, exemptIds), dimOpacity),
1310
- crossfade.t
1311
- );
1312
- }
1313
- return nodeTierAlpha(nodeTier(node, nextFocus, exemptIds), dimOpacity);
1314
- };
1315
- const searchAlpha = (node) => searchMatches !== null && !searchMatches.has(node.id) ? SEARCH_NON_MATCH_ALPHA : 1;
1316
- const entranceAlpha = (node) => entrance ? entrance.nodeAlpha(node) : 1;
1317
- const entranceScale = (node) => entrance ? entrance.nodeScale(node) : 1;
1318
- const entranceShift = (node) => entrance ? entrance.shift(node.id) : ZERO_SHIFT;
1319
- const effectiveAlpha = (node) => focusAlpha(node) * searchAlpha(node) * entranceAlpha(node) * enterAlphaFor(node.id);
1320
- const bulkNodes = [];
1321
- const specialNodes = [];
1322
- for (const node of nodes) {
1323
- if (node.id === hoveredNodeId || selectedNodeIds.has(node.id) || (hoverRadiusScale?.has(node.id) ?? false)) {
1324
- specialNodes.push(node);
1325
- } else {
1326
- bulkNodes.push(node);
1327
- }
1328
- }
1329
- const r = (node) => Math.max(node.radius, minRadius) * entranceScale(node);
1330
- if (showGlow && !entrance) {
1331
- this.drawGlowBatched(ctx, bulkNodes, searchMatches, minRadius);
1332
- }
1333
- const fillGroups = /* @__PURE__ */ new Map();
1334
- for (const node of bulkNodes) {
1335
- const alpha = effectiveAlpha(node);
1336
- const key = `${node.fill}|${alpha.toFixed(3)}`;
1337
- let group = fillGroups.get(key);
1338
- if (!group) {
1339
- group = { fill: node.fill, alpha, nodes: [] };
1340
- fillGroups.set(key, group);
1341
- }
1342
- group.nodes.push(node);
1343
- }
1344
- for (const { fill, alpha, nodes: group } of fillGroups.values()) {
1345
- ctx.fillStyle = fill;
1346
- ctx.globalAlpha = alpha;
1347
- ctx.beginPath();
1348
- for (const node of group) {
1349
- const nr = r(node);
1350
- if (nr <= 0) continue;
1351
- const s = entranceShift(node);
1352
- ctx.moveTo(node.x + s.x + nr, node.y + s.y);
1353
- ctx.arc(node.x + s.x, node.y + s.y, nr, 0, TWO_PI);
1354
- }
1355
- ctx.fill();
1356
- }
1357
- const strokeGroups = /* @__PURE__ */ new Map();
1358
- for (const node of bulkNodes) {
1359
- const alpha = effectiveAlpha(node);
1360
- const key = `${node.stroke}|${node.strokeWidth}|${alpha.toFixed(3)}`;
1361
- let group = strokeGroups.get(key);
1362
- if (!group) {
1363
- group = { stroke: node.stroke, width: node.strokeWidth, alpha, nodes: [] };
1364
- strokeGroups.set(key, group);
1365
- }
1366
- group.nodes.push(node);
1367
- }
1368
- for (const { stroke, width, alpha, nodes: group } of strokeGroups.values()) {
1369
- ctx.strokeStyle = stroke;
1370
- ctx.lineWidth = width;
1371
- ctx.globalAlpha = alpha;
1372
- ctx.beginPath();
1373
- for (const node of group) {
1374
- const nr = r(node);
1375
- if (nr <= 0) continue;
1376
- const s = entranceShift(node);
1377
- ctx.moveTo(node.x + s.x + nr, node.y + s.y);
1378
- ctx.arc(node.x + s.x, node.y + s.y, nr, 0, TWO_PI);
1379
- }
1380
- ctx.stroke();
1381
- }
1382
- for (const node of specialNodes) {
1383
- const isHovered = node.id === hoveredNodeId;
1384
- const isSelected = selectedNodeIds.has(node.id);
1385
- const dimmed = searchMatches !== null && !searchMatches.has(node.id);
1386
- const baseRadius = Math.max(node.radius, minRadius);
1387
- const hoverScale = hoverRadiusScale?.get(node.id) ?? (isHovered ? 1.15 : 1);
1388
- const radius = baseRadius * hoverScale;
1389
- const brightened = isHovered && hoverScale >= 1.075;
1390
- const s = entranceShift(node);
1391
- const nx = node.x + s.x;
1392
- const ny = node.y + s.y;
1393
- ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
1394
- if (showGlow && !dimmed) {
1395
- ctx.beginPath();
1396
- ctx.arc(nx, ny, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
1397
- ctx.fillStyle = node.fill;
1398
- ctx.globalAlpha = GLOW_ALPHA;
1399
- ctx.fill();
1400
- ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
1401
- }
1402
- ctx.beginPath();
1403
- ctx.arc(nx, ny, radius, 0, TWO_PI);
1404
- ctx.fillStyle = brightened ? brighten(node.fill) : node.fill;
1405
- ctx.fill();
1406
- ctx.strokeStyle = node.stroke;
1407
- ctx.lineWidth = node.strokeWidth;
1408
- ctx.stroke();
1409
- if (isSelected) {
1410
- ctx.beginPath();
1411
- ctx.arc(nx, ny, radius + 3, 0, TWO_PI);
1412
- ctx.strokeStyle = theme.colors.categorical[0] ?? "#3b82f6";
1413
- ctx.lineWidth = 2;
1414
- ctx.stroke();
1415
- }
1416
- }
1417
- ctx.globalAlpha = 1;
1418
- }
1419
- /** Batch glow circles by fill color. */
1420
- drawGlowBatched(ctx, nodes, searchMatches, minRadius) {
1421
- const glowGroups = /* @__PURE__ */ new Map();
1422
- for (const node of nodes) {
1423
- if (searchMatches && !searchMatches.has(node.id)) continue;
1424
- let group = glowGroups.get(node.fill);
1425
- if (!group) {
1426
- group = [];
1427
- glowGroups.set(node.fill, group);
1428
- }
1429
- group.push(node);
1430
- }
1431
- ctx.globalAlpha = GLOW_ALPHA;
1432
- for (const [fill, group] of glowGroups) {
1433
- ctx.fillStyle = fill;
1434
- ctx.beginPath();
1435
- for (const node of group) {
1436
- const gr = Math.max(node.radius, minRadius) * GLOW_RADIUS_MULTIPLIER;
1437
- ctx.moveTo(node.x + gr, node.y);
1438
- ctx.arc(node.x, node.y, gr, 0, TWO_PI);
1439
- }
1440
- ctx.fill();
1441
- }
1442
- ctx.globalAlpha = 1;
1443
- }
1444
- // -------------------------------------------------------------------------
1445
- // Labels (drawn individually, skipped during gestures)
1446
- // -------------------------------------------------------------------------
1447
- drawLabels(ctx, nodes, hoveredNodeId, selectedNodeIds, searchMatches, zoom, theme, entrance, enterAlphaFor) {
1448
- const la = entrance ? entrance.labelAlpha : 1;
1449
- const rawSize = 10 / zoom;
1450
- const fontSize = Math.max(LABEL_FONT_MIN, Math.min(LABEL_FONT_MAX, rawSize));
1451
- ctx.font = `${fontSize}px ${theme.fonts.family}`;
1452
- ctx.textAlign = "center";
1453
- ctx.textBaseline = "top";
1454
- const haloColor = theme.colors.background !== "transparent" ? theme.colors.background : theme.isDark ? "rgba(0, 0, 0, 0.7)" : "rgba(255, 255, 255, 0.85)";
1455
- const forced = [];
1456
- const rest = [];
1457
- for (const node of nodes) {
1458
- if (!node.label) continue;
1459
- const isForced = node.id === hoveredNodeId || selectedNodeIds.has(node.id) || node.labelPriority === Infinity || (searchMatches?.has(node.id) ?? false);
1460
- if (isForced) forced.push(node);
1461
- else rest.push(node);
1462
- }
1463
- rest.sort((a, b) => b.labelPriority - a.labelPriority);
1464
- const budget = labelBudget(zoom);
1465
- const candidates = rest.slice(0, budget * 4);
1466
- const placed = [];
1467
- const pad = fontSize * 0.15;
1468
- const lineHeight = fontSize * 1.2;
1469
- const textWidth = (text) => ctx.measureText?.(text)?.width ?? text.length * fontSize * 0.55;
1470
- const boxFor = (node) => {
1471
- const w = textWidth(node.label);
1472
- const y0 = node.y + node.radius + 3;
1473
- return {
1474
- x0: node.x - w / 2 - pad,
1475
- x1: node.x + w / 2 + pad,
1476
- y0: y0 - pad,
1477
- y1: y0 + lineHeight + pad
1478
- };
1479
- };
1480
- const drawOne = (node, isForced) => {
1481
- const dimmed = searchMatches !== null && !searchMatches.has(node.id);
1482
- ctx.globalAlpha = (dimmed ? SEARCH_NON_MATCH_ALPHA : 1) * la * enterAlphaFor(node.id);
1483
- const labelY = node.y + node.radius + 3;
1484
- if (isForced) {
1485
- ctx.strokeStyle = haloColor;
1486
- ctx.lineWidth = 3;
1487
- ctx.lineJoin = "round";
1488
- ctx.miterLimit = 2;
1489
- ctx.strokeText(node.label, node.x, labelY);
1490
- }
1491
- ctx.fillStyle = isForced ? theme.colors.text : theme.colors.axis;
1492
- ctx.fillText(node.label, node.x, labelY);
1493
- };
1494
- for (const node of forced) placed.push(boxFor(node));
1495
- let drawn = 0;
1496
- for (const node of candidates) {
1497
- if (drawn >= budget) break;
1498
- const box = boxFor(node);
1499
- let collides = false;
1500
- for (const other of placed) {
1501
- if (boxesOverlap(box, other)) {
1502
- collides = true;
1503
- break;
1504
- }
1505
- }
1506
- if (collides) continue;
1507
- placed.push(box);
1508
- drawOne(node, false);
1509
- drawn++;
1510
- }
1511
- for (const node of forced) drawOne(node, true);
1512
- ctx.globalAlpha = 1;
1513
- }
1514
- // -------------------------------------------------------------------------
1515
- // Exit ghosts (Phase 7)
1516
- // -------------------------------------------------------------------------
1517
- /**
1518
- * Draw exit ghosts (removed nodes/edges) UNDER the live marks at a global fade
1519
- * alpha. Neutral rendering: no focus dim, no search dim, no selection ring —
1520
- * they're on their way out. Batched by (stroke/style) for edges and (fill) for
1521
- * nodes, culled to the visible rect.
1522
- */
1523
- drawGhosts(ctx, exiting, rect, edgeAlphaDefault) {
1524
- const alpha = exiting.alpha;
1525
- const edgeGroups = /* @__PURE__ */ new Map();
1526
- for (const edge of exiting.edges) {
1527
- if (!edgeInView(edge, rect)) continue;
1528
- const key = `${edge.stroke}|${edge.strokeWidth}|${edge.style}`;
1529
- const group = edgeGroups.get(key);
1530
- if (group) group.push(edge);
1531
- else edgeGroups.set(key, [edge]);
1532
- }
1533
- for (const [, group] of edgeGroups) {
1534
- const sample = group[0];
1535
- const dash = DASH_PATTERNS[sample.style] ?? DASH_PATTERNS.solid;
1536
- ctx.setLineDash(dash);
1537
- ctx.strokeStyle = sample.stroke;
1538
- ctx.lineWidth = sample.strokeWidth;
1539
- ctx.globalAlpha = edgeAlphaDefault * alpha;
1540
- ctx.beginPath();
1541
- for (const edge of group) {
1542
- ctx.moveTo(edge.sourceX, edge.sourceY);
1543
- ctx.lineTo(edge.targetX, edge.targetY);
1544
- }
1545
- ctx.stroke();
1546
- }
1547
- ctx.setLineDash([]);
1548
- const nodeGroups = /* @__PURE__ */ new Map();
1549
- for (const node of exiting.nodes) {
1550
- if (!nodeInView(node, rect)) continue;
1551
- const group = nodeGroups.get(node.fill);
1552
- if (group) group.push(node);
1553
- else nodeGroups.set(node.fill, [node]);
1554
- }
1555
- for (const [fill, group] of nodeGroups) {
1556
- ctx.fillStyle = fill;
1557
- ctx.globalAlpha = alpha;
1558
- ctx.beginPath();
1559
- for (const node of group) {
1560
- ctx.moveTo(node.x + node.radius, node.y);
1561
- ctx.arc(node.x, node.y, node.radius, 0, TWO_PI);
1562
- }
1563
- ctx.fill();
1564
- }
1565
- ctx.globalAlpha = 1;
1566
- }
1567
- };
1568
- function brighten(color) {
1569
- const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
1570
- if (rgbMatch) {
1571
- const r = Math.min(255, parseInt(rgbMatch[1], 10) + 40);
1572
- const g = Math.min(255, parseInt(rgbMatch[2], 10) + 40);
1573
- const b = Math.min(255, parseInt(rgbMatch[3], 10) + 40);
1574
- return `rgb(${r},${g},${b})`;
1575
- }
1576
- const hex = color.replace("#", "");
1577
- const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
1578
- if (full.length === 6) {
1579
- const r = Math.min(255, parseInt(full.slice(0, 2), 16) + 40);
1580
- const g = Math.min(255, parseInt(full.slice(2, 4), 16) + 40);
1581
- const b = Math.min(255, parseInt(full.slice(4, 6), 16) + 40);
1582
- return `rgb(${r},${g},${b})`;
1583
- }
1584
- return color;
1585
- }
1586
-
1587
- // src/graph/focus-transition.ts
1588
- function focusSnapshotsEqual(a, b) {
1589
- return a.hasActive === b.hasActive && setsEqual(a.connected, b.connected) && nullableSetsEqual(a.searchMatches, b.searchMatches) && setsEqual(a.selected, b.selected);
1590
- }
1591
- function setsEqual(a, b) {
1592
- if (a.size !== b.size) return false;
1593
- for (const v of a) if (!b.has(v)) return false;
1594
- return true;
1595
- }
1596
- function nullableSetsEqual(a, b) {
1597
- if (a === null || b === null) return a === b;
1598
- return setsEqual(a, b);
1599
- }
1600
- var FocusTransition = class {
1601
- prev;
1602
- next;
1603
- startTime;
1604
- duration;
1605
- ease;
1606
- constructor(initial, duration, ease, now) {
1607
- this.prev = initial;
1608
- this.next = initial;
1609
- this.startTime = now;
1610
- this.duration = Math.max(0, duration);
1611
- this.ease = ease;
1612
- }
1613
- /**
1614
- * Point a fresh transition at `target`, capturing the endpoint closest to the
1615
- * current display as the new `prev` (the `p < 0.5` rule). A no-op when
1616
- * `target` already equals `next`.
1617
- */
1618
- retarget(target, now) {
1619
- if (focusSnapshotsEqual(target, this.next)) return;
1620
- const p = this.rawProgress(now);
1621
- this.prev = p < 0.5 ? this.prev : this.next;
1622
- this.next = target;
1623
- this.startTime = now;
1624
- }
1625
- /** Raw (un-eased) 0..1 progress. */
1626
- rawProgress(now) {
1627
- if (this.duration <= 0) return 1;
1628
- return Math.min(1, Math.max(0, (now - this.startTime) / this.duration));
1629
- }
1630
- /** Eased 0..1 progress toward `next`. */
1631
- progress(now) {
1632
- return this.ease(this.rawProgress(now));
1633
- }
1634
- /** True once the transition has fully settled onto `next`. */
1635
- isSettled(now) {
1636
- return this.rawProgress(now) >= 1;
1637
- }
1638
- };
1639
- function composeStandingFocus(highlight, searchMatches, selected, adjacency) {
1640
- const hasHighlight = highlight !== null && highlight.size > 0;
1641
- const hasSearch = searchMatches !== null && searchMatches.size > 0;
1642
- let core = null;
1643
- if (highlight !== null && hasHighlight && searchMatches !== null && hasSearch) {
1644
- const inter = intersect(highlight, searchMatches);
1645
- core = inter.size > 0 ? inter : searchMatches;
1646
- } else if (hasHighlight) {
1647
- core = highlight;
1648
- } else if (hasSearch) {
1649
- core = null;
1650
- }
1651
- const connected = /* @__PURE__ */ new Set();
1652
- if (core) {
1653
- for (const id of core) {
1654
- connected.add(id);
1655
- const neighbors = adjacency.get(id);
1656
- if (neighbors) for (const nid of neighbors) connected.add(nid);
1657
- }
1658
- }
1659
- const hasActive = core !== null && core.size > 0 || hasSearch || selected.size > 0;
1660
- return {
1661
- hasActive,
1662
- connected,
1663
- searchMatches: hasSearch ? searchMatches : null,
1664
- selected
1665
- };
1666
- }
1667
- function layerHoverFocus(standing, hoveredId, hoverConnected) {
1668
- if (hoveredId === null || hoverConnected === null) return standing;
1669
- return {
1670
- hasActive: true,
1671
- connected: hoverConnected,
1672
- searchMatches: standing.searchMatches,
1673
- selected: standing.selected
1674
- };
1675
- }
1676
- function intersect(a, b) {
1677
- const [small, large] = a.size <= b.size ? [a, b] : [b, a];
1678
- const out = /* @__PURE__ */ new Set();
1679
- for (const v of small) if (large.has(v)) out.add(v);
1680
- return out;
1681
- }
1682
-
1683
- // src/graph/interaction.ts
1684
- var ZOOM_MIN = 0.05;
1685
- var ZOOM_MAX = 15;
1686
- var ZOOM_STEP = -1e-3;
1687
- var HIT_DISTANCE = 5;
1688
- var GraphInteractionManager = class {
1689
- canvas;
1690
- spatialIndex;
1691
- callbacks;
1692
- transform = ZoomTransform.identity();
1693
- dragState = null;
1694
- panState = null;
1695
- mousedownNodeId = null;
1696
- selectedIds = /* @__PURE__ */ new Set();
1697
- // Touch state
1698
- lastTouchDist = null;
1699
- lastTouchCenter = null;
1700
- // Bound handlers for cleanup
1701
- boundWheel;
1702
- boundMouseDown;
1703
- boundMouseMove;
1704
- boundMouseUp;
1705
- boundDblClick;
1706
- boundTouchStart;
1707
- boundTouchMove;
1708
- boundTouchEnd;
1709
- boundMouseLeave;
1710
- constructor(canvas, spatialIndex, callbacks) {
1711
- this.canvas = canvas;
1712
- this.spatialIndex = spatialIndex;
1713
- this.callbacks = callbacks;
1714
- this.boundWheel = this.onWheel.bind(this);
1715
- this.boundMouseDown = this.onMouseDown.bind(this);
1716
- this.boundMouseMove = this.onMouseMove.bind(this);
1717
- this.boundMouseUp = this.onMouseUp.bind(this);
1718
- this.boundMouseLeave = this.onMouseLeave.bind(this);
1719
- this.boundDblClick = this.onDblClick.bind(this);
1720
- this.boundTouchStart = this.onTouchStart.bind(this);
1721
- this.boundTouchMove = this.onTouchMove.bind(this);
1722
- this.boundTouchEnd = this.onTouchEnd.bind(this);
1723
- canvas.addEventListener("wheel", this.boundWheel, { passive: false });
1724
- canvas.addEventListener("mousedown", this.boundMouseDown);
1725
- canvas.addEventListener("mousemove", this.boundMouseMove);
1726
- canvas.addEventListener("mouseup", this.boundMouseUp);
1727
- canvas.addEventListener("mouseleave", this.boundMouseLeave);
1728
- canvas.addEventListener("dblclick", this.boundDblClick);
1729
- canvas.addEventListener("touchstart", this.boundTouchStart, {
1730
- passive: false
1731
- });
1732
- canvas.addEventListener("touchmove", this.boundTouchMove, {
1733
- passive: false
1734
- });
1735
- canvas.addEventListener("touchend", this.boundTouchEnd);
1736
- }
1737
- setTransform(transform) {
1738
- this.transform = transform;
1739
- }
1740
- getTransform() {
1741
- return this.transform;
1742
- }
1743
- /**
1744
- * Replace the internal selection set. Used by the mount to prune deleted ids
1745
- * after a data update so a later shift-click can't resurrect them through
1746
- * `onSelectionChange`. Does NOT fire `onSelectionChange` — the caller owns any
1747
- * downstream sync (the mount already holds the pruned set).
1748
- */
1749
- setSelection(ids) {
1750
- this.selectedIds = new Set(ids);
1751
- }
1752
- destroy() {
1753
- this.canvas.removeEventListener("wheel", this.boundWheel);
1754
- this.canvas.removeEventListener("mousedown", this.boundMouseDown);
1755
- this.canvas.removeEventListener("mousemove", this.boundMouseMove);
1756
- this.canvas.removeEventListener("mouseup", this.boundMouseUp);
1757
- this.canvas.removeEventListener("mouseleave", this.boundMouseLeave);
1758
- this.canvas.removeEventListener("dblclick", this.boundDblClick);
1759
- this.canvas.removeEventListener("touchstart", this.boundTouchStart);
1760
- this.canvas.removeEventListener("touchmove", this.boundTouchMove);
1761
- this.canvas.removeEventListener("touchend", this.boundTouchEnd);
1762
- }
1763
- // -------------------------------------------------------------------------
1764
- // Mouse handlers
1765
- // -------------------------------------------------------------------------
1766
- canvasXY(e) {
1767
- const rect = this.canvas.getBoundingClientRect();
1768
- return { x: e.clientX - rect.left, y: e.clientY - rect.top };
1769
- }
1770
- hitTest(screenX, screenY) {
1771
- const graph = this.transform.screenToGraph(screenX, screenY);
1772
- const node = this.spatialIndex.findNearest(graph.x, graph.y, HIT_DISTANCE / this.transform.k);
1773
- return node?.id ?? null;
1774
- }
1775
- onWheel(e) {
1776
- e.preventDefault();
1777
- const { x, y } = this.canvasXY(e);
1778
- const factor = e.deltaY * ZOOM_STEP;
1779
- const newK = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.transform.k * (1 + factor)));
1780
- this.transform = this.transform.zoomAt(newK, x, y);
1781
- this.callbacks.onTransformChange(this.transform);
1782
- }
1783
- onMouseDown(e) {
1784
- const { x, y } = this.canvasXY(e);
1785
- const hitId = this.hitTest(x, y);
1786
- if (hitId) {
1787
- this.dragState = { nodeId: hitId, started: false };
1788
- this.mousedownNodeId = hitId;
1789
- } else {
1790
- this.panState = { startX: x, startY: y };
1791
- this.mousedownNodeId = null;
1792
- }
1793
- }
1794
- onMouseMove(e) {
1795
- const { x, y } = this.canvasXY(e);
1796
- if (this.callbacks.onPointerMove) {
1797
- const gp = this.transform.screenToGraph(x, y);
1798
- this.callbacks.onPointerMove(gp.x, gp.y);
1799
- }
1800
- if (this.dragState) {
1801
- const graph = this.transform.screenToGraph(x, y);
1802
- if (!this.dragState.started) {
1803
- this.dragState.started = true;
1804
- this.callbacks.onNodeDragStart(this.dragState.nodeId);
1805
- }
1806
- this.callbacks.onNodeDrag(this.dragState.nodeId, graph.x, graph.y);
1807
- return;
1808
- }
1809
- if (this.panState) {
1810
- const dx = x - this.panState.startX;
1811
- const dy = y - this.panState.startY;
1812
- this.transform = this.transform.pan(dx, dy);
1813
- this.panState = { startX: x, startY: y };
1814
- this.callbacks.onTransformChange(this.transform);
1815
- return;
1816
- }
1817
- const hitId = this.hitTest(x, y);
1818
- this.callbacks.onHoverChange(hitId);
1819
- if (!hitId) {
1820
- const graph = this.transform.screenToGraph(x, y);
1821
- this.callbacks.onBackgroundHover?.(graph.x, graph.y, x, y);
1822
- }
1823
- this.canvas.style.cursor = hitId ? "pointer" : "default";
1824
- }
1825
- onMouseUp(e) {
1826
- const { x, y } = this.canvasXY(e);
1827
- if (this.dragState) {
1828
- if (this.dragState.started) {
1829
- this.callbacks.onNodeDragEnd(this.dragState.nodeId);
1830
- } else {
1831
- this.handleNodeClick(this.dragState.nodeId, e.shiftKey);
1832
- }
1833
- this.dragState = null;
1834
- return;
1835
- }
1836
- if (this.panState) {
1837
- this.panState = null;
1838
- if (!this.mousedownNodeId) {
1839
- const hitId = this.hitTest(x, y);
1840
- if (!hitId) {
1841
- this.selectedIds.clear();
1842
- this.callbacks.onSelectionChange([]);
1843
- }
1844
- }
1845
- return;
1846
- }
1847
- }
1848
- onDblClick(e) {
1849
- const { x, y } = this.canvasXY(e);
1850
- const hitId = this.hitTest(x, y);
1851
- if (hitId) {
1852
- this.callbacks.onDoubleClick(hitId);
1853
- }
1854
- }
1855
- onMouseLeave(_e) {
1856
- this.callbacks.onHoverChange(null);
1857
- this.canvas.style.cursor = "default";
1858
- this.callbacks.onPointerLeave?.();
1859
- if (this.panState) {
1860
- this.panState = null;
1861
- }
1862
- }
1863
- handleNodeClick(nodeId, shiftKey) {
1864
- if (shiftKey) {
1865
- if (this.selectedIds.has(nodeId)) {
1866
- this.selectedIds.delete(nodeId);
1867
- } else {
1868
- this.selectedIds.add(nodeId);
1869
- }
1870
- } else {
1871
- this.selectedIds.clear();
1872
- this.selectedIds.add(nodeId);
1873
- }
1874
- this.callbacks.onSelectionChange([...this.selectedIds]);
1875
- }
1876
- // -------------------------------------------------------------------------
1877
- // Touch handlers
1878
- // -------------------------------------------------------------------------
1879
- onTouchStart(e) {
1880
- e.preventDefault();
1881
- if (e.touches.length === 2) {
1882
- const [t0, t1] = [e.touches[0], e.touches[1]];
1883
- this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
1884
- this.lastTouchCenter = {
1885
- x: (t0.clientX + t1.clientX) / 2,
1886
- y: (t0.clientY + t1.clientY) / 2
1887
- };
1888
- } else if (e.touches.length === 1) {
1889
- const touch = e.touches[0];
1890
- const rect = this.canvas.getBoundingClientRect();
1891
- const x = touch.clientX - rect.left;
1892
- const y = touch.clientY - rect.top;
1893
- const hitId = this.hitTest(x, y);
1894
- if (hitId) {
1895
- this.mousedownNodeId = hitId;
1896
- } else {
1897
- this.panState = { startX: x, startY: y };
1898
- this.mousedownNodeId = null;
1899
- }
1900
- }
1901
- }
1902
- onTouchMove(e) {
1903
- e.preventDefault();
1904
- if (e.touches.length === 2 && this.lastTouchDist !== null) {
1905
- const [t0, t1] = [e.touches[0], e.touches[1]];
1906
- const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
1907
- const rect = this.canvas.getBoundingClientRect();
1908
- const centerX = (t0.clientX + t1.clientX) / 2 - rect.left;
1909
- const centerY = (t0.clientY + t1.clientY) / 2 - rect.top;
1910
- const scale = newDist / this.lastTouchDist;
1911
- const newK = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.transform.k * scale));
1912
- this.transform = this.transform.zoomAt(newK, centerX, centerY);
1913
- if (this.lastTouchCenter) {
1914
- const dx = centerX - (this.lastTouchCenter.x - rect.left);
1915
- const dy = centerY - (this.lastTouchCenter.y - rect.top);
1916
- this.transform = this.transform.pan(dx, dy);
1917
- }
1918
- this.lastTouchDist = newDist;
1919
- this.lastTouchCenter = {
1920
- x: (t0.clientX + t1.clientX) / 2,
1921
- y: (t0.clientY + t1.clientY) / 2
1922
- };
1923
- this.callbacks.onTransformChange(this.transform);
1924
- } else if (e.touches.length === 1 && this.panState) {
1925
- const touch = e.touches[0];
1926
- const rect = this.canvas.getBoundingClientRect();
1927
- const x = touch.clientX - rect.left;
1928
- const y = touch.clientY - rect.top;
1929
- const dx = x - this.panState.startX;
1930
- const dy = y - this.panState.startY;
1931
- this.transform = this.transform.pan(dx, dy);
1932
- this.panState = { startX: x, startY: y };
1933
- this.callbacks.onTransformChange(this.transform);
1934
- }
1935
- }
1936
- onTouchEnd(e) {
1937
- if (e.touches.length === 0) {
1938
- if (this.mousedownNodeId && !this.panState) {
1939
- this.handleNodeClick(this.mousedownNodeId, false);
1940
- } else if (!this.mousedownNodeId && this.panState) {
1941
- this.selectedIds.clear();
1942
- this.callbacks.onSelectionChange([]);
1943
- }
1944
- this.panState = null;
1945
- this.mousedownNodeId = null;
1946
- this.lastTouchDist = null;
1947
- this.lastTouchCenter = null;
1948
- }
1949
- }
1950
- };
1951
-
1952
- // src/graph/keyboard.ts
1953
- function attachGraphKeyboardNav(options) {
1954
- const {
1955
- canvas,
1956
- getNodes,
1957
- getSelectedIds,
1958
- getAdjacency,
1959
- onSelect,
1960
- onDeselect,
1961
- onZoom,
1962
- onFitAll,
1963
- onFocusSearch
1964
- } = options;
1965
- let focusedNodeId = null;
1966
- if (!canvas.hasAttribute("tabindex")) {
1967
- canvas.setAttribute("tabindex", "0");
1968
- }
1969
- function findNodeById(id) {
1970
- return getNodes().find((n) => n.id === id);
1971
- }
1972
- function pickDirectionalNeighbor(fromNode, neighborIds, direction) {
1973
- const nodes = getNodes();
1974
- const candidates = nodes.filter((n) => neighborIds.has(n.id));
1975
- if (candidates.length === 0) return null;
1976
- let best = null;
1977
- let bestScore = -Infinity;
1978
- for (const c of candidates) {
1979
- const dx = c.x - fromNode.x;
1980
- const dy = c.y - fromNode.y;
1981
- let score;
1982
- switch (direction) {
1983
- case "right":
1984
- score = dx - Math.abs(dy) * 0.5;
1985
- break;
1986
- case "left":
1987
- score = -dx - Math.abs(dy) * 0.5;
1988
- break;
1989
- case "down":
1990
- score = dy - Math.abs(dx) * 0.5;
1991
- break;
1992
- case "up":
1993
- score = -dy - Math.abs(dx) * 0.5;
1994
- break;
1995
- }
1996
- if (score > bestScore) {
1997
- bestScore = score;
1998
- best = c;
1999
- }
2000
- }
2001
- return best?.id ?? null;
2002
- }
2003
- function onKeyDown(e) {
2004
- switch (e.key) {
2005
- case "Tab": {
2006
- const selected = getSelectedIds();
2007
- const nodes = getNodes();
2008
- if (nodes.length === 0) return;
2009
- if (selected.length > 0) {
2010
- focusedNodeId = selected[0];
2011
- } else if (!focusedNodeId || !findNodeById(focusedNodeId)) {
2012
- focusedNodeId = nodes[0].id;
2013
- }
2014
- e.preventDefault();
2015
- break;
2016
- }
2017
- case "ArrowUp":
2018
- case "ArrowDown":
2019
- case "ArrowLeft":
2020
- case "ArrowRight": {
2021
- if (!focusedNodeId) return;
2022
- e.preventDefault();
2023
- const focusedNode = findNodeById(focusedNodeId);
2024
- if (!focusedNode) return;
2025
- const adjacency = getAdjacency();
2026
- const neighbors = adjacency.get(focusedNodeId);
2027
- if (!neighbors || neighbors.size === 0) return;
2028
- const dirMap = {
2029
- ArrowUp: "up",
2030
- ArrowDown: "down",
2031
- ArrowLeft: "left",
2032
- ArrowRight: "right"
2033
- };
2034
- const nextId = pickDirectionalNeighbor(focusedNode, neighbors, dirMap[e.key]);
2035
- if (nextId) {
2036
- focusedNodeId = nextId;
2037
- onSelect(nextId);
2038
- }
2039
- break;
2040
- }
2041
- case "Enter": {
2042
- if (focusedNodeId) {
2043
- e.preventDefault();
2044
- const selected = getSelectedIds();
2045
- if (selected.includes(focusedNodeId)) {
2046
- onDeselect();
2047
- } else {
2048
- onSelect(focusedNodeId);
2049
- }
2050
- }
2051
- break;
2052
- }
2053
- case "Escape": {
2054
- e.preventDefault();
2055
- focusedNodeId = null;
2056
- onDeselect();
2057
- break;
2058
- }
2059
- case "+":
2060
- case "=": {
2061
- e.preventDefault();
2062
- onZoom("in");
2063
- break;
2064
- }
2065
- case "-":
2066
- case "_": {
2067
- e.preventDefault();
2068
- onZoom("out");
2069
- break;
2070
- }
2071
- case "Home": {
2072
- e.preventDefault();
2073
- onFitAll();
2074
- break;
2075
- }
2076
- case "/": {
2077
- if (onFocusSearch) {
2078
- e.preventDefault();
2079
- onFocusSearch();
2080
- }
2081
- break;
2082
- }
2083
- }
2084
- }
2085
- canvas.addEventListener("keydown", onKeyDown);
2086
- return () => {
2087
- canvas.removeEventListener("keydown", onKeyDown);
2088
- };
2089
- }
2090
-
2091
- // src/graph/legend.ts
2092
- function escapeHtml(str) {
2093
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
2094
- }
2095
- function createGraphLegend(host, data, callbacks) {
2096
- const listeners = [];
2097
- function render(view) {
2098
- teardownListeners();
2099
- host.replaceChildren();
2100
- if (view.nodes.length === 0 && view.edges.length === 0) {
2101
- host.style.display = "none";
2102
- return;
2103
- }
2104
- host.style.display = "";
2105
- for (const entry of view.nodes) {
2106
- host.appendChild(nodeRow(entry));
2107
- }
2108
- for (const entry of view.edges) {
2109
- host.appendChild(edgeRow(entry));
2110
- }
2111
- }
2112
- function nodeRow(entry) {
2113
- const interactive = callbacks.interactive;
2114
- const el = document.createElement(interactive ? "button" : "div");
2115
- el.className = "oc-graph-legend-item";
2116
- if (!entry.active) el.classList.add("oc-graph-legend-item--inactive");
2117
- if (interactive) {
2118
- const btn = el;
2119
- btn.type = "button";
2120
- btn.setAttribute("aria-pressed", String(entry.active));
2121
- const onClick = () => callbacks.onToggle(entry.label);
2122
- const onEnter = () => callbacks.onHover(entry.label);
2123
- const onLeave = () => callbacks.onHover(null);
2124
- btn.addEventListener("click", onClick);
2125
- btn.addEventListener("mouseenter", onEnter);
2126
- btn.addEventListener("mouseleave", onLeave);
2127
- listeners.push(() => {
2128
- btn.removeEventListener("click", onClick);
2129
- btn.removeEventListener("mouseenter", onEnter);
2130
- btn.removeEventListener("mouseleave", onLeave);
2131
- });
2132
- }
2133
- 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>` : "");
2134
- return el;
2135
- }
2136
- function edgeRow(entry) {
2137
- const el = document.createElement("div");
2138
- el.className = "oc-graph-legend-item oc-graph-legend-item--edge";
2139
- 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>` : "");
2140
- return el;
2141
- }
2142
- function teardownListeners() {
2143
- for (const off of listeners) off();
2144
- listeners.length = 0;
2145
- }
2146
- render(data);
2147
- return {
2148
- update: render,
2149
- destroy() {
2150
- teardownListeners();
2151
- host.replaceChildren();
2152
- }
2153
- };
2154
- }
2155
-
2156
- // src/graph/search.ts
2157
- var GraphSearchManager = class {
2158
- matchedIds = null;
2159
- /** The last active (non-empty) query, so a data update can re-run it. */
2160
- query = null;
2161
- /**
2162
- * Search for nodes matching the query string.
2163
- * Returns a Set of matching node ids, or an empty set if nothing matches.
2164
- */
2165
- search(query, nodes) {
2166
- const q = query.toLowerCase().trim();
2167
- if (q === "") {
2168
- this.matchedIds = null;
2169
- this.query = null;
2170
- return /* @__PURE__ */ new Set();
2171
- }
2172
- this.query = query;
2173
- const matches = /* @__PURE__ */ new Set();
2174
- for (const node of nodes) {
2175
- const label = (node.label ?? "").toLowerCase();
2176
- const id = node.id.toLowerCase();
2177
- if (label.includes(q) || id.includes(q)) {
2178
- matches.add(node.id);
2179
- }
2180
- }
2181
- this.matchedIds = matches;
2182
- return matches;
2183
- }
2184
- /**
2185
- * Clear the current search.
2186
- * Returns null to indicate no active search.
2187
- */
2188
- clearSearch() {
2189
- this.matchedIds = null;
2190
- this.query = null;
2191
- return null;
2192
- }
2193
- /** Get the current set of matched ids, or null if no search is active. */
2194
- getMatches() {
2195
- return this.matchedIds;
2196
- }
2197
- /** The last active query string, or null when no search is active. */
2198
- getQuery() {
2199
- return this.query;
2200
- }
2201
- };
2202
-
2203
- // src/graph/seed.ts
2204
- function hash32(str) {
2205
- let h = 2166136261;
2206
- for (let i = 0; i < str.length; i++) {
2207
- h ^= str.charCodeAt(i);
2208
- h = Math.imul(h, 16777619);
2209
- }
2210
- return h >>> 0;
2211
- }
2212
- function unit(h) {
2213
- return (h >>> 0) / 4294967296;
2214
- }
2215
- var R0 = 300;
2216
- function seedNodePositions(nodes, seed) {
2217
- const n = nodes.length;
2218
- if (n === 0) return;
2219
- for (const node of nodes) {
2220
- const u1 = unit(hash32(`${node.id}|${seed}|r`));
2221
- const u2 = unit(hash32(`${node.id}|${seed}|t`));
2222
- const r = R0 * Math.sqrt(u1);
2223
- const theta = u2 * Math.PI * 2;
2224
- let cx = 0;
2225
- let cy = 0;
2226
- if (node.community) {
2227
- const ca = unit(hash32(`${node.community}|${seed}`)) * Math.PI * 2;
2228
- const bias = 0.6 * R0;
2229
- cx = Math.cos(ca) * bias;
2230
- cy = Math.sin(ca) * bias;
2231
- }
2232
- node.x = cx + r * Math.cos(theta);
2233
- node.y = cy + r * Math.sin(theta);
2234
- }
2235
- }
2236
-
2237
- // src/graph/simulation.ts
2238
- import {
2239
- forceCenter,
2240
- forceCollide,
2241
- forceLink,
2242
- forceManyBody,
2243
- forceSimulation,
2244
- forceX,
2245
- forceY
2246
- } from "d3-force";
2247
- var SYNC_TICKS_PER_BATCH = 15;
2248
- var SYNC_MAX_TICKS_CEIL = 800;
2249
- var DEFAULT_ALPHA_MIN = 1e-3;
2250
- var DEFAULT_WARMUP_BUDGET_MS = 250;
2251
- function ticksToAlphaMin(alphaDecay, alphaMin = DEFAULT_ALPHA_MIN) {
2252
- if (!(alphaDecay > 0) || alphaDecay >= 1) return SYNC_MAX_TICKS_CEIL;
2253
- const n = Math.ceil(Math.log(alphaMin) / Math.log(1 - alphaDecay));
2254
- return Math.min(SYNC_MAX_TICKS_CEIL, Math.max(1, n));
2255
- }
2256
- function forceCluster(nodes, strength) {
2257
- return (alpha) => {
2258
- const cx = /* @__PURE__ */ new Map();
2259
- const cy = /* @__PURE__ */ new Map();
2260
- const count = /* @__PURE__ */ new Map();
2261
- for (const node of nodes) {
2262
- if (!node.community) continue;
2263
- const c = node.community;
2264
- cx.set(c, (cx.get(c) ?? 0) + (node.x ?? 0));
2265
- cy.set(c, (cy.get(c) ?? 0) + (node.y ?? 0));
2266
- count.set(c, (count.get(c) ?? 0) + 1);
2267
- }
2268
- for (const [c, n] of count) {
2269
- cx.set(c, cx.get(c) / n);
2270
- cy.set(c, cy.get(c) / n);
2271
- }
2272
- const k = strength * alpha;
2273
- for (const node of nodes) {
2274
- if (!node.community) continue;
2275
- const targetX = cx.get(node.community);
2276
- const targetY = cy.get(node.community);
2277
- node.vx = (node.vx ?? 0) + (targetX - (node.x ?? 0)) * k;
2278
- node.vy = (node.vy ?? 0) + (targetY - (node.y ?? 0)) * k;
2279
- }
2280
- };
2281
- }
2282
- function forceCursor(nodes, pointer) {
2283
- return (alpha) => {
2284
- if (!pointer.active || pointer.radius <= 0) return;
2285
- const r2 = pointer.radius * pointer.radius;
2286
- const k = pointer.strength * alpha;
2287
- for (const node of nodes) {
2288
- const dx = (node.x ?? 0) - pointer.x;
2289
- const dy = (node.y ?? 0) - pointer.y;
2290
- const dist2 = dx * dx + dy * dy;
2291
- if (dist2 >= r2) continue;
2292
- const dist = Math.sqrt(dist2) || 1e-6;
2293
- const falloff = (pointer.radius - dist) / pointer.radius;
2294
- const push = k * falloff / dist;
2295
- node.vx = (node.vx ?? 0) + dx * push;
2296
- node.vy = (node.vy ?? 0) + dy * push;
2297
- }
2298
- };
2299
- }
2300
- var SimulationManager = class _SimulationManager {
2301
- worker = null;
2302
- syncSim = null;
2303
- syncNodes = [];
2304
- syncNodeMap = /* @__PURE__ */ new Map();
2305
- tickCb = null;
2306
- settledCb = null;
2307
- destroyed = false;
2308
- syncRafId = null;
2309
- /** Derived per-graph cap on sync ticks, from alphaDecay via ticksToAlphaMin. */
2310
- syncMaxTicks = SYNC_MAX_TICKS_CEIL;
2311
- /** True until the sync warmup loop has completed (nothing renders before then). */
2312
- syncWarmupPending = false;
2313
- /** Remaining warmup ticks and the ms budget, consumed by the pre-reveal loop. */
2314
- syncWarmupTicks = 0;
2315
- syncWarmupBudgetMs = DEFAULT_WARMUP_BUDGET_MS;
2316
- /** Injectable clock for the warmup ms budget (deterministic in tests). */
2317
- now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
2318
- // Stored for worker->sync fallback
2319
- initNodes = [];
2320
- initEdges = [];
2321
- initConfig = null;
2322
- // Cursor-repulsion pointer state (sync path). radius=0 keeps the force inert.
2323
- pointer = { x: 0, y: 0, active: false, radius: 0, strength: 0 };
2324
- // Separately tracked alpha-target intents so drag and cursor don't stomp each
2325
- // other. The higher intent wins; releasing one falls back to the other.
2326
- dragAlphaTarget = 0;
2327
- cursorAlphaTarget = 0;
2328
- constructor() {
2329
- }
2330
- /**
2331
- * Create a SimulationManager. Uses Web Worker for large graphs,
2332
- * synchronous fallback for small graphs or when Worker unavailable.
2333
- *
2334
- * `opts.now` injects a clock for the warmup ms budget (tests pass a fake one).
2335
- */
2336
- static create(nodes, edges, config, opts) {
2337
- const mgr = new _SimulationManager();
2338
- if (opts?.now) mgr.now = opts.now;
2339
- const useWorker = typeof Worker !== "undefined";
2340
- if (useWorker) {
2341
- mgr.initWorker(nodes, edges, config);
2342
- } else {
2343
- mgr.initSync(nodes, edges, config);
2344
- }
2345
- return mgr;
2346
- }
2347
- /** Register a callback for position updates. */
2348
- onTick(cb) {
2349
- this.tickCb = cb;
2350
- }
2351
- /** Register a callback for when the simulation has settled. */
2352
- onSettled(cb) {
2353
- this.settledCb = cb;
2354
- }
2355
- /** Reheat the simulation. */
2356
- reheat(alpha) {
2357
- if (this.destroyed) return;
2358
- if (this.worker) {
2359
- this.worker.postMessage({ type: "reheat", alpha });
2360
- } else if (this.syncSim) {
2361
- this.syncSim.alpha(alpha ?? 0.3).restart();
2362
- this.runSyncTicks();
2363
- }
2364
- }
2365
- /**
2366
- * Pin a node to fixed x/y coordinates.
2367
- *
2368
- * When `alphaTarget` is provided (springy drag), the sim is held warm so the
2369
- * pinned node's neighbors follow springily. Omitting it posts a byte-identical
2370
- * legacy message and leaves alpha untouched.
2371
- */
2372
- pinNode(id, x, y, alphaTarget) {
2373
- if (this.destroyed) return;
2374
- if (this.worker) {
2375
- this.worker.postMessage(
2376
- alphaTarget != null ? { type: "pin", nodeId: id, x, y, alphaTarget } : { type: "pin", nodeId: id, x, y }
2377
- );
2378
- } else {
2379
- const node = this.syncNodeMap.get(id);
2380
- if (node) {
2381
- node.fx = x;
2382
- node.fy = y;
2383
- }
2384
- if (alphaTarget != null && this.syncSim) {
2385
- this.dragAlphaTarget = alphaTarget;
2386
- this.syncAlphaTarget();
2387
- this.syncSim.restart();
2388
- this.runSyncTicks();
2389
- }
2390
- }
2391
- }
2392
- /**
2393
- * Unpin a node and reheat so forces settle it into equilibrium.
2394
- *
2395
- * When `alphaTarget` is provided (springy release), the sim cools back toward
2396
- * that target instead of the legacy gentle reheat. Omitting it preserves the
2397
- * exact legacy reheat behavior (and posts a byte-identical message).
2398
- */
2399
- unpinNode(id, alphaTarget) {
2400
- if (this.destroyed) return;
2401
- if (this.worker) {
2402
- this.worker.postMessage(
2403
- alphaTarget != null ? { type: "unpin", nodeId: id, alphaTarget } : { type: "unpin", nodeId: id }
2404
- );
2405
- } else {
2406
- const node = this.syncNodeMap.get(id);
2407
- if (node) {
2408
- node.fx = null;
2409
- node.fy = null;
2410
- }
2411
- if (alphaTarget != null) {
2412
- this.dragAlphaTarget = alphaTarget;
2413
- this.syncAlphaTarget();
2414
- this.runSyncTicks();
2415
- } else if (this.syncSim && this.syncSim.alpha() < 0.1) {
2416
- this.syncSim.alpha(0.1).restart();
2417
- this.runSyncTicks();
2418
- }
2419
- }
2420
- }
2421
- /**
2422
- * Feed the cursor-repulsion force a pointer position. `active: false` clears
2423
- * the force. No-op when the graph has no cursor force configured (radius 0).
2424
- */
2425
- setPointer(x, y, active) {
2426
- if (this.destroyed) return;
2427
- if (this.worker) {
2428
- this.worker.postMessage({ type: "pointer", x, y, active });
2429
- } else {
2430
- if (this.pointer.radius <= 0) return;
2431
- this.pointer.x = x;
2432
- this.pointer.y = y;
2433
- this.pointer.active = active;
2434
- this.cursorAlphaTarget = active ? 0.03 : 0;
2435
- if (this.syncSim) {
2436
- this.syncAlphaTarget();
2437
- if (active) {
2438
- this.syncSim.restart();
2439
- this.runSyncTicks();
2440
- }
2441
- }
2442
- }
2443
- }
2444
- /** Apply the max of the tracked alpha-target intents to the sync sim. */
2445
- syncAlphaTarget() {
2446
- if (!this.syncSim) return;
2447
- this.syncSim.alphaTarget(Math.max(this.dragAlphaTarget, this.cursorAlphaTarget));
2448
- }
2449
- /** Drag a node (pins it and reheats slightly). */
2450
- dragNode(id, x, y) {
2451
- if (this.destroyed) return;
2452
- if (this.worker) {
2453
- this.worker.postMessage({ type: "drag", nodeId: id, x, y });
2454
- } else {
2455
- const node = this.syncNodeMap.get(id);
2456
- if (node) {
2457
- node.fx = x;
2458
- node.fy = y;
2459
- }
2460
- if (this.syncSim && this.syncSim.alpha() < 0.1) {
2461
- this.syncSim.alpha(0.1).restart();
2462
- this.runSyncTicks();
2463
- }
2464
- }
2465
- }
2466
- /** Tear down the simulation and release resources. */
2467
- destroy() {
2468
- this.destroyed = true;
2469
- if (this.syncRafId !== null) {
2470
- cancelAnimationFrame(this.syncRafId);
2471
- this.syncRafId = null;
2472
- }
2473
- if (this.worker) {
2474
- this.worker.postMessage({ type: "stop" });
2475
- this.worker.terminate();
2476
- this.worker = null;
2477
- }
2478
- if (this.syncSim) {
2479
- this.syncSim.stop();
2480
- this.syncSim = null;
2481
- }
2482
- this.tickCb = null;
2483
- this.settledCb = null;
2484
- }
2485
- // -------------------------------------------------------------------------
2486
- // Worker path
2487
- // -------------------------------------------------------------------------
2488
- initWorker(nodes, edges, config) {
2489
- this.initNodes = nodes;
2490
- this.initEdges = edges;
2491
- this.initConfig = config;
2492
- const initMsg = { type: "init", nodes, edges, config };
2493
- const wireWorker = (worker) => {
2494
- this.worker = worker;
2495
- worker.onmessage = (event) => {
2496
- if (this.destroyed) return;
2497
- const msg = event.data;
2498
- switch (msg.type) {
2499
- case "positions":
2500
- this.tickCb?.(msg.nodes, msg.alpha);
2501
- break;
2502
- case "settled":
2503
- this.settledCb?.();
2504
- break;
2505
- case "error":
2506
- console.error("[SimulationManager] Worker error:", msg.message);
2507
- break;
2508
- }
2509
- };
2510
- worker.postMessage(initMsg);
2511
- };
2512
- try {
2513
- const w = new Worker(new URL("./simulation-worker.js", import.meta.url), {
2514
- type: "module"
2515
- });
2516
- w.onerror = () => {
2517
- if (this.destroyed) return;
2518
- w.terminate();
2519
- this.worker = null;
2520
- try {
2521
- const tsUrl = new URL(import.meta.url.replace(/\/[^/]+$/, "/simulation-worker.ts"));
2522
- const w2 = new Worker(tsUrl, { type: "module" });
2523
- w2.onerror = () => {
2524
- if (this.destroyed) return;
2525
- console.warn("[SimulationManager] Worker failed to load, falling back to sync");
2526
- w2.terminate();
2527
- this.worker = null;
2528
- this.initSync(this.initNodes, this.initEdges, this.initConfig);
2529
- };
2530
- wireWorker(w2);
2531
- } catch {
2532
- console.warn("[SimulationManager] Worker creation failed, using sync fallback");
2533
- this.initSync(this.initNodes, this.initEdges, this.initConfig);
2534
- }
2535
- };
2536
- wireWorker(w);
2537
- } catch {
2538
- console.warn("[SimulationManager] Worker creation failed, using sync fallback");
2539
- this.initSync(nodes, edges, config);
2540
- }
2541
- }
2542
- // -------------------------------------------------------------------------
2543
- // Synchronous fallback
2544
- // -------------------------------------------------------------------------
2545
- initSync(nodes, edges, config) {
2546
- this.syncNodes = nodes.map((n) => ({
2547
- id: n.id,
2548
- x: n.x,
2549
- y: n.y,
2550
- radius: n.radius,
2551
- community: n.community
2552
- }));
2553
- this.syncNodeMap = new Map(this.syncNodes.map((n) => [n.id, n]));
2554
- const linkForce = forceLink(edges.map((e) => ({ ...e }))).id((d) => d.id).distance(config.linkDistance);
2555
- if (config.linkStrength != null) {
2556
- linkForce.strength(config.linkStrength);
2557
- }
2558
- const padding = config.collisionPadding ?? 2;
2559
- this.syncSim = forceSimulation(this.syncNodes).force("link", linkForce).force("charge", forceManyBody().strength(config.chargeStrength)).force(
2560
- "collide",
2561
- forceCollide().radius((d) => d.radius + padding)
2562
- ).force("gravityX", forceX(0).strength(0.05)).force("gravityY", forceY(0).strength(0.05)).alphaDecay(config.alphaDecay).velocityDecay(config.velocityDecay).stop();
2563
- if (config.centerForce !== false) {
2564
- this.syncSim.force("center", forceCenter(0, 0));
2565
- }
2566
- if (config.clustering) {
2567
- const clusterFn = forceCluster(this.syncNodes, config.clustering.strength);
2568
- this.syncSim.force("cluster", clusterFn);
2569
- }
2570
- this.pointer.active = false;
2571
- this.pointer.radius = config.cursorRepulsion?.radius ?? 0;
2572
- this.pointer.strength = config.cursorRepulsion?.strength ?? 0;
2573
- this.dragAlphaTarget = 0;
2574
- this.cursorAlphaTarget = 0;
2575
- const cursorFn = forceCursor(this.syncNodes, this.pointer);
2576
- this.syncSim.force("cursor", cursorFn);
2577
- if (config.initialAlpha != null) {
2578
- this.syncSim.alpha(config.initialAlpha);
2579
- }
2580
- this.syncMaxTicks = ticksToAlphaMin(config.alphaDecay);
2581
- this.syncWarmupTicks = config.warmupTicks ?? 0;
2582
- this.syncWarmupBudgetMs = config.warmupBudgetMs ?? DEFAULT_WARMUP_BUDGET_MS;
2583
- this.syncWarmupPending = this.syncWarmupTicks > 0;
2584
- this.runSyncTicks(true);
2585
- }
2586
- /**
2587
- * Run simulation ticks in batches, yielding to the main thread between
2588
- * batches via requestAnimationFrame. This prevents a multi-second freeze
2589
- * when the sync fallback handles large graphs (1k+ nodes).
2590
- *
2591
- * Each batch runs SYNC_TICKS_PER_BATCH ticks, emits positions for
2592
- * progressive rendering, then schedules the next batch.
2593
- *
2594
- * @param deferred - When true, start via microtask (initial run where
2595
- * callbacks aren't wired yet). Otherwise start immediately.
2596
- */
2597
- runSyncTicks(deferred = false) {
2598
- if (!this.syncSim || this.destroyed) return;
2599
- if (this.syncRafId !== null) {
2600
- cancelAnimationFrame(this.syncRafId);
2601
- this.syncRafId = null;
2602
- }
2603
- const sim = this.syncSim;
2604
- const maxTicks = this.syncMaxTicks;
2605
- let tickCount = 0;
2606
- const runWarmup = () => {
2607
- if (this.destroyed || !this.syncSim) return;
2608
- this.syncRafId = null;
2609
- const start2 = this.now();
2610
- while (this.syncWarmupTicks > 0) {
2611
- for (let i = 0; i < SYNC_TICKS_PER_BATCH && this.syncWarmupTicks > 0; i++) {
2612
- sim.tick();
2613
- this.syncWarmupTicks--;
2614
- if (sim.alpha() < DEFAULT_ALPHA_MIN) {
2615
- this.syncWarmupTicks = 0;
2616
- break;
2617
- }
2618
- }
2619
- if (this.syncWarmupTicks > 0 && this.now() - start2 >= this.syncWarmupBudgetMs) {
2620
- this.syncWarmupTicks = 0;
2621
- break;
2622
- }
2623
- }
2624
- this.syncWarmupPending = false;
2625
- runBatch();
2626
- };
2627
- const runBatch = () => {
2628
- if (this.destroyed || !this.syncSim) return;
2629
- this.syncRafId = null;
2630
- for (let i = 0; i < SYNC_TICKS_PER_BATCH && tickCount < maxTicks; i++, tickCount++) {
2631
- sim.tick();
2632
- if (sim.alpha() < DEFAULT_ALPHA_MIN) {
2633
- tickCount = maxTicks;
2634
- break;
2635
- }
2636
- }
2637
- const positions = this.syncNodes.map((n) => ({
2638
- id: n.id,
2639
- x: n.x ?? 0,
2640
- y: n.y ?? 0
2641
- }));
2642
- const alpha = sim.alpha();
2643
- const settled = alpha < DEFAULT_ALPHA_MIN || tickCount >= maxTicks;
2644
- this.tickCb?.(positions, alpha);
2645
- if (settled) {
2646
- this.settledCb?.();
2647
- } else {
2648
- this.syncRafId = requestAnimationFrame(runBatch);
2649
- }
2650
- };
2651
- const start = this.syncWarmupPending ? runWarmup : runBatch;
2652
- if (deferred) {
2653
- queueMicrotask(start);
2654
- } else {
2655
- start();
2656
- }
2657
- }
2658
- };
2659
-
2660
- // src/graph/spatial-index.ts
2661
- var SpatialIndex2 = class extends SpatialIndex {
2662
- };
2663
-
2664
- // src/graph/update-diff-config.ts
2665
- function stableKey(c) {
2666
- return JSON.stringify([
2667
- c.chargeStrength,
2668
- c.linkDistance,
2669
- c.clustering ? [c.clustering.field, c.clustering.strength] : null,
2670
- c.alphaDecay,
2671
- c.velocityDecay,
2672
- c.collisionRadius,
2673
- c.collisionPadding ?? null,
2674
- c.linkStrength ?? null,
2675
- c.centerForce ?? null,
2676
- c.seed ?? null,
2677
- c.warmupTicks ?? null,
2678
- c.warmupBudgetMs ?? null
2679
- ]);
2680
- }
2681
- function simulationConfigEqual(a, b) {
2682
- return stableKey(a) === stableKey(b);
2683
- }
2684
-
2685
- // src/graph/update-diff.ts
2686
- var JITTER = 8;
2687
- function jitter(id, seed) {
2688
- const jx = hash32(`${id}|${seed}|jx`) / 4294967296 * 2 - 1;
2689
- const jy = hash32(`${id}|${seed}|jy`) / 4294967296 * 2 - 1;
2690
- return { x: jx * JITTER, y: jy * JITTER };
2691
- }
2692
- var FALLBACK_R0 = 300;
2693
- function seededDisc(id, seed) {
2694
- const u1 = hash32(`${id}|${seed}|r`) / 4294967296;
2695
- const u2 = hash32(`${id}|${seed}|t`) / 4294967296;
2696
- const r = FALLBACK_R0 * Math.sqrt(u1);
2697
- const theta = u2 * Math.PI * 2;
2698
- return { x: r * Math.cos(theta), y: r * Math.sin(theta) };
2699
- }
2700
- function diffGraphUpdate(prevNodes, prevEdges, next, prevConfig, seed) {
2701
- const prevIds = new Set(prevNodes.map((n) => n.id));
2702
- const nextIds = new Set(next.nodes.map((n) => n.id));
2703
- const survivingPositions = /* @__PURE__ */ new Map();
2704
- const enteringIds = [];
2705
- for (const n of prevNodes) {
2706
- if (nextIds.has(n.id)) survivingPositions.set(n.id, { x: n.x, y: n.y });
2707
- }
2708
- for (const n of next.nodes) {
2709
- if (!prevIds.has(n.id)) enteringIds.push(n.id);
2710
- }
2711
- const exitingNodes = prevNodes.filter((n) => !nextIds.has(n.id));
2712
- const nextEdgeKeys = new Set(next.edges.map((e) => `${e.source} ${e.target}`));
2713
- const exitingEdges = prevEdges.filter((e) => !nextEdgeKeys.has(`${e.source} ${e.target}`));
2714
- const prevEdgeCounts = /* @__PURE__ */ new Map();
2715
- for (const e of prevEdges) {
2716
- const k = `${e.source} ${e.target}`;
2717
- prevEdgeCounts.set(k, (prevEdgeCounts.get(k) ?? 0) + 1);
2718
- }
2719
- let enteringEdgeCount = 0;
2720
- const remainingPrev = new Map(prevEdgeCounts);
2721
- for (const e of next.edges) {
2722
- const k = `${e.source} ${e.target}`;
2723
- const c = remainingPrev.get(k);
2724
- if (c && c > 0) {
2725
- remainingPrev.set(k, c - 1);
2726
- } else {
2727
- enteringEdgeCount++;
2728
- }
2729
- }
2730
- const sameNodes = prevIds.size === nextIds.size && enteringIds.length === 0;
2731
- const sameEdges = edgeSetsEqual(prevEdges, next.edges);
2732
- const visualOnly = sameNodes && sameEdges && simulationConfigEqual(prevConfig, next.simulationConfig);
2733
- const spawnPositions = /* @__PURE__ */ new Map();
2734
- if (enteringIds.length > 0) {
2735
- const enteringSet = new Set(enteringIds);
2736
- const nextAdjacency = buildAdjacency(next.edges);
2737
- for (const id of enteringIds) {
2738
- const neighborPos = firstSurvivingNeighborPos(
2739
- id,
2740
- nextAdjacency,
2741
- survivingPositions,
2742
- enteringSet
2743
- );
2744
- const j = jitter(id, seed);
2745
- if (neighborPos) {
2746
- spawnPositions.set(id, { x: neighborPos.x + j.x, y: neighborPos.y + j.y });
2747
- } else {
2748
- const disc = seededDisc(id, seed);
2749
- spawnPositions.set(id, { x: disc.x + j.x, y: disc.y + j.y });
2750
- }
2751
- }
2752
- }
2753
- return {
2754
- visualOnly,
2755
- enteringIds,
2756
- survivingPositions,
2757
- spawnPositions,
2758
- exitingNodes,
2759
- exitingEdges,
2760
- enteringEdgeCount
2761
- };
2762
- }
2763
- function buildAdjacency(edges) {
2764
- const map = /* @__PURE__ */ new Map();
2765
- const push = (a, b) => {
2766
- const list = map.get(a);
2767
- if (list) list.push(b);
2768
- else map.set(a, [b]);
2769
- };
2770
- for (const e of edges) {
2771
- push(e.source, e.target);
2772
- push(e.target, e.source);
2773
- }
2774
- return map;
2775
- }
2776
- function firstSurvivingNeighborPos(id, adjacency, survivingPositions, enteringSet) {
2777
- const neighbors = adjacency.get(id);
2778
- if (!neighbors) return null;
2779
- for (const nid of neighbors) {
2780
- if (enteringSet.has(nid)) continue;
2781
- const pos = survivingPositions.get(nid);
2782
- if (pos) return pos;
2783
- }
2784
- return null;
2785
- }
2786
- function edgeSetsEqual(prev, next) {
2787
- if (prev.length !== next.length) return false;
2788
- const key = (e) => `${e.source}->${e.target}`;
2789
- const counts = /* @__PURE__ */ new Map();
2790
- for (const e of prev) {
2791
- const k = key(e);
2792
- counts.set(k, (counts.get(k) ?? 0) + 1);
2793
- }
2794
- for (const e of next) {
2795
- const k = key(e);
2796
- const c = counts.get(k);
2797
- if (!c) return false;
2798
- counts.set(k, c - 1);
2799
- }
2800
- return true;
2801
- }
2802
-
2803
- // src/graph-mount.ts
2804
- var SPRINGY_DRAG_MAX_NODES = 5e3;
2805
- var CURSOR_FORCE_MAX_NODES = 2e3;
2806
- var CURSOR_POINTER_THROTTLE_MS = 33;
2807
- var FOLLOW_SETTLE_ALPHA = 0.05;
2808
- function createGraph(container, spec, options) {
2809
- let currentSpec = spec;
2810
- let compilation;
2811
- let destroyed = false;
2812
- let wrapper = null;
2813
- let canvas = null;
2814
- let chromeEl = null;
2815
- let legendEl = null;
2816
- let legendController = null;
2817
- let renderer = null;
2818
- let simulation = null;
2819
- const spatialIndex = new SpatialIndex2();
2820
- let interactionManager = null;
2821
- const searchManager = new GraphSearchManager();
2822
- let tooltipManager = null;
2823
- let cleanupKeyboard = null;
2824
- let disconnectResize = null;
2825
- let positionedNodes = [];
2826
- let positionedEdges = [];
2827
- let adjacencyMap = /* @__PURE__ */ new Map();
2828
- let nodeDataMap = /* @__PURE__ */ new Map();
2829
- let edgeDataMap = /* @__PURE__ */ new Map();
2830
- let hoveredNodeId = null;
2831
- let hoveredEdgeId = null;
2832
- let selectedNodeIds = /* @__PURE__ */ new Set();
2833
- let animFrameId = null;
2834
- let needsRender = false;
2835
- let isGesturing = false;
2836
- const scheduler = new AnimationScheduler(() => scheduleRender());
2837
- let gestureTimeout = null;
2838
- let lastEdgeHitTime = 0;
2839
- let lastPointerFeedTime = 0;
2840
- let activeFlight = null;
2841
- let activeFollow = null;
2842
- let lastAlpha = 1;
2843
- let cameraChangePending = false;
2844
- let highlightSet = null;
2845
- let highlightDimOpacity = null;
2846
- let seedIds = /* @__PURE__ */ new Set();
2847
- let activeCategories = /* @__PURE__ */ new Set();
2848
- let transientHighlight = null;
2849
- let transientTarget = null;
2850
- const seenWarnings = /* @__PURE__ */ new Set();
2851
- let nodeCategory = /* @__PURE__ */ new Map();
2852
- let focusTransition = null;
2853
- let focusAnim = null;
2854
- let hoverRadiusTween = null;
2855
- let entranceProgress = 1;
2856
- let entranceActive = false;
2857
- let entranceStagger = false;
2858
- let entranceOrderMap = null;
2859
- let entranceOffsetMap = null;
2860
- let entranceFitInFlight = false;
2861
- let entranceReveal = null;
2862
- let suppressEntranceOnce = options?.suppressEntrance ?? false;
2863
- let enterAlphaMap = null;
2864
- let exitingGhosts = null;
2865
- function markGesture() {
2866
- isGesturing = true;
2867
- if (gestureTimeout !== null) clearTimeout(gestureTimeout);
2868
- gestureTimeout = setTimeout(() => {
2869
- isGesturing = false;
2870
- gestureTimeout = null;
2871
- needsRender = true;
2872
- scheduleRender();
2873
- }, 150);
2874
- }
2875
- function getContainerDimensions() {
2876
- const rect = container.getBoundingClientRect();
2877
- return {
2878
- width: Math.max(rect.width || 600, 100),
2879
- height: Math.max(rect.height || 400, 100)
2880
- };
2881
- }
2882
- function warnOnce(message) {
2883
- if (seenWarnings.has(message)) return;
2884
- seenWarnings.add(message);
2885
- if (options?.onWarn) options.onWarn(message);
2886
- else console.warn(message);
2887
- }
2888
- function compile() {
2889
- const { width, height } = getContainerDimensions();
2890
- const darkMode = resolveDarkMode(options?.darkMode);
2891
- const compileOpts = {
2892
- width,
2893
- height,
2894
- theme: options?.theme,
2895
- darkMode,
2896
- watermark: options?.watermark,
2897
- onWarn: warnOnce
2898
- };
2899
- return compileGraph(currentSpec, compileOpts);
2900
- }
2901
- function buildDataMaps() {
2902
- nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
2903
- edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
2904
- nodeCategory = /* @__PURE__ */ new Map();
2905
- const field = compilation.legendField;
2906
- if (field) {
2907
- for (const n of compilation.nodes) {
2908
- const v = n.data?.[field];
2909
- if (v != null) nodeCategory.set(n.id, String(v));
2910
- }
2911
- }
2912
- }
2913
- function buildAdjacencyMap(edges) {
2914
- const map = /* @__PURE__ */ new Map();
2915
- for (const edge of edges) {
2916
- if (!map.has(edge.source)) map.set(edge.source, /* @__PURE__ */ new Set());
2917
- if (!map.has(edge.target)) map.set(edge.target, /* @__PURE__ */ new Set());
2918
- map.get(edge.source).add(edge.target);
2919
- map.get(edge.target).add(edge.source);
2920
- }
2921
- return map;
2922
- }
2923
- function toSimNodes(nodes) {
2924
- return nodes.map((n) => ({
2925
- id: n.id,
2926
- radius: n.radius,
2927
- community: n.community
2928
- }));
2929
- }
2930
- function toSimEdges(edges) {
2931
- return edges.map((e) => ({
2932
- source: e.source,
2933
- target: e.target
2934
- }));
2935
- }
2936
- function nodeDataById(nodeId) {
2937
- return nodeDataMap.get(nodeId) ?? {};
2938
- }
2939
- function pointToSegmentDist(px, py, ax, ay, bx, by) {
2940
- const dx = bx - ax;
2941
- const dy = by - ay;
2942
- const lenSq = dx * dx + dy * dy;
2943
- if (lenSq === 0) return Math.hypot(px - ax, py - ay);
2944
- const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
2945
- return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
2946
- }
2947
- function hitTestEdge(graphX, graphY, threshold) {
2948
- let bestDist = threshold;
2949
- let bestEdgeId = null;
2950
- for (const edge of positionedEdges) {
2951
- const dist = pointToSegmentDist(
2952
- graphX,
2953
- graphY,
2954
- edge.sourceX,
2955
- edge.sourceY,
2956
- edge.targetX,
2957
- edge.targetY
2958
- );
2959
- if (dist < bestDist) {
2960
- bestDist = dist;
2961
- bestEdgeId = `${edge.source}->${edge.target}`;
2962
- }
2963
- }
2964
- return bestEdgeId;
2965
- }
2966
- function edgeDataById(edgeId) {
2967
- return edgeDataMap.get(edgeId) ?? null;
2968
- }
2969
- function createDOM() {
2970
- const { width, height } = getContainerDimensions();
2971
- const isDark = resolveDarkMode(options?.darkMode);
2972
- wrapper = document.createElement("div");
2973
- wrapper.className = isDark ? "oc-graph-wrapper oc-dark" : "oc-graph-wrapper";
2974
- if (isDark) {
2975
- container.classList.add("oc-dark");
2976
- } else {
2977
- container.classList.remove("oc-dark");
2978
- }
2979
- const resolvedTheme = compilation.theme;
2980
- if (resolvedTheme) {
2981
- const s = wrapper.style;
2982
- s.setProperty("--oc-bg", resolvedSurface(resolvedTheme));
2983
- s.setProperty("--oc-text", resolvedTheme.colors.text);
2984
- s.setProperty("--oc-text-secondary", resolvedTheme.colors.neutral.secondary);
2985
- s.setProperty("--oc-text-muted", resolvedTheme.colors.axis);
2986
- s.setProperty("--oc-border", resolvedTheme.colors.neutral.border);
2987
- s.setProperty("--oc-font-family", resolvedTheme.fonts.family);
2988
- s.fontFamily = resolvedTheme.fonts.family;
2989
- }
2990
- chromeEl = document.createElement("div");
2991
- chromeEl.className = "oc-graph-chrome";
2992
- renderChrome3();
2993
- wrapper.appendChild(chromeEl);
2994
- canvas = document.createElement("canvas");
2995
- canvas.className = "oc-graph-canvas";
2996
- canvas.setAttribute("role", "img");
2997
- if (compilation.a11y?.altText) {
2998
- canvas.setAttribute("aria-label", compilation.a11y.altText);
2999
- }
3000
- wrapper.appendChild(canvas);
3001
- if (legendSetting() !== false) {
3002
- legendEl = document.createElement("div");
3003
- legendEl.className = "oc-graph-legend";
3004
- renderLegend2();
3005
- wrapper.appendChild(legendEl);
3006
- }
3007
- container.appendChild(wrapper);
3008
- syncChromeInset();
3009
- const canvasHeight = Math.max(height, 200);
3010
- renderer = new GraphCanvasRenderer(canvas);
3011
- renderer.resize(width, canvasHeight);
3012
- }
3013
- function renderChrome3() {
3014
- if (!chromeEl) return;
3015
- let html = "";
3016
- if (compilation.chrome.title) {
3017
- html += `<h2 class="oc-title">${escapeHtml2(compilation.chrome.title.text)}</h2>`;
3018
- }
3019
- if (compilation.chrome.subtitle) {
3020
- html += `<p class="oc-subtitle">${escapeHtml2(compilation.chrome.subtitle.text)}</p>`;
3021
- }
3022
- chromeEl.innerHTML = html;
3023
- if (!html) {
3024
- chromeEl.style.display = "none";
3025
- } else {
3026
- chromeEl.style.display = "";
3027
- }
3028
- }
3029
- function legendSetting() {
3030
- return options?.legend ?? currentSpec.legend;
3031
- }
3032
- function legendConfig() {
3033
- const l = legendSetting();
3034
- if (l && typeof l === "object") {
3035
- return { interactive: l.interactive ?? true, counts: l.counts ?? true };
3036
- }
3037
- return { interactive: true, counts: true };
3038
- }
3039
- function legendViewData() {
3040
- return { nodes: getLegend().nodes, edges: getLegend().edges };
3041
- }
3042
- function renderLegend2() {
3043
- if (!legendEl) return;
3044
- const cfg = legendConfig();
3045
- if (!legendController) {
3046
- legendController = createGraphLegend(legendEl, legendViewData(), {
3047
- interactive: cfg.interactive,
3048
- counts: cfg.counts,
3049
- onToggle: (value) => toggleLegendCategory(value),
3050
- onHover: (value) => {
3051
- const field = compilation.legendField;
3052
- options?.onLegendHover?.(value !== null && field ? { field, value } : null);
3053
- }
3054
- });
3055
- } else {
3056
- legendController.update(legendViewData());
3057
- }
3058
- }
3059
- function syncLegendActiveState() {
3060
- if (legendController) legendController.update(legendViewData());
3061
- syncChromeInset();
3062
- }
3063
- function syncChromeInset() {
3064
- if (!chromeEl) return;
3065
- const legendW = legendEl?.offsetWidth ?? 0;
3066
- chromeEl.style.right = legendW > 0 ? `${legendW + 24}px` : "";
3067
- }
3068
- function chromeInsetTop() {
3069
- if (!chromeEl || chromeEl.style.display === "none") return 0;
3070
- return chromeEl.offsetHeight;
3071
- }
3072
- function springyDragEnabled() {
3073
- return compilation.interaction.springyDrag && compilation.nodes.length <= SPRINGY_DRAG_MAX_NODES;
3074
- }
3075
- function cursorForceEnabled() {
3076
- return compilation.interaction.cursorRepulsion !== null && compilation.nodes.length <= CURSOR_FORCE_MAX_NODES && !prefersReducedMotion();
3077
- }
3078
- function initSimulation(opts) {
3079
- const simNodes = toSimNodes(compilation.nodes);
3080
- const simEdges = toSimEdges(compilation.edges);
3081
- const config = compilation.simulationConfig;
3082
- if (opts?.positions) {
3083
- seedNodePositions(simNodes, config.seed ?? 0);
3084
- for (const n of simNodes) {
3085
- const p = opts.positions.get(n.id);
3086
- if (p) {
3087
- n.x = p.x;
3088
- n.y = p.y;
3089
- }
3090
- }
3091
- } else {
3092
- seedNodePositions(simNodes, config.seed ?? 0);
3093
- }
3094
- simulation = SimulationManager.create(simNodes, simEdges, {
3095
- chargeStrength: config.chargeStrength,
3096
- linkDistance: config.linkDistance,
3097
- clustering: config.clustering,
3098
- alphaDecay: config.alphaDecay,
3099
- velocityDecay: config.velocityDecay,
3100
- collisionRadius: config.collisionRadius,
3101
- collisionPadding: config.collisionPadding,
3102
- linkStrength: config.linkStrength,
3103
- // Update sims suppress the (non-alpha-scaled) center force to avoid a
3104
- // global jump on tick 1; the alpha-scaled forceX/forceY gravity still
3105
- // holds the layout centered.
3106
- centerForce: opts?.suppressCenter ? false : config.centerForce,
3107
- warmupTicks: opts?.skipWarmup ? 0 : config.warmupTicks,
3108
- warmupBudgetMs: config.warmupBudgetMs,
3109
- initialAlpha: opts?.initialAlpha ?? config.initialAlpha,
3110
- // Cursor force radius/strength (null when disabled or gated off by node
3111
- // count). The mount only feeds pointer positions when the same gate holds.
3112
- cursorRepulsion: cursorForceEnabled() ? compilation.interaction.cursorRepulsion : null
3113
- });
3114
- lastAlpha = opts?.initialAlpha ?? 1;
3115
- let initialSettleDone = false;
3116
- let initialFitDone = opts?.skipEntrance ?? false;
3117
- simulation.onTick((positions, alpha) => {
3118
- if (destroyed) return;
3119
- lastAlpha = alpha;
3120
- const posMap = /* @__PURE__ */ new Map();
3121
- for (const p of positions) {
3122
- posMap.set(p.id, { x: p.x, y: p.y });
3123
- }
3124
- positionedNodes = compilation.nodes.map((node, index) => {
3125
- const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
3126
- return { ...node, x: pos.x, y: pos.y, index };
3127
- });
3128
- positionedEdges = compilation.edges.map((edge) => {
3129
- const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
3130
- const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
3131
- return {
3132
- ...edge,
3133
- sourceX: src.x,
3134
- sourceY: src.y,
3135
- targetX: tgt.x,
3136
- targetY: tgt.y
3137
- };
3138
- });
3139
- spatialIndex.rebuild(positionedNodes);
3140
- if (!initialFitDone && positionedNodes.length > 0 && interactionManager && options?.fitOnLoad !== false) {
3141
- initialFitDone = true;
3142
- startEntrance();
3143
- } else if (!initialFitDone && options?.fitOnLoad === false) {
3144
- initialFitDone = true;
3145
- }
3146
- needsRender = true;
3147
- scheduleRender();
3148
- });
3149
- simulation.onSettled(() => {
3150
- if (initialSettleDone) return;
3151
- initialSettleDone = true;
3152
- });
3153
- }
3154
- function computeInitialFit() {
3155
- const { width: cw, height: ch } = getCanvasDimensions();
3156
- const warmed = (compilation.simulationConfig.warmupTicks ?? 0) > 0;
3157
- const { transform } = ZoomTransform.fitBounds(positionedNodes, cw, ch, void 0, {
3158
- spread: !warmed,
3159
- insetTop: chromeInsetTop()
3160
- });
3161
- return transform;
3162
- }
3163
- function startEntrance() {
3164
- if (!interactionManager) return;
3165
- const fit = computeInitialFit();
3166
- const enter = compilation.animation?.enter ?? null;
3167
- const suppressed = suppressEntranceOnce;
3168
- suppressEntranceOnce = false;
3169
- if (suppressed || !enter || prefersReducedMotion()) {
3170
- interactionManager.setTransform(fit);
3171
- cameraChangePending = true;
3172
- entranceActive = false;
3173
- entranceProgress = 1;
3174
- return;
3175
- }
3176
- const { width: cw, height: ch } = getCanvasDimensions();
3177
- const pulledBack = fit.zoomAt(fit.k * 0.7, cw / 2, ch / 2);
3178
- interactionManager.setTransform(pulledBack);
3179
- cameraChangePending = true;
3180
- entranceActive = true;
3181
- entranceProgress = 0;
3182
- entranceStagger = enter.stagger && positionedNodes.length <= ENTRANCE_STAGGER_MAX_NODES;
3183
- if (entranceStagger) {
3184
- entranceOrderMap = entranceOrder(positionedNodes);
3185
- entranceOffsetMap = entranceOffsets(positionedNodes);
3186
- } else {
3187
- entranceOrderMap = null;
3188
- entranceOffsetMap = null;
3189
- }
3190
- if (enter.cameraFit) {
3191
- entranceFitInFlight = true;
3192
- flyCamera(fit, { duration: enter.duration + 100 }, () => {
3193
- entranceFitInFlight = false;
3194
- });
3195
- }
3196
- const ease = resolveEase(enter.ease);
3197
- entranceReveal = createTween({
3198
- duration: enter.duration,
3199
- ease,
3200
- apply: (t) => {
3201
- entranceProgress = t;
3202
- needsRender = true;
3203
- },
3204
- onDone: () => {
3205
- entranceProgress = 1;
3206
- entranceActive = false;
3207
- entranceReveal = null;
3208
- needsRender = true;
3209
- }
3210
- });
3211
- scheduler.add(entranceReveal);
3212
- }
3213
- function getCanvasDimensions() {
3214
- if (!canvas) return { width: 600, height: 400 };
3215
- const rect = canvas.getBoundingClientRect();
3216
- return {
3217
- width: Math.max(rect.width || 600, 100),
3218
- height: Math.max(rect.height || 400, 100)
3219
- };
3220
- }
3221
- function scheduleRender() {
3222
- if (animFrameId !== null || destroyed) return;
3223
- animFrameId = requestAnimationFrame(renderFrame);
3224
- }
3225
- function hoverConnectedSet(nodeId) {
3226
- if (nodeId === null) return null;
3227
- if (compilation.interaction.hoverMode === "category") {
3228
- const cat = nodeCategory.get(nodeId);
3229
- const set2 = /* @__PURE__ */ new Set([nodeId]);
3230
- if (cat !== void 0) {
3231
- for (const [id, c] of nodeCategory) if (c === cat) set2.add(id);
3232
- }
3233
- return set2;
3234
- }
3235
- const set = /* @__PURE__ */ new Set([nodeId]);
3236
- const neighbors = adjacencyMap.get(nodeId);
3237
- if (neighbors) for (const nid of neighbors) set.add(nid);
3238
- return set;
3239
- }
3240
- function standingSnapshot() {
3241
- return composeStandingFocus(
3242
- highlightSet,
3243
- searchManager.getMatches(),
3244
- selectedNodeIds,
3245
- adjacencyMap
3246
- );
3247
- }
3248
- function targetSnapshot() {
3249
- return layerHoverFocus(standingSnapshot(), hoveredNodeId, hoverConnectedSet(hoveredNodeId));
3250
- }
3251
- function armFocus(now) {
3252
- const target = targetSnapshot();
3253
- const hoverCfg = compilation.animation?.hover ?? null;
3254
- const duration = hoverCfg && !prefersReducedMotion() ? hoverCfg.duration : 0;
3255
- const ease = resolveEase(hoverCfg?.ease ?? "smooth");
3256
- if (!focusTransition) {
3257
- focusTransition = new FocusTransition(target, duration, ease, now);
3258
- return;
3259
- }
3260
- focusTransition.retarget(target, now);
3261
- if (focusAnim) scheduler.remove(focusAnim);
3262
- focusAnim = {
3263
- tick: (t) => {
3264
- const running = focusTransition !== null && !focusTransition.isSettled(t);
3265
- if (!running) focusAnim = null;
3266
- return running;
3267
- },
3268
- finish: () => {
3269
- focusAnim = null;
3270
- },
3271
- cancel: () => {
3272
- focusAnim = null;
3273
- }
3274
- };
3275
- scheduler.add(focusAnim);
3276
- }
3277
- function refreshFocus() {
3278
- armFocus(performance.now());
3279
- needsRender = true;
3280
- scheduleRender();
3281
- }
3282
- function resolveHighlightTarget(target) {
3283
- if ("nodeIds" in target) return new Set(target.nodeIds);
3284
- if ("neighborsOf" in target) {
3285
- const set2 = /* @__PURE__ */ new Set();
3286
- if (target.includeSelf !== false) set2.add(target.neighborsOf);
3287
- const neighbors = adjacencyMap.get(target.neighborsOf);
3288
- if (neighbors) for (const nid of neighbors) set2.add(nid);
3289
- return set2;
3290
- }
3291
- const values = new Set(
3292
- Array.isArray(target.category.value) ? target.category.value : [target.category.value]
3293
- );
3294
- const field = target.category.field;
3295
- const set = /* @__PURE__ */ new Set();
3296
- for (const n of compilation.nodes) {
3297
- const v = n.data?.[field];
3298
- if (v != null && values.has(String(v))) set.add(n.id);
3299
- }
3300
- return set;
3301
- }
3302
- function categoryHighlightSet() {
3303
- if (activeCategories.size === 0) return null;
3304
- const set = /* @__PURE__ */ new Set();
3305
- for (const [id, cat] of nodeCategory) if (activeCategories.has(cat)) set.add(id);
3306
- return set;
3307
- }
3308
- function recomputeHighlight() {
3309
- const filter = categoryHighlightSet();
3310
- const transient = transientHighlight !== null && transientHighlight.size > 0 ? transientHighlight : null;
3311
- if (filter !== null && transient !== null) {
3312
- const inter = /* @__PURE__ */ new Set();
3313
- for (const id of transient) if (filter.has(id)) inter.add(id);
3314
- highlightSet = inter.size > 0 ? inter : transient;
3315
- return;
3316
- }
3317
- highlightSet = filter ?? transient;
3318
- }
3319
- function refreshTransientHighlight() {
3320
- if (transientTarget === null) {
3321
- transientHighlight = null;
3322
- return;
3323
- }
3324
- const nextIds = new Set(compilation.nodes.map((n) => n.id));
3325
- const resolved = new Set(
3326
- [...resolveHighlightTarget(transientTarget)].filter((id) => nextIds.has(id))
3327
- );
3328
- transientHighlight = resolved.size > 0 ? resolved : null;
3329
- }
3330
- function effectiveDimOpacity() {
3331
- const custom = transientHighlight !== null ? highlightDimOpacity : null;
3332
- return custom ?? compilation.interaction.dimOpacity;
3333
- }
3334
- function emitHighlightChange() {
3335
- options?.onHighlightChange?.(highlightSet ? [...highlightSet] : null);
3336
- }
3337
- function applyInitialHighlight() {
3338
- const init = compilation.initialHighlight;
3339
- if (!init) return;
3340
- activeCategories = new Set(init.values);
3341
- recomputeHighlight();
3342
- }
3343
- function startHoverRadiusTween(nodeId) {
3344
- const hoverCfg = compilation.animation?.hover ?? null;
3345
- if (nodeId === null || !hoverCfg || prefersReducedMotion()) {
3346
- hoverRadiusTween = nodeId ? { nodeId, scale: 1.15 } : null;
3347
- return;
3348
- }
3349
- hoverRadiusTween = { nodeId, scale: 1 };
3350
- const ease = resolveEase(hoverCfg.ease);
3351
- const tween = createTween({
3352
- duration: hoverCfg.duration,
3353
- ease,
3354
- apply: (t) => {
3355
- if (hoverRadiusTween?.nodeId === nodeId) hoverRadiusTween.scale = 1 + 0.15 * t;
3356
- needsRender = true;
3357
- },
3358
- onDone: () => {
3359
- if (hoverRadiusTween?.nodeId === nodeId) hoverRadiusTween.scale = 1.15;
3360
- }
3361
- });
3362
- scheduler.add(tween);
3363
- }
3364
- function showNodeTooltip(nodeId) {
3365
- if (!tooltipManager || !interactionManager) return;
3366
- const defaults = compilation.tooltipDescriptors.get(nodeId);
3367
- if (!defaults) return;
3368
- const node = positionedNodes.find((n) => n.id === nodeId);
3369
- if (!node) return;
3370
- const screen = interactionManager.getTransform().graphToScreen(node.x, node.y);
3371
- const formatter = tooltipFormatter();
3372
- if (!formatter) {
3373
- tooltipManager.show(defaults, screen.x, screen.y);
3374
- return;
3375
- }
3376
- const result = formatter({ kind: "node", data: nodeDataById(nodeId) }, defaults);
3377
- applyFormatterResult(result, screen.x, screen.y);
3378
- }
3379
- function showEdgeTooltip(edgeId, data, screenX, screenY) {
3380
- if (!tooltipManager) return;
3381
- const edge = compilation.edges.find((e) => `${e.source}->${e.target}` === edgeId);
3382
- const defaults = edge ? buildEdgeTooltip(edge) : { title: edgeId, fields: [] };
3383
- const formatter = tooltipFormatter();
3384
- if (!formatter) {
3385
- tooltipManager.show(defaults, screenX, screenY);
3386
- return;
3387
- }
3388
- const result = formatter({ kind: "edge", data }, defaults);
3389
- applyFormatterResult(result, screenX, screenY);
3390
- }
3391
- function tooltipFormatter() {
3392
- const t = options?.tooltip;
3393
- return t && typeof t === "object" ? t.formatter ?? null : null;
3394
- }
3395
- function applyFormatterResult(result, x, y) {
3396
- if (!tooltipManager) return;
3397
- if (result === null) {
3398
- tooltipManager.hide();
3399
- } else if (typeof result === "string") {
3400
- tooltipManager.show({ text: result }, x, y);
3401
- } else if (result instanceof HTMLElement) {
3402
- tooltipManager.show({ element: result }, x, y);
3403
- } else {
3404
- tooltipManager.show(result, x, y);
3405
- }
3406
- }
3407
- function buildRenderState(now) {
3408
- const transform = interactionManager.getTransform();
3409
- if (!focusTransition) armFocus(now);
3410
- const ft = focusTransition;
3411
- const t = ft.progress(now);
3412
- const focus = t < 1 ? { t, prev: ft.prev, next: ft.next } : void 0;
3413
- const settledNext = ft.next;
3414
- const hoverRadiusScale = hoverRadiusTween ? /* @__PURE__ */ new Map([[hoverRadiusTween.nodeId, hoverRadiusTween.scale]]) : void 0;
3415
- return {
3416
- nodes: positionedNodes,
3417
- edges: positionedEdges,
3418
- transform: { x: transform.x, y: transform.y, k: transform.k },
3419
- hoveredNodeId,
3420
- hoveredEdgeId,
3421
- selectedNodeIds,
3422
- adjacencyMap,
3423
- theme: compilation.theme,
3424
- searchMatches: searchManager.getMatches(),
3425
- exemptIds: seedIds,
3426
- isGesturing,
3427
- watermark: compilation.watermark,
3428
- focus: focus ?? { t: 1, prev: settledNext, next: settledNext },
3429
- hoverRadiusScale,
3430
- dimOpacity: effectiveDimOpacity(),
3431
- entrance: entranceActive && entranceProgress < 1 ? {
3432
- t: entranceProgress,
3433
- stagger: entranceStagger,
3434
- order: entranceOrderMap ?? void 0,
3435
- offsets: entranceOffsetMap ?? void 0
3436
- } : void 0,
3437
- enterAlpha: enterAlphaMap ?? void 0,
3438
- exiting: exitingGhosts ?? void 0
3439
- };
3440
- }
3441
- function renderFrame(now) {
3442
- animFrameId = null;
3443
- if (destroyed || !renderer || !interactionManager) return;
3444
- if (scheduler.tick(now)) needsRender = true;
3445
- if (needsRender) {
3446
- needsRender = false;
3447
- renderer.render(buildRenderState(now));
3448
- }
3449
- if (scheduler.active) scheduleRender();
3450
- if (cameraChangePending) {
3451
- cameraChangePending = false;
3452
- const t = interactionManager.getTransform();
3453
- options?.onCameraChange?.({ x: t.x, y: t.y, k: t.k });
3454
- }
3455
- }
3456
- function flyCamera(to, opts, onDone) {
3457
- if (destroyed || !interactionManager) return;
3458
- cancelFlight();
3459
- const cameraCfg = compilation.animation?.camera ?? null;
3460
- const resolveTarget = () => typeof to === "function" ? to() : to;
3461
- const snap = cameraCfg === null || prefersReducedMotion() || opts?.duration === 0;
3462
- if (snap) {
3463
- interactionManager.setTransform(resolveTarget());
3464
- cameraChangePending = true;
3465
- needsRender = true;
3466
- scheduleRender();
3467
- onDone?.();
3468
- return;
3469
- }
3470
- const { width, height } = getCanvasDimensions();
3471
- const duration = opts?.duration ?? cameraCfg?.duration ?? "auto";
3472
- const ease = opts?.ease ?? cameraCfg?.ease ?? "smooth";
3473
- const heavy = positionedNodes.length > 1e3;
3474
- const flight = createCameraFlight({
3475
- from: interactionManager.getTransform(),
3476
- to,
3477
- viewport: { width, height },
3478
- apply: (t) => {
3479
- interactionManager.setTransform(t);
3480
- isGesturing = heavy;
3481
- cameraChangePending = true;
3482
- needsRender = true;
3483
- },
3484
- onDone: () => {
3485
- activeFlight = null;
3486
- isGesturing = false;
3487
- needsRender = true;
3488
- if (typeof to === "function") startFollow(to);
3489
- scheduleRender();
3490
- onDone?.();
3491
- },
3492
- opts: { duration, ease }
3493
- });
3494
- activeFlight = flight;
3495
- scheduler.add(flight);
3496
- }
3497
- function startFollow(target) {
3498
- const follow = createCameraFollow({
3499
- target,
3500
- apply: (t) => {
3501
- interactionManager.setTransform(t);
3502
- cameraChangePending = true;
3503
- needsRender = true;
3504
- },
3505
- isActive: () => !destroyed && lastAlpha >= FOLLOW_SETTLE_ALPHA
3506
- });
3507
- activeFollow = follow;
3508
- scheduler.add(follow);
3509
- }
3510
- function cancelFlight() {
3511
- if (activeFlight) {
3512
- scheduler.remove(activeFlight);
3513
- activeFlight = null;
3514
- isGesturing = false;
3515
- }
3516
- if (activeFollow) {
3517
- scheduler.remove(activeFollow);
3518
- activeFollow = null;
3519
- }
3520
- }
3521
- function initInteraction() {
3522
- if (!canvas) return;
3523
- if (options?.tooltip !== false) {
3524
- tooltipManager = createTooltipManager(wrapper);
3525
- }
3526
- interactionManager = new GraphInteractionManager(canvas, spatialIndex, {
3527
- onTransformChange(_transform) {
3528
- cancelFlight();
3529
- markGesture();
3530
- cameraChangePending = true;
3531
- needsRender = true;
3532
- scheduleRender();
3533
- },
3534
- onHoverChange(nodeId) {
3535
- if (nodeId === hoveredNodeId) return;
3536
- hoveredNodeId = nodeId;
3537
- armFocus(performance.now());
3538
- startHoverRadiusTween(nodeId);
3539
- needsRender = true;
3540
- scheduleRender();
3541
- if (nodeId && hoveredEdgeId) {
3542
- hoveredEdgeId = null;
3543
- options?.onEdgeHover?.(null);
3544
- tooltipManager?.hide();
3545
- }
3546
- options?.onNodeHover?.(nodeId ? nodeDataById(nodeId) : null);
3547
- if (nodeId && tooltipManager) {
3548
- showNodeTooltip(nodeId);
3549
- } else if (!nodeId) {
3550
- tooltipManager?.hide();
3551
- }
3552
- },
3553
- onBackgroundHover(graphX, graphY, screenX, screenY) {
3554
- if (hoveredNodeId) return;
3555
- const now = performance.now();
3556
- if (now - lastEdgeHitTime < 32) {
3557
- if (hoveredEdgeId) {
3558
- hoveredEdgeId = null;
3559
- needsRender = true;
3560
- scheduleRender();
3561
- options?.onEdgeHover?.(null);
3562
- tooltipManager?.hide();
3563
- }
3564
- return;
3565
- }
3566
- lastEdgeHitTime = now;
3567
- const transform = interactionManager?.getTransform();
3568
- const threshold = 5 / (transform?.k ?? 1);
3569
- const edgeId = hitTestEdge(graphX, graphY, threshold);
3570
- if (edgeId !== hoveredEdgeId) {
3571
- hoveredEdgeId = edgeId;
3572
- needsRender = true;
3573
- scheduleRender();
3574
- if (edgeId) {
3575
- const data = edgeDataById(edgeId);
3576
- options?.onEdgeHover?.(data);
3577
- if (tooltipManager && data) showEdgeTooltip(edgeId, data, screenX, screenY);
3578
- } else {
3579
- options?.onEdgeHover?.(null);
3580
- tooltipManager?.hide();
3581
- }
3582
- }
3583
- },
3584
- onSelectionChange(nodeIds) {
3585
- selectedNodeIds = new Set(nodeIds);
3586
- armFocus(performance.now());
3587
- needsRender = true;
3588
- scheduleRender();
3589
- options?.onSelectionChange?.(nodeIds);
3590
- if (nodeIds.length > 0) {
3591
- const lastId = nodeIds[nodeIds.length - 1];
3592
- options?.onNodeClick?.(nodeDataById(lastId));
3593
- }
3594
- },
3595
- onNodeDragStart(nodeId) {
3596
- const node = positionedNodes.find((n) => n.id === nodeId);
3597
- const x = node?.x ?? 0;
3598
- const y = node?.y ?? 0;
3599
- simulation?.pinNode(nodeId, x, y, springyDragEnabled() ? 0.3 : void 0);
3600
- canvas?.classList.add("oc-graph-canvas--dragging");
3601
- },
3602
- onNodeDrag(nodeId, x, y) {
3603
- simulation?.dragNode(nodeId, x, y);
3604
- },
3605
- onNodeDragEnd(nodeId) {
3606
- simulation?.unpinNode(nodeId, springyDragEnabled() ? 0 : void 0);
3607
- canvas?.classList.remove("oc-graph-canvas--dragging");
3608
- },
3609
- onPointerMove(graphX, graphY) {
3610
- if (!cursorForceEnabled()) return;
3611
- const now = performance.now();
3612
- if (now - lastPointerFeedTime < CURSOR_POINTER_THROTTLE_MS) return;
3613
- lastPointerFeedTime = now;
3614
- simulation?.setPointer(graphX, graphY, true);
3615
- },
3616
- onPointerLeave() {
3617
- if (!cursorForceEnabled()) return;
3618
- simulation?.setPointer(0, 0, false);
3619
- },
3620
- onDoubleClick(nodeId) {
3621
- options?.onNodeDoubleClick?.(nodeDataById(nodeId));
3622
- }
3623
- });
3624
- cleanupKeyboard = attachGraphKeyboardNav({
3625
- canvas,
3626
- getNodes: () => positionedNodes,
3627
- getSelectedIds: () => [...selectedNodeIds],
3628
- getAdjacency: () => adjacencyMap,
3629
- onSelect(nodeId) {
3630
- selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
3631
- needsRender = true;
3632
- scheduleRender();
3633
- options?.onNodeClick?.(nodeDataById(nodeId));
3634
- options?.onSelectionChange?.([nodeId]);
3635
- },
3636
- onDeselect() {
3637
- selectedNodeIds.clear();
3638
- needsRender = true;
3639
- scheduleRender();
3640
- options?.onSelectionChange?.([]);
3641
- },
3642
- onZoom(direction) {
3643
- if (!interactionManager || !canvas) return;
3644
- const t = interactionManager.getTransform();
3645
- const { width: cw, height: ch } = getCanvasDimensions();
3646
- const factor = direction === "in" ? 1.2 : 0.8;
3647
- const newK = t.k * factor;
3648
- const newTransform = t.zoomAt(newK, cw / 2, ch / 2);
3649
- flyCamera(newTransform, { duration: 200 });
3650
- },
3651
- onFitAll() {
3652
- zoomToFit();
3653
- }
3654
- });
3655
- }
3656
- function search(query) {
3657
- if (destroyed) return;
3658
- searchManager.search(query, positionedNodes);
3659
- needsRender = true;
3660
- scheduleRender();
3661
- }
3662
- function clearSearch() {
3663
- if (destroyed) return;
3664
- searchManager.clearSearch();
3665
- needsRender = true;
3666
- scheduleRender();
3667
- }
3668
- function zoomToFit(opts) {
3669
- if (destroyed || !interactionManager || positionedNodes.length === 0) return;
3670
- const { width: cw, height: ch } = getCanvasDimensions();
3671
- const { transform: fitTransform } = ZoomTransform.fitBounds(
3672
- positionedNodes,
3673
- cw,
3674
- ch,
3675
- opts?.padding,
3676
- {
3677
- insetTop: chromeInsetTop()
3678
- }
3679
- );
3680
- flyCamera(fitTransform, opts);
3681
- }
3682
- function zoomToNode(nodeId, opts) {
3683
- if (destroyed || !interactionManager || !canvas) return;
3684
- const node = positionedNodes.find((n) => n.id === nodeId);
3685
- if (!node) return;
3686
- const { width: cw, height: ch } = getCanvasDimensions();
3687
- const k = clampK(opts?.scale ?? 2);
3688
- const provider = () => {
3689
- const live = positionedNodes.find((n) => n.id === nodeId) ?? node;
3690
- return new ZoomTransform(cw / 2 - live.x * k, ch / 2 - live.y * k, k);
3691
- };
3692
- flyCamera(provider, opts);
3693
- }
3694
- function flyTo(target, opts) {
3695
- if (destroyed || !interactionManager) return;
3696
- const { width: cw, height: ch } = getCanvasDimensions();
3697
- const k = clampK(target.k ?? interactionManager.getTransform().k);
3698
- flyCamera(new ZoomTransform(cw / 2 - target.x * k, ch / 2 - target.y * k, k), opts);
3699
- }
3700
- function centerAt(x, y, opts) {
3701
- flyTo({ x, y }, opts);
3702
- }
3703
- function getCamera() {
3704
- const t = interactionManager?.getTransform() ?? ZoomTransform.identity();
3705
- return { x: t.x, y: t.y, k: t.k };
3706
- }
3707
- function selectNode(nodeId, opts) {
3708
- if (destroyed) return;
3709
- selectedNodeIds = /* @__PURE__ */ new Set([nodeId]);
3710
- needsRender = true;
3711
- scheduleRender();
3712
- options?.onSelectionChange?.([nodeId]);
3713
- const shouldFly = opts?.fly ?? compilation.interaction.selectFlyTo;
3714
- if (shouldFly) zoomToNode(nodeId, opts);
3715
- }
3716
- function getSelectedNodes() {
3717
- return [...selectedNodeIds];
3718
- }
3719
- function getSearchMatches() {
3720
- return [...searchManager.getMatches() ?? []];
3721
- }
3722
- function highlight(target, opts) {
3723
- if (destroyed) return;
3724
- transientTarget = target;
3725
- refreshTransientHighlight();
3726
- highlightDimOpacity = opts?.dimOpacity ?? null;
3727
- recomputeHighlight();
3728
- refreshFocus();
3729
- emitHighlightChange();
3730
- }
3731
- function clearHighlight() {
3732
- if (destroyed) return;
3733
- transientTarget = null;
3734
- transientHighlight = null;
3735
- highlightDimOpacity = null;
3736
- recomputeHighlight();
3737
- refreshFocus();
3738
- emitHighlightChange();
3739
- }
3740
- function getHighlight() {
3741
- return highlightSet ? [...highlightSet] : null;
3742
- }
3743
- function getLegend() {
3744
- const nodeEntries = "entries" in compilation.legend ? compilation.legend.entries : [];
3745
- return {
3746
- field: compilation.legendField,
3747
- nodes: nodeEntries.filter((e) => !e.overflow).map((e) => ({
3748
- label: e.label,
3749
- color: e.color,
3750
- count: e.count,
3751
- active: activeCategories.size === 0 || activeCategories.has(e.label)
3752
- })),
3753
- edges: (compilation.edgeLegend ?? []).map((e) => ({
3754
- label: e.label,
3755
- color: e.color,
3756
- count: e.count
3757
- }))
3758
- };
3759
- }
3760
- function setActiveCategories(values) {
3761
- if (destroyed) return;
3762
- activeCategories = new Set(values);
3763
- recomputeHighlight();
3764
- syncLegendActiveState();
3765
- refreshFocus();
3766
- emitHighlightChange();
3767
- }
3768
- function getActiveCategories() {
3769
- return [...activeCategories];
3770
- }
3771
- function toggleLegendCategory(value) {
3772
- if (activeCategories.has(value)) activeCategories.delete(value);
3773
- else activeCategories.add(value);
3774
- recomputeHighlight();
3775
- syncLegendActiveState();
3776
- refreshFocus();
3777
- options?.onLegendToggle?.([...activeCategories]);
3778
- emitHighlightChange();
3779
- }
3780
- function doResize() {
3781
- if (destroyed || !canvas || !renderer || !wrapper) return;
3782
- const { width, height } = getContainerDimensions();
3783
- const canvasHeight = Math.max(height, 200);
3784
- renderer.resize(width, canvasHeight);
3785
- syncChromeInset();
3786
- if (entranceFitInFlight && interactionManager) {
3787
- cancelFlight();
3788
- entranceFitInFlight = false;
3789
- interactionManager.setTransform(computeInitialFit());
3790
- cameraChangePending = true;
3791
- }
3792
- needsRender = true;
3793
- scheduleRender();
3794
- }
3795
- function update(newSpec) {
3796
- if (destroyed) return;
3797
- currentSpec = newSpec;
3798
- scheduler.finishAll();
3799
- entranceActive = false;
3800
- entranceProgress = 1;
3801
- entranceFitInFlight = false;
3802
- entranceReveal = null;
3803
- enterAlphaMap = null;
3804
- exitingGhosts = null;
3805
- const prevNodes = positionedNodes;
3806
- const prevEdges = positionedEdges;
3807
- const prevConfig = compilation.simulationConfig;
3808
- compilation = compile();
3809
- seedIds = new Set(compilation.seedNodeIds);
3810
- const diff = diffGraphUpdate(
3811
- prevNodes,
3812
- prevEdges,
3813
- compilation,
3814
- prevConfig,
3815
- prevConfig.seed ?? 0
3816
- );
3817
- if (diff.visualOnly) {
3818
- runVisualOnlyUpdate();
3819
- return;
3820
- }
3821
- runStructuralUpdate(diff);
3822
- }
3823
- function runVisualOnlyUpdate() {
3824
- adjacencyMap = buildAdjacencyMap(compilation.edges);
3825
- buildDataMaps();
3826
- const posMap = /* @__PURE__ */ new Map();
3827
- for (const node of positionedNodes) {
3828
- posMap.set(node.id, { x: node.x, y: node.y });
3829
- }
3830
- positionedNodes = compilation.nodes.map((node, index) => {
3831
- const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
3832
- return { ...node, x: pos.x, y: pos.y, index };
3833
- });
3834
- positionedEdges = compilation.edges.map((edge) => {
3835
- const src = posMap.get(edge.source) ?? { x: 0, y: 0 };
3836
- const tgt = posMap.get(edge.target) ?? { x: 0, y: 0 };
3837
- return { ...edge, sourceX: src.x, sourceY: src.y, targetX: tgt.x, targetY: tgt.y };
3838
- });
3839
- spatialIndex.rebuild(positionedNodes);
3840
- refreshTransientHighlight();
3841
- recomputeHighlight();
3842
- reRunSearch();
3843
- renderChrome3();
3844
- renderLegend2();
3845
- syncLegendActiveState();
3846
- needsRender = true;
3847
- scheduleRender();
3848
- }
3849
- function runStructuralUpdate(diff) {
3850
- teardownSimOnly();
3851
- adjacencyMap = buildAdjacencyMap(compilation.edges);
3852
- buildDataMaps();
3853
- const positions = /* @__PURE__ */ new Map();
3854
- for (const [id, p] of diff.survivingPositions) positions.set(id, p);
3855
- for (const [id, p] of diff.spawnPositions) positions.set(id, p);
3856
- const prevNodeCount = diff.survivingPositions.size + diff.exitingNodes.length;
3857
- const nextNodeCount = diff.survivingPositions.size + diff.enteringIds.length;
3858
- const nodeRatio = ratio(
3859
- diff.enteringIds.length + diff.exitingNodes.length,
3860
- Math.max(prevNodeCount, nextNodeCount)
3861
- );
3862
- const prevEdgeCount = compilation.edges.length - diff.enteringEdgeCount + diff.exitingEdges.length;
3863
- const nextEdgeCount = compilation.edges.length;
3864
- const edgeRatio = ratio(
3865
- diff.enteringEdgeCount + diff.exitingEdges.length,
3866
- Math.max(prevEdgeCount, nextEdgeCount)
3867
- );
3868
- const changeRatio = Math.max(nodeRatio, edgeRatio);
3869
- const initialAlpha = Math.min(1, 0.3 + 0.7 * changeRatio);
3870
- positionedNodes = compilation.nodes.map((node, index) => {
3871
- const pos = positions.get(node.id) ?? { x: 0, y: 0 };
3872
- return { ...node, x: pos.x, y: pos.y, index };
3873
- });
3874
- positionedEdges = compilation.edges.map((edge) => {
3875
- const src = positions.get(edge.source) ?? { x: 0, y: 0 };
3876
- const tgt = positions.get(edge.target) ?? { x: 0, y: 0 };
3877
- return { ...edge, sourceX: src.x, sourceY: src.y, targetX: tgt.x, targetY: tgt.y };
3878
- });
3879
- spatialIndex.rebuild(positionedNodes);
3880
- initSimulation({
3881
- positions,
3882
- suppressCenter: true,
3883
- initialAlpha,
3884
- skipWarmup: true,
3885
- skipEntrance: true
3886
- });
3887
- renderChrome3();
3888
- renderLegend2();
3889
- reconcileStateAfterUpdate();
3890
- startUpdateTransitions(diff);
3891
- needsRender = true;
3892
- scheduleRender();
3893
- }
3894
- function ratio(numerator, denominator) {
3895
- return denominator > 0 ? numerator / denominator : 0;
3896
- }
3897
- function reconcileStateAfterUpdate() {
3898
- const nextIds = new Set(compilation.nodes.map((n) => n.id));
3899
- if (hoveredNodeId && !nextIds.has(hoveredNodeId)) hoveredNodeId = null;
3900
- if (hoveredEdgeId) {
3901
- const [src, tgt] = hoveredEdgeId.split("->");
3902
- if (!nextIds.has(src) || !nextIds.has(tgt)) hoveredEdgeId = null;
3903
- }
3904
- const survivingSelection = [...selectedNodeIds].filter((id) => nextIds.has(id));
3905
- selectedNodeIds = new Set(survivingSelection);
3906
- interactionManager?.setSelection(survivingSelection);
3907
- refreshTransientHighlight();
3908
- recomputeHighlight();
3909
- syncLegendActiveState();
3910
- reRunSearch();
3911
- focusTransition = null;
3912
- armFocus(performance.now());
3913
- }
3914
- function reRunSearch() {
3915
- const q = searchManager.getQuery();
3916
- if (q !== null) searchManager.search(q, positionedNodes);
3917
- }
3918
- function startUpdateTransitions(diff) {
3919
- const updateCfg = compilation.animation?.update ?? null;
3920
- const exitCfg = compilation.animation?.exit ?? null;
3921
- const reduced = prefersReducedMotion();
3922
- if (diff.enteringIds.length > 0 && updateCfg && !reduced) {
3923
- const entering = diff.enteringIds;
3924
- enterAlphaMap = new Map(entering.map((id) => [id, 0]));
3925
- const ease = resolveEase(updateCfg.ease);
3926
- const tween = createTween({
3927
- duration: updateCfg.duration,
3928
- ease,
3929
- apply: (t) => {
3930
- const q = Math.round(t * 8) / 8;
3931
- if (enterAlphaMap) for (const id of entering) enterAlphaMap.set(id, q);
3932
- needsRender = true;
3933
- },
3934
- onDone: () => {
3935
- enterAlphaMap = null;
3936
- needsRender = true;
3937
- }
3938
- });
3939
- scheduler.add(tween);
3940
- }
3941
- if ((diff.exitingNodes.length > 0 || diff.exitingEdges.length > 0) && exitCfg && !reduced) {
3942
- exitingGhosts = { nodes: diff.exitingNodes, edges: diff.exitingEdges, alpha: 1 };
3943
- const ease = resolveEase(exitCfg.ease);
3944
- const tween = createTween({
3945
- duration: exitCfg.duration,
3946
- ease,
3947
- apply: (t) => {
3948
- if (exitingGhosts) exitingGhosts.alpha = 1 - t;
3949
- needsRender = true;
3950
- },
3951
- onDone: () => {
3952
- exitingGhosts = null;
3953
- needsRender = true;
3954
- }
3955
- });
3956
- scheduler.add(tween);
3957
- }
3958
- }
3959
- function updateVisuals(newSpec) {
3960
- update(newSpec);
3961
- }
3962
- function teardownSimOnly() {
3963
- simulation?.destroy();
3964
- simulation = null;
3965
- }
3966
- function teardownSubsystems() {
3967
- scheduler.cancelAll();
3968
- activeFlight = null;
3969
- activeFollow = null;
3970
- entranceReveal = null;
3971
- entranceActive = false;
3972
- entranceProgress = 1;
3973
- entranceFitInFlight = false;
3974
- enterAlphaMap = null;
3975
- exitingGhosts = null;
3976
- if (animFrameId !== null) {
3977
- cancelAnimationFrame(animFrameId);
3978
- animFrameId = null;
3979
- }
3980
- if (cleanupKeyboard) {
3981
- cleanupKeyboard();
3982
- cleanupKeyboard = null;
3983
- }
3984
- interactionManager?.destroy();
3985
- interactionManager = null;
3986
- simulation?.destroy();
3987
- simulation = null;
3988
- tooltipManager?.destroy();
3989
- tooltipManager = null;
3990
- }
3991
- function destroy() {
3992
- if (destroyed) return;
3993
- destroyed = true;
3994
- if (gestureTimeout !== null) {
3995
- clearTimeout(gestureTimeout);
3996
- gestureTimeout = null;
3997
- }
3998
- teardownSubsystems();
3999
- if (disconnectResize) {
4000
- disconnectResize();
4001
- disconnectResize = null;
4002
- }
4003
- legendController?.destroy();
4004
- legendController = null;
4005
- if (wrapper?.parentNode) {
4006
- wrapper.parentNode.removeChild(wrapper);
4007
- }
4008
- wrapper = null;
4009
- canvas = null;
4010
- chromeEl = null;
4011
- legendEl = null;
4012
- renderer = null;
4013
- container.classList.remove("oc-dark");
4014
- }
4015
- try {
4016
- compilation = compile();
4017
- seedIds = new Set(compilation.seedNodeIds);
4018
- adjacencyMap = buildAdjacencyMap(compilation.edges);
4019
- buildDataMaps();
4020
- applyInitialHighlight();
4021
- createDOM();
4022
- initSimulation();
4023
- initInteraction();
4024
- } catch (err) {
4025
- console.error("[viz] Graph mount failed:", err);
4026
- throw err;
4027
- }
4028
- if (options?.responsive !== false) {
4029
- disconnectResize = observeResize(container, () => {
4030
- doResize();
4031
- });
4032
- }
4033
- return {
4034
- update,
4035
- updateVisuals,
4036
- search,
4037
- clearSearch,
4038
- zoomToFit,
4039
- zoomToNode,
4040
- flyTo,
4041
- centerAt,
4042
- getCamera,
4043
- selectNode,
4044
- getSelectedNodes,
4045
- getSearchMatches,
4046
- highlight,
4047
- clearHighlight,
4048
- getHighlight,
4049
- getLegend,
4050
- setActiveCategories,
4051
- getActiveCategories,
4052
- resize: doResize,
4053
- destroy
4054
- };
4055
- }
4056
- function escapeHtml2(str) {
4057
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4058
- }
4059
-
4060
614
  // src/renderers/table-cells.ts
4061
615
  function applyCellStyle(td, cell) {
4062
616
  if (cell.style.backgroundColor) {
@@ -4415,8 +969,8 @@ import { compileSankey } from "@opendata-ai/openchart-engine";
4415
969
 
4416
970
  // src/sankey-renderer.ts
4417
971
  import {
4418
- BRAND_FONT_SIZE as BRAND_FONT_SIZE2,
4419
- BRAND_MIN_WIDTH as BRAND_MIN_WIDTH2,
972
+ BRAND_FONT_SIZE,
973
+ BRAND_MIN_WIDTH,
4420
974
  estimateTextWidth,
4421
975
  textAscent as textAscent2,
4422
976
  wrapText
@@ -4523,7 +1077,7 @@ function renderChrome2(parent, layout) {
4523
1077
  parent.appendChild(g);
4524
1078
  }
4525
1079
  function renderBrand(parent, layout) {
4526
- if (layout.dimensions.width < BRAND_MIN_WIDTH2) return;
1080
+ if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
4527
1081
  const { width } = layout.dimensions;
4528
1082
  const padding = layout.theme.spacing.padding;
4529
1083
  const rightEdge = width - padding;
@@ -4546,7 +1100,7 @@ function renderBrand(parent, layout) {
4546
1100
  "dominant-baseline": "alphabetic",
4547
1101
  "text-anchor": "end",
4548
1102
  "font-family": layout.theme.fonts.family,
4549
- "font-size": BRAND_FONT_SIZE2,
1103
+ "font-size": BRAND_FONT_SIZE,
4550
1104
  "fill-opacity": 0.55
4551
1105
  });
4552
1106
  text.style.setProperty("fill", fill);
@@ -5424,7 +1978,7 @@ import { BREAKPOINT_COMPACT_MAX } from "@opendata-ai/openchart-core";
5424
1978
  import { compileTable } from "@opendata-ai/openchart-engine";
5425
1979
 
5426
1980
  // src/table-renderer.ts
5427
- import { BRAND_FONT_SIZE as BRAND_FONT_SIZE3, cssTokenDefault } from "@opendata-ai/openchart-core";
1981
+ import { BRAND_FONT_SIZE as BRAND_FONT_SIZE2, cssTokenDefault } from "@opendata-ai/openchart-core";
5428
1982
  import { clampStaggerDelay as clampStaggerDelay2 } from "@opendata-ai/openchart-engine";
5429
1983
  var BRAND_URL3 = "https://tryopendata.ai";
5430
1984
  function renderChromeBlock(layout, position) {
@@ -5703,7 +2257,7 @@ function renderTable(layout, container, opts) {
5703
2257
  brandLink.href = BRAND_URL3;
5704
2258
  brandLink.target = "_blank";
5705
2259
  brandLink.rel = "noopener";
5706
- brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE3}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : "sans-serif"};`;
2260
+ brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE2}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : "sans-serif"};`;
5707
2261
  brandLink.textContent = "OpenData";
5708
2262
  brand.appendChild(brandLink);
5709
2263
  footerRow.appendChild(brand);
@@ -6370,6 +2924,7 @@ function createTileMap(container, spec, options) {
6370
2924
  export {
6371
2925
  CANVAS_DEFAULT_UPDATE_MAX_MARKS,
6372
2926
  DEFAULT_UPDATE_MAX_MARKS,
2927
+ GRAPH_3D_NOT_REGISTERED_ERROR,
6373
2928
  VECTOR_EXPORT_MAX_POINTS,
6374
2929
  attachKeyboardNav,
6375
2930
  createBarList,
@@ -6392,6 +2947,7 @@ export {
6392
2947
  materializeCanvasModeSVG,
6393
2948
  observeResize,
6394
2949
  rectPathWithCorners,
2950
+ registerGraphRenderer,
6395
2951
  registerMarkRenderer,
6396
2952
  renderBarCell,
6397
2953
  renderCategoryCell,