@sentropic/design-system-svelte 0.10.3 → 0.10.5

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.
Files changed (90) hide show
  1. package/dist/AreaChart.svelte +38 -26
  2. package/dist/AreaChart.svelte.d.ts.map +1 -1
  3. package/dist/BackToTop.svelte +157 -0
  4. package/dist/BackToTop.svelte.d.ts +14 -0
  5. package/dist/BackToTop.svelte.d.ts.map +1 -0
  6. package/dist/BarChart.svelte +38 -39
  7. package/dist/BarChart.svelte.d.ts.map +1 -1
  8. package/dist/Card.svelte +2 -0
  9. package/dist/ChartDataList.svelte +33 -0
  10. package/dist/ChartDataList.svelte.d.ts +9 -0
  11. package/dist/ChartDataList.svelte.d.ts.map +1 -0
  12. package/dist/ChatComposer.svelte +20 -3
  13. package/dist/ChatComposer.svelte.d.ts +6 -30
  14. package/dist/ChatComposer.svelte.d.ts.map +1 -1
  15. package/dist/ChatMessage.svelte +9 -2
  16. package/dist/ChatMessage.svelte.d.ts +7 -1
  17. package/dist/ChatMessage.svelte.d.ts.map +1 -1
  18. package/dist/Checkbox.svelte +4 -0
  19. package/dist/Combobox.svelte +1 -1
  20. package/dist/ContentSwitcher.svelte +1 -0
  21. package/dist/DataTable.svelte +4 -1
  22. package/dist/DatePicker.svelte +1 -1
  23. package/dist/DisplaySettings.svelte +210 -0
  24. package/dist/DisplaySettings.svelte.d.ts +24 -0
  25. package/dist/DisplaySettings.svelte.d.ts.map +1 -0
  26. package/dist/DonutChart.svelte +44 -29
  27. package/dist/DonutChart.svelte.d.ts.map +1 -1
  28. package/dist/Dropdown.svelte +1 -1
  29. package/dist/FileUploader.svelte +2 -2
  30. package/dist/ForceGraph.svelte +692 -38
  31. package/dist/ForceGraph.svelte.d.ts +59 -0
  32. package/dist/ForceGraph.svelte.d.ts.map +1 -1
  33. package/dist/GraphLegend.svelte +143 -0
  34. package/dist/GraphLegend.svelte.d.ts +12 -0
  35. package/dist/GraphLegend.svelte.d.ts.map +1 -0
  36. package/dist/Header.svelte +2 -1
  37. package/dist/IconButton.svelte +1 -1
  38. package/dist/InlineLoading.svelte +10 -1
  39. package/dist/InlineLoading.svelte.d.ts.map +1 -1
  40. package/dist/Input.svelte +3 -2
  41. package/dist/LanguageSelector.svelte +2 -1
  42. package/dist/LineChart.svelte +38 -26
  43. package/dist/LineChart.svelte.d.ts.map +1 -1
  44. package/dist/Link.svelte +7 -1
  45. package/dist/MediaContent.svelte +124 -0
  46. package/dist/MediaContent.svelte.d.ts +22 -0
  47. package/dist/MediaContent.svelte.d.ts.map +1 -0
  48. package/dist/Menu.svelte +56 -3
  49. package/dist/Menu.svelte.d.ts.map +1 -1
  50. package/dist/MessageStatusBadge.svelte +1 -1
  51. package/dist/MultiSelect.svelte +2 -2
  52. package/dist/Notification.svelte +150 -0
  53. package/dist/Notification.svelte.d.ts +17 -0
  54. package/dist/Notification.svelte.d.ts.map +1 -0
  55. package/dist/NumberInput.svelte +1 -0
  56. package/dist/OverflowMenu.svelte +84 -13
  57. package/dist/OverflowMenu.svelte.d.ts.map +1 -1
  58. package/dist/Pagination.svelte +7 -0
  59. package/dist/PaginationNav.svelte +2 -2
  60. package/dist/ProgressIndicator.svelte +13 -1
  61. package/dist/ProgressIndicator.svelte.d.ts +1 -0
  62. package/dist/ProgressIndicator.svelte.d.ts.map +1 -1
  63. package/dist/Radio.svelte +7 -3
  64. package/dist/ScatterPlot.svelte +64 -45
  65. package/dist/ScatterPlot.svelte.d.ts.map +1 -1
  66. package/dist/Search.svelte +6 -3
  67. package/dist/Select.svelte +8 -2
  68. package/dist/SideNav.svelte +6 -0
  69. package/dist/StackedBarChart.svelte +51 -30
  70. package/dist/StackedBarChart.svelte.d.ts.map +1 -1
  71. package/dist/StreamingMessage.svelte +2 -2
  72. package/dist/Switch.svelte +4 -0
  73. package/dist/Table.svelte +4 -1
  74. package/dist/TableOfContents.svelte +109 -0
  75. package/dist/TableOfContents.svelte.d.ts +16 -0
  76. package/dist/TableOfContents.svelte.d.ts.map +1 -0
  77. package/dist/Tag.svelte +1 -1
  78. package/dist/Textarea.svelte +3 -2
  79. package/dist/Tile.svelte +4 -0
  80. package/dist/TileGroup.svelte +4 -0
  81. package/dist/Toggle.svelte +4 -0
  82. package/dist/Toggletip.svelte +1 -1
  83. package/dist/Transcription.svelte +135 -0
  84. package/dist/Transcription.svelte.d.ts +19 -0
  85. package/dist/Transcription.svelte.d.ts.map +1 -0
  86. package/dist/TreeView.svelte +2 -2
  87. package/dist/index.d.ts +12 -1
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -0
  90. package/package.json +1 -1
