@sentropic/design-system-svelte 0.16.0 → 0.18.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/Calendar.svelte +237 -42
- package/dist/Calendar.svelte.d.ts.map +1 -1
- package/dist/ComboChart.svelte +620 -0
- package/dist/ComboChart.svelte.d.ts +28 -0
- package/dist/ComboChart.svelte.d.ts.map +1 -0
- package/dist/FunnelChart.svelte +358 -0
- package/dist/FunnelChart.svelte.d.ts +21 -0
- package/dist/FunnelChart.svelte.d.ts.map +1 -0
- package/dist/GaugeChart.svelte +300 -0
- package/dist/GaugeChart.svelte.d.ts +36 -0
- package/dist/GaugeChart.svelte.d.ts.map +1 -0
- package/dist/KpiCard.svelte +318 -0
- package/dist/KpiCard.svelte.d.ts +36 -0
- package/dist/KpiCard.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/Rating.svelte +130 -35
- package/dist/Rating.svelte.d.ts.map +1 -1
- 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/TimePicker.svelte +176 -13
- package/dist/TimePicker.svelte.d.ts.map +1 -1
- package/dist/TreemapChart.svelte +448 -0
- package/dist/TreemapChart.svelte.d.ts +26 -0
- package/dist/TreemapChart.svelte.d.ts.map +1 -0
- package/dist/WaterfallChart.svelte +469 -0
- package/dist/WaterfallChart.svelte.d.ts +19 -0
- package/dist/WaterfallChart.svelte.d.ts.map +1 -0
- package/dist/chartContrast.d.ts +6 -0
- package/dist/chartContrast.d.ts.map +1 -0
- package/dist/chartContrast.js +58 -0
- 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
|
@@ -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} · {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} · {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>
|