@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.
- package/dist/AreaChart.svelte +38 -26
- package/dist/AreaChart.svelte.d.ts.map +1 -1
- package/dist/BackToTop.svelte +157 -0
- package/dist/BackToTop.svelte.d.ts +14 -0
- package/dist/BackToTop.svelte.d.ts.map +1 -0
- package/dist/BarChart.svelte +38 -39
- package/dist/BarChart.svelte.d.ts.map +1 -1
- package/dist/Card.svelte +2 -0
- package/dist/ChartDataList.svelte +33 -0
- package/dist/ChartDataList.svelte.d.ts +9 -0
- package/dist/ChartDataList.svelte.d.ts.map +1 -0
- package/dist/ChatComposer.svelte +20 -3
- package/dist/ChatComposer.svelte.d.ts +6 -30
- package/dist/ChatComposer.svelte.d.ts.map +1 -1
- package/dist/ChatMessage.svelte +9 -2
- package/dist/ChatMessage.svelte.d.ts +7 -1
- package/dist/ChatMessage.svelte.d.ts.map +1 -1
- package/dist/Checkbox.svelte +4 -0
- package/dist/Combobox.svelte +1 -1
- package/dist/ContentSwitcher.svelte +1 -0
- package/dist/DataTable.svelte +4 -1
- package/dist/DatePicker.svelte +1 -1
- package/dist/DisplaySettings.svelte +210 -0
- package/dist/DisplaySettings.svelte.d.ts +24 -0
- package/dist/DisplaySettings.svelte.d.ts.map +1 -0
- package/dist/DonutChart.svelte +44 -29
- package/dist/DonutChart.svelte.d.ts.map +1 -1
- package/dist/Dropdown.svelte +1 -1
- package/dist/FileUploader.svelte +2 -2
- package/dist/ForceGraph.svelte +692 -38
- package/dist/ForceGraph.svelte.d.ts +59 -0
- package/dist/ForceGraph.svelte.d.ts.map +1 -1
- package/dist/GraphLegend.svelte +143 -0
- package/dist/GraphLegend.svelte.d.ts +12 -0
- package/dist/GraphLegend.svelte.d.ts.map +1 -0
- package/dist/Header.svelte +2 -1
- package/dist/IconButton.svelte +1 -1
- package/dist/InlineLoading.svelte +10 -1
- package/dist/InlineLoading.svelte.d.ts.map +1 -1
- package/dist/Input.svelte +3 -2
- package/dist/LanguageSelector.svelte +2 -1
- package/dist/LineChart.svelte +38 -26
- package/dist/LineChart.svelte.d.ts.map +1 -1
- package/dist/Link.svelte +7 -1
- package/dist/MediaContent.svelte +124 -0
- package/dist/MediaContent.svelte.d.ts +22 -0
- package/dist/MediaContent.svelte.d.ts.map +1 -0
- package/dist/Menu.svelte +56 -3
- package/dist/Menu.svelte.d.ts.map +1 -1
- package/dist/MessageStatusBadge.svelte +1 -1
- package/dist/MultiSelect.svelte +2 -2
- package/dist/Notification.svelte +150 -0
- package/dist/Notification.svelte.d.ts +17 -0
- package/dist/Notification.svelte.d.ts.map +1 -0
- package/dist/NumberInput.svelte +1 -0
- package/dist/OverflowMenu.svelte +84 -13
- package/dist/OverflowMenu.svelte.d.ts.map +1 -1
- package/dist/Pagination.svelte +7 -0
- package/dist/PaginationNav.svelte +2 -2
- package/dist/ProgressIndicator.svelte +13 -1
- package/dist/ProgressIndicator.svelte.d.ts +1 -0
- package/dist/ProgressIndicator.svelte.d.ts.map +1 -1
- package/dist/Radio.svelte +7 -3
- package/dist/ScatterPlot.svelte +64 -45
- package/dist/ScatterPlot.svelte.d.ts.map +1 -1
- package/dist/Search.svelte +6 -3
- package/dist/Select.svelte +8 -2
- package/dist/SideNav.svelte +6 -0
- package/dist/StackedBarChart.svelte +51 -30
- package/dist/StackedBarChart.svelte.d.ts.map +1 -1
- package/dist/StreamingMessage.svelte +2 -2
- package/dist/Switch.svelte +4 -0
- package/dist/Table.svelte +4 -1
- package/dist/TableOfContents.svelte +109 -0
- package/dist/TableOfContents.svelte.d.ts +16 -0
- package/dist/TableOfContents.svelte.d.ts.map +1 -0
- package/dist/Tag.svelte +1 -1
- package/dist/Textarea.svelte +3 -2
- package/dist/Tile.svelte +4 -0
- package/dist/TileGroup.svelte +4 -0
- package/dist/Toggle.svelte +4 -0
- package/dist/Toggletip.svelte +1 -1
- package/dist/Transcription.svelte +135 -0
- package/dist/Transcription.svelte.d.ts +19 -0
- package/dist/Transcription.svelte.d.ts.map +1 -0
- package/dist/TreeView.svelte +2 -2
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/package.json +1 -1
package/dist/ForceGraph.svelte
CHANGED
|
@@ -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
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
686
|
+
<div
|
|
687
|
+
class={classes()}
|
|
688
|
+
role="img"
|
|
689
|
+
aria-label={label}
|
|
690
|
+
>
|
|
312
691
|
<svg
|
|
313
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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={
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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 /
|
|
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
|
|
1133
|
+
.st-forceGraph__dot,
|
|
1134
|
+
.st-forceGraph__edge,
|
|
1135
|
+
.st-forceGraph__resetBtn { transition: none; }
|
|
482
1136
|
}
|
|
483
1137
|
</style>
|