@sentropic/design-system-react 0.10.0 → 0.11.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.
package/dist/catalog.js CHANGED
@@ -518,10 +518,32 @@ function forceGraphMulberry32(seed) {
518
518
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
519
519
  };
520
520
  }
521
+ // Stable seed from the SET of node ids (sorted), not from ns.length/es.length.
522
+ // A length-based seed reshuffled the whole layout whenever a node was added or
523
+ // removed (notably after a reconciliation merge), making the graph "jump". A
524
+ // hash over the sorted ids keeps the same topology → same layout, so removing
525
+ // one node leaves the rest essentially in place. (FNV-1a 32-bit over the joined
526
+ // sorted ids; deterministic and order-independent.) PORTED VERBATIM from the
527
+ // Svelte `stableSeed` so the three frameworks produce the SAME layout.
528
+ function forceGraphStableSeed(ns) {
529
+ const ids = ns.map((n) => n.id).sort();
530
+ let h = 0x811c9dc5; // FNV offset basis
531
+ const joined = ids.join("|");
532
+ for (let i = 0; i < joined.length; i++) {
533
+ h ^= joined.charCodeAt(i);
534
+ h = Math.imul(h, 0x01000193); // FNV prime
535
+ }
536
+ // Fold in the count too so wholly different graphs of equal id-hash still
537
+ // differ, but the dominant term is the (order-independent) id hash.
538
+ h ^= ns.length;
539
+ return h >>> 0;
540
+ }
521
541
  function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius, repulsionFactor) {
522
542
  const cx = w / 2;
523
543
  const cy = h / 2;
524
- const rand = forceGraphMulberry32(ns.length * 2654435761 + es.length);
544
+ // Seed from the stable id-set hash so adding/removing a node does not
545
+ // reshuffle the whole layout (same topology → same layout).
546
+ const rand = forceGraphMulberry32(forceGraphStableSeed(ns));
525
547
  const idIndex = new Map();
526
548
  const sim = ns.map((n, i) => {
527
549
  idIndex.set(n.id, i);
@@ -628,9 +650,15 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius, repulsionFacto
628
650
  // Curvature offset factor: how far (relative to chord length) the control
629
651
  // point bows out at edgeCurve=1. Kept modest so edgeCurve≈0.15 reads "light".
630
652
  const FORCE_GRAPH_CURVE_FACTOR = 0.5;
653
+ // Merge (reconciliation) glide duration.
654
+ const FORCE_GRAPH_MERGE_DURATION_MS = 450;
655
+ // ease-in-out (cubic) for a smooth glide.
656
+ function forceGraphEaseInOut(t) {
657
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
658
+ }
631
659
  // Fit-to-content margin (per side, fraction of the content box).
632
660
  const FORCE_GRAPH_CONTENT_MARGIN = 0.08;
633
- export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, edgeCurve = 0.15, repulsion = 1, onNodeHover, className, ...rest }) {
661
+ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, edgeCurve = 0.15, repulsion = 1, onNodeHover, mergePair = null, onMergeComplete, className, ...rest }) {
634
662
  // SSR-safe reduced-motion check (window may be undefined during SSR/tests).
635
663
  const prefersReducedMotion = typeof window !== "undefined" &&
636
664
  typeof window.matchMedia === "function" &&
@@ -746,6 +774,144 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
746
774
  }, [positionedNodes, width, height]);
747
775
  const [hoveredNodeIndex, setHoveredNodeIndex] = React.useState(null);
748
776
  const [hoveredEdgeIndex, setHoveredEdgeIndex] = React.useState(null);
