@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/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) and sized to inscribe within radius r.
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
- return `M 0 ${-r} L ${r} 0 L 0 ${r} L ${-r} 0 Z`;
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 = r * 0.42;
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(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
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 = r * 0.85;
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
- const h = r * 1.1;
416
- return `M 0 ${-h} L ${h * 0.9} ${h * 0.6} L ${-h * 0.9} ${h * 0.6} Z`;
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
- // Keep inside a padded viewport.
555
- sn.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, sn.x));
556
- sn.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, sn.y));
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
- export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, className, ...rest }) {
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: a.x,
608
- y1: a.y,
609
- x2: b.x,
610
- y2: b.y,
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. Store zoom as a scale multiplier + pan offset so syncing
632
- // with width/height props is trivial.
633
- // vbW = width / zoomScale, vbH = height / zoomScale
634
- // vbX / vbY = pan offset in SVG coordinate space
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
- const vbW = width / zoomScale;
644
- const vbH = height / zoomScale;
645
- const vbX = panX;
646
- const vbY = panY;
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 cursorSvgX = panX + ((ev.clientX - rect.left) / rect.width) * (width / zoomScale);
664
- const cursorSvgY = panY + ((ev.clientY - rect.top) / rect.height) * (height / zoomScale);
665
- const newVbW = width / newScale;
666
- const newVbH = height / newScale;
667
- const ratioX = (cursorSvgX - panX) / (width / zoomScale);
668
- const ratioY = (cursorSvgY - panY) / (height / zoomScale);
669
- setPanX(cursorSvgX - ratioX * newVbW);
670
- setPanY(cursorSvgY - ratioY * newVbH);
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) => (_jsxs(React.Fragment, { children: [_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: () => {
705
- setHoveredEdgeIndex(e.i);
706
- onEdgeHover?.(e.edge);
707
- }, onMouseLeave: () => setHoveredEdgeIndex(null) }), _jsx("line", { className: classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", hoveredEdgeIndex === e.i && "st-forceGraph__edge--hovered"), x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, pointerEvents: "none" })] }, e.i))) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
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 && "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));
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: `${(((hoveredEdge.x1 + hoveredEdge.x2) / 2 - vbX) / vbW) * 100}%`,
730
- top: `${(((hoveredEdge.y1 + hoveredEdge.y2) / 2 - vbY) / vbH) * 100}%`,
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: "-8 -8 16 16", 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}` })) })) : (
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
- return (_jsxs("li", { className: "st-graphLegend__entry", children: [entry.shape !== undefined ? (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "-8 -8 16 16", 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") }) })), _jsx("span", { className: "st-graphLegend__label", children: entry.label })] }, idx));
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 }) {