@sentropic/design-system-svelte 0.10.2 → 0.10.4

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 (87) 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/Checkbox.svelte +4 -0
  16. package/dist/Combobox.svelte +1 -1
  17. package/dist/ContentSwitcher.svelte +1 -0
  18. package/dist/DataTable.svelte +4 -1
  19. package/dist/DatePicker.svelte +1 -1
  20. package/dist/DisplaySettings.svelte +210 -0
  21. package/dist/DisplaySettings.svelte.d.ts +24 -0
  22. package/dist/DisplaySettings.svelte.d.ts.map +1 -0
  23. package/dist/DonutChart.svelte +44 -29
  24. package/dist/DonutChart.svelte.d.ts.map +1 -1
  25. package/dist/Dropdown.svelte +1 -1
  26. package/dist/FileUploader.svelte +2 -2
  27. package/dist/ForceGraph.svelte +485 -22
  28. package/dist/ForceGraph.svelte.d.ts +48 -0
  29. package/dist/ForceGraph.svelte.d.ts.map +1 -1
  30. package/dist/GraphLegend.svelte +142 -0
  31. package/dist/GraphLegend.svelte.d.ts +12 -0
  32. package/dist/GraphLegend.svelte.d.ts.map +1 -0
  33. package/dist/Header.svelte +2 -1
  34. package/dist/IconButton.svelte +1 -1
  35. package/dist/InlineLoading.svelte +10 -1
  36. package/dist/InlineLoading.svelte.d.ts.map +1 -1
  37. package/dist/Input.svelte +3 -2
  38. package/dist/LanguageSelector.svelte +2 -1
  39. package/dist/LineChart.svelte +38 -26
  40. package/dist/LineChart.svelte.d.ts.map +1 -1
  41. package/dist/Link.svelte +7 -1
  42. package/dist/MediaContent.svelte +124 -0
  43. package/dist/MediaContent.svelte.d.ts +22 -0
  44. package/dist/MediaContent.svelte.d.ts.map +1 -0
  45. package/dist/Menu.svelte +56 -3
  46. package/dist/Menu.svelte.d.ts.map +1 -1
  47. package/dist/MessageStatusBadge.svelte +1 -1
  48. package/dist/MultiSelect.svelte +2 -2
  49. package/dist/Notification.svelte +150 -0
  50. package/dist/Notification.svelte.d.ts +17 -0
  51. package/dist/Notification.svelte.d.ts.map +1 -0
  52. package/dist/NumberInput.svelte +1 -0
  53. package/dist/OverflowMenu.svelte +84 -13
  54. package/dist/OverflowMenu.svelte.d.ts.map +1 -1
  55. package/dist/Pagination.svelte +7 -0
  56. package/dist/PaginationNav.svelte +2 -2
  57. package/dist/ProgressIndicator.svelte +13 -1
  58. package/dist/ProgressIndicator.svelte.d.ts +1 -0
  59. package/dist/ProgressIndicator.svelte.d.ts.map +1 -1
  60. package/dist/Radio.svelte +7 -3
  61. package/dist/ScatterPlot.svelte +64 -45
  62. package/dist/ScatterPlot.svelte.d.ts.map +1 -1
  63. package/dist/Search.svelte +6 -3
  64. package/dist/Select.svelte +8 -2
  65. package/dist/SideNav.svelte +6 -0
  66. package/dist/StackedBarChart.svelte +51 -30
  67. package/dist/StackedBarChart.svelte.d.ts.map +1 -1
  68. package/dist/StreamingMessage.svelte +2 -2
  69. package/dist/Switch.svelte +4 -0
  70. package/dist/Table.svelte +4 -1
  71. package/dist/TableOfContents.svelte +109 -0
  72. package/dist/TableOfContents.svelte.d.ts +16 -0
  73. package/dist/TableOfContents.svelte.d.ts.map +1 -0
  74. package/dist/Tag.svelte +1 -1
  75. package/dist/Textarea.svelte +3 -2
  76. package/dist/Tile.svelte +4 -0
  77. package/dist/TileGroup.svelte +4 -0
  78. package/dist/Toggle.svelte +4 -0
  79. package/dist/Toggletip.svelte +1 -1
  80. package/dist/Transcription.svelte +135 -0
  81. package/dist/Transcription.svelte.d.ts +19 -0
  82. package/dist/Transcription.svelte.d.ts.map +1 -0
  83. package/dist/TreeView.svelte +2 -2
  84. package/dist/index.d.ts +12 -1
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +8 -0
  87. package/package.json +2 -2
