@sentropic/design-system-svelte 0.10.4 → 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.
@@ -9,8 +9,12 @@
9
9
  | "star"
10
10
  | "hexagon"
11
11
  | "box" | "square"
12
+ | "roundedbox"
12
13
  | "triangle";
13
14
 
15
+ /** Stroke dash style for typed edges. */
16
+ export type ForceGraphEdgeDash = "solid" | "dashed" | "dotted" | "long-dash";
17
+
14
18
  export type ForceGraphNode = {
15
19
  /** Stable identifier; referenced by edges. */
16
20
  id: string;
@@ -45,8 +49,22 @@
45
49
  /**
46
50
  * When true the link renders as a dashed/faded "weak" link. Lets callers
47
51
  * map a confidence dimension onto link strength without extra props.
52
+ * Equivalent to `dash: "dashed"` plus a faded stroke (kept for back-compat).
48
53
  */
49
54
  weak?: boolean;
55
+ /**
56
+ * Typed dash pattern for the stroke. Independent of `weak`.
57
+ * "solid" = none, "dashed" = "6 4", "dotted" = "1 4", "long-dash" = "12 6".
58
+ * When omitted, falls back to the `weak` styling.
59
+ */
60
+ dash?: ForceGraphEdgeDash;
61
+ /**
62
+ * Emphasise the edge (e.g. a reconciliation/match relation) with a thicker,
63
+ * fully-opaque stroke. Defaults to false.
64
+ */
65
+ emphasis?: boolean;
66
+ /** Explicit stroke width override in px. Takes precedence over `emphasis`. */
67
+ width?: number;
50
68
  };
51
69
 
52
70
  export type ForceGraphLegendEntry = {
@@ -58,44 +76,108 @@
58
76
  tone?: ForceGraphTone;
59
77
  /** When true, renders as a dashed line (edge legend). */
60
78
  weak?: boolean;
79
+ /**
80
+ * Typed dash pattern for an edge legend swatch. Independent of `weak`.
81
+ * When set, the swatch line uses the matching dash-array.
82
+ */
83
+ dash?: ForceGraphEdgeDash;
61
84
  };
62
85
 
86
+ /**
87
+ * Maps a dash style (or the legacy `weak` flag) to an SVG stroke-dasharray.
88
+ * Returns null for a solid stroke.
89
+ */
90
+ export function edgeDashArray(
91
+ dash: ForceGraphEdgeDash | undefined,
92
+ weak?: boolean
93
+ ): string | null {
94
+ const effective: ForceGraphEdgeDash | undefined =
95
+ dash ?? (weak ? "dashed" : undefined);
96
+ switch (effective) {
97
+ case "dashed": return "6 4";
98
+ case "dotted": return "1 4";
99
+ case "long-dash": return "12 6";
100
+ case "solid":
101
+ default: return null;
102
+ }
103
+ }
104
+
63
105
  // ---------------------------------------------------------------------------
64
106
  // SVG path helpers for the various node shapes.
65
- // All shapes are centered at (0,0) and sized to inscribe within radius r.
107
+ // All shapes are centered at (0,0). Each shape is scaled so its filled area
108
+ // matches that of the reference circle (π·r²) — this keeps equal-weight nodes
109
+ // visually balanced rather than letting squares/diamonds read as "bigger".
110
+ //
111
+ // Per-shape scale factors (closed-form, area = π·r²):
112
+ // square / roundedbox : half-side = (√π)/2 · r ≈ 0.8862·r
113
+ // diamond : half-diag = √(π/2) · r ≈ 1.2533·r
114
+ // triangle (equilat.) : circumradius= √(π/(3√3/4)) · r ≈ 1.5551·r
115
+ // hexagon (regular) : circumradius= √(π/(3√3/2)) · r ≈ 1.0996·r
116
+ // star (5-pt, k=0.42) : outer radius= √(π/A₁) · r ≈ 1.5953·r
117
+ // where A₁ is the unit-star area (≈1.2343).
66
118
  // ---------------------------------------------------------------------------