777
+ const [mergeState, setMergeState] = React.useState(null);
778
+ // The id of the `from` node to keep MASKED after a completed merge, until the
779
+ // consumer drops it from `nodes` (or a new pair arrives). Decouples the mask
780
+ // from `mergePair` returning to null (otherwise the node would flash back).
781
+ const [maskedFromId, setMaskedFromId] = React.useState(null);
782
+ // The id currently being (or already) handled, so the effect only reacts to a
783
+ // genuinely NEW id. Re-passing the same id (even a fresh object) is a no-op.
784
+ const handledMergeIdRef = React.useRef(null);
785
+ // Set true on unmount so no queued microtask/frame fires a callback or touches
786
+ // state after teardown.
787
+ const disposedRef = React.useRef(false);
788
+ // Latest layout / callback, read inside the effect without retriggering it.
789
+ const layoutRef = React.useRef(layout);
790
+ layoutRef.current = layout;
791
+ const onMergeCompleteRef = React.useRef(onMergeComplete);
792
+ onMergeCompleteRef.current = onMergeComplete;
793
+ const mergeId = mergePair ? mergePair.id : null;
794
+ // Component-lifetime teardown guard: mark disposed on unmount.
795
+ React.useEffect(() => {
796
+ return () => {
797
+ disposedRef.current = true;
798
+ };
799
+ }, []);
800
+ React.useEffect(() => {
801
+ const pair = mergePair;
802
+ const id = pair ? pair.id : null;
803
+ // Idempotent on id: same id (or still null) means nothing to (re)start. A
804
+ // new id always (re)plays, even for the same from/into pair.
805
+ if (id === handledMergeIdRef.current)
806
+ return;
807
+ handledMergeIdRef.current = id;
808
+ // Tear down any in-flight animation for a previous pair.
809
+ setMergeState(null);
810
+ if (!pair)
811
+ return;
812
+ // A genuinely new pair supersedes any lingering mask from a prior merge.
813
+ setMaskedFromId(null);
814
+ let raf = null;
815
+ let cancelled = false;
816
+ // Validate: both endpoints must currently exist.
817
+ const lay = layoutRef.current;
818
+ const fromPos = lay.get(pair.from);
819
+ const intoPos = lay.get(pair.into);
820
+ if (!fromPos || !intoPos)
821
+ return; // invalid pair → no-op, no callback
822
+ const captured = { id: pair.id, from: pair.from, into: pair.into };
823
+ const complete = () => {
824
+ // Keep `from` hidden until the consumer removes it (or a new pair arrives).
825
+ setMaskedFromId(captured.from);
826
+ onMergeCompleteRef.current?.(captured);
827
+ };
828
+ // Reduced motion: no animation, resolve on a microtask. Guarded so a late
829
+ // microtask after unmount or after a newer id took over is a no-op.
830
+ if (prefersReducedMotion || typeof requestAnimationFrame !== "function") {
831
+ queueMicrotask(() => {
832
+ if (disposedRef.current || cancelled || handledMergeIdRef.current !== id)
833
+ return;
834
+ complete();
835
+ });
836
+ return () => {
837
+ cancelled = true;
838
+ };
839
+ }
840
+ const dx = intoPos.x - fromPos.x;
841
+ const dy = intoPos.y - fromPos.y;
842
+ setMergeState({ id: captured.id, from: captured.from, into: captured.into, progress: 0, dx, dy });
843
+ let start = null;
844
+ const tick = (now) => {
845
+ // Bail cleanly if the instance went away or a newer id superseded us.
846
+ if (disposedRef.current || cancelled || handledMergeIdRef.current !== id) {
847
+ raf = null;
848
+ return;
849
+ }
850
+ // Re-validate both endpoints every frame: if either disappears mid-flight
851
+ // (e.g. the consumer removed a node), cancel the glide WITHOUT firing
852
+ // onMergeComplete (no double-tir) and without a dangling frame.
853
+ const live = layoutRef.current;
854
+ if (!live.has(captured.from) || !live.has(captured.into)) {
855
+ raf = null;
856
+ setMergeState(null);
857
+ return;
858
+ }
859
+ if (start === null)
860
+ start = now;
861
+ const t = Math.min(1, Math.max(0, (now - start) / FORCE_GRAPH_MERGE_DURATION_MS));
862
+ setMergeState((prev) => (prev ? { ...prev, progress: t } : prev));
863
+ if (t < 1) {
864
+ raf = requestAnimationFrame(tick);
865
+ }
866
+ else {
867
+ raf = null;
868
+ setMergeState(null);
869
+ complete();
870
+ }
871
+ };
872
+ raf = requestAnimationFrame(tick);
873
+ return () => {
874
+ cancelled = true;
875
+ if (raf != null && typeof cancelAnimationFrame === "function") {
876
+ cancelAnimationFrame(raf);
877
+ }
878
+ };
879
+ // Re-run only when the pair's id changes (not on unrelated re-renders).
880
+ // eslint-disable-next-line react-hooks/exhaustive-deps
881
+ }, [mergeId, prefersReducedMotion]);
882
+ // Eased progress of the `from` node toward `into`. 0 when no merge running.
883
+ const mergeFromId = mergeState?.from ?? null;
884
+ const mergeEased = mergeState ? forceGraphEaseInOut(mergeState.progress) : 0;
885
+ /** True when this id is the (post-merge) masked node — render it fully hidden. */
886
+ function isMasked(id) {
887
+ return maskedFromId === id;
888
+ }
889
+ /** Extra translation applied to the merging node so it glides toward `into`. */
890
+ function mergeOffset(id) {
891
+ if (!mergeState || mergeState.from !== id)
892
+ return { x: 0, y: 0 };
893
+ return { x: mergeState.dx * mergeEased, y: mergeState.dy * mergeEased };
894
+ }
895
+ /**
896
+ * Opacity for a node during/after a merge. The animating `from` fades 1->0; a
897
+ * masked `from` (merge done, awaiting removal) stays at 0. Others unaffected.
898
+ */
899
+ function mergeNodeOpacity(id) {
900
+ if (isMasked(id))
901
+ return 0;
902
+ if (mergeFromId !== id)
903
+ return undefined;
904
+ return 1 - mergeEased;
905
+ }
906
+ /** Opacity for an edge incident to the merging/masked `from` node (fades too). */
907
+ function mergeEdgeOpacity(e) {
908
+ const fromId = mergeFromId ?? maskedFromId;
909
+ if (fromId == null)
910
+ return undefined;
911
+ if (e.source !== fromId && e.target !== fromId)
912
+ return undefined;
913
+ return isMasked(fromId) ? 0 : 1 - mergeEased;
914
+ }
749
915
  const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
