@sentropic/design-system-react 0.4.0 → 0.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.
- package/dist/ForceGraph.d.ts +2 -2
- package/dist/ForceGraph.d.ts.map +1 -1
- package/dist/ForceGraph.js +1 -1
- package/dist/ForceGraph.js.map +1 -1
- package/dist/catalog.d.ts +34 -2
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +241 -46
- package/dist/catalog.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +14 -2
- package/package.json +1 -1
package/dist/catalog.js
CHANGED
|
@@ -377,43 +377,97 @@ export function Footer({ brand, columns, links, copyright, className, ...rest })
|
|
|
377
377
|
const groups = columns ?? (links ? [{ links }] : []);
|
|
378
378
|
return (_jsxs("footer", { ...rest, className: classNames("st-footer", className), children: [_jsxs("div", { className: "st-footer__top", children: [brand ? _jsx("div", { className: "st-footer__brand", children: brand }) : null, _jsx("div", { className: "st-footer__columns", children: groups.map((group, index) => (_jsxs("nav", { children: [group.title ? _jsx("h2", { children: group.title }) : null, group.links.map((link) => (_jsx("a", { href: link.href, children: link.label }, link.href)))] }, index))) })] }), copyright ? _jsx("div", { className: "st-footer__copyright", children: copyright }) : null] }));
|
|
379
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Maps a dash style (or the legacy `weak` flag) to an SVG stroke-dasharray.
|
|
382
|
+
* Returns null for a solid stroke.
|
|
383
|
+
*/
|
|
384
|
+
export function edgeDashArray(dash, weak) {
|
|
385
|
+
const effective = dash ?? (weak ? "dashed" : undefined);
|
|
386
|
+
switch (effective) {
|
|
387
|
+
case "dashed":
|
|
388
|
+
return "6 4";
|
|
389
|
+
case "dotted":
|
|
390
|
+
return "1 4";
|
|
391
|
+
case "long-dash":
|
|
392
|
+
return "12 6";
|
|
393
|
+
case "solid":
|
|
394
|
+
default:
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
380
398
|
// ---------------------------------------------------------------------------
|
|
381
399
|
// SVG path helpers for the various node shapes.
|
|
382
|
-
// All shapes are centered at (0,0)
|
|
400
|
+
// All shapes are centered at (0,0). Each shape is scaled so its filled area
|
|
401
|
+
// matches that of the reference circle (π·r²) — this keeps equal-weight nodes
|
|
402
|
+
// visually balanced rather than letting squares/diamonds read as "bigger".
|
|
403
|
+
//
|
|
404
|
+
// Per-shape scale factors (closed-form, area = π·r²):
|
|
405
|
+
// square / roundedbox : half-side = (√π)/2 · r ≈ 0.8862·r
|
|
406
|
+
// diamond : half-diag = √(π/2) · r ≈ 1.2533·r
|
|
407
|
+
// triangle (equilat.) : circumradius= √(π/(3√3/4)) · r ≈ 1.5551·r
|
|
408
|
+
// hexagon (regular) : circumradius= √(π/(3√3/2)) · r ≈ 1.0996·r
|
|
409
|
+
// star (5-pt, k=0.42) : outer radius= √(π/A₁) · r ≈ 1.5953·r
|
|
410
|
+
// where A₁ is the unit-star area (≈1.2343).
|
|
383
411
|
// ---------------------------------------------------------------------------
|
|
412
|
+
const FORCE_GRAPH_STAR_INNER_RATIO = 0.42;
|
|
413
|
+
const FORCE_GRAPH_STAR_AREA_FACTOR = 1.5953498885642274; // √(π / unit-star-area)
|
|
414
|
+
// Format a coordinate: 4 dp, snapping floating-point near-zero (e.g. 9e-16)
|
|
415
|
+
// to a clean 0 so paths never contain scientific notation.
|
|
416
|
+
function forceGraphFmt(n) {
|
|
417
|
+
const v = Math.abs(n) < 1e-9 ? 0 : n;
|
|
418
|
+
return Number(v.toFixed(4)).toString();
|
|
419
|
+
}
|
|
384
420
|
export function nodeShapePath(shape, r) {
|
|
385
421
|
const s = shape ?? "dot";
|
|
386
422
|
if (s === "dot" || s === "circle")
|
|
387
423
|
return null; // use <circle>
|
|
388
424
|
if (s === "diamond") {
|
|
389
|
-
|
|
425
|
+
const d = Math.sqrt(Math.PI / 2) * r; // half-diagonal
|
|
426
|
+
return `M 0 ${forceGraphFmt(-d)} L ${forceGraphFmt(d)} 0 L 0 ${forceGraphFmt(d)} L ${forceGraphFmt(-d)} 0 Z`;
|
|
390
427
|
}
|
|
391
428
|
if (s === "star") {
|
|
392
|
-
const outer = r;
|
|
393
|
-
const inner =
|
|
429
|
+
const outer = FORCE_GRAPH_STAR_AREA_FACTOR * r;
|
|
430
|
+
const inner = outer * FORCE_GRAPH_STAR_INNER_RATIO;
|
|
394
431
|
const pts = [];
|
|
395
432
|
for (let i = 0; i < 10; i++) {
|
|
396
433
|
const angle = (i * Math.PI) / 5 - Math.PI / 2;
|
|
397
434
|
const rad = i % 2 === 0 ? outer : inner;
|
|
398
|
-
pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
|
|
435
|
+
pts.push(`${forceGraphFmt(rad * Math.cos(angle))},${forceGraphFmt(rad * Math.sin(angle))}`);
|
|
399
436
|
}
|
|
400
437
|
return `M ${pts.join(" L ")} Z`;
|
|
401
438
|
}
|
|
402
439
|
if (s === "hexagon") {
|
|
440
|
+
const R = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 2)) * r; // circumradius
|
|
403
441
|
const pts = [];
|
|
404
442
|
for (let i = 0; i < 6; i++) {
|
|
405
443
|
const angle = (i * Math.PI) / 3 - Math.PI / 6;
|
|
406
|
-
pts.push(`${
|
|
444
|
+
pts.push(`${forceGraphFmt(R * Math.cos(angle))},${forceGraphFmt(R * Math.sin(angle))}`);
|
|
407
445
|
}
|
|
408
446
|
return `M ${pts.join(" L ")} Z`;
|
|
409
447
|
}
|
|
410
448
|
if (s === "box" || s === "square") {
|
|
411
|
-
const h =
|
|
412
|
-
return `M ${-h} ${-h} L ${h} ${-h} L ${h} ${h} L ${-h} ${h} Z`;
|
|
449
|
+
const h = (Math.sqrt(Math.PI) / 2) * r; // half-side, area = (2h)² = π·r²
|
|
450
|
+
return `M ${forceGraphFmt(-h)} ${forceGraphFmt(-h)} L ${forceGraphFmt(h)} ${forceGraphFmt(-h)} L ${forceGraphFmt(h)} ${forceGraphFmt(h)} L ${forceGraphFmt(-h)} ${forceGraphFmt(h)} Z`;
|
|
451
|
+
}
|
|
452
|
+
if (s === "roundedbox") {
|
|
453
|
+
const h = (Math.sqrt(Math.PI) / 2) * r; // same footprint as square
|
|
454
|
+
const rx = h * 0.6; // ≈ r·0.3 rounding radius (h ≈ 0.886·r)
|
|
455
|
+
// Rounded rectangle via arcs, clockwise from top edge.
|
|
456
|
+
return (`M ${forceGraphFmt(-h + rx)} ${forceGraphFmt(-h)} ` +
|
|
457
|
+
`L ${forceGraphFmt(h - rx)} ${forceGraphFmt(-h)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(h)} ${forceGraphFmt(-h + rx)} ` +
|
|
458
|
+
`L ${forceGraphFmt(h)} ${forceGraphFmt(h - rx)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(h - rx)} ${forceGraphFmt(h)} ` +
|
|
459
|
+
`L ${forceGraphFmt(-h + rx)} ${forceGraphFmt(h)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(-h)} ${forceGraphFmt(h - rx)} ` +
|
|
460
|
+
`L ${forceGraphFmt(-h)} ${forceGraphFmt(-h + rx)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(-h + rx)} ${forceGraphFmt(-h)} Z`);
|
|
413
461
|
}
|
|
414
462
|
if (s === "triangle") {
|
|
415
|
-
|
|
416
|
-
|
|
463
|
+
// Equilateral, centred at centroid; circumradius h so apex is up.
|
|
464
|
+
const h = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 4)) * r;
|
|
465
|
+
const pts = [];
|
|
466
|
+
for (let i = 0; i < 3; i++) {
|
|
467
|
+
const angle = (i * 2 * Math.PI) / 3 - Math.PI / 2;
|
|
468
|
+
pts.push(`${forceGraphFmt(h * Math.cos(angle))},${forceGraphFmt(h * Math.sin(angle))}`);
|
|
469
|
+
}
|
|
470
|
+
return `M ${pts.join(" L ")} Z`;
|
|
417
471
|
}
|
|
418
472
|
return null;
|
|
419
473
|
}
|
|
@@ -551,9 +605,14 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
|
|
|
551
605
|
}
|
|
552
606
|
sn.x += sn.vx;
|
|
553
607
|
sn.y += sn.vy;
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
608
|
+
// Soft clamp: allow the layout to overflow the canvas so it keeps a
|
|
609
|
+
// natural shape (fit-to-content reframes it afterwards). The wide bound
|
|
610
|
+
// only guards against runaway coordinates, it no longer glues nodes to
|
|
611
|
+
// the four edges.
|
|
612
|
+
const padX = w * 0.5 + nodeRadius * 2;
|
|
613
|
+
const padY = h * 0.5 + nodeRadius * 2;
|
|
614
|
+
sn.x = Math.max(-padX, Math.min(w + padX, sn.x));
|
|
615
|
+
sn.y = Math.max(-padY, Math.min(h + padY, sn.y));
|
|
557
616
|
}
|
|
558
617
|
temperature *= cooling;
|
|
559
618
|
}
|
|
@@ -562,7 +621,12 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
|
|
|
562
621
|
out.set(sn.id, { x: sn.x, y: sn.y });
|
|
563
622
|
return out;
|
|
564
623
|
}
|
|
565
|
-
|
|
624
|
+
// Curvature offset factor: how far (relative to chord length) the control
|
|
625
|
+
// point bows out at edgeCurve=1. Kept modest so edgeCurve≈0.15 reads "light".
|
|
626
|
+
const FORCE_GRAPH_CURVE_FACTOR = 0.5;
|
|
627
|
+
// Fit-to-content margin (per side, fraction of the content box).
|
|
628
|
+
const FORCE_GRAPH_CONTENT_MARGIN = 0.08;
|
|
629
|
+
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, className, ...rest }) {
|
|
566
630
|
// SSR-safe reduced-motion check (window may be undefined during SSR/tests).
|
|
567
631
|
const prefersReducedMotion = typeof window !== "undefined" &&
|
|
568
632
|
typeof window.matchMedia === "function" &&
|
|
@@ -593,6 +657,7 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
593
657
|
}), [nodes, layout, width, height, nodeRadius, toneMap]);
|
|
594
658
|
const positionedEdges = React.useMemo(() => {
|
|
595
659
|
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
660
|
+
const curve = Math.max(0, edgeCurve ?? 0);
|
|
596
661
|
return edges
|
|
597
662
|
.map((e, i) => {
|
|
598
663
|
const a = layout.get(e.source);
|
|
@@ -601,22 +666,134 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
601
666
|
return null;
|
|
602
667
|
const srcNode = nodeById.get(e.source);
|
|
603
668
|
const tgtNode = nodeById.get(e.target);
|
|
669
|
+
const x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
|
|
670
|
+
// Quadratic control point: midpoint pushed perpendicular to the chord.
|
|
671
|
+
let path = null;
|
|
672
|
+
let cx = (x1 + x2) / 2;
|
|
673
|
+
let cy = (y1 + y2) / 2;
|
|
674
|
+
if (curve > 0) {
|
|
675
|
+
const dx = x2 - x1;
|
|
676
|
+
const dy = y2 - y1;
|
|
677
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
678
|
+
const off = curve * dist * FORCE_GRAPH_CURVE_FACTOR;
|
|
679
|
+
// Unit perpendicular to the chord.
|
|
680
|
+
const px = -dy / dist;
|
|
681
|
+
const py = dx / dist;
|
|
682
|
+
cx = (x1 + x2) / 2 + px * off;
|
|
683
|
+
cy = (y1 + y2) / 2 + py * off;
|
|
684
|
+
path = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`;
|
|
685
|
+
}
|
|
686
|
+
const dashArray = edgeDashArray(e.dash, e.weak);
|
|
687
|
+
const strokeWidth = typeof e.width === "number" ? e.width : e.emphasis ? 2.5 : null;
|
|
604
688
|
return {
|
|
605
689
|
edge: e,
|
|
606
690
|
i,
|
|
607
|
-
x1
|
|
608
|
-
y1
|
|
609
|
-
x2
|
|
610
|
-
y2
|
|
691
|
+
x1,
|
|
692
|
+
y1,
|
|
693
|
+
x2,
|
|
694
|
+
y2,
|
|
695
|
+
// Tooltip / label anchor follows the curve apex when curved.
|
|
696
|
+
midX: cx,
|
|
697
|
+
midY: cy,
|
|
698
|
+
path,
|
|
699
|
+
dashArray,
|
|
700
|
+
strokeWidth,
|
|
611
701
|
srcLabel: srcNode?.label ?? e.source,
|
|
612
702
|
tgtLabel: tgtNode?.label ?? e.target,
|
|
613
703
|
};
|
|
614
704
|
})
|
|
615
705
|
.filter((e) => e !== null);
|
|
616
|
-
}, [nodes, edges, layout]);
|
|
706
|
+
}, [nodes, edges, layout, edgeCurve]);
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// Fit-to-content (Feature 5): after warmup the layout may extend beyond the
|
|
709
|
+
// nominal width/height. Compute the real content bounding-box (node centres
|
|
710
|
+
// ± radius) and frame it with an 8% margin on each side. The base viewBox is
|
|
711
|
+
// this frame (not the fixed 0,0,w,h), so the graph is centred and never
|
|
712
|
+
// clipped, whatever the aspect ratio. Zoom/pan stay relative to this frame.
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
const contentBox = React.useMemo(() => {
|
|
715
|
+
if (positionedNodes.length === 0) {
|
|
716
|
+
return { x: 0, y: 0, w: width, h: height };
|
|
717
|
+
}
|
|
718
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
719
|
+
for (const p of positionedNodes) {
|
|
720
|
+
// Use the worst-case extent for non-circular shapes (area-scaled) so the
|
|
721
|
+
// glyph (and a little label room) is never clipped.
|
|
722
|
+
const ext = p.r * 1.7;
|
|
723
|
+
minX = Math.min(minX, p.x - ext);
|
|
724
|
+
minY = Math.min(minY, p.y - ext);
|
|
725
|
+
maxX = Math.max(maxX, p.x + ext);
|
|
726
|
+
maxY = Math.max(maxY, p.y + ext);
|
|
727
|
+
}
|
|
728
|
+
let w = maxX - minX;
|
|
729
|
+
let h = maxY - minY;
|
|
730
|
+
// Guard against a degenerate (single node / collinear) box.
|
|
731
|
+
if (!(w > 0)) {
|
|
732
|
+
w = width;
|
|
733
|
+
minX = maxX - w / 2;
|
|
734
|
+
}
|
|
735
|
+
if (!(h > 0)) {
|
|
736
|
+
h = height;
|
|
737
|
+
minY = maxY - h / 2;
|
|
738
|
+
}
|
|
739
|
+
const mx = w * FORCE_GRAPH_CONTENT_MARGIN;
|
|
740
|
+
const my = h * FORCE_GRAPH_CONTENT_MARGIN;
|
|
741
|
+
return { x: minX - mx, y: minY - my, w: w + 2 * mx, h: h + 2 * my };
|
|
742
|
+
}, [positionedNodes, width, height]);
|
|
617
743
|
const [hoveredNodeIndex, setHoveredNodeIndex] = React.useState(null);
|
|
618
744
|
const [hoveredEdgeIndex, setHoveredEdgeIndex] = React.useState(null);
|
|
619
745
|
const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
746
|
+
// Adjacency: id -> set of directly connected node ids. Used to keep the
|
|
747
|
+
// direct neighbours of selected/focused nodes fully visible (demand 6).
|
|
748
|
+
const adjacency = React.useMemo(() => {
|
|
749
|
+
const adj = new Map();
|
|
750
|
+
const add = (a, b) => {
|
|
751
|
+
let set = adj.get(a);
|
|
752
|
+
if (!set) {
|
|
753
|
+
set = new Set();
|
|
754
|
+
adj.set(a, set);
|
|
755
|
+
}
|
|
756
|
+
set.add(b);
|
|
757
|
+
};
|
|
758
|
+
for (const e of edges) {
|
|
759
|
+
add(e.source, e.target);
|
|
760
|
+
add(e.target, e.source);
|
|
761
|
+
}
|
|
762
|
+
return adj;
|
|
763
|
+
}, [edges]);
|
|
764
|
+
// True when a selection/focus is active — only then do we dim non-related
|
|
765
|
+
// nodes. The set of "active" ids = selected ∪ focus ∪ all their neighbours.
|
|
766
|
+
const hasActiveSelection = selectedSet.size > 0 || focusId != null;
|
|
767
|
+
const activeAndNeighbours = React.useMemo(() => {
|
|
768
|
+
const active = new Set(selectedSet);
|
|
769
|
+
if (focusId != null)
|
|
770
|
+
active.add(focusId);
|
|
771
|
+
// Expand to direct neighbours so they stay fully visible.
|
|
772
|
+
const withNeighbours = new Set(active);
|
|
773
|
+
for (const id of active) {
|
|
774
|
+
const nb = adjacency.get(id);
|
|
775
|
+
if (nb)
|
|
776
|
+
for (const n of nb)
|
|
777
|
+
withNeighbours.add(n);
|
|
778
|
+
}
|
|
779
|
+
return withNeighbours;
|
|
780
|
+
}, [selectedSet, focusId, adjacency]);
|
|
781
|
+
// A node is dimmed by selection when there IS an active selection and the
|
|
782
|
+
// node is neither selected/focused nor a direct neighbour of one.
|
|
783
|
+
function isSelectionDimmed(id) {
|
|
784
|
+
if (!hasActiveSelection)
|
|
785
|
+
return false;
|
|
786
|
+
return !activeAndNeighbours.has(id);
|
|
787
|
+
}
|
|
788
|
+
// An edge stays fully visible when at least one endpoint is in the
|
|
789
|
+
// selected/focused set (it is a connection of the selection).
|
|
790
|
+
function isEdgeSelectionDimmed(e) {
|
|
791
|
+
if (!hasActiveSelection)
|
|
792
|
+
return false;
|
|
793
|
+
const srcActive = selectedSet.has(e.source) || focusId === e.source;
|
|
794
|
+
const tgtActive = selectedSet.has(e.target) || focusId === e.target;
|
|
795
|
+
return !(srcActive || tgtActive);
|
|
796
|
+
}
|
|
620
797
|
// Keyboard handler for a node element: Space/Enter → onSelect, Enter → onOpenEntity.
|
|
621
798
|
function handleNodeKeydown(id, e) {
|
|
622
799
|
if (e.key === "Enter" || e.key === " ") {
|
|
@@ -628,10 +805,11 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
628
805
|
}
|
|
629
806
|
}
|
|
630
807
|
// ---------------------------------------------------------------------------
|
|
631
|
-
// Zoom + pan state
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
808
|
+
// Zoom + pan state (framed by the fit-to-content box, Feature 5). The base
|
|
809
|
+
// frame is `contentBox` (not 0,0,w,h). Zoom is a scale multiplier and pan is
|
|
810
|
+
// an offset in SVG coords, both relative to that base frame:
|
|
811
|
+
// vbW = baseW / zoomScale, vbH = baseH / zoomScale
|
|
812
|
+
// vbX = baseX + panX, vbY = baseY + panY
|
|
635
813
|
// ---------------------------------------------------------------------------
|
|
636
814
|
const [zoomScale, setZoomScale] = React.useState(1);
|
|
637
815
|
const [panX, setPanX] = React.useState(0);
|
|
@@ -640,10 +818,15 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
640
818
|
const panStartRef = React.useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
|
641
819
|
const svgRef = React.useRef(null);
|
|
642
820
|
const [isPanning, setIsPanning] = React.useState(false);
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
const
|
|
821
|
+
// Base frame dimensions = fit-to-content box.
|
|
822
|
+
const baseW = contentBox.w;
|
|
823
|
+
const baseH = contentBox.h;
|
|
824
|
+
const baseX = contentBox.x;
|
|
825
|
+
const baseY = contentBox.y;
|
|
826
|
+
const vbW = baseW / zoomScale;
|
|
827
|
+
const vbH = baseH / zoomScale;
|
|
828
|
+
const vbX = baseX + panX;
|
|
829
|
+
const vbY = baseY + panY;
|
|
647
830
|
function resetView() {
|
|
648
831
|
setZoomScale(1);
|
|
649
832
|
setPanX(0);
|
|
@@ -660,14 +843,17 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
660
843
|
// Anchor zoom around the cursor position in SVG coords.
|
|
661
844
|
if (svgRef.current) {
|
|
662
845
|
const rect = svgRef.current.getBoundingClientRect();
|
|
663
|
-
const
|
|
664
|
-
const
|
|
665
|
-
const
|
|
666
|
-
const
|
|
667
|
-
const
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
846
|
+
const curW = baseW / zoomScale;
|
|
847
|
+
const curH = baseH / zoomScale;
|
|
848
|
+
const cursorSvgX = vbX + ((ev.clientX - rect.left) / rect.width) * curW;
|
|
849
|
+
const cursorSvgY = vbY + ((ev.clientY - rect.top) / rect.height) * curH;
|
|
850
|
+
const newVbW = baseW / newScale;
|
|
851
|
+
const newVbH = baseH / newScale;
|
|
852
|
+
const ratioX = (cursorSvgX - vbX) / curW;
|
|
853
|
+
const ratioY = (cursorSvgY - vbY) / curH;
|
|
854
|
+
// New top-left so the cursor anchor stays put, then back out the pan term.
|
|
855
|
+
setPanX(cursorSvgX - ratioX * newVbW - baseX);
|
|
856
|
+
setPanY(cursorSvgY - ratioY * newVbH - baseY);
|
|
671
857
|
}
|
|
672
858
|
setZoomScale(newScale);
|
|
673
859
|
}
|
|
@@ -701,10 +887,15 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
701
887
|
const hoveredNodeRelCount = hoveredNode
|
|
702
888
|
? positionedEdges.filter((e) => e.edge.source === hoveredNode.node.id || e.edge.target === hoveredNode.node.id).length
|
|
703
889
|
: 0;
|
|
704
|
-
return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) =>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
890
|
+
return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) => {
|
|
891
|
+
const onHitEnter = () => {
|
|
892
|
+
setHoveredEdgeIndex(e.i);
|
|
893
|
+
onEdgeHover?.(e.edge);
|
|
894
|
+
};
|
|
895
|
+
const onHitLeave = () => setHoveredEdgeIndex(null);
|
|
896
|
+
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) && "st-forceGraph__edge--dim");
|
|
897
|
+
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));
|
|
898
|
+
}) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
|
|
708
899
|
const ariaLabel = `${p.title}${p.node.group !== undefined ? `: ${p.node.group}` : ""}`;
|
|
709
900
|
const pressed = selectedSet.has(p.node.id);
|
|
710
901
|
const shapeProps = {
|
|
@@ -721,28 +912,32 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
|
|
|
721
912
|
onDoubleClick: () => onOpenEntity?.(p.node.id),
|
|
722
913
|
onKeyDown: (e) => handleNodeKeydown(p.node.id, e),
|
|
723
914
|
};
|
|
724
|
-
return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, hoveredNodeIndex !== null && hoveredNodeIndex !== p.i
|
|
915
|
+
return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, ((hoveredNodeIndex !== null && hoveredNodeIndex !== p.i) ||
|
|
916
|
+
isSelectionDimmed(p.node.id)) &&
|
|
917
|
+
"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));
|
|
725
918
|
}) })] }), hoveredNode ? (_jsxs("div", { className: "st-forceGraph__tooltip", role: "presentation", style: {
|
|
726
919
|
left: `${((hoveredNode.x - vbX) / vbW) * 100}%`,
|
|
727
920
|
top: `${((hoveredNode.y - vbY) / vbH) * 100}%`,
|
|
728
921
|
}, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredNode.title }), hoveredNode.node.group !== undefined ? (_jsx("span", { className: "st-forceGraph__tooltipMeta", children: hoveredNode.node.group })) : null, hoveredNodeRelCount > 0 ? (_jsxs("span", { className: "st-forceGraph__tooltipMeta", children: [hoveredNodeRelCount, " relation", hoveredNodeRelCount === 1 ? "" : "s"] })) : null] })) : null, hoveredEdge ? (_jsxs("div", { className: "st-forceGraph__tooltip st-forceGraph__tooltip--edge", role: "presentation", style: {
|
|
729
|
-
left: `${((
|
|
730
|
-
top: `${((
|
|
922
|
+
left: `${((hoveredEdge.midX - vbX) / vbW) * 100}%`,
|
|
923
|
+
top: `${((hoveredEdge.midY - vbY) / vbH) * 100}%`,
|
|
731
924
|
}, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.srcLabel }), hoveredEdge.edge.relation ? (_jsx("span", { className: "st-forceGraph__tooltipRelation", children: hoveredEdge.edge.relation })) : null, _jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.tgtLabel })] })) : null, isZoomed ? (_jsx("button", { className: "st-forceGraph__resetBtn", type: "button", "aria-label": "Reset view", onClick: resetView, children: "\u21BA" })) : null, legend && legend.length > 0 ? (_jsx("div", { className: "st-forceGraph__legend", "aria-label": "Graph legend", children: legend.map((entry, idx) => {
|
|
732
925
|
const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
|
|
733
926
|
const swatchTone = entry.tone ?? "category1";
|
|
927
|
+
const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null;
|
|
734
928
|
return (_jsxs("div", { className: "st-forceGraph__legendEntry", children: [entry.shape !== undefined ? (
|
|
735
|
-
// Node shape legend entry
|
|
736
|
-
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-
|
|
929
|
+
// Node shape legend entry (viewBox widened for area-scaled glyphs)
|
|
930
|
+
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-13 -13 26 26", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) })) : (
|
|
737
931
|
// Edge style legend entry
|
|
738
|
-
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak") }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
|
|
932
|
+
_jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak"), strokeDasharray: swatchDash ?? undefined }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
|
|
739
933
|
}) })) : null] }));
|
|
740
934
|
}
|
|
741
935
|
export function GraphLegend({ entries, title, className, ...rest }) {
|
|
742
936
|
return (_jsxs("div", { ...rest, className: classNames("st-graphLegend", className), "aria-label": title ?? "Graph legend", children: [title ? _jsx("p", { className: "st-graphLegend__title", children: title }) : null, _jsx("ul", { className: "st-graphLegend__list", role: "list", children: entries.map((entry, idx) => {
|
|
743
937
|
const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
|
|
744
938
|
const swatchTone = entry.tone ?? "category1";
|
|
745
|
-
|
|
939
|
+
const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null;
|
|
940
|
+
return (_jsxs("li", { className: "st-graphLegend__entry", children: [entry.shape !== undefined ? (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "-13 -13 26 26", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) })) : (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-graphLegend__edge", entry.weak && "st-graphLegend__edge--weak"), strokeDasharray: swatchDash ?? undefined }) })), _jsx("span", { className: "st-graphLegend__label", children: entry.label })] }, idx));
|
|
746
941
|
}) })] }));
|
|
747
942
|
}
|
|
748
943
|
export function Form({ status = "idle", message, children, className, ...rest }) {
|