@sentropic/design-system-svelte 0.18.0 → 0.20.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/BulletChart.svelte +479 -0
- package/dist/BulletChart.svelte.d.ts +32 -0
- package/dist/BulletChart.svelte.d.ts.map +1 -0
- package/dist/BumpChart.svelte +387 -0
- package/dist/BumpChart.svelte.d.ts +35 -0
- package/dist/BumpChart.svelte.d.ts.map +1 -0
- package/dist/CalendarHeatmapChart.svelte +345 -0
- package/dist/CalendarHeatmapChart.svelte.d.ts +28 -0
- package/dist/CalendarHeatmapChart.svelte.d.ts.map +1 -0
- package/dist/CandlestickChart.svelte +333 -0
- package/dist/CandlestickChart.svelte.d.ts +31 -0
- package/dist/CandlestickChart.svelte.d.ts.map +1 -0
- 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/MarimekkoChart.svelte +319 -0
- package/dist/MarimekkoChart.svelte.d.ts +35 -0
- package/dist/MarimekkoChart.svelte.d.ts.map +1 -0
- package/dist/ParallelCoordinatesChart.svelte +315 -0
- package/dist/ParallelCoordinatesChart.svelte.d.ts +35 -0
- package/dist/ParallelCoordinatesChart.svelte.d.ts.map +1 -0
- 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/SankeyChart.svelte +364 -0
- package/dist/SankeyChart.svelte.d.ts +45 -0
- package/dist/SankeyChart.svelte.d.ts.map +1 -0
- 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/chartContrast.d.ts +0 -4
- package/dist/chartContrast.d.ts.map +1 -1
- package/dist/chartContrast.js +4 -56
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* ParallelCoordinatesChart - axes verticaux parallèles, polylignes par enregistrement.
|
|
4
|
+
* API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
5
|
+
*
|
|
6
|
+
* Props obligatoires :
|
|
7
|
+
* axes ParallelAxis[] - définition des axes {key, label, min?, max?}
|
|
8
|
+
* data Array<Record<string, unknown>> - enregistrements
|
|
9
|
+
* label string - aria-label
|
|
10
|
+
*
|
|
11
|
+
* Props optionnelles :
|
|
12
|
+
* tones tone par série (string[]) - one tone per datum row, cycled
|
|
13
|
+
* width number (défaut 480)
|
|
14
|
+
* height number (défaut 300)
|
|
15
|
+
* class string
|
|
16
|
+
*/
|
|
17
|
+
export type ParallelCoordinatesChartTone =
|
|
18
|
+
| "category1"
|
|
19
|
+
| "category2"
|
|
20
|
+
| "category3"
|
|
21
|
+
| "category4"
|
|
22
|
+
| "category5"
|
|
23
|
+
| "category6"
|
|
24
|
+
| "category7"
|
|
25
|
+
| "category8";
|
|
26
|
+
|
|
27
|
+
export type ParallelAxis = {
|
|
28
|
+
key: string;
|
|
29
|
+
label: string;
|
|
30
|
+
min?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
};
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script lang="ts">
|
|
36
|
+
import ChartDataList from "./ChartDataList.svelte";
|
|
37
|
+
|
|
38
|
+
const TONES: ParallelCoordinatesChartTone[] = [
|
|
39
|
+
"category1","category2","category3","category4",
|
|
40
|
+
"category5","category6","category7","category8"
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
type ParallelCoordinatesChartProps = {
|
|
44
|
+
axes: ParallelAxis[];
|
|
45
|
+
data: Record<string, unknown>[];
|
|
46
|
+
label: string;
|
|
47
|
+
// FIX #5 : renommage tone → tones (cohérence avec les autres charts)
|
|
48
|
+
tones?: ParallelCoordinatesChartTone[];
|
|
49
|
+
width?: number;
|
|
50
|
+
height?: number;
|
|
51
|
+
class?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let {
|
|
55
|
+
axes = [],
|
|
56
|
+
data = [],
|
|
57
|
+
label,
|
|
58
|
+
tones,
|
|
59
|
+
width = 480,
|
|
60
|
+
height = 300,
|
|
61
|
+
class: className
|
|
62
|
+
}: ParallelCoordinatesChartProps = $props();
|
|
63
|
+
|
|
64
|
+
const MARGIN = { top: 32, right: 24, bottom: 16, left: 24 };
|
|
65
|
+
|
|
66
|
+
let hoveredIndex: number | null = $state(null);
|
|
67
|
+
|
|
68
|
+
const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
|
|
69
|
+
const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
|
|
70
|
+
|
|
71
|
+
// FIX #5 : domaines par axe explicites, sans coercion Number() silencieuse
|
|
72
|
+
function axisDomain(axis: ParallelAxis): { min: number; max: number } {
|
|
73
|
+
// Parse STRICT : seules les valeurs finies comptent
|
|
74
|
+
const vals = data
|
|
75
|
+
.map((d) => {
|
|
76
|
+
const raw = d[axis.key];
|
|
77
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
|
78
|
+
if (typeof raw === "string" && raw !== "") {
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
return Number.isFinite(n) ? n : null;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
})
|
|
84
|
+
.filter((v): v is number => v !== null);
|
|
85
|
+
|
|
86
|
+
const rawMin = vals.length > 0 ? Math.min(...vals) : 0;
|
|
87
|
+
const rawMax = vals.length > 0 ? Math.max(...vals) : 1;
|
|
88
|
+
const safeMax = rawMax === rawMin ? rawMin + 1 : rawMax;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
min: Number.isFinite(axis.min) ? (axis.min as number) : rawMin,
|
|
92
|
+
max: Number.isFinite(axis.max) ? (axis.max as number) : safeMax
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const axisX = $derived(
|
|
97
|
+
axes.map((_, i) => MARGIN.left + (axes.length <= 1 ? plotWidth / 2 : (i / (axes.length - 1)) * plotWidth))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* FIX #5 : parse STRICT d'une valeur de ligne.
|
|
102
|
+
* Retourne null si la valeur n'est pas finie → crée un GAP dans le path.
|
|
103
|
+
*/
|
|
104
|
+
function parseStrictFinite(raw: unknown): number | null {
|
|
105
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
|
106
|
+
if (typeof raw === "string" && raw !== "") {
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
return Number.isFinite(n) ? n : null;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Construit un path SVG avec GAP (M...L... / M...) pour les points null.
|
|
115
|
+
* Un segment contenant un point null ne sera pas tracé.
|
|
116
|
+
*/
|
|
117
|
+
function buildPathWithGaps(points: ({ x: number; y: number } | null)[]): string {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
let segment: { x: number; y: number }[] = [];
|
|
120
|
+
|
|
121
|
+
for (const pt of points) {
|
|
122
|
+
if (pt === null) {
|
|
123
|
+
if (segment.length > 0) {
|
|
124
|
+
parts.push(segment.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" "));
|
|
125
|
+
segment = [];
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
segment.push(pt);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (segment.length > 0) {
|
|
132
|
+
parts.push(segment.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" "));
|
|
133
|
+
}
|
|
134
|
+
return parts.join(" ");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lines = $derived.by(() => {
|
|
138
|
+
return data.map((row, ri) => {
|
|
139
|
+
const seriesTones = tones ?? [];
|
|
140
|
+
const rowTone = seriesTones[ri] ?? TONES[ri % TONES.length];
|
|
141
|
+
const points: ({ x: number; y: number } | null)[] = axes.map((axis, ai) => {
|
|
142
|
+
const domain = axisDomain(axis);
|
|
143
|
+
// FIX #5 : parse strict → null si invalide
|
|
144
|
+
const val = parseStrictFinite(row[axis.key]);
|
|
145
|
+
if (val === null) return null; // GAP
|
|
146
|
+
// FIX #5 : clamp aux bornes du domaine
|
|
147
|
+
const clamped = Math.min(Math.max(val, domain.min), domain.max);
|
|
148
|
+
const t = domain.max === domain.min ? 0.5 : (clamped - domain.min) / (domain.max - domain.min);
|
|
149
|
+
return {
|
|
150
|
+
x: axisX[ai],
|
|
151
|
+
y: MARGIN.top + (1 - t) * plotHeight
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
return { points, tone: rowTone, index: ri, row, path: buildPathWithGaps(points) };
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const dataValueItems = $derived(
|
|
159
|
+
data.map((row) =>
|
|
160
|
+
axes.map((axis) => `${axis.label}: ${row[axis.key] ?? ""}`).join(", ")
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
function formatTick(v: number): string {
|
|
165
|
+
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
|
166
|
+
if (Number.isInteger(v)) return String(v);
|
|
167
|
+
return v.toFixed(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handlePointerMove(event: PointerEvent) {
|
|
171
|
+
const target = event.target;
|
|
172
|
+
if (!(target instanceof Element)) { hoveredIndex = null; return; }
|
|
173
|
+
const idx = Number(target.getAttribute("data-chart-index"));
|
|
174
|
+
hoveredIndex = Number.isInteger(idx) ? idx : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const classes = () => ["st-parallelCoordinatesChart", className].filter(Boolean).join(" ");
|
|
178
|
+
</script>
|
|
179
|
+
|
|
180
|
+
<div class={classes()}>
|
|
181
|
+
<div
|
|
182
|
+
class="st-parallelCoordinatesChart__visual"
|
|
183
|
+
role="img"
|
|
184
|
+
aria-label={label}
|
|
185
|
+
onpointermove={handlePointerMove}
|
|
186
|
+
onpointerleave={() => (hoveredIndex = null)}
|
|
187
|
+
>
|
|
188
|
+
<svg
|
|
189
|
+
viewBox="0 0 {width} {height}"
|
|
190
|
+
preserveAspectRatio="xMidYMid meet"
|
|
191
|
+
width="100%"
|
|
192
|
+
height="100%"
|
|
193
|
+
focusable="false"
|
|
194
|
+
aria-hidden="true"
|
|
195
|
+
>
|
|
196
|
+
<!-- polylines (draw non-hovered first, then hovered on top) -->
|
|
197
|
+
{#each lines as line (line.index)}
|
|
198
|
+
<path
|
|
199
|
+
class="st-parallelCoordinatesChart__line st-parallelCoordinatesChart__line--{line.tone}"
|
|
200
|
+
class:st-parallelCoordinatesChart__line--dim={hoveredIndex !== null && hoveredIndex !== line.index}
|
|
201
|
+
class:st-parallelCoordinatesChart__line--active={hoveredIndex === line.index}
|
|
202
|
+
d={line.path}
|
|
203
|
+
fill="none"
|
|
204
|
+
data-chart-index={line.index}
|
|
205
|
+
/>
|
|
206
|
+
{/each}
|
|
207
|
+
|
|
208
|
+
<!-- axes and labels -->
|
|
209
|
+
{#each axes as axis, ai (axis.key)}
|
|
210
|
+
{@const domain = axisDomain(axis)}
|
|
211
|
+
{@const ax = axisX[ai]}
|
|
212
|
+
<line
|
|
213
|
+
class="st-parallelCoordinatesChart__axis"
|
|
214
|
+
x1={ax}
|
|
215
|
+
x2={ax}
|
|
216
|
+
y1={MARGIN.top}
|
|
217
|
+
y2={MARGIN.top + plotHeight}
|
|
218
|
+
/>
|
|
219
|
+
<text
|
|
220
|
+
class="st-parallelCoordinatesChart__axisLabel"
|
|
221
|
+
x={ax}
|
|
222
|
+
y={MARGIN.top - 10}
|
|
223
|
+
text-anchor="middle"
|
|
224
|
+
>
|
|
225
|
+
{axis.label}
|
|
226
|
+
</text>
|
|
227
|
+
<!-- min/max ticks -->
|
|
228
|
+
<text
|
|
229
|
+
class="st-parallelCoordinatesChart__tickLabel"
|
|
230
|
+
x={ax + 4}
|
|
231
|
+
y={MARGIN.top + plotHeight}
|
|
232
|
+
dominant-baseline="auto"
|
|
233
|
+
>
|
|
234
|
+
{formatTick(domain.min)}
|
|
235
|
+
</text>
|
|
236
|
+
<text
|
|
237
|
+
class="st-parallelCoordinatesChart__tickLabel"
|
|
238
|
+
x={ax + 4}
|
|
239
|
+
y={MARGIN.top}
|
|
240
|
+
dominant-baseline="hanging"
|
|
241
|
+
>
|
|
242
|
+
{formatTick(domain.max)}
|
|
243
|
+
</text>
|
|
244
|
+
{/each}
|
|
245
|
+
</svg>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<ChartDataList {label} items={dataValueItems} />
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<style>
|
|
252
|
+
.st-parallelCoordinatesChart {
|
|
253
|
+
color: var(--st-semantic-text-secondary);
|
|
254
|
+
display: block;
|
|
255
|
+
font-family: inherit;
|
|
256
|
+
position: relative;
|
|
257
|
+
width: 100%;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.st-parallelCoordinatesChart svg {
|
|
261
|
+
display: block;
|
|
262
|
+
overflow: visible;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.st-parallelCoordinatesChart__visual {
|
|
266
|
+
display: block;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.st-parallelCoordinatesChart__axis {
|
|
270
|
+
stroke: var(--st-semantic-border-subtle);
|
|
271
|
+
stroke-width: 1.5;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.st-parallelCoordinatesChart__axisLabel {
|
|
275
|
+
fill: var(--st-semantic-text-secondary);
|
|
276
|
+
font-size: 0.6875rem;
|
|
277
|
+
font-weight: 600;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.st-parallelCoordinatesChart__tickLabel {
|
|
281
|
+
fill: var(--st-semantic-text-secondary);
|
|
282
|
+
font-size: 0.5625rem;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.st-parallelCoordinatesChart__line {
|
|
286
|
+
cursor: pointer;
|
|
287
|
+
stroke-width: 1.5;
|
|
288
|
+
stroke-opacity: 0.65;
|
|
289
|
+
transition: stroke-opacity 120ms ease, stroke-width 120ms ease;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.st-parallelCoordinatesChart__line--dim {
|
|
293
|
+
stroke-opacity: 0.12;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.st-parallelCoordinatesChart__line--active {
|
|
297
|
+
stroke-opacity: 1;
|
|
298
|
+
stroke-width: 2.5;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@media (prefers-reduced-motion: reduce) {
|
|
302
|
+
.st-parallelCoordinatesChart__line {
|
|
303
|
+
transition: none;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.st-parallelCoordinatesChart__line--category1 { stroke: var(--st-semantic-data-category1); }
|
|
308
|
+
.st-parallelCoordinatesChart__line--category2 { stroke: var(--st-semantic-data-category2); }
|
|
309
|
+
.st-parallelCoordinatesChart__line--category3 { stroke: var(--st-semantic-data-category3); }
|
|
310
|
+
.st-parallelCoordinatesChart__line--category4 { stroke: var(--st-semantic-data-category4); }
|
|
311
|
+
.st-parallelCoordinatesChart__line--category5 { stroke: var(--st-semantic-data-category5); }
|
|
312
|
+
.st-parallelCoordinatesChart__line--category6 { stroke: var(--st-semantic-data-category6); }
|
|
313
|
+
.st-parallelCoordinatesChart__line--category7 { stroke: var(--st-semantic-data-category7); }
|
|
314
|
+
.st-parallelCoordinatesChart__line--category8 { stroke: var(--st-semantic-data-category8); }
|
|
315
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParallelCoordinatesChart - axes verticaux parallèles, polylignes par enregistrement.
|
|
3
|
+
* API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
4
|
+
*
|
|
5
|
+
* Props obligatoires :
|
|
6
|
+
* axes ParallelAxis[] - définition des axes {key, label, min?, max?}
|
|
7
|
+
* data Array<Record<string, unknown>> - enregistrements
|
|
8
|
+
* label string - aria-label
|
|
9
|
+
*
|
|
10
|
+
* Props optionnelles :
|
|
11
|
+
* tones tone par série (string[]) - one tone per datum row, cycled
|
|
12
|
+
* width number (défaut 480)
|
|
13
|
+
* height number (défaut 300)
|
|
14
|
+
* class string
|
|
15
|
+
*/
|
|
16
|
+
export type ParallelCoordinatesChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
|
|
17
|
+
export type ParallelAxis = {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
min?: number;
|
|
21
|
+
max?: number;
|
|
22
|
+
};
|
|
23
|
+
type ParallelCoordinatesChartProps = {
|
|
24
|
+
axes: ParallelAxis[];
|
|
25
|
+
data: Record<string, unknown>[];
|
|
26
|
+
label: string;
|
|
27
|
+
tones?: ParallelCoordinatesChartTone[];
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
class?: string;
|
|
31
|
+
};
|
|
32
|
+
declare const ParallelCoordinatesChart: import("svelte").Component<ParallelCoordinatesChartProps, {}, "">;
|
|
33
|
+
type ParallelCoordinatesChart = ReturnType<typeof ParallelCoordinatesChart>;
|
|
34
|
+
export default ParallelCoordinatesChart;
|
|
35
|
+
//# sourceMappingURL=ParallelCoordinatesChart.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ParallelCoordinatesChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ParallelCoordinatesChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,4BAA4B,GACpC,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAMF,KAAK,6BAA6B,GAAG;IACnC,IAAI,EAAE,YAAY,EAAE,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC;IAEd,KAAK,CAAC,EAAE,4BAA4B,EAAE,CAAC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4KJ,QAAA,MAAM,wBAAwB,mEAAwC,CAAC;AACvE,KAAK,wBAAwB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAC5E,eAAe,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* RadarChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
4
|
+
*
|
|
5
|
+
* Props obligatoires :
|
|
6
|
+
* axes string[] - libellés des axes (N axes = polygone à N côtés)
|
|
7
|
+
* series RadarChartSeries[] - séries {label, values: number[], tone?}
|
|
8
|
+
* label string - aria-label du graphique
|
|
9
|
+
*
|
|
10
|
+
* Props optionnelles :
|
|
11
|
+
* maxValue number (défaut : max des valeurs, min 1) - valeur plafond du
|
|
12
|
+
* domaine. PAS de plancher arbitraire à 100 - l'échelle
|
|
13
|
+
* s'adapte aux données. React/Vue doivent supprimer leur
|
|
14
|
+
* `Math.max(100, …)` pour s'aligner sur ce comportement.
|
|
15
|
+
* levels number (défaut 4) - nombre de cercles / anneaux de grille
|
|
16
|
+
* legend boolean (défaut false) - affiche la légende des séries
|
|
17
|
+
* width number (défaut 360) - largeur du viewBox en px
|
|
18
|
+
* height number (défaut 320) - hauteur du viewBox en px
|
|
19
|
+
* class string - classe CSS supplémentaire
|
|
20
|
+
*
|
|
21
|
+
* NaN/vide : les valeurs non-finies sont exclues du calcul du domaine
|
|
22
|
+
* (filter Number.isFinite). Séries vides → polygone nul sans crash.
|
|
23
|
+
*/
|
|
24
|
+
export type RadarChartTone =
|
|
25
|
+
| "category1"
|
|
26
|
+
| "category2"
|
|
27
|
+
| "category3"
|
|
28
|
+
| "category4"
|
|
29
|
+
| "category5"
|
|
30
|
+
| "category6"
|
|
31
|
+
| "category7"
|
|
32
|
+
| "category8";
|
|
33
|
+
|
|
34
|
+
export type RadarChartSeries = {
|
|
35
|
+
label: string;
|
|
36
|
+
values: number[];
|
|
37
|
+
tone?: RadarChartTone;
|
|
38
|
+
};
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<script lang="ts">
|
|
42
|
+
import ChartDataList from "./ChartDataList.svelte";
|
|
43
|
+
|
|
44
|
+
type RadarChartProps = {
|
|
45
|
+
axes: string[];
|
|
46
|
+
series: RadarChartSeries[];
|
|
47
|
+
label: string;
|
|
48
|
+
legend?: boolean;
|
|
49
|
+
maxValue?: number;
|
|
50
|
+
levels?: number;
|
|
51
|
+
width?: number;
|
|
52
|
+
height?: number;
|
|
53
|
+
class?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let {
|
|
57
|
+
axes,
|
|
58
|
+
series,
|
|
59
|
+
label,
|
|
60
|
+
legend = false,
|
|
61
|
+
maxValue,
|
|
62
|
+
levels = 4,
|
|
63
|
+
width = 360,
|
|
64
|
+
height = 320,
|
|
65
|
+
class: className
|
|
66
|
+
}: RadarChartProps = $props();
|
|
67
|
+
|
|
68
|
+
const TONES = [
|
|
69
|
+
"category1",
|
|
70
|
+
"category2",
|
|
71
|
+
"category3",
|
|
72
|
+
"category4",
|
|
73
|
+
"category5",
|
|
74
|
+
"category6",
|
|
75
|
+
"category7",
|
|
76
|
+
"category8"
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
function pointAt(cx: number, cy: number, radius: number, angle: number) {
|
|
80
|
+
return {
|
|
81
|
+
x: cx + radius * Math.cos(angle),
|
|
82
|
+
y: cy + radius * Math.sin(angle)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let hoveredIndex: number | null = $state(null);
|
|
87
|
+
|
|
88
|
+
const center = $derived({ x: width / 2, y: height / 2 });
|
|
89
|
+
const radius = $derived(Math.max(Math.min(width, height) / 2 - 42, 1));
|
|
90
|
+
const safeLevelCount = $derived(Math.max(1, Math.floor(levels)));
|
|
91
|
+
const domainMax = $derived.by(() => {
|
|
92
|
+
if (Number.isFinite(maxValue) && (maxValue ?? 0) > 0) return maxValue as number;
|
|
93
|
+
const values = series.flatMap((entry) => entry.values).filter(Number.isFinite);
|
|
94
|
+
return Math.max(1, ...values);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const axisEntries = $derived(
|
|
98
|
+
axes.map((axis, index) => {
|
|
99
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(axes.length, 1);
|
|
100
|
+
const end = pointAt(center.x, center.y, radius, angle);
|
|
101
|
+
const labelPoint = pointAt(center.x, center.y, radius + 22, angle);
|
|
102
|
+
return { axis, index, angle, end, labelPoint };
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const rings = $derived(
|
|
107
|
+
Array.from({ length: safeLevelCount }, (_, index) => {
|
|
108
|
+
const ringRadius = (radius * (index + 1)) / safeLevelCount;
|
|
109
|
+
return axisEntries.map((axis) => pointAt(center.x, center.y, ringRadius, axis.angle)).map((point) => `${point.x},${point.y}`).join(" ");
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const polygons = $derived(
|
|
114
|
+
series.map((entry, seriesIndex) => {
|
|
115
|
+
const tone = entry.tone ?? TONES[seriesIndex % TONES.length];
|
|
116
|
+
const points = axes.map((_, axisIndex) => {
|
|
117
|
+
const value = Math.max(0, entry.values[axisIndex] ?? 0);
|
|
118
|
+
const scaled = Math.min(value / domainMax, 1) * radius;
|
|
119
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * axisIndex) / Math.max(axes.length, 1);
|
|
120
|
+
return pointAt(center.x, center.y, scaled, angle);
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
entry,
|
|
124
|
+
tone,
|
|
125
|
+
points,
|
|
126
|
+
pointString: points.map((point) => `${point.x},${point.y}`).join(" ")
|
|
127
|
+
};
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const legendItems = $derived(series.map((entry, index) => ({ label: entry.label, tone: entry.tone ?? TONES[index % TONES.length] })));
|
|
132
|
+
|
|
133
|
+
const dataValueItems = $derived(
|
|
134
|
+
series.flatMap((entry) => axes.map((axis, axisIndex) => `${entry.label}, ${axis}: ${entry.values[axisIndex] ?? 0}`))
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
function handleVisualPointerMove(event: PointerEvent) {
|
|
138
|
+
const target = event.target;
|
|
139
|
+
if (!(target instanceof Element)) {
|
|
140
|
+
hoveredIndex = null;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const index = Number(target.getAttribute("data-chart-index"));
|
|
144
|
+
hoveredIndex = Number.isInteger(index) ? index : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const classes = () => ["st-radarChart", className].filter(Boolean).join(" ");
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<div class={classes()}>
|
|
151
|
+
<div
|
|
152
|
+
class="st-radarChart__visual"
|
|
153
|
+
role="img"
|
|
154
|
+
aria-label={label}
|
|
155
|
+
onpointermove={handleVisualPointerMove}
|
|
156
|
+
onpointerleave={() => (hoveredIndex = null)}
|
|
157
|
+
>
|
|
158
|
+
<svg
|
|
159
|
+
viewBox="0 0 {width} {height}"
|
|
160
|
+
preserveAspectRatio="xMidYMid meet"
|
|
161
|
+
width="100%"
|
|
162
|
+
height="100%"
|
|
163
|
+
focusable="false"
|
|
164
|
+
aria-hidden="true"
|
|
165
|
+
>
|
|
166
|
+
{#each rings as ring, i (i)}
|
|
167
|
+
<polygon class="st-radarChart__ring" points={ring} />
|
|
168
|
+
{/each}
|
|
169
|
+
|
|
170
|
+
{#each axisEntries as axis (axis.axis)}
|
|
171
|
+
<line class="st-radarChart__axis" x1={center.x} x2={axis.end.x} y1={center.y} y2={axis.end.y} />
|
|
172
|
+
<text
|
|
173
|
+
class="st-radarChart__axisLabel"
|
|
174
|
+
x={axis.labelPoint.x}
|
|
175
|
+
y={axis.labelPoint.y}
|
|
176
|
+
text-anchor="middle"
|
|
177
|
+
dominant-baseline="middle"
|
|
178
|
+
>
|
|
179
|
+
{axis.axis}
|
|
180
|
+
</text>
|
|
181
|
+
{/each}
|
|
182
|
+
|
|
183
|
+
{#each polygons as polygon, i (polygon.entry.label)}
|
|
184
|
+
<polygon
|
|
185
|
+
class="st-radarChart__polygon st-radarChart__polygon--{polygon.tone}"
|
|
186
|
+
class:st-radarChart__polygon--dim={hoveredIndex !== null && hoveredIndex !== i}
|
|
187
|
+
points={polygon.pointString}
|
|
188
|
+
data-chart-index={i}
|
|
189
|
+
/>
|
|
190
|
+
{#each polygon.points as point, pointIndex (`${polygon.entry.label}-${pointIndex}`)}
|
|
191
|
+
<circle class="st-radarChart__point st-radarChart__point--{polygon.tone}" cx={point.x} cy={point.y} r="3" data-chart-index={i} />
|
|
192
|
+
{/each}
|
|
193
|
+
{/each}
|
|
194
|
+
</svg>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<ChartDataList {label} items={dataValueItems} />
|
|
198
|
+
|
|
199
|
+
{#if hoveredIndex !== null && polygons[hoveredIndex]}
|
|
200
|
+
{@const polygon = polygons[hoveredIndex]}
|
|
201
|
+
<div class="st-radarChart__tooltip" role="presentation">
|
|
202
|
+
<span class="st-radarChart__tooltipLabel">{polygon.entry.label}</span>
|
|
203
|
+
</div>
|
|
204
|
+
{/if}
|
|
205
|
+
|
|
206
|
+
{#if legend && legendItems.length > 0}
|
|
207
|
+
<ul class="st-radarChart__legend" aria-hidden="true">
|
|
208
|
+
{#each legendItems as item (item.label)}
|
|
209
|
+
<li class="st-radarChart__legendItem">
|
|
210
|
+
<span class="st-radarChart__legendSwatch st-radarChart__legendSwatch--{item.tone}"></span>
|
|
211
|
+
{item.label}
|
|
212
|
+
</li>
|
|
213
|
+
{/each}
|
|
214
|
+
</ul>
|
|
215
|
+
{/if}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<style>
|
|
219
|
+
.st-radarChart {
|
|
220
|
+
color: var(--st-semantic-text-secondary);
|
|
221
|
+
display: block;
|
|
222
|
+
font-family: inherit;
|
|
223
|
+
max-width: 100%;
|
|
224
|
+
position: relative;
|
|
225
|
+
width: 100%;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.st-radarChart svg,
|
|
229
|
+
.st-radarChart__visual {
|
|
230
|
+
display: block;
|
|
231
|
+
overflow: visible;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.st-radarChart__ring {
|
|
235
|
+
fill: none;
|
|
236
|
+
stroke: var(--st-semantic-border-subtle);
|
|
237
|
+
stroke-width: 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.st-radarChart__axis {
|
|
241
|
+
stroke: var(--st-semantic-border-subtle);
|
|
242
|
+
stroke-width: 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.st-radarChart__axisLabel {
|
|
246
|
+
fill: var(--st-semantic-text-secondary);
|
|
247
|
+
font-size: 0.72rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.st-radarChart__polygon {
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
fill-opacity: 0.16;
|
|
253
|
+
stroke-width: 2;
|
|
254
|
+
transition: opacity 120ms ease;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.st-radarChart__polygon--dim {
|
|
258
|
+
opacity: 0.35;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@media (prefers-reduced-motion: reduce) {
|
|
262
|
+
.st-radarChart__polygon {
|
|
263
|
+
transition: none;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.st-radarChart__point {
|
|
268
|
+
stroke: var(--st-semantic-surface-default, Canvas);
|
|
269
|
+
stroke-width: 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.st-radarChart__polygon--category1,
|
|
273
|
+
.st-radarChart__point--category1,
|
|
274
|
+
.st-radarChart__legendSwatch--category1 { fill: var(--st-semantic-data-category1); stroke: var(--st-semantic-data-category1); background: var(--st-semantic-data-category1); }
|
|
275
|
+
.st-radarChart__polygon--category2,
|
|
276
|
+
.st-radarChart__point--category2,
|
|
277
|
+
.st-radarChart__legendSwatch--category2 { fill: var(--st-semantic-data-category2); stroke: var(--st-semantic-data-category2); background: var(--st-semantic-data-category2); }
|
|
278
|
+
.st-radarChart__polygon--category3,
|
|
279
|
+
.st-radarChart__point--category3,
|
|
280
|
+
.st-radarChart__legendSwatch--category3 { fill: var(--st-semantic-data-category3); stroke: var(--st-semantic-data-category3); background: var(--st-semantic-data-category3); }
|
|
281
|
+
.st-radarChart__polygon--category4,
|
|
282
|
+
.st-radarChart__point--category4,
|
|
283
|
+
.st-radarChart__legendSwatch--category4 { fill: var(--st-semantic-data-category4); stroke: var(--st-semantic-data-category4); background: var(--st-semantic-data-category4); }
|
|
284
|
+
.st-radarChart__polygon--category5,
|
|
285
|
+
.st-radarChart__point--category5,
|
|
286
|
+
.st-radarChart__legendSwatch--category5 { fill: var(--st-semantic-data-category5); stroke: var(--st-semantic-data-category5); background: var(--st-semantic-data-category5); }
|
|
287
|
+
.st-radarChart__polygon--category6,
|
|
288
|
+
.st-radarChart__point--category6,
|
|
289
|
+
.st-radarChart__legendSwatch--category6 { fill: var(--st-semantic-data-category6); stroke: var(--st-semantic-data-category6); background: var(--st-semantic-data-category6); }
|
|
290
|
+
.st-radarChart__polygon--category7,
|
|
291
|
+
.st-radarChart__point--category7,
|
|
292
|
+
.st-radarChart__legendSwatch--category7 { fill: var(--st-semantic-data-category7); stroke: var(--st-semantic-data-category7); background: var(--st-semantic-data-category7); }
|
|
293
|
+
.st-radarChart__polygon--category8,
|
|
294
|
+
.st-radarChart__point--category8,
|
|
295
|
+
.st-radarChart__legendSwatch--category8 { fill: var(--st-semantic-data-category8); stroke: var(--st-semantic-data-category8); background: var(--st-semantic-data-category8); }
|
|
296
|
+
|
|
297
|
+
.st-radarChart__legend {
|
|
298
|
+
display: flex;
|
|
299
|
+
flex-wrap: wrap;
|
|
300
|
+
gap: var(--st-spacing-2, 0.5rem) var(--st-spacing-4, 1rem);
|
|
301
|
+
list-style: none;
|
|
302
|
+
margin: var(--st-spacing-2, 0.5rem) 0 0;
|
|
303
|
+
padding: 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.st-radarChart__legendItem {
|
|
307
|
+
align-items: center;
|
|
308
|
+
color: var(--st-semantic-text-secondary);
|
|
309
|
+
display: inline-flex;
|
|
310
|
+
font-size: 0.75rem;
|
|
311
|
+
gap: var(--st-spacing-2, 0.5rem);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.st-radarChart__legendSwatch {
|
|
315
|
+
display: inline-block;
|
|
316
|
+
height: 0.625rem;
|
|
317
|
+
width: 0.625rem;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.st-radarChart__tooltip {
|
|
321
|
+
background: var(--st-semantic-surface-inverse);
|
|
322
|
+
border-radius: var(--st-radius-sm, 0.25rem);
|
|
323
|
+
color: var(--st-semantic-text-inverse);
|
|
324
|
+
display: inline-flex;
|
|
325
|
+
font-size: 0.75rem;
|
|
326
|
+
left: 50%;
|
|
327
|
+
line-height: 1.2;
|
|
328
|
+
padding: 0.375rem 0.5rem;
|
|
329
|
+
pointer-events: none;
|
|
330
|
+
position: absolute;
|
|
331
|
+
top: 50%;
|
|
332
|
+
transform: translate(-50%, -50%);
|
|
333
|
+
white-space: nowrap;
|
|
334
|
+
z-index: 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.st-radarChart__tooltipLabel {
|
|
338
|
+
font-weight: 600;
|
|
339
|
+
}
|
|
340
|
+
</style>
|