119
+ const STAR_INNER_RATIO = 0.42;
120
+ const STAR_AREA_FACTOR = 1.5953498885642274; // √(π / unit-star-area)
121
+
122
+ // Format a coordinate: 4 dp, snapping floating-point near-zero (e.g. 9e-16)
123
+ // to a clean 0 so paths never contain scientific notation.
124
+ function fmt(n: number): string {
125
+ const v = Math.abs(n) < 1e-9 ? 0 : n;
126
+ return Number(v.toFixed(4)).toString();
127
+ }
128
+
67
129
  export function nodeShapePath(shape: ForceGraphNodeShape | undefined, r: number): string | null {
68
130
  const s = shape ?? "dot";
69
131
  if (s === "dot" || s === "circle") return null; // use <circle>
70
132
  if (s === "diamond") {
71
- return `M 0 ${-r} L ${r} 0 L 0 ${r} L ${-r} 0 Z`;
133
+ const d = Math.sqrt(Math.PI / 2) * r; // half-diagonal
134
+ return `M 0 ${fmt(-d)} L ${fmt(d)} 0 L 0 ${fmt(d)} L ${fmt(-d)} 0 Z`;
72
135
  }
73
136
  if (s === "star") {
74
- const outer = r;
75
- const inner = r * 0.42;
137
+ const outer = STAR_AREA_FACTOR * r;
138
+ const inner = outer * STAR_INNER_RATIO;
76
139
  const pts: string[] = [];
77
140
  for (let i = 0; i < 10; i++) {
78
141
  const angle = (i * Math.PI) / 5 - Math.PI / 2;
79
142
  const rad = i % 2 === 0 ? outer : inner;
80
- pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
143
+ pts.push(`${fmt(rad * Math.cos(angle))},${fmt(rad * Math.sin(angle))}`);
81
144
  }
82
145
  return `M ${pts.join(" L ")} Z`;
83
146
  }
84
147
  if (s === "hexagon") {
148
+ const R = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 2)) * r; // circumradius
85
149
  const pts: string[] = [];
86
150
  for (let i = 0; i < 6; i++) {
87
151
  const angle = (i * Math.PI) / 3 - Math.PI / 6;
88
- pts.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
152
+ pts.push(`${fmt(R * Math.cos(angle))},${fmt(R * Math.sin(angle))}`);
89
153
  }
90
154
  return `M ${pts.join(" L ")} Z`;
91
155
  }
92
156
  if (s === "box" || s === "square") {
93
- const h = r * 0.85;
94
- return `M ${-h} ${-h} L ${h} ${-h} L ${h} ${h} L ${-h} ${h} Z`;
157
+ const h = (Math.sqrt(Math.PI) / 2) * r; // half-side, area = (2h)² = π·r²
158
+ return `M ${fmt(-h)} ${fmt(-h)} L ${fmt(h)} ${fmt(-h)} L ${fmt(h)} ${fmt(h)} L ${fmt(-h)} ${fmt(h)} Z`;
159
+ }
160
+ if (s === "roundedbox") {
161
+ const h = (Math.sqrt(Math.PI) / 2) * r; // same footprint as square
162
+ const rx = h * 0.6; // ≈ r·0.3 rounding radius (h ≈ 0.886·r)
163
+ // Rounded rectangle via arcs, clockwise from top edge.
164
+ return (
165
+ `M ${fmt(-h + rx)} ${fmt(-h)} ` +
166
+ `L ${fmt(h - rx)} ${fmt(-h)} A ${fmt(rx)} ${fmt(rx)} 0 0 1 ${fmt(h)} ${fmt(-h + rx)} ` +
167
+ `L ${fmt(h)} ${fmt(h - rx)} A ${fmt(rx)} ${fmt(rx)} 0 0 1 ${fmt(h - rx)} ${fmt(h)} ` +
168
+ `L ${fmt(-h + rx)} ${fmt(h)} A ${fmt(rx)} ${fmt(rx)} 0 0 1 ${fmt(-h)} ${fmt(h - rx)} ` +
169
+ `L ${fmt(-h)} ${fmt(-h + rx)} A ${fmt(rx)} ${fmt(rx)} 0 0 1 ${fmt(-h + rx)} ${fmt(-h)} Z`
170
+ );
95
171
  }
96
172
  if (s === "triangle") {
97
- const h = r * 1.1;
98
- return `M 0 ${-h} L ${h * 0.9} ${h * 0.6} L ${-h * 0.9} ${h * 0.6} Z`;
173
+ // Equilateral, centred at centroid; circumradius h so apex is up.
174
+ const h = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 4)) * r;
175
+ const pts: string[] = [];
176
+ for (let i = 0; i < 3; i++) {
177
+ const angle = (i * 2 * Math.PI) / 3 - Math.PI / 2;
178
+ pts.push(`${fmt(h * Math.cos(angle))},${fmt(h * Math.sin(angle))}`);
179
+ }
180
+ return `M ${pts.join(" L ")} Z`;
99
181
  }