@@ -3,6 +3,14 @@
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
+ | "triangle";
13
+
6
14
  export type ForceGraphNode = {
7
15
  /** Stable identifier; referenced by edges. */
8
16
  id: string;
@@ -20,6 +28,11 @@
20
28
  /** Pin the node to a fixed position (ignored by the simulation). */
21
29
  fx?: number;
22
30
  fy?: number;
31
+ /**
32
+ * Visual shape for the node. Defaults to 'dot' (circle).
33
+ * Supported: 'dot'|'circle', 'diamond', 'star', 'hexagon', 'box'|'square', 'triangle'.
34
+ */
35
+ shape?: ForceGraphNodeShape;
23
36
  };
24
37
 
25
38
  export type ForceGraphEdge = {
@@ -35,6 +48,57 @@
35
48
  */
36
49
  weak?: boolean;
37
50
  };
51
+
52
+ export type ForceGraphLegendEntry = {
53
+ /** Label shown in the legend. */
54
+ label: string;
55
+ /** Shape for this entry (node legend). Absent = line-style legend entry. */
56
+ shape?: ForceGraphNodeShape;
57
+ /** Tone for this entry. Defaults to category1. */
58
+ tone?: ForceGraphTone;
59
+ /** When true, renders as a dashed line (edge legend). */
60
+ weak?: boolean;
61
+ };
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // SVG path helpers for the various node shapes.
65
+ // All shapes are centered at (0,0) and sized to inscribe within radius r.
66
+ // ---------------------------------------------------------------------------
67
+ export function nodeShapePath(shape: ForceGraphNodeShape | undefined, r: number): string | null {
68
+ const s = shape ?? "dot";
69
+ if (s === "dot" || s === "circle") return null; // use <circle>
70
+ if (s === "diamond") {
71
+ return `M 0 ${-r} L ${r} 0 L 0 ${r} L ${-r} 0 Z`;
72
+ }
73
+ if (s === "star") {
74
+ const outer = r;
75
+ const inner = r * 0.42;
76
+ const pts: string[] = [];
77
+ for (let i = 0; i < 10; i++) {
78
+ const angle = (i * Math.PI) / 5 - Math.PI / 2;
79
+ const rad = i % 2 === 0 ? outer : inner;
80
+ pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
81
+ }
82
+ return `M ${pts.join(" L ")} Z`;
83
+ }
84
+ if (s === "hexagon") {
85
+ const pts: string[] = [];
86
+ for (let i = 0; i < 6; i++) {
87
+ const angle = (i * Math.PI) / 3 - Math.PI / 6;
88
+ pts.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
89
+ }
90
+ return `M ${pts.join(" L ")} Z`;
91
+ }
92
+ 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`;
95
+ }
96
+ 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`;
99
+ }
100
+ return null;
101
+ }
38
102
  </script>
39
103
 
40
104
  <script lang="ts">
@@ -54,6 +118,37 @@
54
118
  * animates the remainder unless reduced motion is requested.
55
119
  */
56
120
  iterations?: number;
121
+ /**
122
+ * IDs of currently selected nodes. Highlighted visually without
123
+ * re-running the layout. Defaults to [].
124
+ */
125
+ selectedIds?: string[];
126
+ /**
127
+ * ID of the node to focus/centre visually (ring highlight). Does not
128
+ * re-run the layout. Defaults to null.
129
+ */
130
+ focusId?: string | null;
131
+ /**
132
+ * Called when the user clicks (or presses Space/Enter) a node.
133
+ * Fires with the node's stable id.
134
+ */
135
+ onSelect?: (id: string) => void;
136
+ /**
137
+ * Called when the user activates a node (double-click or Enter key while
138
+ * keyboard-focused). Intended to open a detail panel.
139
+ * Fires with the node's stable id.
140
+ */
141
+ onOpenEntity?: (id: string) => void;
142
+ /**
143
+ * Called when the user hovers an edge.
144
+ * Fires with the edge object (source/target/relation/weak).
145
+ */
146
+ onEdgeHover?: (edge: ForceGraphEdge) => void;
147
+ /**
148
+ * Legend entries rendered as a corner overlay.
149
+ * Each entry has a label + optional shape (node) or weak (edge).
150
+ */
151
+ legend?: ForceGraphLegendEntry[];
57
152
  class?: string;