@@ -3,6 +3,18 @@
3
3
  | "category1" | "category2" | "category3" | "category4"
4
4
  | "category5" | "category6" | "category7" | "category8";
5
5
 
6
+ export type ForceGraphNodeShape =
7
+ | "dot" | "circle"
8
+ | "diamond"
9
+ | "star"
10
+ | "hexagon"
11
+ | "box" | "square"
12
+ | "roundedbox"
13
+ | "triangle";
14
+
15
+ /** Stroke dash style for typed edges. */
16
+ export type ForceGraphEdgeDash = "solid" | "dashed" | "dotted" | "long-dash";
17
+
6
18
  export type ForceGraphNode = {
7
19
  /** Stable identifier; referenced by edges. */
8
20
  id: string;
@@ -20,6 +32,11 @@
20
32
  /** Pin the node to a fixed position (ignored by the simulation). */
21
33
  fx?: number;
22
34
  fy?: number;
35
+ /**
36
+ * Visual shape for the node. Defaults to 'dot' (circle).
37
+ * Supported: 'dot'|'circle', 'diamond', 'star', 'hexagon', 'box'|'square', 'triangle'.
38
+ */
39
+ shape?: ForceGraphNodeShape;
23
40
  };
24
41
 
25
42
  export type ForceGraphEdge = {
@@ -32,9 +49,138 @@
32
49
  /**
33
50
  * When true the link renders as a dashed/faded "weak" link. Lets callers
34
51
  * map a confidence dimension onto link strength without extra props.
52
+ * Equivalent to `dash: "dashed"` plus a faded stroke (kept for back-compat).
53
+ */
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.
35
64
  */
65
+ emphasis?: boolean;
66
+ /** Explicit stroke width override in px. Takes precedence over `emphasis`. */
67
+ width?: number;
68
+ };
69
+
70
+ export type ForceGraphLegendEntry = {
71
+ /** Label shown in the legend. */
72
+ label: string;
73
+ /** Shape for this entry (node legend). Absent = line-style legend entry. */
74
+ shape?: ForceGraphNodeShape;
75
+ /** Tone for this entry. Defaults to category1. */
76
+ tone?: ForceGraphTone;
77
+ /** When true, renders as a dashed line (edge legend). */
36
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;
37
84
  };
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
+
105
+ // ---------------------------------------------------------------------------
106
+ // SVG path helpers for the various node shapes.
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).
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
+
129
+ export function nodeShapePath(shape: ForceGraphNodeShape | undefined, r: number): string | null {
130
+ const s = shape ?? "dot";
131
+ if (s === "dot" || s === "circle") return null; // use <circle>
132
+ if (s === "diamond") {
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`;
135
+ }
136
+ if (s === "star") {
137
+ const outer = STAR_AREA_FACTOR * r;
138
+ const inner = outer * STAR_INNER_RATIO;
139
+ const pts: string[] = [];
140
+ for (let i = 0; i < 10; i++) {
141
+ const angle = (i * Math.PI) / 5 - Math.PI / 2;
142
+ const rad = i % 2 === 0 ? outer : inner;
143
+ pts.push(`${fmt(rad * Math.cos(angle))},${fmt(rad * Math.sin(angle))}`);
144
+ }
145
+ return `M ${pts.join(" L ")} Z`;
146
+ }
147
+ if (s === "hexagon") {
148
+ const R = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 2)) * r; // circumradius
149
+ const pts: string[] = [];
150
+ for (let i = 0; i < 6; i++) {
151
+ const angle = (i * Math.PI) / 3 - Math.PI / 6;
152
+ pts.push(`${fmt(R * Math.cos(angle))},${fmt(R * Math.sin(angle))}`);
153
+ }
154
+ return `M ${pts.join(" L ")} Z`;
155
+ }
156
+ if (s === "box" || s === "square") {
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
+ );
171
+ }
172
+ if (s === "triangle") {
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`;
181
+ }
182
+ return null;
183
+ }
38
184
  </script>