100
182
  return null;
101
183
  }
@@ -149,6 +231,12 @@
149
231
  * Each entry has a label + optional shape (node) or weak (edge).
150
232
  */
151
233
  legend?: ForceGraphLegendEntry[];
234
+ /**
235
+ * Edge curvature, 0..1. 0 = straight <line> (back-compat). Larger values
236
+ * bow each edge into a quadratic <path>, offset perpendicular to the chord
237
+ * by `edgeCurve * dist * factor`. Defaults to a light 0.15.
238
+ */
239
+ edgeCurve?: number;
152
240
  class?: string;
153
241
  };
154
242
 
@@ -167,6 +255,7 @@
167
255
  onOpenEntity,
168
256
  onEdgeHover,
169
257
  legend,
258
+ edgeCurve = 0.15,
170
259
  class: className
171
260
  }: ForceGraphProps = $props();
172
261
 
@@ -305,9 +394,14 @@
305
394
  }
306
395
  node.x += node.vx;
307
396
  node.y += node.vy;
308
- // Keep inside a padded viewport.
309
- node.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, node.x));
310
- node.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, node.y));
397
+ // Soft clamp: allow the layout to overflow the canvas so it keeps a
398
+ // natural shape (fit-to-content reframes it afterwards). The wide bound
399
+ // only guards against runaway coordinates, it no longer glues nodes to
400
+ // the four edges.
401
+ const padX = w * 0.5 + nodeRadius * 2;
402
+ const padY = h * 0.5 + nodeRadius * 2;
403
+ node.x = Math.max(-padX, Math.min(w + padX, node.x));
404
+ node.y = Math.max(-padY, Math.min(h + padY, node.y));
311
405
  }
312
406
  temperature *= cooling;
313
407
  }
@@ -353,8 +447,45 @@
353
447
  })
354
448
  );
355
449
 
