@sentropic/design-system-svelte 0.10.1 → 0.10.2
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/Accordion.svelte +11 -3
- package/dist/Alert.svelte +46 -7
- package/dist/Badge.svelte +16 -7
- package/dist/Breadcrumb.svelte +11 -1
- package/dist/Button.svelte +11 -2
- package/dist/Checkbox.svelte +7 -2
- package/dist/ForceGraph.svelte +422 -0
- package/dist/ForceGraph.svelte.d.ts +54 -0
- package/dist/ForceGraph.svelte.d.ts.map +1 -0
- package/dist/Header.svelte +241 -2
- package/dist/Header.svelte.d.ts +37 -0
- package/dist/Header.svelte.d.ts.map +1 -1
- package/dist/Pagination.svelte +24 -6
- package/dist/Radio.svelte +10 -2
- package/dist/Search.svelte +13 -1
- package/dist/Switch.svelte +11 -9
- package/dist/Tabs.svelte +4 -1
- package/dist/Tag.svelte +18 -10
- package/dist/Toggle.svelte +17 -6
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/package.json +4 -4
package/dist/Accordion.svelte
CHANGED
|
@@ -115,14 +115,22 @@
|
|
|
115
115
|
align-items: center;
|
|
116
116
|
background: transparent;
|
|
117
117
|
border: 0;
|
|
118
|
-
|
|
118
|
+
/* Trigger colour (P-B): per theme. Default = primary text (inherited base
|
|
119
|
+
render); DSFR = Bleu France, Carbon = primary. */
|
|
120
|
+
color: var(--st-component-accordion-text, inherit);
|
|
119
121
|
cursor: pointer;
|
|
120
122
|
display: flex;
|
|
121
123
|
font: inherit;
|
|
122
|
-
|
|
124
|
+
/* Per-theme trigger typography (P-B). Defaults reproduce the prior render:
|
|
125
|
+
font-size inherits the heading size (≈18.72px), weight 600, normal
|
|
126
|
+
line-height. DSFR pins 16px / 500 / 24px, Carbon 13.33px / 400. */
|
|
127
|
+
font-size: var(--st-component-accordion-fontSize, inherit);
|
|
128
|
+
font-weight: var(--st-component-accordion-fontWeight, 600);
|
|
129
|
+
line-height: var(--st-component-accordion-lineHeight, normal);
|
|
123
130
|
gap: 0.75rem;
|
|
124
131
|
justify-content: space-between;
|
|
125
|
-
padding: 0.875rem
|
|
132
|
+
padding: var(--st-component-accordion-paddingBlock, 0.875rem)
|
|
133
|
+
var(--st-component-accordion-paddingInline, 0.5rem);
|
|
126
134
|
text-align: start;
|
|
127
135
|
transition: background-color var(--st-motion-fast, 120ms) var(--st-motion-easing, ease);
|
|
128
136
|
width: 100%;
|
package/dist/Alert.svelte
CHANGED
|
@@ -41,30 +41,69 @@
|
|
|
41
41
|
<style>
|
|
42
42
|
.st-alert {
|
|
43
43
|
background: var(--st-component-alert-background, var(--st-semantic-surface-raised));
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/* Per-side box border (P-B): top/right/bottom resolve per theme (base = 1px
|
|
45
|
+
subtle box; DSFR = none; Carbon = none — its only visible edge is the left
|
|
46
|
+
bar). Fallbacks reproduce the prior 1px subtle box. */
|
|
47
|
+
border-top: var(
|
|
48
|
+
--st-component-alert-borderTop,
|
|
49
|
+
1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle))
|
|
50
|
+
);
|
|
51
|
+
border-right: var(
|
|
52
|
+
--st-component-alert-borderRight,
|
|
53
|
+
1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle))
|
|
54
|
+
);
|
|
55
|
+
border-bottom: var(
|
|
56
|
+
--st-component-alert-borderBottom,
|
|
57
|
+
1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle))
|
|
58
|
+
);
|
|
59
|
+
/* Left accent edge: a real left border of `accentWidth` (base 4px / Carbon
|
|
60
|
+
3px), coloured per severity via --alert-accent. DSFR sets accentWidth 0 and
|
|
61
|
+
draws the accent as a ::before filet instead (see below). */
|
|
62
|
+
border-left-style: solid;
|
|
63
|
+
border-left-width: var(--st-component-alert-accentWidth, 0.25rem);
|
|
64
|
+
border-left-color: var(--alert-accent, var(--st-semantic-feedback-info));
|
|
46
65
|
border-radius: 0;
|
|
47
66
|
color: var(--st-component-alert-text, var(--st-semantic-text-primary));
|
|
48
67
|
display: flex;
|
|
68
|
+
font-size: var(--st-component-alert-fontSize, inherit);
|
|
69
|
+
line-height: var(--st-component-alert-lineHeight, normal);
|
|
70
|
+
letter-spacing: var(--st-component-alert-letterSpacing, normal);
|
|
49
71
|
gap: var(--st-spacing-4, 1rem);
|
|
50
72
|
justify-content: space-between;
|
|
51
|
-
|
|
73
|
+
position: relative;
|
|
74
|
+
padding: var(--st-component-alert-paddingTop, var(--st-spacing-4, 1rem))
|
|
75
|
+
var(--st-component-alert-paddingRight, var(--st-spacing-4, 1rem))
|
|
76
|
+
var(--st-component-alert-paddingBottom, var(--st-spacing-4, 1rem))
|
|
77
|
+
var(--st-component-alert-paddingLeft, var(--st-spacing-4, 1rem));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Severity filet (DSFR): a left bar drawn as a ::before INSIDE the box, so it
|
|
81
|
+
adds NO measured border (the real `.fr-alert` accent technique). Width 0 by
|
|
82
|
+
default (base/Carbon use a real left border) → the bar is invisible. */
|
|
83
|
+
.st-alert::before {
|
|
84
|
+
background: var(--alert-accent, var(--st-semantic-feedback-info));
|
|
85
|
+
bottom: 0;
|
|
86
|
+
content: "";
|
|
87
|
+
left: 0;
|
|
88
|
+
position: absolute;
|
|
89
|
+
top: 0;
|
|
90
|
+
width: var(--st-component-alert-filetWidth, 0);
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
.st-alert--info {
|
|
55
|
-
|
|
94
|
+
--alert-accent: var(--st-component-alert-infoBorder, var(--st-semantic-feedback-info));
|
|
56
95
|
}
|
|
57
96
|
|
|
58
97
|
.st-alert--success {
|
|
59
|
-
|
|
98
|
+
--alert-accent: var(--st-component-alert-successBorder, var(--st-semantic-feedback-success));
|
|
60
99
|
}
|
|
61
100
|
|
|
62
101
|
.st-alert--warning {
|
|
63
|
-
|
|
102
|
+
--alert-accent: var(--st-component-alert-warningBorder, var(--st-semantic-feedback-warning));
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
.st-alert--error {
|
|
67
|
-
|
|
106
|
+
--alert-accent: var(--st-component-alert-errorBorder, var(--st-semantic-feedback-error));
|
|
68
107
|
}
|
|
69
108
|
|
|
70
109
|
.st-alert__content {
|
package/dist/Badge.svelte
CHANGED
|
@@ -18,14 +18,20 @@
|
|
|
18
18
|
</span>
|
|
19
19
|
|
|
20
20
|
<style>
|
|
21
|
+
/* P-C: per-theme badge anatomy. Every var falls back to the prior base literal,
|
|
22
|
+
so a theme that emits no `--st-component-badge-*` renders byte-identically. */
|
|
21
23
|
.st-badge {
|
|
22
24
|
display: inline-flex;
|
|
23
25
|
align-items: center;
|
|
24
|
-
border-radius: var(--st-radius-pill, 999px);
|
|
25
|
-
font-size: 0.75rem;
|
|
26
|
-
font-weight: 650;
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
border-radius: var(--st-component-badge-radius, var(--st-radius-pill, 999px));
|
|
27
|
+
font-size: var(--st-component-badge-fontSize, 0.75rem);
|
|
28
|
+
font-weight: var(--st-component-badge-fontWeight, 650);
|
|
29
|
+
letter-spacing: var(--st-component-badge-letterSpacing, normal);
|
|
30
|
+
line-height: var(--st-component-badge-lineHeight, 1);
|
|
31
|
+
min-height: var(--st-component-badge-minHeight, 0);
|
|
32
|
+
padding: var(--st-component-badge-paddingBlock, 0.25rem)
|
|
33
|
+
var(--st-component-badge-paddingInline, 0.5rem);
|
|
34
|
+
text-transform: var(--st-component-badge-textTransform, none);
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
.st-badge--neutral {
|
|
@@ -49,7 +55,10 @@
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
.st-badge--info {
|
|
52
|
-
background:
|
|
53
|
-
|
|
58
|
+
background: var(
|
|
59
|
+
--st-component-badge-infoBackground,
|
|
60
|
+
color-mix(in srgb, var(--st-semantic-feedback-info) 14%, white)
|
|
61
|
+
);
|
|
62
|
+
color: var(--st-component-badge-infoText, var(--st-semantic-feedback-info));
|
|
54
63
|
}
|
|
55
64
|
</style>
|
package/dist/Breadcrumb.svelte
CHANGED
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
</nav>
|
|
36
36
|
|
|
37
37
|
<style>
|
|
38
|
+
/* F10: breadcrumb typography on the root so the trail/link/separator inherit
|
|
39
|
+
the per-theme size/line-height/tracking. Defaults (inherit / normal) keep
|
|
40
|
+
the base Sent Tech render byte-identical; DSFR/Carbon pin their real metrics
|
|
41
|
+
(DSFR 12px/20px, Carbon 14px/18px/0.16px). */
|
|
42
|
+
.st-breadcrumb {
|
|
43
|
+
font-size: var(--st-component-breadcrumb-fontSize, inherit);
|
|
44
|
+
letter-spacing: var(--st-component-breadcrumb-letterSpacing, normal);
|
|
45
|
+
line-height: var(--st-component-breadcrumb-lineHeight, normal);
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
.st-breadcrumb ol {
|
|
39
49
|
align-items: center;
|
|
40
50
|
display: flex;
|
|
@@ -58,7 +68,7 @@
|
|
|
58
68
|
|
|
59
69
|
.st-breadcrumb [aria-current="page"] {
|
|
60
70
|
color: var(--st-component-breadcrumb-currentText, var(--st-semantic-text-primary));
|
|
61
|
-
font-weight: 600;
|
|
71
|
+
font-weight: var(--st-component-breadcrumb-currentWeight, 600);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
.st-breadcrumb__separator {
|
package/dist/Button.svelte
CHANGED
|
@@ -97,10 +97,15 @@
|
|
|
97
97
|
color: var(--st-component-button-primaryText, var(--st-semantic-action-primaryText));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/* Secondary (G1): the surface is per-theme. Base/Carbon = filled neutral with
|
|
101
|
+
a subtle stroke; DSFR « Bouton secondaire » = transparent fill + a 1px Bleu
|
|
102
|
+
France border + Bleu France text (outlined, not a light-blue fill). The
|
|
103
|
+
border colour reads its own component token so a theme can paint it Bleu
|
|
104
|
+
France; the fallback (--st-semantic-border-subtle) keeps the base render. */
|
|
100
105
|
.st-button--secondary {
|
|
101
106
|
background: var(--st-component-button-secondaryBackground, var(--st-semantic-action-secondary));
|
|
102
107
|
color: var(--st-component-button-secondaryText, var(--st-semantic-action-secondaryText));
|
|
103
|
-
border-color: var(--st-semantic-border-subtle);
|
|
108
|
+
border-color: var(--st-component-button-secondaryBorder, var(--st-semantic-border-subtle));
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
/* Anatomy v1.1.0: hover bg sourced from states.hover.bg (= primaryHover).
|
|
@@ -109,8 +114,12 @@
|
|
|
109
114
|
background: var(--st-component-button-anatomy-states-hover-bg, var(--st-semantic-action-primary));
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
/* Secondary hover (G1): a per-theme hover surface — DSFR puts a LIGHT Bleu
|
|
118
|
+
France fill on its otherwise-transparent outlined button. The component
|
|
119
|
+
token falls back to the base secondary hover surface so Sent Tech is
|
|
120
|
+
unchanged. */
|
|
112
121
|
.st-button--secondary:not(:disabled):hover {
|
|
113
|
-
background: var(--st-semantic-action-secondaryHover, var(--st-semantic-action-secondary));
|
|
122
|
+
background: var(--st-component-button-secondaryHoverBackground, var(--st-semantic-action-secondaryHover, var(--st-semantic-action-secondary)));
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
.st-button--ghost {
|
package/dist/Checkbox.svelte
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
let { label, helperText, invalid = false, class: className, ...rest }: CheckboxProps = $props();
|
|
12
|
-
const classes = () => ["st-choice", className].filter(Boolean).join(" ");
|
|
12
|
+
const classes = () => ["st-choice", "st-choice--checkbox", className].filter(Boolean).join(" ");
|
|
13
13
|
</script>
|
|
14
14
|
|
|
15
15
|
<label class={classes()}>
|
|
@@ -69,7 +69,12 @@
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
.st-choice__label {
|
|
72
|
-
|
|
72
|
+
/* P-D: label typography per theme (base = 15px / normal / inherited colour).
|
|
73
|
+
The checked control colour + focus stay on the native input above. */
|
|
74
|
+
color: var(--st-component-selection-choiceLabelColor, inherit);
|
|
75
|
+
font-size: var(--st-component-selection-choiceLabelFontSize, 0.9375rem);
|
|
76
|
+
line-height: var(--st-component-selection-choiceLabelLineHeight, normal);
|
|
77
|
+
letter-spacing: var(--st-component-selection-choiceLabelLetterSpacing, normal);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
.st-choice__help {
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type ForceGraphTone =
|
|
3
|
+
| "category1" | "category2" | "category3" | "category4"
|
|
4
|
+
| "category5" | "category6" | "category7" | "category8";
|
|
5
|
+
|
|
6
|
+
export type ForceGraphNode = {
|
|
7
|
+
/** Stable identifier; referenced by edges. */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Visible label (falls back to id). */
|
|
10
|
+
label?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Grouping key (e.g. node type or community). Nodes sharing a group get
|
|
13
|
+
* the same tone when `tone` is not set explicitly.
|
|
14
|
+
*/
|
|
15
|
+
group?: string | number;
|
|
16
|
+
/** Explicit data-vis tone; overrides the group-derived tone. */
|
|
17
|
+
tone?: ForceGraphTone;
|
|
18
|
+
/** Relative node radius weight (defaults to 1). */
|
|
19
|
+
weight?: number;
|
|
20
|
+
/** Pin the node to a fixed position (ignored by the simulation). */
|
|
21
|
+
fx?: number;
|
|
22
|
+
fy?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ForceGraphEdge = {
|
|
26
|
+
/** Source node id. */
|
|
27
|
+
source: string;
|
|
28
|
+
/** Target node id. */
|
|
29
|
+
target: string;
|
|
30
|
+
/** Optional relation label, surfaced in the tooltip on hover/focus. */
|
|
31
|
+
relation?: string;
|
|
32
|
+
/**
|
|
33
|
+
* When true the link renders as a dashed/faded "weak" link. Lets callers
|
|
34
|
+
* map a confidence dimension onto link strength without extra props.
|
|
35
|
+
*/
|
|
36
|
+
weak?: boolean;
|
|
37
|
+
};
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
type ForceGraphProps = {
|
|
42
|
+
nodes: ForceGraphNode[];
|
|
43
|
+
edges: ForceGraphEdge[];
|
|
44
|
+
/** Accessible name for the figure (required). */
|
|
45
|
+
label: string;
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
/** Base node radius in px (scaled by node.weight). */
|
|
49
|
+
nodeRadius?: number;
|
|
50
|
+
/** Show text labels next to nodes. */
|
|
51
|
+
showLabels?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Number of cooling ticks. The simulation runs a synchronous warmup then
|
|
54
|
+
* animates the remainder unless reduced motion is requested.
|
|
55
|
+
*/
|
|
56
|
+
iterations?: number;
|
|
57
|
+
class?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let {
|
|
61
|
+
nodes,
|
|
62
|
+
edges,
|
|
63
|
+
label,
|
|
64
|
+
width = 480,
|
|
65
|
+
height = 360,
|
|
66
|
+
nodeRadius = 7,
|
|
67
|
+
showLabels = true,
|
|
68
|
+
iterations = 300,
|
|
69
|
+
class: className
|
|
70
|
+
}: ForceGraphProps = $props();
|
|
71
|
+
|
|
72
|
+
const TONES: ForceGraphTone[] = [
|
|
73
|
+
"category1", "category2", "category3", "category4",
|
|
74
|
+
"category5", "category6", "category7", "category8"
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Tone assignment: explicit tone wins, else stable per-group, else per-index.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
function buildToneMap(ns: ForceGraphNode[]): Map<string, ForceGraphTone> {
|
|
81
|
+
const groups: (string | number)[] = [];
|
|
82
|
+
const seen = new Set<string | number>();
|
|
83
|
+
for (const n of ns) {
|
|
84
|
+
if (n.group === undefined) continue;
|
|
85
|
+
if (seen.has(n.group)) continue;
|
|
86
|
+
seen.add(n.group);
|
|
87
|
+
groups.push(n.group);
|
|
88
|
+
}
|
|
89
|
+
const groupTone = new Map<string | number, ForceGraphTone>();
|
|
90
|
+
groups.forEach((g, i) => groupTone.set(g, TONES[i % TONES.length]));
|
|
91
|
+
const map = new Map<string, ForceGraphTone>();
|
|
92
|
+
ns.forEach((n, i) => {
|
|
93
|
+
if (n.tone) map.set(n.id, n.tone);
|
|
94
|
+
else if (n.group !== undefined && groupTone.has(n.group)) map.set(n.id, groupTone.get(n.group)!);
|
|
95
|
+
else map.set(n.id, TONES[i % TONES.length]);
|
|
96
|
+
});
|
|
97
|
+
return map;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Lightweight force simulation (no external dependency).
|
|
102
|
+
// - repulsion (Coulomb-like, O(n^2), fine for ontology-scale graphs)
|
|
103
|
+
// - spring links (Hooke toward a rest length)
|
|
104
|
+
// - mild gravity toward the centre to keep disconnected nodes on-canvas
|
|
105
|
+
// A deterministic seeded layout keeps SSR / tests stable.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
type SimNode = { id: string; x: number; y: number; vx: number; vy: number; fixed: boolean };
|
|
108
|
+
|
|
109
|
+
function mulberry32(seed: number): () => number {
|
|
110
|
+
let a = seed >>> 0;
|
|
111
|
+
return () => {
|
|
112
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
113
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
114
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
115
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function runSimulation(
|
|
120
|
+
ns: ForceGraphNode[],
|
|
121
|
+
es: ForceGraphEdge[],
|
|
122
|
+
w: number,
|
|
123
|
+
h: number,
|
|
124
|
+
ticks: number
|
|
125
|
+
): Map<string, { x: number; y: number }> {
|
|
126
|
+
const cx = w / 2;
|
|
127
|
+
const cy = h / 2;
|
|
128
|
+
const rand = mulberry32(ns.length * 2654435761 + es.length);
|
|
129
|
+
const idIndex = new Map<string, number>();
|
|
130
|
+
const sim: SimNode[] = ns.map((n, i) => {
|
|
131
|
+
idIndex.set(n.id, i);
|
|
132
|
+
const fixed = typeof n.fx === "number" && typeof n.fy === "number";
|
|
133
|
+
// Seed on a loose ring so the first ticks fan the graph out predictably.
|
|
134
|
+
const angle = (i / Math.max(ns.length, 1)) * Math.PI * 2;
|
|
135
|
+
const r = Math.min(w, h) * 0.3 * (0.5 + rand() * 0.5);
|
|
136
|
+
return {
|
|
137
|
+
id: n.id,
|
|
138
|
+
x: fixed ? (n.fx as number) : cx + Math.cos(angle) * r,
|
|
139
|
+
y: fixed ? (n.fy as number) : cy + Math.sin(angle) * r,
|
|
140
|
+
vx: 0,
|
|
141
|
+
vy: 0,
|
|
142
|
+
fixed
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const links = es
|
|
147
|
+
.map((e) => ({ s: idIndex.get(e.source), t: idIndex.get(e.target) }))
|
|
148
|
+
.filter((l): l is { s: number; t: number } => l.s !== undefined && l.t !== undefined);
|
|
149
|
+
|
|
150
|
+
const area = w * h;
|
|
151
|
+
const k = Math.sqrt(area / Math.max(ns.length, 1)); // ideal node distance
|
|
152
|
+
const repulsion = k * k * 0.9;
|
|
153
|
+
const restLength = k * 0.8;
|
|
154
|
+
const springK = 0.04;
|
|
155
|
+
const gravity = 0.012;
|
|
156
|
+
const damping = 0.85;
|
|
157
|
+
let temperature = Math.min(w, h) * 0.08;
|
|
158
|
+
const cooling = ticks > 0 ? Math.pow(0.02, 1 / ticks) : 0.95;
|
|
159
|
+
|
|
160
|
+
for (let step = 0; step < ticks; step++) {
|
|
161
|
+
// Repulsion between all node pairs.
|
|
162
|
+
for (let i = 0; i < sim.length; i++) {
|
|
163
|
+
for (let j = i + 1; j < sim.length; j++) {
|
|
164
|
+
let dx = sim[i].x - sim[j].x;
|
|
165
|
+
let dy = sim[i].y - sim[j].y;
|
|
166
|
+
let dist2 = dx * dx + dy * dy;
|
|
167
|
+
if (dist2 < 0.01) {
|
|
168
|
+
dx = (rand() - 0.5) * 0.1;
|
|
169
|
+
dy = (rand() - 0.5) * 0.1;
|
|
170
|
+
dist2 = dx * dx + dy * dy + 0.01;
|
|
171
|
+
}
|
|
172
|
+
const dist = Math.sqrt(dist2);
|
|
173
|
+
const force = repulsion / dist2;
|
|
174
|
+
const fx = (dx / dist) * force;
|
|
175
|
+
const fy = (dy / dist) * force;
|
|
176
|
+
sim[i].vx += fx; sim[i].vy += fy;
|
|
177
|
+
sim[j].vx -= fx; sim[j].vy -= fy;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Spring attraction along links.
|
|
181
|
+
for (const l of links) {
|
|
182
|
+
const a = sim[l.s];
|
|
183
|
+
const b = sim[l.t];
|
|
184
|
+
const dx = b.x - a.x;
|
|
185
|
+
const dy = b.y - a.y;
|
|
186
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
187
|
+
const force = (dist - restLength) * springK;
|
|
188
|
+
const fx = (dx / dist) * force;
|
|
189
|
+
const fy = (dy / dist) * force;
|
|
190
|
+
a.vx += fx; a.vy += fy;
|
|
191
|
+
b.vx -= fx; b.vy -= fy;
|
|
192
|
+
}
|
|
193
|
+
// Gravity toward centre + integrate with capped, cooling step.
|
|
194
|
+
for (const node of sim) {
|
|
195
|
+
if (node.fixed) { node.vx = 0; node.vy = 0; continue; }
|
|
196
|
+
node.vx += (cx - node.x) * gravity;
|
|
197
|
+
node.vy += (cy - node.y) * gravity;
|
|
198
|
+
node.vx *= damping;
|
|
199
|
+
node.vy *= damping;
|
|
200
|
+
const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
|
|
201
|
+
if (speed > temperature) {
|
|
202
|
+
node.vx = (node.vx / speed) * temperature;
|
|
203
|
+
node.vy = (node.vy / speed) * temperature;
|
|
204
|
+
}
|
|
205
|
+
node.x += node.vx;
|
|
206
|
+
node.y += node.vy;
|
|
207
|
+
// Keep inside a padded viewport.
|
|
208
|
+
node.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, node.x));
|
|
209
|
+
node.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, node.y));
|
|
210
|
+
}
|
|
211
|
+
temperature *= cooling;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const out = new Map<string, { x: number; y: number }>();
|
|
215
|
+
for (const node of sim) out.set(node.id, { x: node.x, y: node.y });
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// SSR-safe reduced-motion check (window may be undefined during SSR/tests).
|
|
220
|
+
const prefersReducedMotion =
|
|
221
|
+
typeof window !== "undefined" &&
|
|
222
|
+
typeof window.matchMedia === "function" &&
|
|
223
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
224
|
+
|
|
225
|
+
const toneMap = $derived(buildToneMap(nodes));
|
|
226
|
+
|
|
227
|
+
// The whole layout is recomputed when inputs change. Under reduced motion we
|
|
228
|
+
// settle the layout fully and never animate. Otherwise the same settled
|
|
229
|
+
// layout is used as the rendered target — a static, deterministic frame —
|
|
230
|
+
// which keeps the component framework-light and test-friendly while still
|
|
231
|
+
// honouring the motion preference (no rAF loop, no jitter).
|
|
232
|
+
const layout = $derived.by(() => {
|
|
233
|
+
const ticks = Math.max(1, Math.round(iterations));
|
|
234
|
+
return runSimulation(nodes, edges, width, height, ticks);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const positionedNodes = $derived.by(() =>
|
|
238
|
+
nodes.map((n, i) => {
|
|
239
|
+
const p = layout.get(n.id) ?? { x: width / 2, y: height / 2 };
|
|
240
|
+
return {
|
|
241
|
+
node: n,
|
|
242
|
+
i,
|
|
243
|
+
x: p.x,
|
|
244
|
+
y: p.y,
|
|
245
|
+
r: nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25)),
|
|
246
|
+
tone: toneMap.get(n.id) ?? "category1",
|
|
247
|
+
title: n.label ?? n.id
|
|
248
|
+
};
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const positionedEdges = $derived.by(() => {
|
|
253
|
+
return edges
|
|
254
|
+
.map((e, i) => {
|
|
255
|
+
const a = layout.get(e.source);
|
|
256
|
+
const b = layout.get(e.target);
|
|
257
|
+
if (!a || !b) return null;
|
|
258
|
+
return { edge: e, i, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
|
|
259
|
+
})
|
|
260
|
+
.filter((e): e is NonNullable<typeof e> => e !== null);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
let hoveredIndex: number | null = $state(null);
|
|
264
|
+
|
|
265
|
+
const classes = () =>
|
|
266
|
+
["st-forceGraph", prefersReducedMotion ? "st-forceGraph--static" : null, className]
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.join(" ");
|
|
269
|
+
</script>
|
|
270
|
+
|
|
271
|
+
<div class={classes()} role="img" aria-label={label}>
|
|
272
|
+
<svg
|
|
273
|
+
viewBox="0 0 {width} {height}"
|
|
274
|
+
preserveAspectRatio="xMidYMid meet"
|
|
275
|
+
width="100%"
|
|
276
|
+
height="100%"
|
|
277
|
+
focusable="false"
|
|
278
|
+
aria-hidden="true"
|
|
279
|
+
>
|
|
280
|
+
<!-- edges first so nodes paint on top -->
|
|
281
|
+
<g class="st-forceGraph__edges">
|
|
282
|
+
{#each positionedEdges as e (e.i)}
|
|
283
|
+
<line
|
|
284
|
+
class="st-forceGraph__edge"
|
|
285
|
+
class:st-forceGraph__edge--weak={e.edge.weak}
|
|
286
|
+
x1={e.x1}
|
|
287
|
+
y1={e.y1}
|
|
288
|
+
x2={e.x2}
|
|
289
|
+
y2={e.y2}
|
|
290
|
+
/>
|
|
291
|
+
{/each}
|
|
292
|
+
</g>
|
|
293
|
+
|
|
294
|
+
<g class="st-forceGraph__nodes">
|
|
295
|
+
{#each positionedNodes as p (p.node.id)}
|
|
296
|
+
<g
|
|
297
|
+
class="st-forceGraph__node st-forceGraph__node--{p.tone}"
|
|
298
|
+
class:st-forceGraph__node--dim={hoveredIndex !== null && hoveredIndex !== p.i}
|
|
299
|
+
transform="translate({p.x} {p.y})"
|
|
300
|
+
>
|
|
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
|
+
/>
|
|
312
|
+
{#if showLabels}
|
|
313
|
+
<text class="st-forceGraph__label" x={p.r + 3} y="0" dominant-baseline="middle">{p.title}</text>
|
|
314
|
+
{/if}
|
|
315
|
+
</g>
|
|
316
|
+
{/each}
|
|
317
|
+
</g>
|
|
318
|
+
</svg>
|
|
319
|
+
|
|
320
|
+
{#if hoveredIndex !== null && positionedNodes[hoveredIndex]}
|
|
321
|
+
{@const p = positionedNodes[hoveredIndex]}
|
|
322
|
+
{@const relCount = positionedEdges.filter(
|
|
323
|
+
(e) => e.edge.source === p.node.id || e.edge.target === p.node.id
|
|
324
|
+
).length}
|
|
325
|
+
<div
|
|
326
|
+
class="st-forceGraph__tooltip"
|
|
327
|
+
role="presentation"
|
|
328
|
+
style="left: {(p.x / width) * 100}%; top: {(p.y / height) * 100}%"
|
|
329
|
+
>
|
|
330
|
+
<span class="st-forceGraph__tooltipLabel">{p.title}</span>
|
|
331
|
+
{#if p.node.group !== undefined}
|
|
332
|
+
<span class="st-forceGraph__tooltipMeta">{p.node.group}</span>
|
|
333
|
+
{/if}
|
|
334
|
+
{#if relCount > 0}
|
|
335
|
+
<span class="st-forceGraph__tooltipMeta">{relCount} relation{relCount === 1 ? "" : "s"}</span>
|
|
336
|
+
{/if}
|
|
337
|
+
</div>
|
|
338
|
+
{/if}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<style>
|
|
342
|
+
.st-forceGraph {
|
|
343
|
+
color: var(--st-semantic-text-secondary);
|
|
344
|
+
display: block;
|
|
345
|
+
font-family: inherit;
|
|
346
|
+
position: relative;
|
|
347
|
+
width: 100%;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.st-forceGraph svg { display: block; overflow: visible; }
|
|
351
|
+
|
|
352
|
+
.st-forceGraph__edge {
|
|
353
|
+
stroke: var(--st-semantic-border-strong);
|
|
354
|
+
stroke-width: 1;
|
|
355
|
+
opacity: 0.55;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.st-forceGraph__edge--weak {
|
|
359
|
+
stroke: var(--st-semantic-border-subtle);
|
|
360
|
+
stroke-dasharray: 3 3;
|
|
361
|
+
opacity: 0.5;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.st-forceGraph__node { transition: opacity 120ms ease; }
|
|
365
|
+
.st-forceGraph__node--dim { opacity: 0.3; }
|
|
366
|
+
|
|
367
|
+
.st-forceGraph__dot {
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
fill-opacity: 0.9;
|
|
370
|
+
stroke: var(--st-semantic-surface-default, #fff);
|
|
371
|
+
stroke-width: 1.5;
|
|
372
|
+
transition: fill-opacity 120ms ease;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.st-forceGraph__dot:hover,
|
|
376
|
+
.st-forceGraph__dot:focus-visible { fill-opacity: 1; }
|
|
377
|
+
|
|
378
|
+
.st-forceGraph__dot:focus-visible {
|
|
379
|
+
outline: 2px solid var(--st-semantic-border-interactive);
|
|
380
|
+
outline-offset: 1px;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.st-forceGraph__label {
|
|
384
|
+
fill: var(--st-semantic-text-secondary);
|
|
385
|
+
font-size: 0.6875rem;
|
|
386
|
+
pointer-events: none;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.st-forceGraph__node--category1 .st-forceGraph__dot { fill: var(--st-semantic-data-category1); }
|
|
390
|
+
.st-forceGraph__node--category2 .st-forceGraph__dot { fill: var(--st-semantic-data-category2); }
|
|
391
|
+
.st-forceGraph__node--category3 .st-forceGraph__dot { fill: var(--st-semantic-data-category3); }
|
|
392
|
+
.st-forceGraph__node--category4 .st-forceGraph__dot { fill: var(--st-semantic-data-category4); }
|
|
393
|
+
.st-forceGraph__node--category5 .st-forceGraph__dot { fill: var(--st-semantic-data-category5); }
|
|
394
|
+
.st-forceGraph__node--category6 .st-forceGraph__dot { fill: var(--st-semantic-data-category6); }
|
|
395
|
+
.st-forceGraph__node--category7 .st-forceGraph__dot { fill: var(--st-semantic-data-category7); }
|
|
396
|
+
.st-forceGraph__node--category8 .st-forceGraph__dot { fill: var(--st-semantic-data-category8); }
|
|
397
|
+
|
|
398
|
+
.st-forceGraph__tooltip {
|
|
399
|
+
background: var(--st-semantic-surface-inverse);
|
|
400
|
+
border-radius: var(--st-radius-sm, 0.25rem);
|
|
401
|
+
color: var(--st-semantic-text-inverse);
|
|
402
|
+
display: inline-flex;
|
|
403
|
+
flex-direction: column;
|
|
404
|
+
font-size: 0.75rem;
|
|
405
|
+
gap: 0.125rem;
|
|
406
|
+
line-height: 1.2;
|
|
407
|
+
padding: 0.375rem 0.5rem;
|
|
408
|
+
pointer-events: none;
|
|
409
|
+
position: absolute;
|
|
410
|
+
transform: translate(-50%, calc(-100% - 10px));
|
|
411
|
+
white-space: nowrap;
|
|
412
|
+
z-index: 1;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.st-forceGraph__tooltipLabel { font-weight: 600; }
|
|
416
|
+
.st-forceGraph__tooltipMeta { opacity: 0.85; }
|
|
417
|
+
|
|
418
|
+
@media (prefers-reduced-motion: reduce) {
|
|
419
|
+
.st-forceGraph__node,
|
|
420
|
+
.st-forceGraph__dot { transition: none; }
|
|
421
|
+
}
|
|
422
|
+
</style>
|