58
153
  };
59
154
 
@@ -66,6 +161,12 @@
66
161
  nodeRadius = 7,
67
162
  showLabels = true,
68
163
  iterations = 300,
164
+ selectedIds = [],
165
+ focusId = null,
166
+ onSelect,
167
+ onOpenEntity,
168
+ onEdgeHover,
169
+ legend,
69
170
  class: className
70
171
  }: ForceGraphProps = $props();
71
172
 
@@ -237,30 +338,134 @@
237
338
  const positionedNodes = $derived.by(() =>
238
339
  nodes.map((n, i) => {
239
340
  const p = layout.get(n.id) ?? { x: width / 2, y: height / 2 };
341
+ const r = nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25));
342
+ const shapePath = nodeShapePath(n.shape, r);
240
343
  return {
241
344
  node: n,
242
345
  i,
243
346
  x: p.x,
244
347
  y: p.y,
245
- r: nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25)),
348
+ r,
246
349
  tone: toneMap.get(n.id) ?? "category1",
247
- title: n.label ?? n.id
350
+ title: n.label ?? n.id,
351
+ shapePath
248
352
  };
249
353
  })
250
354
  );
251
355
 
252
356
  const positionedEdges = $derived.by(() => {
357
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
253
358
  return edges
254
359
  .map((e, i) => {
255
360
  const a = layout.get(e.source);
256
361
  const b = layout.get(e.target);
257
362
  if (!a || !b) return null;
258
- return { edge: e, i, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
363
+ const srcNode = nodeById.get(e.source);
364
+ const tgtNode = nodeById.get(e.target);
365
+ return {
366
+ edge: e,
367
+ i,
368
+ x1: a.x,
369
+ y1: a.y,
370
+ x2: b.x,
371
+ y2: b.y,
372
+ srcLabel: srcNode?.label ?? e.source,
373
+ tgtLabel: tgtNode?.label ?? e.target
374
+ };
259
375
  })
260
376
  .filter((e): e is NonNullable<typeof e> => e !== null);
261
377
  });
262
378
 
263
- let hoveredIndex: number | null = $state(null);
379
+ let hoveredNodeIndex: number | null = $state(null);
380
+ let hoveredEdgeIndex: number | null = $state(null);
381
+
382
+ // Fast lookup sets — recomputed only when selectedIds/focusId props change,
383
+ // never when nodes/edges change.
384
+ const selectedSet = $derived(new Set<string>(selectedIds));
385
+
386
+ // Keyboard handler for a node circle: Space/Enter → onSelect, Enter → onOpenEntity.
387
+ function handleNodeKeydown(id: string, e: KeyboardEvent) {
388
+ if (e.key === "Enter" || e.key === " ") {
389
+ e.preventDefault();
390
+ onSelect?.(id);
391
+ }
392
+ if (e.key === "Enter") {
393
+ onOpenEntity?.(id);
394
+ }
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
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
403
+ // ---------------------------------------------------------------------------
404
+ let zoomScale = $state(1);
405
+ let panX = $state(0);
406
+ let panY = $state(0);
407
+
408
+ let isPanning = $state(false);
409
+ let panStart = $state({ x: 0, y: 0, panX: 0, panY: 0 });
410
+ let svgEl: SVGSVGElement | null = $state(null);
411
+
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);
417
+
418
+ function resetView() {
419
+ zoomScale = 1;
420
+ panX = 0;
421
+ panY = 0;
422
+ }
423
+
424
+ function handleWheel(ev: WheelEvent) {
425
+ if (prefersReducedMotion) return;
426
+ ev.preventDefault();
427
+ // Zoom factor: ~10% per step.
428
+ const factor = ev.deltaY > 0 ? 0.9 : 1.1;
429
+ // Clamp zoom: 0.2x – 8x.
430
+ const newScale = Math.min(Math.max(zoomScale * factor, 0.2), 8);
431
+ // Anchor zoom around the cursor position in SVG coords.
432
+ if (svgEl) {
433
+ 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;
442
+ }
443
+ zoomScale = newScale;
444
+ }
445
+
446
+ function handleBgMouseDown(ev: MouseEvent) {
447
+ // Only start pan when clicking the background (not a node/edge element).
448
+ if ((ev.target as Element).closest(".st-forceGraph__node")) return;
449
+ if (prefersReducedMotion) return;
450
+ isPanning = true;
451
+ panStart = { x: ev.clientX, y: ev.clientY, panX, panY };
452
+ }
453
+
454
+ function handleMouseMove(ev: MouseEvent) {
455
+ if (!isPanning || !svgEl) return;
456
+ const rect = svgEl.getBoundingClientRect();
457
+ const dx = ((ev.clientX - panStart.x) / rect.width) * vbW;
458
+ const dy = ((ev.clientY - panStart.y) / rect.height) * vbH;
459
+ panX = panStart.panX - dx;
460
+ panY = panStart.panY - dy;
461
+ }
462
+
463
+ function handleMouseUp() {
464
+ isPanning = false;
465
+ }
466
+
467
+ const viewBox = $derived(`${vbX} ${vbY} ${vbW} ${vbH}`);
468
+ const isZoomed = $derived(zoomScale !== 1 || panX !== 0 || panY !== 0);
264
469
 
265
470
  const classes = () =>
266
471
  ["st-forceGraph", prefersReducedMotion ? "st-forceGraph--static" : null, className]
@@ -268,25 +473,49 @@
268
473
  .join(" ");
269
474
  </script>
270
475
 
271
- <div class={classes()} role="img" aria-label={label}>
476
+ <div
477
+ class={classes()}
478
+ role="img"
479
+ aria-label={label}
480
+ >
272
481
  <svg
273
- viewBox="0 0 {width} {height}"
482
+ bind:this={svgEl}
483
+ viewBox={viewBox}
274
484
  preserveAspectRatio="xMidYMid meet"
275
485
  width="100%"
276
486
  height="100%"
277
487
  focusable="false"
278
488
  aria-hidden="true"
489
+ class:st-forceGraph__svg--panning={isPanning}
490
+ onwheel={handleWheel}
491
+ onmousedown={handleBgMouseDown}
492
+ onmousemove={handleMouseMove}
493
+ onmouseup={handleMouseUp}
494
+ onmouseleave={handleMouseUp}
279
495
  >
280
496
  <!-- edges first so nodes paint on top -->
281
497
  <g class="st-forceGraph__edges">
282
498
  {#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
+ />
283
510
  <line
284
511
  class="st-forceGraph__edge"
285
512
  class:st-forceGraph__edge--weak={e.edge.weak}
513
+ class:st-forceGraph__edge--hovered={hoveredEdgeIndex === e.i}
286
514
  x1={e.x1}
287
515
  y1={e.y1}
288
516
  x2={e.x2}
289
517
  y2={e.y2}
518
+ pointer-events="none"
290
519
  />
291
520
  {/each}
292
521
  </g>
@@ -295,20 +524,44 @@
295
524
  {#each positionedNodes as p (p.node.id)}
296
525
  <g
297
526
  class="st-forceGraph__node st-forceGraph__node--{p.tone}"
298
- class:st-forceGraph__node--dim={hoveredIndex !== null && hoveredIndex !== p.i}
527
+ class:st-forceGraph__node--dim={hoveredNodeIndex !== null && hoveredNodeIndex !== p.i}
528
+ class:st-forceGraph__node--selected={selectedSet.has(p.node.id)}
529
+ class:st-forceGraph__node--focus={focusId === p.node.id}
299
530
  transform="translate({p.x} {p.y})"
300
531
  >
301
- <circle
302
- class="st-forceGraph__dot"
303
- r={p.r}
304
- tabindex="0"
305
- role="img"
306
- aria-label="{p.title}{p.node.group !== undefined ? ` — ${p.node.group}` : ''}"
307
- onmouseenter={() => (hoveredIndex = p.i)}
308
- onmouseleave={() => (hoveredIndex = null)}
309
- onfocus={() => (hoveredIndex = p.i)}
310
- onblur={() => (hoveredIndex = null)}
311
- />
532
+ {#if p.shapePath}
533
+ <path
534
+ class="st-forceGraph__dot"
535
+ d={p.shapePath}
536
+ tabindex="0"
537
+ role="button"
538
+ aria-label="{p.title}{p.node.group !== undefined ? `: ${p.node.group}` : ''}"
539
+ aria-pressed={selectedSet.has(p.node.id)}
540
+ onmouseenter={() => (hoveredNodeIndex = p.i)}
541
+ onmouseleave={() => (hoveredNodeIndex = null)}
542
+ onfocus={() => (hoveredNodeIndex = p.i)}
543
+ onblur={() => (hoveredNodeIndex = null)}
544
+ onclick={() => onSelect?.(p.node.id)}
545
+ ondblclick={() => onOpenEntity?.(p.node.id)}
546
+ onkeydown={(e) => handleNodeKeydown(p.node.id, e)}
547
+ />
548
+ {:else}
549
+ <circle
550
+ class="st-forceGraph__dot"
551
+ r={p.r}
552
+ tabindex="0"
553
+ role="button"
554
+ aria-label="{p.title}{p.node.group !== undefined ? `: ${p.node.group}` : ''}"
555
+ aria-pressed={selectedSet.has(p.node.id)}
556
+ onmouseenter={() => (hoveredNodeIndex = p.i)}
557
+ onmouseleave={() => (hoveredNodeIndex = null)}
558
+ onfocus={() => (hoveredNodeIndex = p.i)}
559
+ onblur={() => (hoveredNodeIndex = null)}
560
+ onclick={() => onSelect?.(p.node.id)}
561
+ ondblclick={() => onOpenEntity?.(p.node.id)}
562
+ onkeydown={(e) => handleNodeKeydown(p.node.id, e)}
563
+ />
564
+ {/if}
312
565
  {#if showLabels}
313
566
  <text class="st-forceGraph__label" x={p.r + 3} y="0" dominant-baseline="middle">{p.title}</text>
314
567
  {/if}
@@ -317,15 +570,16 @@
317
570
  </g>
318
571
  </svg>
319
572
 
320
- {#if hoveredIndex !== null && positionedNodes[hoveredIndex]}
321
- {@const p = positionedNodes[hoveredIndex]}
573
+ <!-- Node tooltip -->
574
+ {#if hoveredNodeIndex !== null && positionedNodes[hoveredNodeIndex]}
575
+ {@const p = positionedNodes[hoveredNodeIndex]}
322
576
  {@const relCount = positionedEdges.filter(
323
577
  (e) => e.edge.source === p.node.id || e.edge.target === p.node.id
324
578
  ).length}
325
579
  <div
326
580
  class="st-forceGraph__tooltip"
327
581
  role="presentation"
328
- style="left: {(p.x / width) * 100}%; top: {(p.y / height) * 100}%"
582
+ style="left: {((p.x - vbX) / vbW) * 100}%; top: {((p.y - vbY) / vbH) * 100}%"
329
583
  >
330
584
  <span class="st-forceGraph__tooltipLabel">{p.title}</span>
331
585
  {#if p.node.group !== undefined}
@@ -336,6 +590,91 @@
336
590
  {/if}
337
591
  </div>
338
592
  {/if}
593
+
594
+ <!-- Edge tooltip -->
595
+ {#if hoveredEdgeIndex !== null}
596
+ {@const e = positionedEdges.find((pe) => pe.i === hoveredEdgeIndex)}
597
+ {#if e}
598
+ {@const midX = (e.x1 + e.x2) / 2}
599
+ {@const midY = (e.y1 + e.y2) / 2}
600
+ <div
601
+ class="st-forceGraph__tooltip st-forceGraph__tooltip--edge"
602
+ role="presentation"
603
+ style="left: {((midX - vbX) / vbW) * 100}%; top: {((midY - vbY) / vbH) * 100}%"
604
+ >
605
+ <span class="st-forceGraph__tooltipLabel">{e.srcLabel}</span>
606
+ {#if e.edge.relation}
607
+ <span class="st-forceGraph__tooltipRelation">{e.edge.relation}</span>
608
+ {/if}
609
+ <span class="st-forceGraph__tooltipLabel">{e.tgtLabel}</span>
610
+ </div>
611
+ {/if}
612
+ {/if}
613
+
614
+ <!-- Reset view button (only shown when zoomed/panned) -->
615
+ {#if isZoomed}
616
+ <button
617
+ class="st-forceGraph__resetBtn"
618
+ type="button"
619
+ aria-label="Reset view"
620
+ onclick={resetView}
621
+ >
622
+
623
+ </button>
624
+ {/if}
625
+
626
+ <!-- Legend overlay -->
627
+ {#if legend && legend.length > 0}
628
+ <div class="st-forceGraph__legend" aria-label="Graph legend">
629
+ {#each legend as entry}
630
+ {@const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null}
631
+ {@const swatchTone = entry.tone ?? "category1"}
632
+ <div class="st-forceGraph__legendEntry">
633
+ {#if entry.shape !== undefined}
634
+ <!-- Node shape legend entry -->
635
+ <svg
636
+ class="st-forceGraph__legendSwatch"
637
+ viewBox="-8 -8 16 16"
638
+ width="16"
639
+ height="16"
640
+ aria-hidden="true"
641
+ >
642
+ {#if swatchPath}
643
+ <path
644
+ d={swatchPath}
645
+ class="st-forceGraph__legendShape st-forceGraph__legendShape--{swatchTone}"
646
+ />
647
+ {:else}
648
+ <circle
649
+ r="7"
650
+ class="st-forceGraph__legendShape st-forceGraph__legendShape--{swatchTone}"
651
+ />
652
+ {/if}
653
+ </svg>
654
+ {:else}
655
+ <!-- Edge style legend entry -->
656
+ <svg
657
+ class="st-forceGraph__legendSwatch"
658
+ viewBox="0 0 16 8"
659
+ width="16"
660
+ height="8"
661
+ aria-hidden="true"
662
+ >
663
+ <line
664
+ x1="0"
665
+ y1="4"
666
+ x2="16"
667
+ y2="4"
668
+ class="st-forceGraph__legendEdge"
669
+ class:st-forceGraph__legendEdge--weak={entry.weak}
670
+ />
671
+ </svg>
672
+ {/if}
673
+ <span class="st-forceGraph__legendLabel">{entry.label}</span>
674
+ </div>
675
+ {/each}
676
+ </div>
677
+ {/if}
339
678
  </div>
340
679
 
341
680
  <style>
@@ -349,10 +688,13 @@
349
688
 
350
689
  .st-forceGraph svg { display: block; overflow: visible; }
351
690
 
691
+ .st-forceGraph__svg--panning { cursor: grabbing; }
692
+
352
693
  .st-forceGraph__edge {
353
694
  stroke: var(--st-semantic-border-strong);
354
695
  stroke-width: 1;
355
696
  opacity: 0.55;
697
+ transition: opacity 120ms ease, stroke-width 120ms ease;
356
698
  }
357
699
 
358
700
  .st-forceGraph__edge--weak {
@@ -361,6 +703,19 @@
361
703
  opacity: 0.5;
362
704
  }
363
705
 
706
+ .st-forceGraph__edge--hovered {
707
+ opacity: 0.9;
708
+ stroke-width: 2;
709
+ }
710
+
711
+ /* Invisible wide hit target for edge hover */
712
+ .st-forceGraph__edgeHit {
713
+ stroke: transparent;
714
+ stroke-width: 10;
715
+ fill: none;
716
+ cursor: crosshair;
717
+ }
718
+
364
719
  .st-forceGraph__node { transition: opacity 120ms ease; }
365
720
  .st-forceGraph__node--dim { opacity: 0.3; }
366
721
 
@@ -380,6 +735,21 @@
380
735
  outline-offset: 1px;
381
736
  }
382
737
 
738
+ /* Selection highlight: slightly thicker stroke ring, full opacity. */
739
+ .st-forceGraph__node--selected .st-forceGraph__dot {
740
+ fill-opacity: 1;
741
+ stroke: var(--st-semantic-border-interactive, #0f62fe);
742
+ stroke-width: 2.5;
743
+ }
744
+
745
+ /* Focus (keyboard/programmatic focus): stronger ring + slight scale. */
746
+ .st-forceGraph__node--focus .st-forceGraph__dot {
747
+ fill-opacity: 1;
748
+ stroke: var(--st-semantic-border-interactive, #0f62fe);
749
+ stroke-width: 3.5;
750
+ filter: drop-shadow(0 0 4px var(--st-semantic-border-interactive, #0f62fe));
751
+ }
752
+
383
753
  .st-forceGraph__label {
384
754
  fill: var(--st-semantic-text-secondary);
385
755
  font-size: 0.6875rem;
@@ -414,9 +784,102 @@
414
784
 
415
785
  .st-forceGraph__tooltipLabel { font-weight: 600; }
416
786
  .st-forceGraph__tooltipMeta { opacity: 0.85; }
787
+ .st-forceGraph__tooltipRelation {
788
+ opacity: 0.75;
789
+ font-style: italic;
790
+ font-size: 0.6875rem;
791
+ }
792
+
793
+ /* Reset view button */
794
+ .st-forceGraph__resetBtn {
795
+ background: var(--st-semantic-surface-overlay, rgba(0,0,0,0.55));
796
+ border: none;
797
+ border-radius: var(--st-radius-sm, 0.25rem);
798
+ color: var(--st-semantic-text-inverse, #fff);
799
+ cursor: pointer;
800
+ font-size: 1rem;
801
+ line-height: 1;
802
+ padding: 0.25rem 0.5rem;
803
+ position: absolute;
804
+ bottom: 0.5rem;
805
+ right: 0.5rem;
806
+ opacity: 0.8;
807
+ transition: opacity 120ms ease;
808
+ z-index: 2;
809
+ }
810
+
811
+ .st-forceGraph__resetBtn:hover,
812
+ .st-forceGraph__resetBtn:focus-visible {
813
+ opacity: 1;
814
+ }
815
+
816
+ .st-forceGraph__resetBtn:focus-visible {
817
+ outline: 2px solid var(--st-semantic-border-interactive);
818
+ outline-offset: 2px;
819
+ }
820
+
821
+ /* Legend overlay */
822
+ .st-forceGraph__legend {
823
+ background: var(--st-semantic-surface-overlay, rgba(0,0,0,0.45));
824
+ border-radius: var(--st-radius-sm, 0.25rem);
825
+ color: var(--st-semantic-text-inverse, #fff);
826
+ display: flex;
827
+ flex-direction: column;
828
+ font-size: 0.6875rem;
829
+ gap: 0.25rem;
830
+ padding: 0.375rem 0.5rem;
831
+ pointer-events: none;
832
+ position: absolute;
833
+ bottom: 0.5rem;
834
+ left: 0.5rem;
835
+ z-index: 2;
836
+ }
837
+
838
+ .st-forceGraph__legendEntry {
839
+ align-items: center;
840
+ display: flex;
841
+ gap: 0.375rem;
842
+ }
843
+
844
+ .st-forceGraph__legendSwatch {
845
+ flex-shrink: 0;
846
+ }
847
+
848
+ .st-forceGraph__legendLabel {
849
+ white-space: nowrap;
850
+ }
851
+
852
+ .st-forceGraph__legendShape {
853
+ fill-opacity: 0.9;
854
+ stroke: var(--st-semantic-surface-default, #fff);
855
+ stroke-width: 1;
856
+ }
857
+
858
+ .st-forceGraph__legendShape--category1 { fill: var(--st-semantic-data-category1); }
859
+ .st-forceGraph__legendShape--category2 { fill: var(--st-semantic-data-category2); }
860
+ .st-forceGraph__legendShape--category3 { fill: var(--st-semantic-data-category3); }
861
+ .st-forceGraph__legendShape--category4 { fill: var(--st-semantic-data-category4); }
862
+ .st-forceGraph__legendShape--category5 { fill: var(--st-semantic-data-category5); }
863
+ .st-forceGraph__legendShape--category6 { fill: var(--st-semantic-data-category6); }
864
+ .st-forceGraph__legendShape--category7 { fill: var(--st-semantic-data-category7); }
865
+ .st-forceGraph__legendShape--category8 { fill: var(--st-semantic-data-category8); }
866
+
867
+ .st-forceGraph__legendEdge {
868
+ stroke: var(--st-semantic-border-strong, #888);
869
+ stroke-width: 1.5;
870
+ opacity: 0.8;
871
+ }
872
+
873
+ .st-forceGraph__legendEdge--weak {
874
+ stroke: var(--st-semantic-border-subtle, #aaa);
875
+ stroke-dasharray: 3 3;
876
+ opacity: 0.65;
877
+ }
417
878
 
418
879
  @media (prefers-reduced-motion: reduce) {
419
880
  .st-forceGraph__node,
420
- .st-forceGraph__dot { transition: none; }
881
+ .st-forceGraph__dot,
882
+ .st-forceGraph__edge,
883
+ .st-forceGraph__resetBtn { transition: none; }
421
884
  }
422
885
  </style>