39
185
 
40
186
  <script lang="ts">
@@ -75,6 +221,22 @@
75
221
  * Fires with the node's stable id.
76
222
  */
77
223
  onOpenEntity?: (id: string) => void;
224
+ /**
225
+ * Called when the user hovers an edge.
226
+ * Fires with the edge object (source/target/relation/weak).
227
+ */
228
+ onEdgeHover?: (edge: ForceGraphEdge) => void;
229
+ /**
230
+ * Legend entries rendered as a corner overlay.
231
+ * Each entry has a label + optional shape (node) or weak (edge).
232
+ */
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;
78
240
  class?: string;
79
241
  };
80
242
 
@@ -91,6 +253,9 @@
91
253
  focusId = null,
92
254
  onSelect,
93
255
  onOpenEntity,
256
+ onEdgeHover,
257
+ legend,
258
+ edgeCurve = 0.15,
94
259
  class: className
95
260
  }: ForceGraphProps = $props();
96
261
 
@@ -229,9 +394,14 @@
229
394
  }
230
395
  node.x += node.vx;
231
396
  node.y += node.vy;
232
- // Keep inside a padded viewport.
233
- node.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, node.x));
234
- 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));
235
405
  }
236
406
  temperature *= cooling;
237
407
  }
@@ -262,35 +432,158 @@
262
432
  const positionedNodes = $derived.by(() =>
263
433
  nodes.map((n, i) => {
264
434
  const p = layout.get(n.id) ?? { x: width / 2, y: height / 2 };
435
+ const r = nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25));
436
+ const shapePath = nodeShapePath(n.shape, r);
265
437
  return {
266
438
  node: n,
267
439
  i,
268
440
  x: p.x,
269
441
  y: p.y,
270
- r: nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25)),
442
+ r,
271
443
  tone: toneMap.get(n.id) ?? "category1",
272
- title: n.label ?? n.id
444
+ title: n.label ?? n.id,
445
+ shapePath
273
446
  };
274
447
  })
275
448
  );
