@sentropic/design-system-react 0.10.0 → 0.12.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/ComboChart.d.ts +27 -0
- package/dist/ComboChart.d.ts.map +1 -0
- package/dist/ComboChart.js +189 -0
- package/dist/ComboChart.js.map +1 -0
- package/dist/FunnelChart.d.ts +20 -0
- package/dist/FunnelChart.d.ts.map +1 -0
- package/dist/FunnelChart.js +117 -0
- package/dist/FunnelChart.js.map +1 -0
- package/dist/GaugeChart.d.ts +35 -0
- package/dist/GaugeChart.d.ts.map +1 -0
- package/dist/GaugeChart.js +115 -0
- package/dist/GaugeChart.js.map +1 -0
- package/dist/KpiCard.d.ts +45 -0
- package/dist/KpiCard.d.ts.map +1 -0
- package/dist/KpiCard.js +67 -0
- package/dist/KpiCard.js.map +1 -0
- package/dist/SelectableList.d.ts +41 -0
- package/dist/SelectableList.d.ts.map +1 -0
- package/dist/SelectableList.js +156 -0
- package/dist/SelectableList.js.map +1 -0
- package/dist/SelectableRow.d.ts +107 -0
- package/dist/SelectableRow.d.ts.map +1 -0
- package/dist/SelectableRow.js +93 -0
- package/dist/SelectableRow.js.map +1 -0
- package/dist/TreemapChart.d.ts +25 -0
- package/dist/TreemapChart.d.ts.map +1 -0
- package/dist/TreemapChart.js +179 -0
- package/dist/TreemapChart.js.map +1 -0
- package/dist/WaterfallChart.d.ts +18 -0
- package/dist/WaterfallChart.d.ts.map +1 -0
- package/dist/WaterfallChart.js +133 -0
- package/dist/WaterfallChart.js.map +1 -0
- package/dist/catalog.d.ts +33 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +176 -5
- package/dist/catalog.js.map +1 -1
- package/dist/chartContrast.d.ts +6 -0
- package/dist/chartContrast.d.ts.map +1 -0
- package/dist/chartContrast.js +59 -0
- package/dist/chartContrast.js.map +1 -0
- package/dist/chartScale.d.ts +6 -0
- package/dist/chartScale.d.ts.map +1 -1
- package/dist/chartScale.js +41 -0
- package/dist/chartScale.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +869 -2
- package/package.json +1 -1
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
|
-
|
|
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"
|
|
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}%`,
|