750
916
  // Adjacency: id -> set of directly connected node ids. Used to keep the
751
917
  // direct neighbours of selected/focused nodes fully visible (demand 6).
@@ -929,9 +1095,10 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
929
1095
  onEdgeHover?.(e.edge);
930
1096
  };
931
1097
  const onHitLeave = () => setHoveredEdgeIndex(null);
1098
+ const mEdgeOpacity = mergeEdgeOpacity(e.edge);
932
1099
  const edgeClass = classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", e.edge.emphasis && "st-forceGraph__edge--emphasis", hoveredEdgeIndex === e.i && "st-forceGraph__edge--hovered", (isEdgeSelectionDimmed(e.edge) || isHoverDimmedEdge(e.edge)) &&
933
- "st-forceGraph__edge--dim");
934
- return (_jsxs(React.Fragment, { children: [e.path ? (_jsx("path", { className: "st-forceGraph__edgeHit", role: "presentation", d: e.path, fill: "none", onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })) : (_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })), e.path ? (_jsx("path", { className: edgeClass, d: e.path, fill: "none", strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, pointerEvents: "none" })) : (_jsx("line", { className: edgeClass, x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, pointerEvents: "none" }))] }, e.i));
1100
+ "st-forceGraph__edge--dim", mEdgeOpacity !== undefined && "st-forceGraph__edge--merging");
1101
+ return (_jsxs(React.Fragment, { children: [e.path ? (_jsx("path", { className: "st-forceGraph__edgeHit", role: "presentation", d: e.path, fill: "none", onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })) : (_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })), e.path ? (_jsx("path", { className: edgeClass, d: e.path, fill: "none", strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, opacity: mEdgeOpacity, pointerEvents: "none" })) : (_jsx("line", { className: edgeClass, x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, opacity: mEdgeOpacity, pointerEvents: "none" }))] }, e.i));
935
1102
  }) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
936
1103
  const ariaLabel = `${p.title}${p.node.group !== undefined ? `: ${p.node.group}` : ""}`;
937
1104
  const pressed = selectedSet.has(p.node.id);
@@ -961,8 +1128,12 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
961
1128
  onDoubleClick: () => onOpenEntity?.(p.node.id),
962
1129
  onKeyDown: (e) => handleNodeKeydown(p.node.id, e),
963
1130
  };
1131
+ const mOff = mergeOffset(p.node.id);
1132
+ const mOpacity = mergeNodeOpacity(p.node.id);
1133
+ const mMasked = isMasked(p.node.id);
964
1134
  return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, (isHoverDimmedNode(p.node.id) || isSelectionDimmed(p.node.id)) &&
965
- "st-forceGraph__node--dim", pressed && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus"), transform: `translate(${p.x} ${p.y})`, children: [p.shapePath ? (_jsx("path", { ...shapeProps, d: p.shapePath })) : (_jsx("circle", { ...shapeProps, r: p.r })), showLabels ? (_jsx("text", { className: "st-forceGraph__label", x: p.r + 3, y: 0, dominantBaseline: "middle", children: p.title })) : null] }, p.node.id));
1135
+ "st-forceGraph__node--dim", pressed && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus", (mergeFromId === p.node.id || mMasked) &&
1136
+ "st-forceGraph__node--merging"), "aria-hidden": mMasked ? "true" : undefined, opacity: mOpacity, transform: `translate(${p.x + mOff.x} ${p.y + mOff.y})`, children: [p.shapePath ? (_jsx("path", { ...shapeProps, d: p.shapePath })) : (_jsx("circle", { ...shapeProps, r: p.r })), showLabels ? (_jsx("text", { className: "st-forceGraph__label", x: p.r + 3, y: 0, dominantBaseline: "middle", children: p.title })) : null] }, p.node.id));
966
1137
  }) })] }), hoveredNode ? (_jsxs("div", { className: "st-forceGraph__tooltip", role: "presentation", style: {
967
1138
  left: `${((hoveredNode.x - vbX) / vbW) * 100}%`,
968
1139
  top: `${((hoveredNode.y - vbY) / vbH) * 100}%`,