@sentropic/design-system-svelte 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BoxPlotChart.svelte +302 -0
- package/dist/BoxPlotChart.svelte.d.ts +40 -0
- package/dist/BoxPlotChart.svelte.d.ts.map +1 -0
- package/dist/Calendar.svelte +237 -42
- package/dist/Calendar.svelte.d.ts.map +1 -1
- package/dist/HeatmapChart.svelte +337 -0
- package/dist/HeatmapChart.svelte.d.ts +35 -0
- package/dist/HeatmapChart.svelte.d.ts.map +1 -0
- package/dist/HistogramChart.svelte +294 -0
- package/dist/HistogramChart.svelte.d.ts +38 -0
- package/dist/HistogramChart.svelte.d.ts.map +1 -0
- package/dist/Popper.svelte +157 -0
- package/dist/Popper.svelte.d.ts +17 -0
- package/dist/Popper.svelte.d.ts.map +1 -1
- package/dist/RadarChart.svelte +340 -0
- package/dist/RadarChart.svelte.d.ts +43 -0
- package/dist/RadarChart.svelte.d.ts.map +1 -0
- package/dist/Rating.svelte +130 -35
- package/dist/Rating.svelte.d.ts.map +1 -1
- package/dist/SankeyChart.svelte +364 -0
- package/dist/SankeyChart.svelte.d.ts +45 -0
- package/dist/SankeyChart.svelte.d.ts.map +1 -0
- package/dist/SelectableList.svelte +60 -12
- package/dist/SelectableList.svelte.d.ts.map +1 -1
- package/dist/SelectableRow.svelte +23 -8
- package/dist/SelectableRow.svelte.d.ts +5 -4
- package/dist/SelectableRow.svelte.d.ts.map +1 -1
- package/dist/SlideIndicator.svelte +17 -3
- package/dist/SlideIndicator.svelte.d.ts.map +1 -1
- package/dist/SunburstChart.svelte +388 -0
- package/dist/SunburstChart.svelte.d.ts +39 -0
- package/dist/SunburstChart.svelte.d.ts.map +1 -0
- package/dist/TimePicker.svelte +176 -13
- package/dist/TimePicker.svelte.d.ts.map +1 -1
- package/dist/chartContrast.d.ts +0 -4
- package/dist/chartContrast.d.ts.map +1 -1
- package/dist/chartContrast.js +4 -56
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/package.json +1 -1
package/dist/Rating.svelte
CHANGED
|
@@ -49,8 +49,21 @@
|
|
|
49
49
|
const stars = $derived(Array.from({ length: max }, (_, i) => i + 1));
|
|
50
50
|
|
|
51
51
|
// L'étoile « focusable » (tabindex 0) suit la valeur ; à 0 c'est la première.
|
|
52
|
+
// En mode allowHalf, la valeur peut être un demi-entier : on focus l'étoile plafond.
|
|
52
53
|
const focusedStar = $derived(value > 0 ? Math.ceil(value) : 1);
|
|
53
54
|
|
|
55
|
+
// Refs des boutons radio pour déplacer le focus programmatiquement.
|
|
56
|
+
let radioRefs = $state<Record<number, HTMLElement | null>>({});
|
|
57
|
+
|
|
58
|
+
// Texte accessible décrivant la valeur courante (utilisé pour aria-valuetext et aria-label readonly).
|
|
59
|
+
const valueText = $derived(
|
|
60
|
+
value === 0
|
|
61
|
+
? `0 / ${max}`
|
|
62
|
+
: allowHalf && value % 1 !== 0
|
|
63
|
+
? `${value} / ${max}`
|
|
64
|
+
: `${value} / ${max}`
|
|
65
|
+
);
|
|
66
|
+
|
|
54
67
|
function fill(star: number): "full" | "half" | "empty" {
|
|
55
68
|
if (value >= star) return "full";
|
|
56
69
|
if (allowHalf && value >= star - 0.5) return "half";
|
|
@@ -85,64 +98,140 @@
|
|
|
85
98
|
if (readonly) return;
|
|
86
99
|
const step = allowHalf ? 0.5 : 1;
|
|
87
100
|
let handled = true;
|
|
101
|
+
let next: number | null = null;
|
|
88
102
|
switch (event.key) {
|
|
89
103
|
case "ArrowRight":
|
|
90
104
|
case "ArrowUp":
|
|
91
|
-
|
|
105
|
+
next = Math.min(max, value + step);
|
|
92
106
|
break;
|
|
93
107
|
case "ArrowLeft":
|
|
94
108
|
case "ArrowDown":
|
|
95
|
-
|
|
109
|
+
// En mode entier, ne pas descendre sous 1 (pas de radio "0").
|
|
110
|
+
next = allowHalf ? Math.max(0, value - step) : Math.max(1, value - step);
|
|
96
111
|
break;
|
|
97
112
|
case "Home":
|
|
98
|
-
|
|
113
|
+
// Home → première étoile (1), pas 0 (aucun radio "0" n'existe).
|
|
114
|
+
next = allowHalf ? 0 : 1;
|
|
99
115
|
break;
|
|
100
116
|
case "End":
|
|
101
|
-
|
|
117
|
+
next = max;
|
|
102
118
|
break;
|
|
103
119
|
default:
|
|
104
120
|
handled = false;
|
|
105
121
|
}
|
|
106
|
-
if (handled)
|
|
122
|
+
if (handled) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
if (next !== null) {
|
|
125
|
+
commit(next);
|
|
126
|
+
// En mode entier, déplacer le focus DOM vers le radio cible.
|
|
127
|
+
if (!allowHalf) {
|
|
128
|
+
const targetStar = next > 0 ? Math.ceil(next) : 1;
|
|
129
|
+
const targetEl = radioRefs[targetStar];
|
|
130
|
+
if (targetEl) targetEl.focus();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
107
134
|
}
|
|
135
|
+
|
|
136
|
+
// En mode allowHalf, on expose un slider ARIA (valeurs fractionnaires non représentables
|
|
137
|
+
// fidèlement par un radiogroup). En mode entier, on garde radiogroup/radio.
|
|
138
|
+
// Readonly : rendu non interactif avec span + aria-label global pour éviter les boutons disabled
|
|
139
|
+
// qui disparaissent de l'arbre d'accessibilité interactif.
|
|
108
140
|
</script>
|
|
109
141
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
{#if readonly}
|
|
143
|
+
<!-- Readonly : pas d'éléments interactifs disabled — on expose la note via aria-label sur le groupe. -->
|
|
144
|
+
<div
|
|
145
|
+
{...rest}
|
|
146
|
+
class={classes}
|
|
147
|
+
role="img"
|
|
148
|
+
aria-label={label ? `${label} : ${valueText}` : valueText}
|
|
149
|
+
>
|
|
150
|
+
{#each stars as star (star)}
|
|
151
|
+
{@const state = fill(star)}
|
|
152
|
+
<span class="st-rating__star" class:st-rating__star--full={state === "full"} class:st-rating__star--half={state === "half"} aria-hidden="true">
|
|
153
|
+
{#if state === "half"}
|
|
154
|
+
<StarHalf size={iconSize} strokeWidth={1.75} aria-hidden="true" />
|
|
155
|
+
{:else}
|
|
156
|
+
<Star
|
|
157
|
+
size={iconSize}
|
|
158
|
+
strokeWidth={1.75}
|
|
159
|
+
fill={state === "full" ? "currentColor" : "none"}
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
/>
|
|
162
|
+
{/if}
|
|
163
|
+
</span>
|
|
164
|
+
{/each}
|
|
165
|
+
</div>
|
|
166
|
+
{:else if allowHalf}
|
|
167
|
+
<!-- allowHalf : slider ARIA — valeurs fractionnaires (0.5 step), plus fidèle que radiogroup. -->
|
|
168
|
+
<div
|
|
169
|
+
{...rest}
|
|
170
|
+
class={classes}
|
|
171
|
+
role="slider"
|
|
172
|
+
aria-label={label}
|
|
173
|
+
aria-valuemin={0}
|
|
174
|
+
aria-valuemax={max}
|
|
175
|
+
aria-valuenow={value}
|
|
176
|
+
aria-valuetext={valueText}
|
|
177
|
+
tabindex={0}
|
|
178
|
+
onkeydown={onKeyDown}
|
|
179
|
+
>
|
|
180
|
+
{#each stars as star (star)}
|
|
181
|
+
{@const state = fill(star)}
|
|
182
|
+
<span
|
|
183
|
+
class="st-rating__star"
|
|
184
|
+
class:st-rating__star--full={state === "full"}
|
|
185
|
+
class:st-rating__star--half={state === "half"}
|
|
186
|
+
aria-hidden="true"
|
|
187
|
+
onclick={(event) => onStarClick(event, star)}
|
|
188
|
+
>
|
|
189
|
+
{#if state === "half"}
|
|
190
|
+
<StarHalf size={iconSize} strokeWidth={1.75} aria-hidden="true" />
|
|
191
|
+
{:else}
|
|
192
|
+
<Star
|
|
193
|
+
size={iconSize}
|
|
194
|
+
strokeWidth={1.75}
|
|
195
|
+
fill={state === "full" ? "currentColor" : "none"}
|
|
196
|
+
aria-hidden="true"
|
|
197
|
+
/>
|
|
198
|
+
{/if}
|
|
199
|
+
</span>
|
|
200
|
+
{/each}
|
|
201
|
+
</div>
|
|
202
|
+
{:else}
|
|
203
|
+
<!-- Mode entier : radiogroup / radio. aria-checked=true uniquement sur l'étoile == value. -->
|
|
204
|
+
<div
|
|
205
|
+
{...rest}
|
|
206
|
+
class={classes}
|
|
207
|
+
role="radiogroup"
|
|
208
|
+
aria-label={label}
|
|
209
|
+
>
|
|
210
|
+
{#each stars as star (star)}
|
|
211
|
+
{@const state = fill(star)}
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class="st-rating__star"
|
|
215
|
+
class:st-rating__star--full={state === "full"}
|
|
216
|
+
role="radio"
|
|
217
|
+
name={name}
|
|
218
|
+
aria-checked={value === star ? "true" : "false"}
|
|
219
|
+
aria-label={`${star} / ${max}`}
|
|
220
|
+
tabindex={star === focusedStar ? 0 : -1}
|
|
221
|
+
bind:this={radioRefs[star]}
|
|
222
|
+
onclick={(event) => onStarClick(event, star)}
|
|
223
|
+
onkeydown={onKeyDown}
|
|
224
|
+
>
|
|
136
225
|
<Star
|
|
137
226
|
size={iconSize}
|
|
138
227
|
strokeWidth={1.75}
|
|
139
228
|
fill={state === "full" ? "currentColor" : "none"}
|
|
140
229
|
aria-hidden="true"
|
|
141
230
|
/>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
231
|
+
</button>
|
|
232
|
+
{/each}
|
|
233
|
+
</div>
|
|
234
|
+
{/if}
|
|
146
235
|
|
|
147
236
|
<style>
|
|
148
237
|
.st-rating {
|
|
@@ -186,4 +275,10 @@
|
|
|
186
275
|
.st-rating--readonly .st-rating__star {
|
|
187
276
|
cursor: default;
|
|
188
277
|
}
|
|
278
|
+
|
|
279
|
+
/* Mode allowHalf : le slider (conteneur) doit afficher un focus-visible. */
|
|
280
|
+
[role="slider"].st-rating:focus-visible {
|
|
281
|
+
outline: 2px solid var(--st-component-control-focusRing, var(--st-semantic-border-interactive));
|
|
282
|
+
outline-offset: 2px;
|
|
283
|
+
}
|
|
189
284
|
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Rating.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Rating.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAI9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,WAAW,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAC9E,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,sDAAsD;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;
|
|
1
|
+
{"version":3,"file":"Rating.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Rating.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAI9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,WAAW,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAC9E,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,sDAAsD;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAsKJ,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* SankeyChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
4
|
+
*
|
|
5
|
+
* Props obligatoires :
|
|
6
|
+
* nodes SankeyChartNode[] - liste des nœuds {id, label, tone?}
|
|
7
|
+
* links SankeyChartLink[] - liste des liens {source, target, value, tone?}
|
|
8
|
+
* source/target = id d'un nœud existant ;
|
|
9
|
+
* les liens orphelins (nœud absent) sont rendus
|
|
10
|
+
* avec un fallback (pas de drop silencieux)
|
|
11
|
+
* label string - aria-label du graphique
|
|
12
|
+
*
|
|
13
|
+
* Props optionnelles :
|
|
14
|
+
* width number (défaut 560) - largeur du viewBox en px
|
|
15
|
+
* height number (défaut 280) - hauteur du viewBox en px
|
|
16
|
+
* class string - classe CSS supplémentaire
|
|
17
|
+
*
|
|
18
|
+
* Layout :
|
|
19
|
+
* Hauteur d'un nœud = max(valeurs entrantes sommées, valeurs sortantes sommées)
|
|
20
|
+
* - conservation de flux : un nœud agrégateur occupe autant que la somme
|
|
21
|
+
* de ses flux, pas juste le max d'un lien individuel.
|
|
22
|
+
*/
|
|
23
|
+
export type SankeyChartTone =
|
|
24
|
+
| "category1"
|
|
25
|
+
| "category2"
|
|
26
|
+
| "category3"
|
|
27
|
+
| "category4"
|
|
28
|
+
| "category5"
|
|
29
|
+
| "category6"
|
|
30
|
+
| "category7"
|
|
31
|
+
| "category8";
|
|
32
|
+
|
|
33
|
+
export type SankeyChartNode = {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
tone?: SankeyChartTone;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SankeyChartLink = {
|
|
40
|
+
source: string;
|
|
41
|
+
target: string;
|
|
42
|
+
value: number;
|
|
43
|
+
tone?: SankeyChartTone;
|
|
44
|
+
};
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<script lang="ts">
|
|
48
|
+
import ChartDataList from "./ChartDataList.svelte";
|
|
49
|
+
|
|
50
|
+
type SankeyChartProps = {
|
|
51
|
+
nodes: SankeyChartNode[];
|
|
52
|
+
links: SankeyChartLink[];
|
|
53
|
+
label: string;
|
|
54
|
+
width?: number;
|
|
55
|
+
height?: number;
|
|
56
|
+
class?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let {
|
|
60
|
+
nodes,
|
|
61
|
+
links,
|
|
62
|
+
label,
|
|
63
|
+
width = 560,
|
|
64
|
+
height = 280,
|
|
65
|
+
class: className
|
|
66
|
+
}: SankeyChartProps = $props();
|
|
67
|
+
|
|
68
|
+
const MARGIN = { top: 18, right: 26, bottom: 18, left: 26 };
|
|
69
|
+
const NODE_WIDTH = 14;
|
|
70
|
+
const TONES = [
|
|
71
|
+
"category1",
|
|
72
|
+
"category2",
|
|
73
|
+
"category3",
|
|
74
|
+
"category4",
|
|
75
|
+
"category5",
|
|
76
|
+
"category6",
|
|
77
|
+
"category7",
|
|
78
|
+
"category8"
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
function magnitude(value: number): number {
|
|
82
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nodeDepths(): Map<string, number> {
|
|
86
|
+
const depths = new Map(nodes.map((node) => [node.id, 0]));
|
|
87
|
+
for (let pass = 0; pass < nodes.length; pass += 1) {
|
|
88
|
+
let changed = false;
|
|
89
|
+
for (const link of links) {
|
|
90
|
+
const sourceDepth = depths.get(link.source) ?? 0;
|
|
91
|
+
const targetDepth = depths.get(link.target) ?? 0;
|
|
92
|
+
if (sourceDepth + 1 > targetDepth) {
|
|
93
|
+
depths.set(link.target, sourceDepth + 1);
|
|
94
|
+
changed = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!changed) break;
|
|
98
|
+
}
|
|
99
|
+
return depths;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let hoveredLinkIndex: number | null = $state(null);
|
|
103
|
+
|
|
104
|
+
const nodeById = $derived(new Map(nodes.map((node) => [node.id, node])));
|
|
105
|
+
|
|
106
|
+
// Conservation de flux : hauteur nœud = max(Σ flux sortants, Σ flux entrants)
|
|
107
|
+
const nodeValues = $derived.by(() => {
|
|
108
|
+
const valueOut = new Map<string, number>();
|
|
109
|
+
const valueIn = new Map<string, number>();
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
valueOut.set(node.id, 0);
|
|
112
|
+
valueIn.set(node.id, 0);
|
|
113
|
+
}
|
|
114
|
+
for (const link of links) {
|
|
115
|
+
const value = magnitude(link.value);
|
|
116
|
+
valueOut.set(link.source, (valueOut.get(link.source) ?? 0) + value);
|
|
117
|
+
valueIn.set(link.target, (valueIn.get(link.target) ?? 0) + value);
|
|
118
|
+
}
|
|
119
|
+
const values = new Map<string, number>();
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
values.set(node.id, Math.max(valueOut.get(node.id) ?? 0, valueIn.get(node.id) ?? 0));
|
|
122
|
+
}
|
|
123
|
+
return values;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const layout = $derived.by(() => {
|
|
127
|
+
const depths = nodeDepths();
|
|
128
|
+
const maxDepth = Math.max(0, ...Array.from(depths.values()));
|
|
129
|
+
const plotWidth = Math.max(width - MARGIN.left - MARGIN.right - NODE_WIDTH, 1);
|
|
130
|
+
const plotHeight = Math.max(height - MARGIN.top - MARGIN.bottom, 1);
|
|
131
|
+
const maxNodeValue = Math.max(1, ...Array.from(nodeValues.values()));
|
|
132
|
+
const byDepth = new Map<number, SankeyChartNode[]>();
|
|
133
|
+
|
|
134
|
+
nodes.forEach((node) => {
|
|
135
|
+
const depth = depths.get(node.id) ?? 0;
|
|
136
|
+
const bucket = byDepth.get(depth) ?? [];
|
|
137
|
+
bucket.push(node);
|
|
138
|
+
byDepth.set(depth, bucket);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const positionedNodes = nodes.map((node, index) => {
|
|
142
|
+
const depth = depths.get(node.id) ?? 0;
|
|
143
|
+
const bucket = byDepth.get(depth) ?? [node];
|
|
144
|
+
const row = Math.max(0, bucket.findIndex((entry) => entry.id === node.id));
|
|
145
|
+
const slot = plotHeight / Math.max(bucket.length, 1);
|
|
146
|
+
const nodeHeight = Math.max(24, Math.min(slot * 0.72, 18 + ((nodeValues.get(node.id) ?? 0) / maxNodeValue) * 54));
|
|
147
|
+
const x = MARGIN.left + (maxDepth === 0 ? plotWidth / 2 : (plotWidth * depth) / maxDepth);
|
|
148
|
+
const y = MARGIN.top + slot * row + (slot - nodeHeight) / 2;
|
|
149
|
+
const tone = node.tone ?? TONES[index % TONES.length];
|
|
150
|
+
return {
|
|
151
|
+
node,
|
|
152
|
+
tone,
|
|
153
|
+
x,
|
|
154
|
+
y,
|
|
155
|
+
width: NODE_WIDTH,
|
|
156
|
+
height: nodeHeight,
|
|
157
|
+
centerY: y + nodeHeight / 2
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const positionedById = new Map(positionedNodes.map((node) => [node.node.id, node]));
|
|
162
|
+
const maxLinkValue = Math.max(1, ...links.map((link) => magnitude(link.value)));
|
|
163
|
+
|
|
164
|
+
const positionedLinks = links.map((link, index) => {
|
|
165
|
+
const source = positionedById.get(link.source);
|
|
166
|
+
const target = positionedById.get(link.target);
|
|
167
|
+
const fallbackY = MARGIN.top + plotHeight / 2;
|
|
168
|
+
const x1 = (source?.x ?? MARGIN.left) + NODE_WIDTH;
|
|
169
|
+
const y1 = source?.centerY ?? fallbackY;
|
|
170
|
+
const x2 = target?.x ?? width - MARGIN.right;
|
|
171
|
+
const y2 = target?.centerY ?? fallbackY;
|
|
172
|
+
const c = Math.max(32, Math.abs(x2 - x1) * 0.5);
|
|
173
|
+
return {
|
|
174
|
+
link,
|
|
175
|
+
source,
|
|
176
|
+
target,
|
|
177
|
+
tone: link.tone ?? source?.tone ?? TONES[index % TONES.length],
|
|
178
|
+
width: Math.max(2, (magnitude(link.value) / maxLinkValue) * 18),
|
|
179
|
+
path: `M ${x1} ${y1} C ${x1 + c} ${y1}, ${x2 - c} ${y2}, ${x2} ${y2}`,
|
|
180
|
+
midX: (x1 + x2) / 2,
|
|
181
|
+
midY: (y1 + y2) / 2
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return { nodes: positionedNodes, links: positionedLinks };
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const dataValueItems = $derived(
|
|
189
|
+
links.map((link) => {
|
|
190
|
+
const source = nodeById.get(link.source)?.label ?? link.source;
|
|
191
|
+
const target = nodeById.get(link.target)?.label ?? link.target;
|
|
192
|
+
return `${source} -> ${target}: ${link.value}`;
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
function handleVisualPointerMove(event: PointerEvent) {
|
|
197
|
+
const target = event.target;
|
|
198
|
+
if (!(target instanceof Element)) {
|
|
199
|
+
hoveredLinkIndex = null;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const index = Number(target.getAttribute("data-link-index"));
|
|
203
|
+
hoveredLinkIndex = Number.isInteger(index) ? index : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const classes = () => ["st-sankeyChart", className].filter(Boolean).join(" ");
|
|
207
|
+
</script>
|
|
208
|
+
|
|
209
|
+
<div class={classes()}>
|
|
210
|
+
<div
|
|
211
|
+
class="st-sankeyChart__visual"
|
|
212
|
+
role="img"
|
|
213
|
+
aria-label={label}
|
|
214
|
+
onpointermove={handleVisualPointerMove}
|
|
215
|
+
onpointerleave={() => (hoveredLinkIndex = null)}
|
|
216
|
+
>
|
|
217
|
+
<svg
|
|
218
|
+
viewBox="0 0 {width} {height}"
|
|
219
|
+
preserveAspectRatio="xMidYMid meet"
|
|
220
|
+
width="100%"
|
|
221
|
+
height="100%"
|
|
222
|
+
focusable="false"
|
|
223
|
+
aria-hidden="true"
|
|
224
|
+
>
|
|
225
|
+
<g class="st-sankeyChart__links">
|
|
226
|
+
{#each layout.links as flow, i (`${flow.link.source}-${flow.link.target}-${i}`)}
|
|
227
|
+
<path
|
|
228
|
+
class="st-sankeyChart__link st-sankeyChart__link--{flow.tone}"
|
|
229
|
+
class:st-sankeyChart__link--dim={hoveredLinkIndex !== null && hoveredLinkIndex !== i}
|
|
230
|
+
d={flow.path}
|
|
231
|
+
stroke-width={flow.width}
|
|
232
|
+
data-link-index={i}
|
|
233
|
+
/>
|
|
234
|
+
{/each}
|
|
235
|
+
</g>
|
|
236
|
+
|
|
237
|
+
<g class="st-sankeyChart__nodes">
|
|
238
|
+
{#each layout.nodes as entry (entry.node.id)}
|
|
239
|
+
<rect
|
|
240
|
+
class="st-sankeyChart__node st-sankeyChart__node--{entry.tone}"
|
|
241
|
+
x={entry.x}
|
|
242
|
+
y={entry.y}
|
|
243
|
+
width={entry.width}
|
|
244
|
+
height={entry.height}
|
|
245
|
+
rx="2"
|
|
246
|
+
/>
|
|
247
|
+
<text
|
|
248
|
+
class="st-sankeyChart__nodeLabel"
|
|
249
|
+
x={entry.x + entry.width + 6}
|
|
250
|
+
y={entry.centerY}
|
|
251
|
+
dominant-baseline="middle"
|
|
252
|
+
>
|
|
253
|
+
{entry.node.label}
|
|
254
|
+
</text>
|
|
255
|
+
{/each}
|
|
256
|
+
</g>
|
|
257
|
+
</svg>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<ChartDataList {label} items={dataValueItems} />
|
|
261
|
+
|
|
262
|
+
{#if hoveredLinkIndex !== null && layout.links[hoveredLinkIndex]}
|
|
263
|
+
{@const flow = layout.links[hoveredLinkIndex]}
|
|
264
|
+
<div
|
|
265
|
+
class="st-sankeyChart__tooltip"
|
|
266
|
+
role="presentation"
|
|
267
|
+
style="left: {(flow.midX / width) * 100}%; top: {(flow.midY / height) * 100}%"
|
|
268
|
+
>
|
|
269
|
+
<span class="st-sankeyChart__tooltipLabel">{flow.source?.node.label ?? flow.link.source} -> {flow.target?.node.label ?? flow.link.target}</span>
|
|
270
|
+
<span class="st-sankeyChart__tooltipValue">{flow.link.value}</span>
|
|
271
|
+
</div>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<style>
|
|
276
|
+
.st-sankeyChart {
|
|
277
|
+
color: var(--st-semantic-text-secondary);
|
|
278
|
+
display: block;
|
|
279
|
+
font-family: inherit;
|
|
280
|
+
max-width: 100%;
|
|
281
|
+
position: relative;
|
|
282
|
+
width: 100%;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.st-sankeyChart svg,
|
|
286
|
+
.st-sankeyChart__visual {
|
|
287
|
+
display: block;
|
|
288
|
+
overflow: visible;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.st-sankeyChart__link {
|
|
292
|
+
cursor: pointer;
|
|
293
|
+
fill: none;
|
|
294
|
+
opacity: 0.38;
|
|
295
|
+
stroke-linecap: round;
|
|
296
|
+
transition: opacity 120ms ease;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.st-sankeyChart__link:hover {
|
|
300
|
+
opacity: 0.62;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.st-sankeyChart__link--dim {
|
|
304
|
+
opacity: 0.16;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@media (prefers-reduced-motion: reduce) {
|
|
308
|
+
.st-sankeyChart__link {
|
|
309
|
+
transition: none;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.st-sankeyChart__node {
|
|
314
|
+
stroke: var(--st-semantic-surface-default, Canvas);
|
|
315
|
+
stroke-width: 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.st-sankeyChart__link--category1,
|
|
319
|
+
.st-sankeyChart__node--category1 { stroke: var(--st-semantic-data-category1); fill: var(--st-semantic-data-category1); }
|
|
320
|
+
.st-sankeyChart__link--category2,
|
|
321
|
+
.st-sankeyChart__node--category2 { stroke: var(--st-semantic-data-category2); fill: var(--st-semantic-data-category2); }
|
|
322
|
+
.st-sankeyChart__link--category3,
|
|
323
|
+
.st-sankeyChart__node--category3 { stroke: var(--st-semantic-data-category3); fill: var(--st-semantic-data-category3); }
|
|
324
|
+
.st-sankeyChart__link--category4,
|
|
325
|
+
.st-sankeyChart__node--category4 { stroke: var(--st-semantic-data-category4); fill: var(--st-semantic-data-category4); }
|
|
326
|
+
.st-sankeyChart__link--category5,
|
|
327
|
+
.st-sankeyChart__node--category5 { stroke: var(--st-semantic-data-category5); fill: var(--st-semantic-data-category5); }
|
|
328
|
+
.st-sankeyChart__link--category6,
|
|
329
|
+
.st-sankeyChart__node--category6 { stroke: var(--st-semantic-data-category6); fill: var(--st-semantic-data-category6); }
|
|
330
|
+
.st-sankeyChart__link--category7,
|
|
331
|
+
.st-sankeyChart__node--category7 { stroke: var(--st-semantic-data-category7); fill: var(--st-semantic-data-category7); }
|
|
332
|
+
.st-sankeyChart__link--category8,
|
|
333
|
+
.st-sankeyChart__node--category8 { stroke: var(--st-semantic-data-category8); fill: var(--st-semantic-data-category8); }
|
|
334
|
+
|
|
335
|
+
.st-sankeyChart__nodeLabel {
|
|
336
|
+
fill: var(--st-semantic-text-secondary);
|
|
337
|
+
font-size: 0.75rem;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.st-sankeyChart__tooltip {
|
|
341
|
+
background: var(--st-semantic-surface-inverse);
|
|
342
|
+
border-radius: var(--st-radius-sm, 0.25rem);
|
|
343
|
+
color: var(--st-semantic-text-inverse);
|
|
344
|
+
display: inline-flex;
|
|
345
|
+
flex-direction: column;
|
|
346
|
+
font-size: 0.75rem;
|
|
347
|
+
gap: 0.125rem;
|
|
348
|
+
line-height: 1.2;
|
|
349
|
+
padding: 0.375rem 0.5rem;
|
|
350
|
+
pointer-events: none;
|
|
351
|
+
position: absolute;
|
|
352
|
+
transform: translate(-50%, -115%);
|
|
353
|
+
white-space: nowrap;
|
|
354
|
+
z-index: 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.st-sankeyChart__tooltipLabel {
|
|
358
|
+
font-weight: 600;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.st-sankeyChart__tooltipValue {
|
|
362
|
+
opacity: 0.85;
|
|
363
|
+
}
|
|
364
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SankeyChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
3
|
+
*
|
|
4
|
+
* Props obligatoires :
|
|
5
|
+
* nodes SankeyChartNode[] - liste des nœuds {id, label, tone?}
|
|
6
|
+
* links SankeyChartLink[] - liste des liens {source, target, value, tone?}
|
|
7
|
+
* source/target = id d'un nœud existant ;
|
|
8
|
+
* les liens orphelins (nœud absent) sont rendus
|
|
9
|
+
* avec un fallback (pas de drop silencieux)
|
|
10
|
+
* label string - aria-label du graphique
|
|
11
|
+
*
|
|
12
|
+
* Props optionnelles :
|
|
13
|
+
* width number (défaut 560) - largeur du viewBox en px
|
|
14
|
+
* height number (défaut 280) - hauteur du viewBox en px
|
|
15
|
+
* class string - classe CSS supplémentaire
|
|
16
|
+
*
|
|
17
|
+
* Layout :
|
|
18
|
+
* Hauteur d'un nœud = max(valeurs entrantes sommées, valeurs sortantes sommées)
|
|
19
|
+
* - conservation de flux : un nœud agrégateur occupe autant que la somme
|
|
20
|
+
* de ses flux, pas juste le max d'un lien individuel.
|
|
21
|
+
*/
|
|
22
|
+
export type SankeyChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
|
|
23
|
+
export type SankeyChartNode = {
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
tone?: SankeyChartTone;
|
|
27
|
+
};
|
|
28
|
+
export type SankeyChartLink = {
|
|
29
|
+
source: string;
|
|
30
|
+
target: string;
|
|
31
|
+
value: number;
|
|
32
|
+
tone?: SankeyChartTone;
|
|
33
|
+
};
|
|
34
|
+
type SankeyChartProps = {
|
|
35
|
+
nodes: SankeyChartNode[];
|
|
36
|
+
links: SankeyChartLink[];
|
|
37
|
+
label: string;
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
class?: string;
|
|
41
|
+
};
|
|
42
|
+
declare const SankeyChart: import("svelte").Component<SankeyChartProps, {}, "">;
|
|
43
|
+
type SankeyChart = ReturnType<typeof SankeyChart>;
|
|
44
|
+
export default SankeyChart;
|
|
45
|
+
//# sourceMappingURL=SankeyChart.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SankeyChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SankeyChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,MAAM,eAAe,GACvB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,eAAe,GAAG;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,eAAe,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,eAAe,CAAC;CACxB,CAAC;AAMF,KAAK,gBAAgB,GAAG;IACtB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAgMJ,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|