276
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
+
277
486
  const positionedEdges = $derived.by(() => {
487
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
488
+ const curve = Math.max(0, edgeCurve ?? 0);
278
489
  return edges
279
490
  .map((e, i) => {
280
491
  const a = layout.get(e.source);
281
492
  const b = layout.get(e.target);
282
493
  if (!a || !b) return null;
283
- return { edge: e, i, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
494
+ const srcNode = nodeById.get(e.source);
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;
516
+ return {
517
+ edge: e,
518
+ i,
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,
526
+ srcLabel: srcNode?.label ?? e.source,
527
+ tgtLabel: tgtNode?.label ?? e.target
528
+ };
284
529
  })
285
530
  .filter((e): e is NonNullable<typeof e> => e !== null);
286
531
  });
287
532
 
288
- let hoveredIndex: number | null = $state(null);
533
+ let hoveredNodeIndex: number | null = $state(null);
534
+ let hoveredEdgeIndex: number | null = $state(null);
289
535
 
290
536
  // Fast lookup sets — recomputed only when selectedIds/focusId props change,
291
537
  // never when nodes/edges change.
292
538
  const selectedSet = $derived(new Set<string>(selectedIds));
293
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
+
294
587
  // Keyboard handler for a node circle: Space/Enter → onSelect, Enter → onOpenEntity.
295
588
  function handleNodeKeydown(id: string, e: KeyboardEvent) {
296
589
  if (e.key === "Enter" || e.key === " ") {
@@ -302,32 +595,168 @@
302
595
  }
303
596
  }
304
597
 
598
+ // ---------------------------------------------------------------------------
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
604
+ // ---------------------------------------------------------------------------
605
+ let zoomScale = $state(1);
606
+ let panX = $state(0);
607
+ let panY = $state(0);
608
+
609
+ let isPanning = $state(false);
610
+ let panStart = $state({ x: 0, y: 0, panX: 0, panY: 0 });
611
+ let svgEl: SVGSVGElement | null = $state(null);
612
+
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);
624
+
625
+ function resetView() {
626
+ zoomScale = 1;
627
+ panX = 0;
628
+ panY = 0;
629
+ }
630
+
631
+ function handleWheel(ev: WheelEvent) {
632
+ if (prefersReducedMotion) return;
633
+ ev.preventDefault();
634
+ // Zoom factor: ~10% per step.
635
+ const factor = ev.deltaY > 0 ? 0.9 : 1.1;
636
+ // Clamp zoom: 0.2x – 8x.
637
+ const newScale = Math.min(Math.max(zoomScale * factor, 0.2), 8);
638
+ // Anchor zoom around the cursor position in SVG coords.
639
+ if (svgEl) {
640
+ const rect = svgEl.getBoundingClientRect();
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;
652
+ }
653
+ zoomScale = newScale;
654
+ }
655
+
656
+ function handleBgMouseDown(ev: MouseEvent) {
657
+ // Only start pan when clicking the background (not a node/edge element).
658
+ if ((ev.target as Element).closest(".st-forceGraph__node")) return;
659
+ if (prefersReducedMotion) return;
660
+ isPanning = true;
661
+ panStart = { x: ev.clientX, y: ev.clientY, panX, panY };
662
+ }
663
+
664
+ function handleMouseMove(ev: MouseEvent) {
665
+ if (!isPanning || !svgEl) return;
666
+ const rect = svgEl.getBoundingClientRect();
667
+ const dx = ((ev.clientX - panStart.x) / rect.width) * vbW;
668
+ const dy = ((ev.clientY - panStart.y) / rect.height) * vbH;
669
+ panX = panStart.panX - dx;
670
+ panY = panStart.panY - dy;
671
+ }
672
+
673
+ function handleMouseUp() {
674
+ isPanning = false;
675
+ }
676
+
677
+ const viewBox = $derived(`${vbX} ${vbY} ${vbW} ${vbH}`);
678
+ const isZoomed = $derived(zoomScale !== 1 || panX !== 0 || panY !== 0);
679
+
305
680
  const classes = () =>
306
681
  ["st-forceGraph", prefersReducedMotion ? "st-forceGraph--static" : null, className]