450
+ // Curvature offset factor: how far (relative to chord length) the control
451
+ // point bows out at edgeCurve=1. Kept modest so edgeCurve≈0.15 reads "light".
452
+ const CURVE_FACTOR = 0.5;
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Fit-to-content (Feature 5): after warmup the layout may extend beyond the
456
+ // nominal width/height. Compute the real content bounding-box (node centres
457
+ // ± radius) and frame it with an 8% margin on each side. The base viewBox is
458
+ // this frame (not the fixed 0,0,w,h), so the graph is centred and never
459
+ // clipped, whatever the aspect ratio. Zoom/pan stay relative to this frame.
460
+ // ---------------------------------------------------------------------------
461
+ const CONTENT_MARGIN = 0.08;
462
+ const contentBox = $derived.by(() => {
463
+ if (positionedNodes.length === 0) {
464
+ return { x: 0, y: 0, w: width, h: height };
465
+ }
466
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
467
+ for (const p of positionedNodes) {
468
+ // Use the worst-case extent for non-circular shapes (area-scaled) so the
469
+ // glyph (and a little label room) is never clipped.
470
+ const ext = p.r * 1.7;
471
+ minX = Math.min(minX, p.x - ext);
472
+ minY = Math.min(minY, p.y - ext);
473
+ maxX = Math.max(maxX, p.x + ext);
474
+ maxY = Math.max(maxY, p.y + ext);
475
+ }
476
+ let w = maxX - minX;
477
+ let h = maxY - minY;
478
+ // Guard against a degenerate (single node / collinear) box.
479
+ if (!(w > 0)) { w = width; minX = maxX - w / 2; }
480
+ if (!(h > 0)) { h = height; minY = maxY - h / 2; }
481
+ const mx = w * CONTENT_MARGIN;
482
+ const my = h * CONTENT_MARGIN;
483
+ return { x: minX - mx, y: minY - my, w: w + 2 * mx, h: h + 2 * my };
484
+ });
485
+
356
486
  const positionedEdges = $derived.by(() => {
357
487
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
488
+ const curve = Math.max(0, edgeCurve ?? 0);
358
489
  return edges
359
490
  .map((e, i) => {
360
491
  const a = layout.get(e.source);
@@ -362,13 +493,36 @@
362
493
  if (!a || !b) return null;
363
494
  const srcNode = nodeById.get(e.source);
364
495
  const tgtNode = nodeById.get(e.target);
496
+ const x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
497
+ // Quadratic control point: midpoint pushed perpendicular to the chord.
498
+ let path: string | null = null;
499
+ let cx = (x1 + x2) / 2;
500
+ let cy = (y1 + y2) / 2;
501
+ if (curve > 0) {
502
+ const dx = x2 - x1;
503
+ const dy = y2 - y1;
504
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
505
+ const off = curve * dist * CURVE_FACTOR;
506
+ // Unit perpendicular to the chord.
507
+ const px = -dy / dist;
508
+ const py = dx / dist;
509
+ cx = (x1 + x2) / 2 + px * off;
510
+ cy = (y1 + y2) / 2 + py * off;
511
+ path = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`;
512
+ }
513
+ const dashArray = edgeDashArray(e.dash, e.weak);
514
+ const strokeWidth =
515
+ typeof e.width === "number" ? e.width : e.emphasis ? 2.5 : null;
365
516
  return {
366
517
  edge: e,
367
518
  i,
368
- x1: a.x,
369
- y1: a.y,
370
- x2: b.x,
371
- y2: b.y,
519
+ x1, y1, x2, y2,
520
+ // Tooltip / label anchor follows the curve apex when curved.
521
+ midX: cx,
522
+ midY: cy,
523
+ path,
524
+ dashArray,
525
+ strokeWidth,
372
526
  srcLabel: srcNode?.label ?? e.source,
373
527
  tgtLabel: tgtNode?.label ?? e.target
374
528
  };
@@ -383,6 +537,53 @@
383
537
  // never when nodes/edges change.
384
538
  const selectedSet = $derived(new Set<string>(selectedIds));
385
539
 
540
+ // Adjacency: id -> set of directly connected node ids. Used to keep the
541
+ // direct neighbours of selected/focused nodes fully visible (demand 6).
542
+ const adjacency = $derived.by(() => {
543
+ const adj = new Map<string, Set<string>>();
544
+ const add = (a: string, b: string) => {
545
+ let set = adj.get(a);
546
+ if (!set) { set = new Set(); adj.set(a, set); }
547
+ set.add(b);
548
+ };
549
+ for (const e of edges) {
550
+ add(e.source, e.target);
551
+ add(e.target, e.source);
552
+ }
553
+ return adj;
554
+ });
555
+
556
+ // True when a selection/focus is active — only then do we dim non-related
557
+ // nodes. The set of "active" ids = selected ∪ focus ∪ all their neighbours.
558
+ const hasActiveSelection = $derived(selectedSet.size > 0 || focusId != null);
559
+ const activeAndNeighbours = $derived.by(() => {
560
+ const active = new Set<string>(selectedSet);
561
+ if (focusId != null) active.add(focusId);
562
+ // Expand to direct neighbours so they stay fully visible.
563
+ const withNeighbours = new Set<string>(active);
564
+ for (const id of active) {
565
+ const nb = adjacency.get(id);
566
+ if (nb) for (const n of nb) withNeighbours.add(n);
567
+ }
568
+ return withNeighbours;
569
+ });
570
+
571
+ // A node is dimmed by selection when there IS an active selection and the
572
+ // node is neither selected/focused nor a direct neighbour of one.
573
+ function isSelectionDimmed(id: string): boolean {
574
+ if (!hasActiveSelection) return false;
575
+ return !activeAndNeighbours.has(id);
576
+ }
577
+
578
+ // An edge stays fully visible when at least one endpoint is in the
579
+ // selected/focused set (it is a connection of the selection).
580
+ function isEdgeSelectionDimmed(e: ForceGraphEdge): boolean {
581
+ if (!hasActiveSelection) return false;
582
+ const srcActive = selectedSet.has(e.source) || focusId === e.source;
583
+ const tgtActive = selectedSet.has(e.target) || focusId === e.target;
584
+ return !(srcActive || tgtActive);
585
+ }
586
+
386
587
  // Keyboard handler for a node circle: Space/Enter → onSelect, Enter → onOpenEntity.
387
588
  function handleNodeKeydown(id: string, e: KeyboardEvent) {
388
589
  if (e.key === "Enter" || e.key === " ") {
@@ -395,11 +596,11 @@
395
596
  }
396
597
 
397
598
  // ---------------------------------------------------------------------------
398
- // Zoom + pan state (Feature 2)
399
- // Store zoom as a scale multiplier + pan offset so syncing with width/height
400
- // props is trivial (no stale-capture warnings).
401
- // vbW = width / zoomScale, vbH = height / zoomScale
402
- // vbX / vbY = pan offset in SVG coordinate space
599
+ // Zoom + pan state (Feature 2), framed by the fit-to-content box (Feature 5).
600
+ // The base frame is `contentBox` (not 0,0,w,h). Zoom is a scale multiplier and
601
+ // pan is an offset in SVG coords, both relative to that base frame:
602
+ // vbW = baseW / zoomScale, vbH = baseH / zoomScale
603
+ // vbX = baseX + panX, vbY = baseY + panY
403
604
  // ---------------------------------------------------------------------------
404
605
  let zoomScale = $state(1);
405
606
  let panX = $state(0);
@@ -409,11 +610,17 @@
409
610
  let panStart = $state({ x: 0, y: 0, panX: 0, panY: 0 });
410
611
  let svgEl: SVGSVGElement | null = $state(null);
411
612
 
412
- // Derived viewBox dimensions always reflect current props + zoom.
413
- const vbW = $derived(width / zoomScale);
414
- const vbH = $derived(height / zoomScale);
415
- const vbX = $derived(panX);
416
- const vbY = $derived(panY);
613
+ // Base frame dimensions = fit-to-content box.
614
+ const baseW = $derived(contentBox.w);
615
+ const baseH = $derived(contentBox.h);
616
+ const baseX = $derived(contentBox.x);
617
+ const baseY = $derived(contentBox.y);
618
+
619
+ // Derived viewBox dimensions reflect the content frame + zoom/pan.
620
+ const vbW = $derived(baseW / zoomScale);
621
+ const vbH = $derived(baseH / zoomScale);
622
+ const vbX = $derived(baseX + panX);
623
+ const vbY = $derived(baseY + panY);
417
624
 
418
625
  function resetView() {
419
626
  zoomScale = 1;
@@ -431,14 +638,17 @@
431
638
  // Anchor zoom around the cursor position in SVG coords.
432
639
  if (svgEl) {
433
640
  const rect = svgEl.getBoundingClientRect();
434
- const cursorSvgX = panX + ((ev.clientX - rect.left) / rect.width) * (width / zoomScale);
435
- const cursorSvgY = panY + ((ev.clientY - rect.top) / rect.height) * (height / zoomScale);
436
- const newVbW = width / newScale;
437
- const newVbH = height / newScale;
438
- const ratioX = (cursorSvgX - panX) / (width / zoomScale);
439
- const ratioY = (cursorSvgY - panY) / (height / zoomScale);
440
- panX = cursorSvgX - ratioX * newVbW;
441
- panY = cursorSvgY - ratioY * newVbH;
641
+ const curW = baseW / zoomScale;
642
+ const curH = baseH / zoomScale;
643
+ const cursorSvgX = vbX + ((ev.clientX - rect.left) / rect.width) * curW;
644
+ const cursorSvgY = vbY + ((ev.clientY - rect.top) / rect.height) * curH;
645
+ const newVbW = baseW / newScale;
646
+ const newVbH = baseH / newScale;
647
+ const ratioX = (cursorSvgX - vbX) / curW;
648
+ const ratioY = (cursorSvgY - vbY) / curH;
649
+ // New top-left so the cursor anchor stays put, then back out the pan term.
650
+ panX = cursorSvgX - ratioX * newVbW - baseX;
651
+ panY = cursorSvgY - ratioY * newVbH - baseY;
442
652
  }
443
653
  zoomScale = newScale;
444
654
  }
@@ -496,27 +706,57 @@
496
706
  <!-- edges first so nodes paint on top -->
497
707
  <g class="st-forceGraph__edges">
498
708
  {#each positionedEdges as e (e.i)}
499
- <!-- Invisible wider hit area for edge hover -->
500
- <line
501
- class="st-forceGraph__edgeHit"
502
- role="presentation"
503
- x1={e.x1}
504
- y1={e.y1}
505
- x2={e.x2}
506
- y2={e.y2}
507
- onmouseenter={() => { hoveredEdgeIndex = e.i; onEdgeHover?.(e.edge); }}
508
- onmouseleave={() => { hoveredEdgeIndex = null; }}
509
- />
510
- <line
511
- class="st-forceGraph__edge"
512
- class:st-forceGraph__edge--weak={e.edge.weak}
513
- class:st-forceGraph__edge--hovered={hoveredEdgeIndex === e.i}
514
- x1={e.x1}
515
- y1={e.y1}
516
- x2={e.x2}
517
- y2={e.y2}
518
- pointer-events="none"
519
- />
709
+ <!-- Invisible wider hit area for edge hover (follows the curve) -->
710
+ {#if e.path}
711
+ <path
712
+ class="st-forceGraph__edgeHit"
713
+ role="presentation"
714
+ d={e.path}
715
+ fill="none"
716
+ onmouseenter={() => { hoveredEdgeIndex = e.i; onEdgeHover?.(e.edge); }}
717
+ onmouseleave={() => { hoveredEdgeIndex = null; }}
718
+ />
719
+ {:else}
720
+ <line
721
+ class="st-forceGraph__edgeHit"
722
+ role="presentation"
723
+ x1={e.x1}
724
+ y1={e.y1}
725
+ x2={e.x2}
726
+ y2={e.y2}
727
+ onmouseenter={() => { hoveredEdgeIndex = e.i; onEdgeHover?.(e.edge); }}
728
+ onmouseleave={() => { hoveredEdgeIndex = null; }}
729
+ />
730
+ {/if}
731
+ {#if e.path}
732
+ <path
733
+ class="st-forceGraph__edge"
734
+ class:st-forceGraph__edge--weak={e.edge.weak}
735
+ class:st-forceGraph__edge--emphasis={e.edge.emphasis}
736
+ class:st-forceGraph__edge--hovered={hoveredEdgeIndex === e.i}
737
+ class:st-forceGraph__edge--dim={isEdgeSelectionDimmed(e.edge)}
738
+ d={e.path}
739
+ fill="none"
740
+ stroke-dasharray={e.dashArray}
741
+ stroke-width={e.strokeWidth}
742
+ pointer-events="none"
743
+ />
744
+ {:else}
745
+ <line
746
+ class="st-forceGraph__edge"
747
+ class:st-forceGraph__edge--weak={e.edge.weak}
748
+ class:st-forceGraph__edge--emphasis={e.edge.emphasis}
749
+ class:st-forceGraph__edge--hovered={hoveredEdgeIndex === e.i}
750
+ class:st-forceGraph__edge--dim={isEdgeSelectionDimmed(e.edge)}
751
+ x1={e.x1}
752
+ y1={e.y1}
753
+ x2={e.x2}
754
+ y2={e.y2}
755
+ stroke-dasharray={e.dashArray}
756
+ stroke-width={e.strokeWidth}
757
+ pointer-events="none"
758
+ />
759
+ {/if}
520
760
  {/each}
521
761
  </g>
522
762
 
@@ -524,7 +764,7 @@
524
764
  {#each positionedNodes as p (p.node.id)}
525
765
  <g
526
766
  class="st-forceGraph__node st-forceGraph__node--{p.tone}"
527
- class:st-forceGraph__node--dim={hoveredNodeIndex !== null && hoveredNodeIndex !== p.i}
767
+ class:st-forceGraph__node--dim={(hoveredNodeIndex !== null && hoveredNodeIndex !== p.i) || isSelectionDimmed(p.node.id)}
528
768
  class:st-forceGraph__node--selected={selectedSet.has(p.node.id)}
529
769
  class:st-forceGraph__node--focus={focusId === p.node.id}
530
770
  transform="translate({p.x} {p.y})"
@@ -595,12 +835,10 @@
595
835
  {#if hoveredEdgeIndex !== null}
596
836
  {@const e = positionedEdges.find((pe) => pe.i === hoveredEdgeIndex)}
597
837
  {#if e}
598
- {@const midX = (e.x1 + e.x2) / 2}
599
- {@const midY = (e.y1 + e.y2) / 2}
600
838
  <div
601
839
  class="st-forceGraph__tooltip st-forceGraph__tooltip--edge"
602
840
  role="presentation"
603
- style="left: {((midX - vbX) / vbW) * 100}%; top: {((midY - vbY) / vbH) * 100}%"
841
+ style="left: {((e.midX - vbX) / vbW) * 100}%; top: {((e.midY - vbY) / vbH) * 100}%"
604
842
  >
605
843
  <span class="st-forceGraph__tooltipLabel">{e.srcLabel}</span>
606
844
  {#if e.edge.relation}
@@ -629,12 +867,13 @@
629
867
  {#each legend as entry}
630
868
  {@const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null}
631
869
  {@const swatchTone = entry.tone ?? "category1"}
870
+ {@const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null}
632
871
  <div class="st-forceGraph__legendEntry">
633
872
  {#if entry.shape !== undefined}
634
- <!-- Node shape legend entry -->
873
+ <!-- Node shape legend entry (viewBox widened for area-scaled glyphs) -->
635
874
  <svg
636
875
  class="st-forceGraph__legendSwatch"
637
- viewBox="-8 -8 16 16"
876
+ viewBox="-13 -13 26 26"
638
877
  width="16"
639
878
  height="16"
640
879
  aria-hidden="true"
@@ -667,6 +906,7 @@
667
906
  y2="4"
668
907
  class="st-forceGraph__legendEdge"
669
908
  class:st-forceGraph__legendEdge--weak={entry.weak}
909
+ stroke-dasharray={swatchDash}
670
910
  />
671
911
  </svg>
672
912
  {/if}
@@ -699,15 +939,27 @@
699
939
 
700
940
  .st-forceGraph__edge--weak {
701
941
  stroke: var(--st-semantic-border-subtle);
702
- stroke-dasharray: 3 3;
703
942
  opacity: 0.5;
704
943
  }
705
944
 
945
+ /* Emphasised edge (e.g. reconciliation match): thicker, fully opaque, on top. */
946
+ .st-forceGraph__edge--emphasis {
947
+ stroke: var(--st-semantic-border-interactive, var(--st-semantic-border-strong));
948
+ opacity: 0.95;
949
+ }
950
+
951
+ /* Hover: match the node hover (fully visible) so the edge is never paler
952
+ than the two nodes it connects. */
706
953
  .st-forceGraph__edge--hovered {
707
- opacity: 0.9;
954
+ opacity: 1;
708
955
  stroke-width: 2;
709
956
  }
710
957
 
958
+ /* Dimmed by an active selection (edge touches no selected/focused node). */
959
+ .st-forceGraph__edge--dim {
960
+ opacity: 0.12;
961
+ }
962
+
711
963
  /* Invisible wide hit target for edge hover */
712
964
  .st-forceGraph__edgeHit {
713
965
  stroke: transparent;
@@ -1,5 +1,7 @@
1
1
  export type ForceGraphTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
2
- export type ForceGraphNodeShape = "dot" | "circle" | "diamond" | "star" | "hexagon" | "box" | "square" | "triangle";
2
+ export type ForceGraphNodeShape = "dot" | "circle" | "diamond" | "star" | "hexagon" | "box" | "square" | "roundedbox" | "triangle";
3
+ /** Stroke dash style for typed edges. */
4
+ export type ForceGraphEdgeDash = "solid" | "dashed" | "dotted" | "long-dash";
3
5
  export type ForceGraphNode = {
4
6
  /** Stable identifier; referenced by edges. */
5
7
  id: string;
@@ -33,8 +35,22 @@ export type ForceGraphEdge = {
33
35
  /**
34
36
  * When true the link renders as a dashed/faded "weak" link. Lets callers
35
37
  * map a confidence dimension onto link strength without extra props.
38
+ * Equivalent to `dash: "dashed"` plus a faded stroke (kept for back-compat).
36
39
  */
37
40
  weak?: boolean;
41
+ /**
42
+ * Typed dash pattern for the stroke. Independent of `weak`.
43
+ * "solid" = none, "dashed" = "6 4", "dotted" = "1 4", "long-dash" = "12 6".
44
+ * When omitted, falls back to the `weak` styling.
45
+ */
46
+ dash?: ForceGraphEdgeDash;
47
+ /**
48
+ * Emphasise the edge (e.g. a reconciliation/match relation) with a thicker,
49
+ * fully-opaque stroke. Defaults to false.
50
+ */
51
+ emphasis?: boolean;
52
+ /** Explicit stroke width override in px. Takes precedence over `emphasis`. */
53
+ width?: number;
38
54
  };
39
55
  export type ForceGraphLegendEntry = {
40
56
  /** Label shown in the legend. */
@@ -45,7 +61,17 @@ export type ForceGraphLegendEntry = {
45
61
  tone?: ForceGraphTone;
46
62
  /** When true, renders as a dashed line (edge legend). */
47
63
  weak?: boolean;
64
+ /**
65
+ * Typed dash pattern for an edge legend swatch. Independent of `weak`.
66
+ * When set, the swatch line uses the matching dash-array.
67
+ */
68
+ dash?: ForceGraphEdgeDash;
48
69
  };
70
+ /**
71
+ * Maps a dash style (or the legacy `weak` flag) to an SVG stroke-dasharray.
72
+ * Returns null for a solid stroke.
73
+ */
74
+ export declare function edgeDashArray(dash: ForceGraphEdgeDash | undefined, weak?: boolean): string | null;
49
75
  export declare function nodeShapePath(shape: ForceGraphNodeShape | undefined, r: number): string | null;
50
76
  type ForceGraphProps = {
51
77
  nodes: ForceGraphNode[];
@@ -94,6 +120,12 @@ type ForceGraphProps = {
94
120
  * Each entry has a label + optional shape (node) or weak (edge).
95
121
  */
96
122
  legend?: ForceGraphLegendEntry[];
123
+ /**
124
+ * Edge curvature, 0..1. 0 = straight <line> (back-compat). Larger values
125
+ * bow each edge into a quadratic <path>, offset perpendicular to the chord
126
+ * by `edgeCurve * dist * factor`. Defaults to a light 0.15.
127
+ */
128
+ edgeCurve?: number;
97
129
  class?: string;
98
130
  };
99
131
  declare const ForceGraph: import("svelte").Component<ForceGraphProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"ForceGraph.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ForceGraph.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,mBAAmB,GAC3B,KAAK,GAAG,QAAQ,GAChB,SAAS,GACT,MAAM,GACN,SAAS,GACT,KAAK,GAAG,QAAQ,GAChB,UAAU,CAAC;AAEf,MAAM,MAAM,cAAc,GAAG;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,CAAC;IACX,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,gEAAgE;IAChE,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,KAAK,CAAC,EAAE,mBAAmB,CAAC;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,yDAAyD;IACzD,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAMF,wBAAgB,aAAa,CAAC,KAAK,EAAE,mBAAmB,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkC9F;AAED,KAAK,eAAe,GAAG;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAC;IAC7C;;;OAGG;IACH,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4aJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ForceGraph.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ForceGraph.svelte.ts"],"names":[],"mappings":"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"}
@@ -1,10 +1,10 @@
1
1
  <script lang="ts" module>
2
2
  // Re-export types so GraphLegend can be used standalone without importing from ForceGraph.
3
- export type { ForceGraphLegendEntry, ForceGraphNodeShape, ForceGraphTone } from "./ForceGraph.svelte";
3
+ export type { ForceGraphLegendEntry, ForceGraphNodeShape, ForceGraphTone, ForceGraphEdgeDash } from "./ForceGraph.svelte";
4
4
  </script>
5
5
 
6
6
  <script lang="ts">
7
- import { nodeShapePath } from "./ForceGraph.svelte";
7
+ import { nodeShapePath, edgeDashArray } from "./ForceGraph.svelte";
8
8
  import type { ForceGraphLegendEntry } from "./ForceGraph.svelte";
9
9
 
10
10
  type GraphLegendProps = {
@@ -28,11 +28,12 @@
28
28
  {#each entries as entry}
29
29
  {@const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null}
30
30
  {@const swatchTone = entry.tone ?? "category1"}
31
+ {@const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null}
31
32
  <li class="st-graphLegend__entry">
32
33
  {#if entry.shape !== undefined}
33
34
  <svg
34
35
  class="st-graphLegend__swatch"
35
- viewBox="-8 -8 16 16"
36
+ viewBox="-13 -13 26 26"
36
37
  width="16"
37
38
  height="16"
38
39
  aria-hidden="true"
@@ -64,6 +65,7 @@
64
65
  y2="4"
65
66
  class="st-graphLegend__edge"
66
67
  class:st-graphLegend__edge--weak={entry.weak}
68
+ stroke-dasharray={swatchDash}
67
69
  />
68
70
  </svg>
69
71
  {/if}
@@ -136,7 +138,6 @@
136
138
 
137
139
  .st-graphLegend__edge--weak {
138
140
  stroke: var(--st-semantic-border-subtle, #aaa);
139
- stroke-dasharray: 3 3;
140
141
  opacity: 0.65;
141
142
  }
142
143
  </style>