@sentropic/design-system-svelte 0.16.0 → 0.17.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.
@@ -0,0 +1,358 @@
1
+ <script lang="ts" module>
2
+ export type FunnelChartTone =
3
+ | "category1" | "category2" | "category3" | "category4"
4
+ | "category5" | "category6" | "category7" | "category8";
5
+
6
+ export type FunnelChartDatum = {
7
+ label: string;
8
+ value: number;
9
+ tone?: FunnelChartTone;
10
+ };
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import ChartDataList from "./ChartDataList.svelte";
15
+ import { contrastTextForTone } from "./chartContrast";
16
+
17
+ type FunnelChartProps = {
18
+ data: FunnelChartDatum[];
19
+ orientation?: "vertical" | "horizontal";
20
+ showPercentages?: boolean;
21
+ percentMode?: "ofFirst" | "ofPrevious";
22
+ legend?: boolean;
23
+ label: string;
24
+ width?: number;
25
+ height?: number;
26
+ class?: string;
27
+ };
28
+
29
+ let {
30
+ data,
31
+ orientation = "vertical",
32
+ showPercentages = true,
33
+ percentMode = "ofFirst",
34
+ legend = false,
35
+ label,
36
+ width = 480,
37
+ height = 280,
38
+ class: className
39
+ }: FunnelChartProps = $props();
40
+
41
+ const MARGIN = { top: 16, right: 16, bottom: 16, left: 16 };
42
+ const GAP = 6;
43
+ const TONES = [
44
+ "category1", "category2", "category3", "category4",
45
+ "category5", "category6", "category7", "category8"
46
+ ] as const;
47
+
48
+ function formatPercent(p: number): string {
49
+ if (!Number.isFinite(p)) return "0%";
50
+ return `${p % 1 === 0 ? p.toFixed(0) : p.toFixed(1)}%`;
51
+ }
52
+
53
+ let hoveredIndex: number | null = $state(null);
54
+
55
+ /**
56
+ * Magnitude normalisée d'une étape : un entonnoir ne représente que des
57
+ * grandeurs positives. Les valeurs non finies ou négatives sont ramenées à 0
58
+ * (jamais de NaN/Infinity dans la géométrie ou les pourcentages).
59
+ */
60
+ function magnitude(v: number): number {
61
+ return Number.isFinite(v) && v > 0 ? v : 0;
62
+ }
63
+
64
+ // Pourcentages calculés par rapport à la première étape ou à la précédente.
65
+ // Référence nulle → 0% (pas de NaN), première étape ofPrevious → 100%.
66
+ const percents = $derived.by(() => {
67
+ const first = magnitude(data[0]?.value ?? 0);
68
+ return data.map((d, i) => {
69
+ const value = magnitude(d.value);
70
+ const ref = percentMode === "ofPrevious" ? magnitude(data[i - 1]?.value ?? value) : first;
71
+ return ref === 0 ? 0 : (value / ref) * 100;
72
+ });
73
+ });
74
+
75
+ // Trapèzes décroissants centrés : la demi-largeur de chaque étape est
76
+ // proportionnelle à sa valeur (relative au max). Les segments se rejoignent.
77
+ const segments = $derived.by(() => {
78
+ if (data.length === 0) return [];
79
+ const maxValue = Math.max(0, ...data.map((d) => magnitude(d.value)));
80
+ const safeMax = maxValue === 0 ? 1 : maxValue;
81
+ const plotW = Math.max(width - MARGIN.left - MARGIN.right, 1);
82
+ const plotH = Math.max(height - MARGIN.top - MARGIN.bottom, 1);
83
+
84
+ if (orientation === "vertical") {
85
+ const band = plotH / data.length;
86
+ const segH = Math.max(band - GAP, 1);
87
+ const cx = MARGIN.left + plotW / 2;
88
+ return data.map((d, i) => {
89
+ const tone = d.tone ?? TONES[i % TONES.length];
90
+ const topHalf = (magnitude(d.value) / safeMax) * (plotW / 2);
91
+ const nextVal = data[i + 1] ? magnitude(data[i + 1].value) : magnitude(d.value);
92
+ // Forme strictement décroissante : le bas ne dépasse jamais le haut.
93
+ const botHalf = Math.min((nextVal / safeMax) * (plotW / 2), topHalf);
94
+ const y0 = MARGIN.top + band * i;
95
+ const y1 = y0 + segH;
96
+ const points = [
97
+ `${cx - topHalf},${y0}`,
98
+ `${cx + topHalf},${y0}`,
99
+ `${cx + botHalf},${y1}`,
100
+ `${cx - botHalf},${y1}`
101
+ ].join(" ");
102
+ return {
103
+ points,
104
+ datum: d,
105
+ tone,
106
+ textColor: contrastTextForTone(tone),
107
+ cx,
108
+ cy: (y0 + y1) / 2,
109
+ labelX: cx,
110
+ labelY: (y0 + y1) / 2,
111
+ percent: percents[i]
112
+ };
113
+ });
114
+ }
115
+
116
+ // horizontal : entonnoir qui se rétrécit de gauche à droite.
117
+ const band = plotW / data.length;
118
+ const segW = Math.max(band - GAP, 1);
119
+ const cy = MARGIN.top + plotH / 2;
120
+ return data.map((d, i) => {
121
+ const tone = d.tone ?? TONES[i % TONES.length];
122
+ const leftHalf = (magnitude(d.value) / safeMax) * (plotH / 2);
123
+ const nextVal = data[i + 1] ? magnitude(data[i + 1].value) : magnitude(d.value);
124
+ const rightHalf = Math.min((nextVal / safeMax) * (plotH / 2), leftHalf);
125
+ const x0 = MARGIN.left + band * i;
126
+ const x1 = x0 + segW;
127
+ const points = [
128
+ `${x0},${cy - leftHalf}`,
129
+ `${x1},${cy - rightHalf}`,
130
+ `${x1},${cy + rightHalf}`,
131
+ `${x0},${cy + leftHalf}`
132
+ ].join(" ");
133
+ return {
134
+ points,
135
+ datum: d,
136
+ tone,
137
+ textColor: contrastTextForTone(tone),
138
+ cx: (x0 + x1) / 2,
139
+ cy,
140
+ labelX: (x0 + x1) / 2,
141
+ labelY: cy,
142
+ percent: percents[i]
143
+ };
144
+ });
145
+ });
146
+
147
+ const dataValueItems = $derived(
148
+ data.map((d, i) =>
149
+ showPercentages
150
+ ? `${d.label}: ${d.value} (${formatPercent(percents[i])})`
151
+ : `${d.label}: ${d.value}`
152
+ )
153
+ );
154
+
155
+ const legendItems = $derived(
156
+ data.map((d, i) => ({ label: d.label, tone: d.tone ?? TONES[i % TONES.length] }))
157
+ );
158
+
159
+ function handleVisualPointerMove(event: PointerEvent) {
160
+ const target = event.target;
161
+ if (!(target instanceof Element)) {
162
+ hoveredIndex = null;
163
+ return;
164
+ }
165
+ const index = Number(target.getAttribute("data-chart-index"));
166
+ hoveredIndex = Number.isInteger(index) ? index : null;
167
+ }
168
+
169
+ const classes = () => ["st-funnelChart", className].filter(Boolean).join(" ");
170
+ </script>
171
+
172
+ <div class={classes()}>
173
+ <div
174
+ class="st-funnelChart__visual"
175
+ role="img"
176
+ aria-label={label}
177
+ onpointermove={handleVisualPointerMove}
178
+ onpointerleave={() => (hoveredIndex = null)}
179
+ >
180
+ <svg
181
+ viewBox="0 0 {width} {height}"
182
+ preserveAspectRatio="xMidYMid meet"
183
+ width="100%"
184
+ height="100%"
185
+ focusable="false"
186
+ aria-hidden="true"
187
+ >
188
+ {#each segments as seg, i (seg.datum.label)}
189
+ <polygon
190
+ class="st-funnelChart__segment st-funnelChart__segment--{seg.tone}"
191
+ class:st-funnelChart__segment--dim={hoveredIndex !== null && hoveredIndex !== i}
192
+ points={seg.points}
193
+ data-chart-index={i}
194
+ />
195
+ {/each}
196
+
197
+ {#each segments as seg, i (seg.datum.label)}
198
+ <text
199
+ class="st-funnelChart__label"
200
+ x={seg.labelX}
201
+ y={seg.labelY - 6}
202
+ text-anchor="middle"
203
+ dominant-baseline="middle"
204
+ style="fill: {seg.textColor}"
205
+ >
206
+ {seg.datum.label}
207
+ </text>
208
+ <text
209
+ class="st-funnelChart__value"
210
+ x={seg.labelX}
211
+ y={seg.labelY + 8}
212
+ text-anchor="middle"
213
+ dominant-baseline="middle"
214
+ style="fill: {seg.textColor}"
215
+ >
216
+ {seg.datum.value}{#if showPercentages}&nbsp;·&nbsp;{formatPercent(seg.percent)}{/if}
217
+ </text>
218
+ {/each}
219
+ </svg>
220
+ </div>
221
+
222
+ <ChartDataList {label} items={dataValueItems} />
223
+
224
+ {#if hoveredIndex !== null && segments[hoveredIndex]}
225
+ {@const seg = segments[hoveredIndex]}
226
+ <div
227
+ class="st-funnelChart__tooltip"
228
+ role="presentation"
229
+ style="left: {(seg.cx / width) * 100}%; top: {(seg.cy / height) * 100}%"
230
+ >
231
+ <span class="st-funnelChart__tooltipLabel">{seg.datum.label}</span>
232
+ <span class="st-funnelChart__tooltipValue">
233
+ {seg.datum.value}{#if showPercentages}&nbsp;·&nbsp;{formatPercent(seg.percent)}{/if}
234
+ </span>
235
+ </div>
236
+ {/if}
237
+
238
+ {#if legend && legendItems.length > 0}
239
+ <ul class="st-funnelChart__legend" aria-hidden="true">
240
+ {#each legendItems as item (item.label)}
241
+ <li class="st-funnelChart__legendItem">
242
+ <span
243
+ class="st-funnelChart__legendSwatch st-funnelChart__legendSwatch--{item.tone}"
244
+ aria-hidden="true"
245
+ ></span>
246
+ {item.label}
247
+ </li>
248
+ {/each}
249
+ </ul>
250
+ {/if}
251
+ </div>
252
+
253
+ <style>
254
+ .st-funnelChart {
255
+ color: var(--st-semantic-text-secondary);
256
+ display: block;
257
+ font-family: inherit;
258
+ position: relative;
259
+ width: 100%;
260
+ }
261
+
262
+ .st-funnelChart svg,
263
+ .st-funnelChart__visual {
264
+ display: block;
265
+ overflow: visible;
266
+ }
267
+
268
+ .st-funnelChart__segment {
269
+ cursor: pointer;
270
+ stroke: var(--st-semantic-surface-default, #fff);
271
+ stroke-width: 1;
272
+ transition: opacity 120ms ease;
273
+ }
274
+
275
+ .st-funnelChart__segment--dim {
276
+ opacity: 0.45;
277
+ }
278
+
279
+ .st-funnelChart__segment--category1 { fill: var(--st-semantic-data-category1); }
280
+ .st-funnelChart__segment--category2 { fill: var(--st-semantic-data-category2); }
281
+ .st-funnelChart__segment--category3 { fill: var(--st-semantic-data-category3); }
282
+ .st-funnelChart__segment--category4 { fill: var(--st-semantic-data-category4); }
283
+ .st-funnelChart__segment--category5 { fill: var(--st-semantic-data-category5); }
284
+ .st-funnelChart__segment--category6 { fill: var(--st-semantic-data-category6); }
285
+ .st-funnelChart__segment--category7 { fill: var(--st-semantic-data-category7); }
286
+ .st-funnelChart__segment--category8 { fill: var(--st-semantic-data-category8); }
287
+
288
+ .st-funnelChart__label {
289
+ fill: var(--st-component-funnelChart-labelColor, var(--st-semantic-text-inverse, #fff));
290
+ font-size: 0.75rem;
291
+ font-weight: 600;
292
+ pointer-events: none;
293
+ }
294
+
295
+ .st-funnelChart__value {
296
+ fill: var(--st-component-funnelChart-valueColor, var(--st-semantic-text-inverse, #fff));
297
+ font-size: 0.6875rem;
298
+ pointer-events: none;
299
+ }
300
+
301
+ .st-funnelChart__tooltip {
302
+ background: var(--st-semantic-surface-inverse);
303
+ border-radius: var(--st-radius-sm, 0.25rem);
304
+ color: var(--st-semantic-text-inverse);
305
+ display: inline-flex;
306
+ flex-direction: column;
307
+ font-size: 0.75rem;
308
+ gap: 0.125rem;
309
+ line-height: 1.2;
310
+ padding: 0.375rem 0.5rem;
311
+ pointer-events: none;
312
+ position: absolute;
313
+ transform: translate(-50%, calc(-100% - 8px));
314
+ white-space: nowrap;
315
+ z-index: 1;
316
+ }
317
+
318
+ .st-funnelChart__tooltipLabel { font-weight: 600; }
319
+ .st-funnelChart__tooltipValue { opacity: 0.85; }
320
+
321
+ .st-funnelChart__legend {
322
+ display: flex;
323
+ flex-wrap: wrap;
324
+ gap: var(--st-spacing-3, 0.75rem);
325
+ list-style: none;
326
+ margin: var(--st-spacing-2, 0.5rem) 0 0;
327
+ padding: 0;
328
+ }
329
+
330
+ .st-funnelChart__legendItem {
331
+ align-items: center;
332
+ color: var(--st-semantic-text-secondary);
333
+ display: inline-flex;
334
+ font-size: 0.75rem;
335
+ gap: var(--st-spacing-1, 0.25rem);
336
+ }
337
+
338
+ .st-funnelChart__legendSwatch {
339
+ border-radius: 2px;
340
+ height: 0.7rem;
341
+ width: 0.7rem;
342
+ }
343
+
344
+ .st-funnelChart__legendSwatch--category1 { background: var(--st-semantic-data-category1); }
345
+ .st-funnelChart__legendSwatch--category2 { background: var(--st-semantic-data-category2); }
346
+ .st-funnelChart__legendSwatch--category3 { background: var(--st-semantic-data-category3); }
347
+ .st-funnelChart__legendSwatch--category4 { background: var(--st-semantic-data-category4); }
348
+ .st-funnelChart__legendSwatch--category5 { background: var(--st-semantic-data-category5); }
349
+ .st-funnelChart__legendSwatch--category6 { background: var(--st-semantic-data-category6); }
350
+ .st-funnelChart__legendSwatch--category7 { background: var(--st-semantic-data-category7); }
351
+ .st-funnelChart__legendSwatch--category8 { background: var(--st-semantic-data-category8); }
352
+
353
+ @media (prefers-reduced-motion: reduce) {
354
+ .st-funnelChart__segment {
355
+ transition: none;
356
+ }
357
+ }
358
+ </style>
@@ -0,0 +1,21 @@
1
+ export type FunnelChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
2
+ export type FunnelChartDatum = {
3
+ label: string;
4
+ value: number;
5
+ tone?: FunnelChartTone;
6
+ };
7
+ type FunnelChartProps = {
8
+ data: FunnelChartDatum[];
9
+ orientation?: "vertical" | "horizontal";
10
+ showPercentages?: boolean;
11
+ percentMode?: "ofFirst" | "ofPrevious";
12
+ legend?: boolean;
13
+ label: string;
14
+ width?: number;
15
+ height?: number;
16
+ class?: string;
17
+ };
18
+ declare const FunnelChart: import("svelte").Component<FunnelChartProps, {}, "">;
19
+ type FunnelChart = ReturnType<typeof FunnelChart>;
20
+ export default FunnelChart;
21
+ //# sourceMappingURL=FunnelChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FunnelChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/FunnelChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,eAAe,GACvB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,eAAe,CAAC;CACxB,CAAC;AAOF,KAAK,gBAAgB,GAAG;IACtB,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACzB,WAAW,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;IACxC,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,WAAW,CAAC,EAAE,SAAS,GAAG,YAAY,CAAC;IACvC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAqMJ,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,300 @@
1
+ <script lang="ts" module>
2
+ export type GaugeChartTone =
3
+ | "neutral" | "info" | "success" | "warning" | "error"
4
+ | "category1" | "category2" | "category3" | "category4"
5
+ | "category5" | "category6" | "category7" | "category8";
6
+
7
+ /**
8
+ * Seuil de coloration. La bande s'étend depuis `value` (ou le minimum)
9
+ * jusqu'au seuil suivant (ou le maximum). `tone` choisit la couleur.
10
+ */
11
+ export type GaugeChartThreshold = {
12
+ value: number;
13
+ tone: GaugeChartTone;
14
+ };
15
+
16
+ export type GaugeChartFormat = "number" | "percent";
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import ChartDataList from "./ChartDataList.svelte";
21
+
22
+ type GaugeChartProps = {
23
+ value: number;
24
+ min?: number;
25
+ max?: number;
26
+ /** Bandes colorées sur la piste. Triées par `value` croissante. */
27
+ thresholds?: GaugeChartThreshold[];
28
+ /** Libellé décrivant la jauge (a11y + texte sous la valeur). */
29
+ label?: string;
30
+ /** Format de la valeur centrale. */
31
+ format?: GaugeChartFormat;
32
+ /** Suffixe d'unité (ignoré pour `percent`). */
33
+ unit?: string;
34
+ /** Diamètre du SVG. */
35
+ size?: number;
36
+ /** Épaisseur de l'arc. */
37
+ thickness?: number;
38
+ /** Angle de départ en degrés (0 = est, sens horaire). */
39
+ startAngle?: number;
40
+ /** Angle de fin en degrés. */
41
+ endAngle?: number;
42
+ class?: string;
43
+ };
44
+
45
+ let {
46
+ value,
47
+ min = 0,
48
+ max = 100,
49
+ thresholds,
50
+ label,
51
+ format = "number",
52
+ unit,
53
+ size = 220,
54
+ thickness = 22,
55
+ startAngle = 180,
56
+ endAngle = 360,
57
+ class: className
58
+ }: GaugeChartProps = $props();
59
+
60
+ const TAU = Math.PI * 2;
61
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
62
+
63
+ const span = $derived(Math.max(max - min, 0));
64
+ const clamped = $derived(Math.min(Math.max(value, min), max));
65
+ const frac = $derived(span > 0 ? (clamped - min) / span : 0);
66
+
67
+ // Géométrie commune.
68
+ const cx = $derived(size / 2);
69
+ const r = $derived(size / 2 - thickness / 2 - 2);
70
+ const a0 = $derived(toRad(startAngle));
71
+ const a1 = $derived(toRad(endAngle));
72
+ const totalAngle = $derived(a1 - a0);
73
+
74
+ const polar = (radius: number, angle: number, centerX: number, centerY: number): [number, number] => [
75
+ centerX + radius * Math.cos(angle),
76
+ centerY + radius * Math.sin(angle)
77
+ ];
78
+
79
+ // Hauteur réelle de l'arc pour cadrer le viewBox (demi-cercle → moitié).
80
+ const geometry = $derived.by(() => {
81
+ const cyRaw = size / 2;
82
+ // Échantillonnage des extrema y/x pour un cadrage stable quel que soit l'angle.
83
+ const samples = 64;
84
+ let minY = Infinity;
85
+ let maxY = -Infinity;
86
+ for (let i = 0; i <= samples; i++) {
87
+ const a = a0 + (totalAngle * i) / samples;
88
+ const yOuter = cyRaw + (r + thickness / 2) * Math.sin(a);
89
+ minY = Math.min(minY, yOuter);
90
+ maxY = Math.max(maxY, yOuter);
91
+ }
92
+ minY = Math.min(minY, cyRaw - (r + thickness / 2));
93
+ const vbHeight = Math.min(maxY, size) - Math.max(minY, 0);
94
+ return { cy: cyRaw, vbTop: Math.max(minY, 0), vbHeight: Math.max(vbHeight, thickness) };
95
+ });
96
+
97
+ const cy = $derived(geometry.cy);
98
+
99
+ function arcPath(fromFrac: number, toFrac: number): string {
100
+ const from = a0 + totalAngle * fromFrac;
101
+ const to = a0 + totalAngle * toFrac;
102
+ const [x0, y0] = polar(r, from, cx, cy);
103
+ const [x1, y1] = polar(r, to, cx, cy);
104
+ const large = Math.abs(to - from) > Math.PI ? 1 : 0;
105
+ const sweep = totalAngle >= 0 ? 1 : 0;
106
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${large} ${sweep} ${x1} ${y1}`;
107
+ }
108
+
109
+ // Bandes colorées issues des seuils.
110
+ const bands = $derived.by(() => {
111
+ if (!thresholds || thresholds.length === 0 || span <= 0) return [];
112
+ const sorted = [...thresholds].sort((a, b) => a.value - b.value);
113
+ const segments: Array<{ from: number; to: number; tone: GaugeChartTone }> = [];
114
+ let start = min;
115
+ for (const t of sorted) {
116
+ const end = Math.min(Math.max(t.value, min), max);
117
+ if (end <= start) continue;
118
+ segments.push({ from: (start - min) / span, to: (end - min) / span, tone: t.tone });
119
+ start = end;
120
+ }
121
+ if (start < max) {
122
+ const lastTone = sorted[sorted.length - 1]?.tone ?? "neutral";
123
+ segments.push({ from: (start - min) / span, to: 1, tone: lastTone });
124
+ }
125
+ return segments;
126
+ });
127
+
128
+ // Position de l'aiguille.
129
+ const needle = $derived.by(() => {
130
+ const a = a0 + totalAngle * frac;
131
+ const tip = polar(r + thickness / 2, a, cx, cy);
132
+ const left = polar(thickness * 0.18, a + Math.PI / 2, cx, cy);
133
+ const right = polar(thickness * 0.18, a - Math.PI / 2, cx, cy);
134
+ return `M ${left[0]} ${left[1]} L ${tip[0]} ${tip[1]} L ${right[0]} ${right[1]} Z`;
135
+ });
136
+
137
+ const formatted = $derived.by(() => {
138
+ if (format === "percent") {
139
+ const pct = span > 0 ? Math.round(frac * 100) : 0;
140
+ return `${pct}%`;
141
+ }
142
+ const num = Number.isInteger(clamped) ? String(clamped) : clamped.toFixed(1);
143
+ return unit ? `${num} ${unit}` : num;
144
+ });
145
+
146
+ const ariaValueText = $derived(label ? `${label}: ${formatted}` : formatted);
147
+
148
+ const classes = $derived(["st-gaugeChart", className].filter(Boolean).join(" "));
149
+ const dataValueItems = $derived([
150
+ `${label ? `${label}: ` : ""}${formatted} (min ${min}, max ${max})`
151
+ ]);
152
+ </script>
153
+
154
+ <div class={classes}>
155
+ <div
156
+ class="st-gaugeChart__visual"
157
+ role="meter"
158
+ aria-valuenow={clamped}
159
+ aria-valuemin={min}
160
+ aria-valuemax={max}
161
+ aria-valuetext={ariaValueText}
162
+ aria-label={label}
163
+ >
164
+ <svg
165
+ viewBox="0 {geometry.vbTop} {size} {geometry.vbHeight}"
166
+ width="100%"
167
+ height="100%"
168
+ focusable="false"
169
+ aria-hidden="true"
170
+ >
171
+ <!-- Piste de fond -->
172
+ <path class="st-gaugeChart__track" d={arcPath(0, 1)} fill="none" stroke-width={thickness} />
173
+
174
+ <!-- Bandes de seuils (sous le remplissage) -->
175
+ {#each bands as band, i (i)}
176
+ <path
177
+ class="st-gaugeChart__band st-gaugeChart__band--{band.tone}"
178
+ d={arcPath(band.from, band.to)}
179
+ fill="none"
180
+ stroke-width={thickness}
181
+ />
182
+ {/each}
183
+
184
+ <!-- Arc de progression (uniquement sans seuils) -->
185
+ {#if bands.length === 0}
186
+ <path
187
+ class="st-gaugeChart__progress"
188
+ d={arcPath(0, frac)}
189
+ fill="none"
190
+ stroke-width={thickness}
191
+ />
192
+ {/if}
193
+
194
+ <!-- Aiguille -->
195
+ <path class="st-gaugeChart__needle" d={needle} />
196
+ <circle class="st-gaugeChart__hub" cx={cx} cy={cy} r={Math.max(thickness * 0.22, 4)} />
197
+
198
+ <!-- Valeur centrale -->
199
+ <text
200
+ class="st-gaugeChart__value"
201
+ x={cx}
202
+ y={cy - thickness * 0.55}
203
+ text-anchor="middle"
204
+ dominant-baseline="auto"
205
+ >
206
+ {formatted}
207
+ </text>
208
+ {#if label}
209
+ <text
210
+ class="st-gaugeChart__label"
211
+ x={cx}
212
+ y={cy - thickness * 0.05}
213
+ text-anchor="middle"
214
+ dominant-baseline="hanging"
215
+ >
216
+ {label}
217
+ </text>
218
+ {/if}
219
+ </svg>
220
+ </div>
221
+
222
+ <ChartDataList label={label ?? "gauge"} items={dataValueItems} />
223
+ </div>
224
+
225
+ <style>
226
+ .st-gaugeChart {
227
+ color: var(--st-semantic-text-secondary);
228
+ display: block;
229
+ font-family: inherit;
230
+ max-width: 100%;
231
+ position: relative;
232
+ }
233
+
234
+ .st-gaugeChart__visual,
235
+ .st-gaugeChart svg {
236
+ display: block;
237
+ overflow: visible;
238
+ }
239
+
240
+ .st-gaugeChart__track {
241
+ stroke: var(--st-semantic-surface-subtle);
242
+ stroke-linecap: round;
243
+ }
244
+
245
+ .st-gaugeChart__band {
246
+ stroke-linecap: butt;
247
+ }
248
+
249
+ .st-gaugeChart__progress {
250
+ stroke: var(--st-semantic-action-primary);
251
+ stroke-linecap: round;
252
+ transition: d var(--st-motion-fast, 200ms) var(--st-motion-easing, ease);
253
+ }
254
+
255
+ .st-gaugeChart__needle {
256
+ fill: var(--st-semantic-text-primary);
257
+ transition: d var(--st-motion-fast, 200ms) var(--st-motion-easing, ease);
258
+ }
259
+
260
+ .st-gaugeChart__hub {
261
+ fill: var(--st-semantic-text-primary);
262
+ }
263
+
264
+ .st-gaugeChart__value {
265
+ fill: var(--st-semantic-text-primary);
266
+ font-size: 1.5rem;
267
+ font-variant-numeric: tabular-nums;
268
+ font-weight: 700;
269
+ }
270
+
271
+ .st-gaugeChart__label {
272
+ fill: var(--st-semantic-text-secondary);
273
+ font-size: 0.8125rem;
274
+ font-weight: 500;
275
+ }
276
+
277
+ /* Tons sémantiques de feedback */
278
+ .st-gaugeChart__band--neutral { stroke: var(--st-semantic-border-strong, var(--st-semantic-surface-subtle)); }
279
+ .st-gaugeChart__band--info { stroke: var(--st-semantic-feedback-info, var(--st-semantic-action-primary)); }
280
+ .st-gaugeChart__band--success { stroke: var(--st-semantic-feedback-success); }
281
+ .st-gaugeChart__band--warning { stroke: var(--st-semantic-feedback-warning); }
282
+ .st-gaugeChart__band--error { stroke: var(--st-semantic-feedback-error); }
283
+
284
+ /* Tons catégoriels data */
285
+ .st-gaugeChart__band--category1 { stroke: var(--st-semantic-data-category1); }
286
+ .st-gaugeChart__band--category2 { stroke: var(--st-semantic-data-category2); }
287
+ .st-gaugeChart__band--category3 { stroke: var(--st-semantic-data-category3); }
288
+ .st-gaugeChart__band--category4 { stroke: var(--st-semantic-data-category4); }
289
+ .st-gaugeChart__band--category5 { stroke: var(--st-semantic-data-category5); }
290
+ .st-gaugeChart__band--category6 { stroke: var(--st-semantic-data-category6); }
291
+ .st-gaugeChart__band--category7 { stroke: var(--st-semantic-data-category7); }
292
+ .st-gaugeChart__band--category8 { stroke: var(--st-semantic-data-category8); }
293
+
294
+ @media (prefers-reduced-motion: reduce) {
295
+ .st-gaugeChart__progress,
296
+ .st-gaugeChart__needle {
297
+ transition: none;
298
+ }
299
+ }
300
+ </style>