307
682
  .filter(Boolean)
308
683
  .join(" ");
309
684
  </script>
310
685
 
311
- <div class={classes()} role="img" aria-label={label}>
686
+ <div
687
+ class={classes()}
688
+ role="img"
689
+ aria-label={label}
690
+ >
312
691
  <svg
313
- viewBox="0 0 {width} {height}"
692
+ bind:this={svgEl}
693
+ viewBox={viewBox}
314
694
  preserveAspectRatio="xMidYMid meet"
315
695
  width="100%"
316
696
  height="100%"
317
697
  focusable="false"
318
698
  aria-hidden="true"
699
+ class:st-forceGraph__svg--panning={isPanning}
700
+ onwheel={handleWheel}
701
+ onmousedown={handleBgMouseDown}
702
+ onmousemove={handleMouseMove}
703
+ onmouseup={handleMouseUp}
704
+ onmouseleave={handleMouseUp}
319
705
  >
320
706
  <!-- edges first so nodes paint on top -->
321
707
  <g class="st-forceGraph__edges">
322
708
  {#each positionedEdges as e (e.i)}
323
- <line
324
- class="st-forceGraph__edge"
325
- class:st-forceGraph__edge--weak={e.edge.weak}
326
- x1={e.x1}
327
- y1={e.y1}
328
- x2={e.x2}
329
- y2={e.y2}
330
- />
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}
331
760
  {/each}
332
761
  </g>
333
762
 
