@sentropic/design-system-svelte 0.27.0 → 0.29.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/ChordDiagram.svelte +396 -0
- package/dist/ChordDiagram.svelte.d.ts +41 -0
- package/dist/ChordDiagram.svelte.d.ts.map +1 -0
- package/dist/PackedBubblesChart.svelte +322 -0
- package/dist/PackedBubblesChart.svelte.d.ts +38 -0
- package/dist/PackedBubblesChart.svelte.d.ts.map +1 -0
- package/dist/RoseChart.svelte +291 -0
- package/dist/RoseChart.svelte.d.ts +49 -0
- package/dist/RoseChart.svelte.d.ts.map +1 -0
- package/dist/ViolinChart.svelte +371 -0
- package/dist/ViolinChart.svelte.d.ts +53 -0
- package/dist/ViolinChart.svelte.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* PackedBubblesChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
4
|
+
*
|
|
5
|
+
* Tasse des cercles dont l'aire est proportionnelle à `value`. Le rayon est
|
|
6
|
+
* calculé par sqrt(value) puis normalisé pour tenir dans le viewBox. Le layout
|
|
7
|
+
* est déterministe (spirale + détection de collision), sans dépendance externe.
|
|
8
|
+
*
|
|
9
|
+
* Props obligatoires :
|
|
10
|
+
* data PackedBubblesChartDatum[] - liste {label, value, tone?}
|
|
11
|
+
* label string - aria-label du graphique
|
|
12
|
+
*
|
|
13
|
+
* Props optionnelles :
|
|
14
|
+
* width number (défaut 360) - largeur du viewBox en px
|
|
15
|
+
* height number (défaut 360) - hauteur du viewBox en px
|
|
16
|
+
* class string - classe CSS supplémentaire
|
|
17
|
+
*
|
|
18
|
+
* Garde : seuls les data dont `value` est finie et > 0 sont rendus. Les NaN /
|
|
19
|
+
* Infinity / valeurs négatives ou nulles sont ignorés (pas de crash).
|
|
20
|
+
* Le label est affiché dans la bulle si elle est assez grande, avec une couleur
|
|
21
|
+
* de texte calculée par contraste (contrastTextForTone).
|
|
22
|
+
*/
|
|
23
|
+
export type PackedBubblesChartTone =
|
|
24
|
+
| "category1"
|
|
25
|
+
| "category2"
|
|
26
|
+
| "category3"
|
|
27
|
+
| "category4"
|
|
28
|
+
| "category5"
|
|
29
|
+
| "category6"
|
|
30
|
+
| "category7"
|
|
31
|
+
| "category8";
|
|
32
|
+
|
|
33
|
+
export type PackedBubblesChartDatum = {
|
|
34
|
+
label: string;
|
|
35
|
+
value: number;
|
|
36
|
+
tone?: PackedBubblesChartTone;
|
|
37
|
+
};
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
import ChartDataList from "./ChartDataList.svelte";
|
|
42
|
+
import { contrastTextForTone } from "./chartContrast.js";
|
|
43
|
+
|
|
44
|
+
type PackedBubblesChartProps = {
|
|
45
|
+
data: PackedBubblesChartDatum[];
|
|
46
|
+
label: string;
|
|
47
|
+
width?: number;
|
|
48
|
+
height?: number;
|
|
49
|
+
class?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let {
|
|
53
|
+
data,
|
|
54
|
+
label,
|
|
55
|
+
width = 360,
|
|
56
|
+
height = 360,
|
|
57
|
+
class: className
|
|
58
|
+
}: PackedBubblesChartProps = $props();
|
|
59
|
+
|
|
60
|
+
const TONES = [
|
|
61
|
+
"category1",
|
|
62
|
+
"category2",
|
|
63
|
+
"category3",
|
|
64
|
+
"category4",
|
|
65
|
+
"category5",
|
|
66
|
+
"category6",
|
|
67
|
+
"category7",
|
|
68
|
+
"category8"
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
const PADDING = 2; // espace entre bulles (px)
|
|
72
|
+
const LABEL_MIN_RADIUS = 18; // rayon mini pour afficher le label
|
|
73
|
+
|
|
74
|
+
function magnitude(value: number): number {
|
|
75
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Bubble = {
|
|
79
|
+
label: string;
|
|
80
|
+
value: number;
|
|
81
|
+
tone: PackedBubblesChartTone;
|
|
82
|
+
textColor: string;
|
|
83
|
+
cx: number;
|
|
84
|
+
cy: number;
|
|
85
|
+
r: number;
|
|
86
|
+
index: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
let hoveredIndex: number | null = $state(null);
|
|
90
|
+
|
|
91
|
+
const bubbles = $derived.by<Bubble[]>(() => {
|
|
92
|
+
const cx = width / 2;
|
|
93
|
+
const cy = height / 2;
|
|
94
|
+
|
|
95
|
+
// Données valides triées par valeur décroissante (les grosses au centre).
|
|
96
|
+
const valid = data
|
|
97
|
+
.map((datum, index) => ({ datum, index, value: magnitude(datum.value) }))
|
|
98
|
+
.filter((entry) => entry.value > 0)
|
|
99
|
+
.sort((a, b) => b.value - a.value);
|
|
100
|
+
|
|
101
|
+
if (valid.length === 0) return [];
|
|
102
|
+
|
|
103
|
+
const maxValue = Math.max(...valid.map((entry) => entry.value));
|
|
104
|
+
// Rayon brut ∝ sqrt(value) ; échelle pour que la plus grosse bulle tienne.
|
|
105
|
+
const limit = Math.max(Math.min(width, height) / 2 - 4, 1);
|
|
106
|
+
const baseMax = Math.sqrt(maxValue);
|
|
107
|
+
const targetMax = Math.min(limit * 0.42, limit);
|
|
108
|
+
const radiusOf = (value: number) => Math.max((Math.sqrt(value) / baseMax) * targetMax, 3);
|
|
109
|
+
|
|
110
|
+
const placed: Array<{ cx: number; cy: number; r: number }> = [];
|
|
111
|
+
|
|
112
|
+
function collides(x: number, y: number, r: number): boolean {
|
|
113
|
+
for (const p of placed) {
|
|
114
|
+
const dx = x - p.cx;
|
|
115
|
+
const dy = y - p.cy;
|
|
116
|
+
const minDist = r + p.r + PADDING;
|
|
117
|
+
if (dx * dx + dy * dy < minDist * minDist) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result: Bubble[] = [];
|
|
123
|
+
valid.forEach((entry, order) => {
|
|
124
|
+
const r = radiusOf(entry.value);
|
|
125
|
+
let x = cx;
|
|
126
|
+
let y = cy;
|
|
127
|
+
|
|
128
|
+
if (placed.length > 0) {
|
|
129
|
+
// Spirale d'Archimède déterministe : on avance jusqu'au 1er creux libre.
|
|
130
|
+
const step = Math.max(r * 0.5, 2);
|
|
131
|
+
let angle = order * 2.399963; // angle d'or pour disperser
|
|
132
|
+
let radius = step;
|
|
133
|
+
let found = false;
|
|
134
|
+
// Borne d'itérations généreuse mais finie (pas de boucle infinie).
|
|
135
|
+
for (let i = 0; i < 4000; i += 1) {
|
|
136
|
+
x = cx + radius * Math.cos(angle);
|
|
137
|
+
y = cy + radius * Math.sin(angle);
|
|
138
|
+
if (!collides(x, y, r)) {
|
|
139
|
+
found = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
angle += 0.5;
|
|
143
|
+
radius += step * 0.06;
|
|
144
|
+
}
|
|
145
|
+
if (!found) {
|
|
146
|
+
x = cx + radius * Math.cos(angle);
|
|
147
|
+
y = cy + radius * Math.sin(angle);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
placed.push({ cx: x, cy: y, r });
|
|
152
|
+
const tone = entry.datum.tone ?? TONES[entry.index % TONES.length];
|
|
153
|
+
result.push({
|
|
154
|
+
label: entry.datum.label,
|
|
155
|
+
value: entry.value,
|
|
156
|
+
tone,
|
|
157
|
+
textColor: contrastTextForTone(tone),
|
|
158
|
+
cx: x,
|
|
159
|
+
cy: y,
|
|
160
|
+
r,
|
|
161
|
+
index: entry.index
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return result;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const dataValueItems = $derived(
|
|
169
|
+
data
|
|
170
|
+
.filter((datum) => magnitude(datum.value) > 0)
|
|
171
|
+
.map((datum) => `${datum.label}: ${datum.value}`)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
function handleVisualPointerMove(event: PointerEvent) {
|
|
175
|
+
const target = event.target;
|
|
176
|
+
if (!(target instanceof Element)) {
|
|
177
|
+
hoveredIndex = null;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const index = Number(target.getAttribute("data-chart-index"));
|
|
181
|
+
hoveredIndex = Number.isInteger(index) ? index : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hovered = $derived(
|
|
185
|
+
hoveredIndex !== null ? bubbles.find((b) => b.index === hoveredIndex) : undefined
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const classes = () => ["st-packedBubblesChart", className].filter(Boolean).join(" ");
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<div class={classes()}>
|
|
192
|
+
<div
|
|
193
|
+
class="st-packedBubblesChart__visual"
|
|
194
|
+
role="img"
|
|
195
|
+
aria-label={label}
|
|
196
|
+
onpointermove={handleVisualPointerMove}
|
|
197
|
+
onpointerleave={() => (hoveredIndex = null)}
|
|
198
|
+
>
|
|
199
|
+
<svg
|
|
200
|
+
viewBox="0 0 {width} {height}"
|
|
201
|
+
preserveAspectRatio="xMidYMid meet"
|
|
202
|
+
width="100%"
|
|
203
|
+
height="100%"
|
|
204
|
+
focusable="false"
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
>
|
|
207
|
+
{#each bubbles as bubble (bubble.index)}
|
|
208
|
+
<g class="st-packedBubblesChart__bubble" data-chart-index={bubble.index}>
|
|
209
|
+
<circle
|
|
210
|
+
class="st-packedBubblesChart__circle st-packedBubblesChart__circle--{bubble.tone}"
|
|
211
|
+
class:st-packedBubblesChart__circle--dim={hoveredIndex !== null && hoveredIndex !== bubble.index}
|
|
212
|
+
cx={bubble.cx}
|
|
213
|
+
cy={bubble.cy}
|
|
214
|
+
r={bubble.r}
|
|
215
|
+
data-chart-index={bubble.index}
|
|
216
|
+
/>
|
|
217
|
+
{#if bubble.r >= LABEL_MIN_RADIUS}
|
|
218
|
+
<text
|
|
219
|
+
class="st-packedBubblesChart__label"
|
|
220
|
+
x={bubble.cx}
|
|
221
|
+
y={bubble.cy}
|
|
222
|
+
text-anchor="middle"
|
|
223
|
+
dominant-baseline="middle"
|
|
224
|
+
fill={bubble.textColor}
|
|
225
|
+
data-chart-index={bubble.index}
|
|
226
|
+
>
|
|
227
|
+
{bubble.label}
|
|
228
|
+
</text>
|
|
229
|
+
{/if}
|
|
230
|
+
</g>
|
|
231
|
+
{/each}
|
|
232
|
+
</svg>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<ChartDataList {label} items={dataValueItems} />
|
|
236
|
+
|
|
237
|
+
{#if hovered}
|
|
238
|
+
<div
|
|
239
|
+
class="st-packedBubblesChart__tooltip"
|
|
240
|
+
role="presentation"
|
|
241
|
+
style="left: {(hovered.cx / width) * 100}%; top: {((hovered.cy - hovered.r) / height) * 100}%"
|
|
242
|
+
>
|
|
243
|
+
<span class="st-packedBubblesChart__tooltipLabel">{hovered.label}</span>
|
|
244
|
+
<span class="st-packedBubblesChart__tooltipValue">{hovered.value}</span>
|
|
245
|
+
</div>
|
|
246
|
+
{/if}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<style>
|
|
250
|
+
.st-packedBubblesChart {
|
|
251
|
+
color: var(--st-semantic-text-secondary);
|
|
252
|
+
display: block;
|
|
253
|
+
font-family: inherit;
|
|
254
|
+
max-width: 100%;
|
|
255
|
+
position: relative;
|
|
256
|
+
width: 100%;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.st-packedBubblesChart svg,
|
|
260
|
+
.st-packedBubblesChart__visual {
|
|
261
|
+
display: block;
|
|
262
|
+
overflow: visible;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.st-packedBubblesChart__circle {
|
|
266
|
+
cursor: pointer;
|
|
267
|
+
stroke: var(--st-semantic-surface-default, Canvas);
|
|
268
|
+
stroke-width: 1.5;
|
|
269
|
+
transition: opacity 120ms ease;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.st-packedBubblesChart__circle--dim {
|
|
273
|
+
opacity: 0.4;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@media (prefers-reduced-motion: reduce) {
|
|
277
|
+
.st-packedBubblesChart__circle {
|
|
278
|
+
transition: none;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.st-packedBubblesChart__circle--category1 { fill: var(--st-semantic-data-category1); }
|
|
283
|
+
.st-packedBubblesChart__circle--category2 { fill: var(--st-semantic-data-category2); }
|
|
284
|
+
.st-packedBubblesChart__circle--category3 { fill: var(--st-semantic-data-category3); }
|
|
285
|
+
.st-packedBubblesChart__circle--category4 { fill: var(--st-semantic-data-category4); }
|
|
286
|
+
.st-packedBubblesChart__circle--category5 { fill: var(--st-semantic-data-category5); }
|
|
287
|
+
.st-packedBubblesChart__circle--category6 { fill: var(--st-semantic-data-category6); }
|
|
288
|
+
.st-packedBubblesChart__circle--category7 { fill: var(--st-semantic-data-category7); }
|
|
289
|
+
.st-packedBubblesChart__circle--category8 { fill: var(--st-semantic-data-category8); }
|
|
290
|
+
|
|
291
|
+
.st-packedBubblesChart__label {
|
|
292
|
+
/* fill calculé par contrastTextForTone() en inline - pas de blanc fixe */
|
|
293
|
+
font-size: 0.7rem;
|
|
294
|
+
font-weight: 600;
|
|
295
|
+
pointer-events: none;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.st-packedBubblesChart__tooltip {
|
|
299
|
+
background: var(--st-semantic-surface-inverse);
|
|
300
|
+
border-radius: var(--st-radius-sm, 0.25rem);
|
|
301
|
+
color: var(--st-semantic-text-inverse);
|
|
302
|
+
display: inline-flex;
|
|
303
|
+
flex-direction: column;
|
|
304
|
+
font-size: 0.75rem;
|
|
305
|
+
gap: 0.125rem;
|
|
306
|
+
line-height: 1.2;
|
|
307
|
+
padding: 0.375rem 0.5rem;
|
|
308
|
+
pointer-events: none;
|
|
309
|
+
position: absolute;
|
|
310
|
+
transform: translate(-50%, -115%);
|
|
311
|
+
white-space: nowrap;
|
|
312
|
+
z-index: 1;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.st-packedBubblesChart__tooltipLabel {
|
|
316
|
+
font-weight: 600;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.st-packedBubblesChart__tooltipValue {
|
|
320
|
+
opacity: 0.85;
|
|
321
|
+
}
|
|
322
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PackedBubblesChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
|
|
3
|
+
*
|
|
4
|
+
* Tasse des cercles dont l'aire est proportionnelle à `value`. Le rayon est
|
|
5
|
+
* calculé par sqrt(value) puis normalisé pour tenir dans le viewBox. Le layout
|
|
6
|
+
* est déterministe (spirale + détection de collision), sans dépendance externe.
|
|
7
|
+
*
|
|
8
|
+
* Props obligatoires :
|
|
9
|
+
* data PackedBubblesChartDatum[] - liste {label, value, tone?}
|
|
10
|
+
* label string - aria-label du graphique
|
|
11
|
+
*
|
|
12
|
+
* Props optionnelles :
|
|
13
|
+
* width number (défaut 360) - largeur du viewBox en px
|
|
14
|
+
* height number (défaut 360) - hauteur du viewBox en px
|
|
15
|
+
* class string - classe CSS supplémentaire
|
|
16
|
+
*
|
|
17
|
+
* Garde : seuls les data dont `value` est finie et > 0 sont rendus. Les NaN /
|
|
18
|
+
* Infinity / valeurs négatives ou nulles sont ignorés (pas de crash).
|
|
19
|
+
* Le label est affiché dans la bulle si elle est assez grande, avec une couleur
|
|
20
|
+
* de texte calculée par contraste (contrastTextForTone).
|
|
21
|
+
*/
|
|
22
|
+
export type PackedBubblesChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
|
|
23
|
+
export type PackedBubblesChartDatum = {
|
|
24
|
+
label: string;
|
|
25
|
+
value: number;
|
|
26
|
+
tone?: PackedBubblesChartTone;
|
|
27
|
+
};
|
|
28
|
+
type PackedBubblesChartProps = {
|
|
29
|
+
data: PackedBubblesChartDatum[];
|
|
30
|
+
label: string;
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
class?: string;
|
|
34
|
+
};
|
|
35
|
+
declare const PackedBubblesChart: import("svelte").Component<PackedBubblesChartProps, {}, "">;
|
|
36
|
+
type PackedBubblesChart = ReturnType<typeof PackedBubblesChart>;
|
|
37
|
+
export default PackedBubblesChart;
|
|
38
|
+
//# sourceMappingURL=PackedBubblesChart.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PackedBubblesChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/PackedBubblesChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,MAAM,sBAAsB,GAC9B,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,sBAAsB,CAAC;CAC/B,CAAC;AAOF,KAAK,uBAAuB,GAAG;IAC7B,IAAI,EAAE,uBAAuB,EAAE,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAkLJ,QAAA,MAAM,kBAAkB,6DAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* RoseChart (nightingale / polar area) - API canonique (référence Svelte,
|
|
4
|
+
* React/Vue doivent s'aligner)
|
|
5
|
+
*
|
|
6
|
+
* Diagramme polaire de Florence Nightingale : N secteurs d'angle ÉGAL
|
|
7
|
+
* (360° / N), le RAYON de chaque secteur ∝ value (c'est le rayon qui porte
|
|
8
|
+
* l'information, PAS l'angle - voilà ce qui le distingue d'un camembert où
|
|
9
|
+
* l'angle porte l'information et le rayon est constant).
|
|
10
|
+
*
|
|
11
|
+
* Échelle du rayon : rayon = sqrt(value / maxValue) * R.
|
|
12
|
+
* La racine carrée rend l'AIRE du secteur proportionnelle à la valeur
|
|
13
|
+
* (aire ∝ rayon²), donc honnête perceptuellement - un secteur deux fois
|
|
14
|
+
* plus « gros » à l'œil vaut bien deux fois plus. Un mapping linéaire
|
|
15
|
+
* (value/maxValue * R) exagérerait les grandes valeurs (aire ∝ value²).
|
|
16
|
+
*
|
|
17
|
+
* Props obligatoires :
|
|
18
|
+
* data RoseChartDatum[] - {label, value, tone?}
|
|
19
|
+
* label string - aria-label du graphique
|
|
20
|
+
*
|
|
21
|
+
* Props optionnelles :
|
|
22
|
+
* width number (défaut 320) - largeur du viewBox en px
|
|
23
|
+
* height number (défaut 320) - hauteur du viewBox en px
|
|
24
|
+
* class string - classe CSS supplémentaire
|
|
25
|
+
*
|
|
26
|
+
* Labels : le libellé est posé sur le secteur si son rayon est assez grand
|
|
27
|
+
* (> 40% de R). Couleur de texte calculée par contrastTextForTone() pour
|
|
28
|
+
* garantir le contraste WCAG sur chaque fond catégoriel - pas de blanc fixe.
|
|
29
|
+
*
|
|
30
|
+
* NaN/négatif : les valeurs non-finies ou ≤ 0 sont ignorées (rayon 0, pas de
|
|
31
|
+
* secteur dessiné) et exclues du calcul de maxValue. Tableau vide → rendu
|
|
32
|
+
* vide sans crash.
|
|
33
|
+
*/
|
|
34
|
+
export type RoseChartTone =
|
|
35
|
+
| "category1"
|
|
36
|
+
| "category2"
|
|
37
|
+
| "category3"
|
|
38
|
+
| "category4"
|
|
39
|
+
| "category5"
|
|
40
|
+
| "category6"
|
|
41
|
+
| "category7"
|
|
42
|
+
| "category8";
|
|
43
|
+
|
|
44
|
+
export type RoseChartDatum = {
|
|
45
|
+
label: string;
|
|
46
|
+
value: number;
|
|
47
|
+
tone?: RoseChartTone;
|
|
48
|
+
};
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<script lang="ts">
|
|
52
|
+
import ChartDataList from "./ChartDataList.svelte";
|
|
53
|
+
import { contrastTextForTone } from "./chartContrast.js";
|
|
54
|
+
|
|
55
|
+
type RoseChartProps = {
|
|
56
|
+
data: RoseChartDatum[];
|
|
57
|
+
label: string;
|
|
58
|
+
width?: number;
|
|
59
|
+
height?: number;
|
|
60
|
+
class?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let {
|
|
64
|
+
data,
|
|
65
|
+
label,
|
|
66
|
+
width = 320,
|
|
67
|
+
height = 320,
|
|
68
|
+
class: className
|
|
69
|
+
}: RoseChartProps = $props();
|
|
70
|
+
|
|
71
|
+
const TONES = [
|
|
72
|
+
"category1",
|
|
73
|
+
"category2",
|
|
74
|
+
"category3",
|
|
75
|
+
"category4",
|
|
76
|
+
"category5",
|
|
77
|
+
"category6",
|
|
78
|
+
"category7",
|
|
79
|
+
"category8"
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
function safeValue(value: number): number {
|
|
83
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatNumber(value: number): string {
|
|
87
|
+
if (!Number.isFinite(value)) return "0";
|
|
88
|
+
if (Number.isInteger(value)) return String(value);
|
|
89
|
+
return value.toFixed(2).replace(/\.?0+$/, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function point(cx: number, cy: number, radius: number, angle: number) {
|
|
93
|
+
return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sectorPath(cx: number, cy: number, radius: number, start: number, end: number): string {
|
|
97
|
+
const safeEnd = Math.min(end, start + Math.PI * 2 - 0.0001);
|
|
98
|
+
const large = safeEnd - start > Math.PI ? 1 : 0;
|
|
99
|
+
const outerStart = point(cx, cy, radius, start);
|
|
100
|
+
const outerEnd = point(cx, cy, radius, safeEnd);
|
|
101
|
+
return `M ${cx} ${cy} L ${outerStart.x} ${outerStart.y} A ${radius} ${radius} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y} Z`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let hoveredIndex: number | null = $state(null);
|
|
105
|
+
|
|
106
|
+
const sectors = $derived.by(() => {
|
|
107
|
+
const cx = width / 2;
|
|
108
|
+
const cy = height / 2;
|
|
109
|
+
const outerLimit = Math.max(Math.min(width, height) / 2 - 6, 1);
|
|
110
|
+
const count = data.length;
|
|
111
|
+
if (count === 0) return [];
|
|
112
|
+
|
|
113
|
+
const maxValue = Math.max(0, ...data.map((datum) => safeValue(datum.value)));
|
|
114
|
+
const safeMax = maxValue > 0 ? maxValue : 1;
|
|
115
|
+
const sweep = (Math.PI * 2) / count;
|
|
116
|
+
|
|
117
|
+
return data.map((datum, index) => {
|
|
118
|
+
const value = safeValue(datum.value);
|
|
119
|
+
// sqrt → aire du secteur ∝ value (honnête perceptuellement)
|
|
120
|
+
const radius = Math.sqrt(value / safeMax) * outerLimit;
|
|
121
|
+
const start = -Math.PI / 2 + sweep * index;
|
|
122
|
+
const end = start + sweep;
|
|
123
|
+
const midAngle = (start + end) / 2;
|
|
124
|
+
const labelPoint = point(cx, cy, radius * 0.62, midAngle);
|
|
125
|
+
return {
|
|
126
|
+
datum,
|
|
127
|
+
value,
|
|
128
|
+
tone: datum.tone ?? TONES[index % TONES.length],
|
|
129
|
+
radius,
|
|
130
|
+
start,
|
|
131
|
+
end,
|
|
132
|
+
path: value > 0 ? sectorPath(cx, cy, radius, start, end) : "",
|
|
133
|
+
labelX: labelPoint.x,
|
|
134
|
+
labelY: labelPoint.y,
|
|
135
|
+
// label posé si le secteur est assez grand (rayon > 40% de R)
|
|
136
|
+
showLabel: value > 0 && radius > outerLimit * 0.4
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const dataValueItems = $derived(
|
|
142
|
+
data.map((datum) => `${datum.label}: ${formatNumber(safeValue(datum.value))}`)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
function handleVisualPointerMove(event: PointerEvent) {
|
|
146
|
+
const target = event.target;
|
|
147
|
+
if (!(target instanceof Element)) {
|
|
148
|
+
hoveredIndex = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const index = Number(target.getAttribute("data-chart-index"));
|
|
152
|
+
hoveredIndex = Number.isInteger(index) ? index : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const classes = () => ["st-roseChart", className].filter(Boolean).join(" ");
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<div class={classes()}>
|
|
159
|
+
<div
|
|
160
|
+
class="st-roseChart__visual"
|
|
161
|
+
role="img"
|
|
162
|
+
aria-label={label}
|
|
163
|
+
onpointermove={handleVisualPointerMove}
|
|
164
|
+
onpointerleave={() => (hoveredIndex = null)}
|
|
165
|
+
>
|
|
166
|
+
<svg
|
|
167
|
+
viewBox="0 0 {width} {height}"
|
|
168
|
+
preserveAspectRatio="xMidYMid meet"
|
|
169
|
+
width="100%"
|
|
170
|
+
height="100%"
|
|
171
|
+
focusable="false"
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
>
|
|
174
|
+
{#each sectors as sector, i (sector.datum.label)}
|
|
175
|
+
{#if sector.path}
|
|
176
|
+
<path
|
|
177
|
+
class="st-roseChart__sector st-roseChart__sector--{sector.tone}"
|
|
178
|
+
class:st-roseChart__sector--dim={hoveredIndex !== null && hoveredIndex !== i}
|
|
179
|
+
d={sector.path}
|
|
180
|
+
data-chart-index={i}
|
|
181
|
+
/>
|
|
182
|
+
{/if}
|
|
183
|
+
{/each}
|
|
184
|
+
|
|
185
|
+
{#each sectors as sector (sector.datum.label)}
|
|
186
|
+
{#if sector.showLabel}
|
|
187
|
+
<text
|
|
188
|
+
class="st-roseChart__label"
|
|
189
|
+
x={sector.labelX}
|
|
190
|
+
y={sector.labelY}
|
|
191
|
+
text-anchor="middle"
|
|
192
|
+
dominant-baseline="middle"
|
|
193
|
+
fill={contrastTextForTone(sector.tone)}
|
|
194
|
+
>
|
|
195
|
+
{sector.datum.label}
|
|
196
|
+
</text>
|
|
197
|
+
{/if}
|
|
198
|
+
{/each}
|
|
199
|
+
</svg>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<ChartDataList {label} items={dataValueItems} />
|
|
203
|
+
|
|
204
|
+
{#if hoveredIndex !== null && sectors[hoveredIndex] && sectors[hoveredIndex].value > 0}
|
|
205
|
+
{@const sector = sectors[hoveredIndex]}
|
|
206
|
+
<div
|
|
207
|
+
class="st-roseChart__tooltip"
|
|
208
|
+
role="presentation"
|
|
209
|
+
style="left: {(sector.labelX / width) * 100}%; top: {(sector.labelY / height) * 100}%"
|
|
210
|
+
>
|
|
211
|
+
<span class="st-roseChart__tooltipLabel">{sector.datum.label}</span>
|
|
212
|
+
<span class="st-roseChart__tooltipValue">{formatNumber(sector.value)}</span>
|
|
213
|
+
</div>
|
|
214
|
+
{/if}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<style>
|
|
218
|
+
.st-roseChart {
|
|
219
|
+
color: var(--st-semantic-text-secondary);
|
|
220
|
+
display: block;
|
|
221
|
+
font-family: inherit;
|
|
222
|
+
max-width: 100%;
|
|
223
|
+
position: relative;
|
|
224
|
+
width: 100%;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.st-roseChart svg,
|
|
228
|
+
.st-roseChart__visual {
|
|
229
|
+
display: block;
|
|
230
|
+
overflow: visible;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.st-roseChart__sector {
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
fill-opacity: 0.82;
|
|
236
|
+
stroke: var(--st-semantic-surface-default, Canvas);
|
|
237
|
+
stroke-width: 1;
|
|
238
|
+
transition: opacity 120ms ease;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.st-roseChart__sector--dim {
|
|
242
|
+
opacity: 0.4;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@media (prefers-reduced-motion: reduce) {
|
|
246
|
+
.st-roseChart__sector {
|
|
247
|
+
transition: none;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.st-roseChart__sector--category1 { fill: var(--st-semantic-data-category1); }
|
|
252
|
+
.st-roseChart__sector--category2 { fill: var(--st-semantic-data-category2); }
|
|
253
|
+
.st-roseChart__sector--category3 { fill: var(--st-semantic-data-category3); }
|
|
254
|
+
.st-roseChart__sector--category4 { fill: var(--st-semantic-data-category4); }
|
|
255
|
+
.st-roseChart__sector--category5 { fill: var(--st-semantic-data-category5); }
|
|
256
|
+
.st-roseChart__sector--category6 { fill: var(--st-semantic-data-category6); }
|
|
257
|
+
.st-roseChart__sector--category7 { fill: var(--st-semantic-data-category7); }
|
|
258
|
+
.st-roseChart__sector--category8 { fill: var(--st-semantic-data-category8); }
|
|
259
|
+
|
|
260
|
+
.st-roseChart__label {
|
|
261
|
+
/* fill calculé par contrastTextForTone() en inline - pas de blanc fixe */
|
|
262
|
+
font-size: 0.68rem;
|
|
263
|
+
font-weight: 650;
|
|
264
|
+
pointer-events: none;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.st-roseChart__tooltip {
|
|
268
|
+
background: var(--st-semantic-surface-inverse);
|
|
269
|
+
border-radius: var(--st-radius-sm, 0.25rem);
|
|
270
|
+
color: var(--st-semantic-text-inverse);
|
|
271
|
+
display: inline-flex;
|
|
272
|
+
flex-direction: column;
|
|
273
|
+
font-size: 0.75rem;
|
|
274
|
+
gap: 0.125rem;
|
|
275
|
+
line-height: 1.2;
|
|
276
|
+
padding: 0.375rem 0.5rem;
|
|
277
|
+
pointer-events: none;
|
|
278
|
+
position: absolute;
|
|
279
|
+
transform: translate(-50%, -115%);
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
z-index: 1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.st-roseChart__tooltipLabel {
|
|
285
|
+
font-weight: 600;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.st-roseChart__tooltipValue {
|
|
289
|
+
opacity: 0.85;
|
|
290
|
+
}
|
|
291
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoseChart (nightingale / polar area) - API canonique (référence Svelte,
|
|
3
|
+
* React/Vue doivent s'aligner)
|
|
4
|
+
*
|
|
5
|
+
* Diagramme polaire de Florence Nightingale : N secteurs d'angle ÉGAL
|
|
6
|
+
* (360° / N), le RAYON de chaque secteur ∝ value (c'est le rayon qui porte
|
|
7
|
+
* l'information, PAS l'angle - voilà ce qui le distingue d'un camembert où
|
|
8
|
+
* l'angle porte l'information et le rayon est constant).
|
|
9
|
+
*
|
|
10
|
+
* Échelle du rayon : rayon = sqrt(value / maxValue) * R.
|
|
11
|
+
* La racine carrée rend l'AIRE du secteur proportionnelle à la valeur
|
|
12
|
+
* (aire ∝ rayon²), donc honnête perceptuellement - un secteur deux fois
|
|
13
|
+
* plus « gros » à l'œil vaut bien deux fois plus. Un mapping linéaire
|
|
14
|
+
* (value/maxValue * R) exagérerait les grandes valeurs (aire ∝ value²).
|
|
15
|
+
*
|
|
16
|
+
* Props obligatoires :
|
|
17
|
+
* data RoseChartDatum[] - {label, value, tone?}
|
|
18
|
+
* label string - aria-label du graphique
|
|
19
|
+
*
|
|
20
|
+
* Props optionnelles :
|
|
21
|
+
* width number (défaut 320) - largeur du viewBox en px
|
|
22
|
+
* height number (défaut 320) - hauteur du viewBox en px
|
|
23
|
+
* class string - classe CSS supplémentaire
|
|
24
|
+
*
|
|
25
|
+
* Labels : le libellé est posé sur le secteur si son rayon est assez grand
|
|
26
|
+
* (> 40% de R). Couleur de texte calculée par contrastTextForTone() pour
|
|
27
|
+
* garantir le contraste WCAG sur chaque fond catégoriel - pas de blanc fixe.
|
|
28
|
+
*
|
|
29
|
+
* NaN/négatif : les valeurs non-finies ou ≤ 0 sont ignorées (rayon 0, pas de
|
|
30
|
+
* secteur dessiné) et exclues du calcul de maxValue. Tableau vide → rendu
|
|
31
|
+
* vide sans crash.
|
|
32
|
+
*/
|
|
33
|
+
export type RoseChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
|
|
34
|
+
export type RoseChartDatum = {
|
|
35
|
+
label: string;
|
|
36
|
+
value: number;
|
|
37
|
+
tone?: RoseChartTone;
|
|
38
|
+
};
|
|
39
|
+
type RoseChartProps = {
|
|
40
|
+
data: RoseChartDatum[];
|
|
41
|
+
label: string;
|
|
42
|
+
width?: number;
|
|
43
|
+
height?: number;
|
|
44
|
+
class?: string;
|
|
45
|
+
};
|
|
46
|
+
declare const RoseChart: import("svelte").Component<RoseChartProps, {}, "">;
|
|
47
|
+
type RoseChart = ReturnType<typeof RoseChart>;
|
|
48
|
+
export default RoseChart;
|
|
49
|
+
//# sourceMappingURL=RoseChart.svelte.d.ts.map
|