@@ -335,26 +764,44 @@
335
764
  {#each positionedNodes as p (p.node.id)}
336
765
  <g
337
766
  class="st-forceGraph__node st-forceGraph__node--{p.tone}"
338
- class:st-forceGraph__node--dim={hoveredIndex !== null && hoveredIndex !== p.i}
767
+ class:st-forceGraph__node--dim={(hoveredNodeIndex !== null && hoveredNodeIndex !== p.i) || isSelectionDimmed(p.node.id)}
339
768
  class:st-forceGraph__node--selected={selectedSet.has(p.node.id)}
340
769
  class:st-forceGraph__node--focus={focusId === p.node.id}
341
770
  transform="translate({p.x} {p.y})"
342
771
  >
343
- <circle
344
- class="st-forceGraph__dot"
345
- r={p.r}
346
- tabindex="0"
347
- role="button"
348
- aria-label="{p.title}{p.node.group !== undefined ? ` — ${p.node.group}` : ''}"
349
- aria-pressed={selectedSet.has(p.node.id)}
350
- onmouseenter={() => (hoveredIndex = p.i)}
351
- onmouseleave={() => (hoveredIndex = null)}
352
- onfocus={() => (hoveredIndex = p.i)}
353
- onblur={() => (hoveredIndex = null)}
354
- onclick={() => onSelect?.(p.node.id)}
355
- ondblclick={() => onOpenEntity?.(p.node.id)}
356
- onkeydown={(e) => handleNodeKeydown(p.node.id, e)}
357
- />
772
+ {#if p.shapePath}
773
+ <path
774
+ class="st-forceGraph__dot"
775
+ d={p.shapePath}
776
+ tabindex="0"
777
+ role="button"
778
+ aria-label="{p.title}{p.node.group !== undefined ? `: ${p.node.group}` : ''}"
779
+ aria-pressed={selectedSet.has(p.node.id)}
780
+ onmouseenter={() => (hoveredNodeIndex = p.i)}
781
+ onmouseleave={() => (hoveredNodeIndex = null)}
782
+ onfocus={() => (hoveredNodeIndex = p.i)}
783
+ onblur={() => (hoveredNodeIndex = null)}
784
+ onclick={() => onSelect?.(p.node.id)}
785
+ ondblclick={() => onOpenEntity?.(p.node.id)}
786
+ onkeydown={(e) => handleNodeKeydown(p.node.id, e)}
787
+ />
788
+ {:else}
789
+ <circle
790
+ class="st-forceGraph__dot"
791
+ r={p.r}
792
+ tabindex="0"
793
+ role="button"
794
+ aria-label="{p.title}{p.node.group !== undefined ? `: ${p.node.group}` : ''}"
795
+ aria-pressed={selectedSet.has(p.node.id)}
796
+ onmouseenter={() => (hoveredNodeIndex = p.i)}
797
+ onmouseleave={() => (hoveredNodeIndex = null)}
798
+ onfocus={() => (hoveredNodeIndex = p.i)}
799
+ onblur={() => (hoveredNodeIndex = null)}
800
+ onclick={() => onSelect?.(p.node.id)}
801
+ ondblclick={() => onOpenEntity?.(p.node.id)}
802
+ onkeydown={(e) => handleNodeKeydown(p.node.id, e)}
803
+ />
804
+ {/if}
358
805
  {#if showLabels}
359
806
  <text class="st-forceGraph__label" x={p.r + 3} y="0" dominant-baseline="middle">{p.title}</text>
360
807
  {/if}
@@ -363,15 +810,16 @@
363
810
  </g>
364
811
  </svg>
365
812
 
366
- {#if hoveredIndex !== null && positionedNodes[hoveredIndex]}
367
- {@const p = positionedNodes[hoveredIndex]}
813
+ <!-- Node tooltip -->
814
+ {#if hoveredNodeIndex !== null && positionedNodes[hoveredNodeIndex]}
815
+ {@const p = positionedNodes[hoveredNodeIndex]}
368
816
  {@const relCount = positionedEdges.filter(
369
817
  (e) => e.edge.source === p.node.id || e.edge.target === p.node.id
370
818
  ).length}
371
819
  <div
372
820
  class="st-forceGraph__tooltip"
373
821
  role="presentation"
374
- style="left: {(p.x / width) * 100}%; top: {(p.y / height) * 100}%"
822
+ style="left: {((p.x - vbX) / vbW) * 100}%; top: {((p.y - vbY) / vbH) * 100}%"
375
823
  >
376
824
  <span class="st-forceGraph__tooltipLabel">{p.title}</span>
377
825
  {#if p.node.group !== undefined}
@@ -382,6 +830,91 @@
382
830
  {/if}
383
831
  </div>
384
832
  {/if}
833
+
834
+ <!-- Edge tooltip -->
835
+ {#if hoveredEdgeIndex !== null}
836
+ {@const e = positionedEdges.find((pe) => pe.i === hoveredEdgeIndex)}
837
+ {#if e}
838
+ <div
839
+ class="st-forceGraph__tooltip st-forceGraph__tooltip--edge"
840
+ role="presentation"
841
+ style="left: {((e.midX - vbX) / vbW) * 100}%; top: {((e.midY - vbY) / vbH) * 100}%"
842
+ >
843
+ <span class="st-forceGraph__tooltipLabel">{e.srcLabel}</span>
844
+ {#if e.edge.relation}
845
+ <span class="st-forceGraph__tooltipRelation">{e.edge.relation}</span>
846
+ {/if}
847
+ <span class="st-forceGraph__tooltipLabel">{e.tgtLabel}</span>
848
+ </div>
849
+ {/if}
850
+ {/if}
851
+
852
+ <!-- Reset view button (only shown when zoomed/panned) -->
853
+ {#if isZoomed}
854
+ <button
855
+ class="st-forceGraph__resetBtn"
856
+ type="button"
857
+ aria-label="Reset view"
858
+ onclick={resetView}
859
+ >
860
+
861
+ </button>
862
+ {/if}
863
+
864
+ <!-- Legend overlay -->
865
+ {#if legend && legend.length > 0}
866
+ <div class="st-forceGraph__legend" aria-label="Graph legend">
867
+ {#each legend as entry}
868
+ {@const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null}
869
+ {@const swatchTone = entry.tone ?? "category1"}
870
+ {@const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null}
871
+ <div class="st-forceGraph__legendEntry">
872
+ {#if entry.shape !== undefined}
873
+ <!-- Node shape legend entry (viewBox widened for area-scaled glyphs) -->
874
+ <svg
875
+ class="st-forceGraph__legendSwatch"
876
+ viewBox="-13 -13 26 26"
877
+ width="16"
878
+ height="16"
879
+ aria-hidden="true"
880
+ >
881
+ {#if swatchPath}
882
+ <path
883
+ d={swatchPath}
884
+ class="st-forceGraph__legendShape st-forceGraph__legendShape--{swatchTone}"
885
+ />
886
+ {:else}
887
+ <circle
888
+ r="7"
889
+ class="st-forceGraph__legendShape st-forceGraph__legendShape--{swatchTone}"
890
+ />
891
+ {/if}
892
+ </svg>
893
+ {:else}
894
+ <!-- Edge style legend entry -->
895
+ <svg
896
+ class="st-forceGraph__legendSwatch"
897
+ viewBox="0 0 16 8"
898
+ width="16"
899
+ height="8"
900
+ aria-hidden="true"
901
+ >
902
+ <line
903
+ x1="0"
904
+ y1="4"
905
+ x2="16"
906
+ y2="4"
907
+ class="st-forceGraph__legendEdge"
908
+ class:st-forceGraph__legendEdge--weak={entry.weak}
909
+ stroke-dasharray={swatchDash}
910
+ />
911
+ </svg>
912
+ {/if}
913
+ <span class="st-forceGraph__legendLabel">{entry.label}</span>
914
+ </div>
915
+ {/each}
916
+ </div>
917
+ {/if}
385
918
  </div>
386
919
 
387
920
  <style>
@@ -395,18 +928,46 @@
395
928
 
396
929
  .st-forceGraph svg { display: block; overflow: visible; }
397
930
 
931
+ .st-forceGraph__svg--panning { cursor: grabbing; }
932
+
398
933
  .st-forceGraph__edge {
399
934
  stroke: var(--st-semantic-border-strong);
400
935
  stroke-width: 1;
401
936
  opacity: 0.55;
937
+ transition: opacity 120ms ease, stroke-width 120ms ease;
402
938
  }
403
939
 
404
940
  .st-forceGraph__edge--weak {
405
941
  stroke: var(--st-semantic-border-subtle);
406
- stroke-dasharray: 3 3;
407
942
  opacity: 0.5;
408
943
  }
409
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. */
953
+ .st-forceGraph__edge--hovered {
954
+ opacity: 1;
955
+ stroke-width: 2;
956
+ }
957
+
958
+ /* Dimmed by an active selection (edge touches no selected/focused node). */
959
+ .st-forceGraph__edge--dim {
960
+ opacity: 0.12;
961
+ }
962
+
963
+ /* Invisible wide hit target for edge hover */
964
+ .st-forceGraph__edgeHit {
965
+ stroke: transparent;
966
+ stroke-width: 10;
967
+ fill: none;
968
+ cursor: crosshair;
969
+ }
970
+
410
971
  .st-forceGraph__node { transition: opacity 120ms ease; }
411
972
  .st-forceGraph__node--dim { opacity: 0.3; }
412
973
 
@@ -475,9 +1036,102 @@
475
1036
 
476
1037
  .st-forceGraph__tooltipLabel { font-weight: 600; }
477
1038
  .st-forceGraph__tooltipMeta { opacity: 0.85; }
1039
+ .st-forceGraph__tooltipRelation {
1040
+ opacity: 0.75;
1041
+ font-style: italic;
1042
+ font-size: 0.6875rem;
1043
+ }
1044
+
1045
+ /* Reset view button */
1046
+ .st-forceGraph__resetBtn {
1047
+ background: var(--st-semantic-surface-overlay, rgba(0,0,0,0.55));
1048
+ border: none;
1049
+ border-radius: var(--st-radius-sm, 0.25rem);
1050
+ color: var(--st-semantic-text-inverse, #fff);
1051
+ cursor: pointer;
1052
+ font-size: 1rem;
1053
+ line-height: 1;
1054
+ padding: 0.25rem 0.5rem;
1055
+ position: absolute;
1056
+ bottom: 0.5rem;
1057
+ right: 0.5rem;
1058
+ opacity: 0.8;
1059
+ transition: opacity 120ms ease;
1060
+ z-index: 2;
1061
+ }
1062
+
1063
+ .st-forceGraph__resetBtn:hover,
1064
+ .st-forceGraph__resetBtn:focus-visible {
1065
+ opacity: 1;
1066
+ }
1067
+
1068
+ .st-forceGraph__resetBtn:focus-visible {
1069
+ outline: 2px solid var(--st-semantic-border-interactive);
1070
+ outline-offset: 2px;
1071
+ }
1072
+
1073
+ /* Legend overlay */
1074
+ .st-forceGraph__legend {
1075
+ background: var(--st-semantic-surface-overlay, rgba(0,0,0,0.45));
1076
+ border-radius: var(--st-radius-sm, 0.25rem);
1077
+ color: var(--st-semantic-text-inverse, #fff);
1078
+ display: flex;
1079
+ flex-direction: column;
1080
+ font-size: 0.6875rem;
1081
+ gap: 0.25rem;
1082
+ padding: 0.375rem 0.5rem;
1083
+ pointer-events: none;
1084
+ position: absolute;
1085
+ bottom: 0.5rem;
1086
+ left: 0.5rem;
1087
+ z-index: 2;
1088
+ }
1089
+
1090
+ .st-forceGraph__legendEntry {
1091
+ align-items: center;
1092
+ display: flex;
1093
+ gap: 0.375rem;
1094
+ }
1095
+
1096
+ .st-forceGraph__legendSwatch {
1097
+ flex-shrink: 0;
1098
+ }
1099
+
1100
+ .st-forceGraph__legendLabel {
1101
+ white-space: nowrap;
1102
+ }
1103
+
1104
+ .st-forceGraph__legendShape {
1105
+ fill-opacity: 0.9;
1106
+ stroke: var(--st-semantic-surface-default, #fff);
1107
+ stroke-width: 1;
1108
+ }
1109
+
1110
+ .st-forceGraph__legendShape--category1 { fill: var(--st-semantic-data-category1); }
1111
+ .st-forceGraph__legendShape--category2 { fill: var(--st-semantic-data-category2); }
1112
+ .st-forceGraph__legendShape--category3 { fill: var(--st-semantic-data-category3); }
1113
+ .st-forceGraph__legendShape--category4 { fill: var(--st-semantic-data-category4); }
1114
+ .st-forceGraph__legendShape--category5 { fill: var(--st-semantic-data-category5); }
1115
+ .st-forceGraph__legendShape--category6 { fill: var(--st-semantic-data-category6); }
1116
+ .st-forceGraph__legendShape--category7 { fill: var(--st-semantic-data-category7); }
1117
+ .st-forceGraph__legendShape--category8 { fill: var(--st-semantic-data-category8); }
1118
+
1119
+ .st-forceGraph__legendEdge {
1120
+ stroke: var(--st-semantic-border-strong, #888);
1121
+ stroke-width: 1.5;
1122
+ opacity: 0.8;
1123
+ }
1124
+
1125
+ .st-forceGraph__legendEdge--weak {
1126
+ stroke: var(--st-semantic-border-subtle, #aaa);
1127
+ stroke-dasharray: 3 3;
1128
+ opacity: 0.65;
1129
+ }
478
1130
 
479
1131
  @media (prefers-reduced-motion: reduce) {
480
1132
  .st-forceGraph__node,
481
- .st-forceGraph__dot { transition: none; }
1133
+ .st-forceGraph__dot,
1134
+ .st-forceGraph__edge,
1135
+ .st-forceGraph__resetBtn { transition: none; }
482
1136
  }
483
1137
  </style>