@sentropic/design-system-svelte 0.28.0 → 0.30.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.
@@ -13,6 +13,31 @@
13
13
  x: number | string;
14
14
  y: number;
15
15
  };
16
+
17
+ /**
18
+ * Semantic tone for an analytical overlay (reference line / band / goal).
19
+ * Maps to the feedback token family — markers, not categorical series.
20
+ */
21
+ export type ChartOverlayTone = "neutral" | "success" | "warning" | "error" | "info";
22
+
23
+ export type ChartReferenceLine = {
24
+ value: number;
25
+ label?: string;
26
+ tone?: ChartOverlayTone;
27
+ axis?: "x" | "y";
28
+ };
29
+
30
+ export type ChartBand = {
31
+ from: number;
32
+ to: number;
33
+ label?: string;
34
+ tone?: ChartOverlayTone;
35
+ };
36
+
37
+ export type ChartGoalLine = {
38
+ value: number;
39
+ label?: string;
40
+ };
16
41
  </script>
17
42
 
18
43
  <script lang="ts">
@@ -26,6 +51,14 @@
26
51
  smooth?: boolean;
27
52
  area?: boolean;
28
53
  label: string;
54
+ /** Reference lines (default `axis: "y"` → horizontal at `value`). */
55
+ referenceLines?: ChartReferenceLine[];
56
+ /** Shaded value-axis bands between `from`..`to`. */
57
+ bands?: ChartBand[];
58
+ /** A single goal line, emphasised above the data. */
59
+ goalLine?: ChartGoalLine;
60
+ /** Least-squares trend line over the data points. */
61
+ trend?: boolean;
29
62
  class?: string;
30
63
  };
31
64
 
@@ -37,6 +70,10 @@
37
70
  smooth = false,
38
71
  area = false,
39
72
  label,
73
+ referenceLines,
74
+ bands,
75
+ goalLine,
76
+ trend = false,
40
77
  class: className
41
78
  }: LineChartProps = $props();
42
79
 
@@ -80,6 +117,86 @@
80
117
  return typeof x === "number" && Number.isFinite(x);
81
118
  }
82
119
 
120
+ // --- Analytical overlay helpers (inline; parity with chartScale) ----------
121
+ function overlayToneClass(prefix: string, t: ChartOverlayTone | undefined): string {
122
+ return `${prefix}--${t ?? "neutral"}`;
123
+ }
124
+
125
+ function linearRegression(
126
+ pts: { x: number; y: number }[]
127
+ ): { slope: number; intercept: number; minX: number; maxX: number } | null {
128
+ const finite = pts.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
129
+ if (finite.length < 2) return null;
130
+ const n = finite.length;
131
+ let sx = 0;
132
+ let sy = 0;
133
+ let sxx = 0;
134
+ let sxy = 0;
135
+ let minX = Infinity;
136
+ let maxX = -Infinity;
137
+ for (const p of finite) {
138
+ sx += p.x;
139
+ sy += p.y;
140
+ sxx += p.x * p.x;
141
+ sxy += p.x * p.y;
142
+ if (p.x < minX) minX = p.x;
143
+ if (p.x > maxX) maxX = p.x;
144
+ }
145
+ const denom = n * sxx - sx * sx;
146
+ if (denom === 0) return null;
147
+ const slope = (n * sxy - sx * sy) / denom;
148
+ const intercept = (sy - slope * sx) / n;
149
+ if (!Number.isFinite(slope) || !Number.isFinite(intercept)) return null;
150
+ return { slope, intercept, minX, maxX };
151
+ }
152
+
153
+ function extendValueDomain(
154
+ minV: number,
155
+ maxV: number,
156
+ refs: ChartReferenceLine[] | undefined,
157
+ bnds: ChartBand[] | undefined,
158
+ goal: ChartGoalLine | null
159
+ ): [number, number] {
160
+ let lo = minV;
161
+ let hi = maxV;
162
+ const fold = (v: number | undefined) => {
163
+ if (v === undefined || !Number.isFinite(v)) return;
164
+ if (v < lo) lo = v;
165
+ if (v > hi) hi = v;
166
+ };
167
+ for (const r of refs ?? []) if ((r.axis ?? "y") === "y") fold(r.value);
168
+ for (const b of bnds ?? []) {
169
+ fold(b.from);
170
+ fold(b.to);
171
+ }
172
+ if (goal) fold(goal.value);
173
+ return [lo, hi];
174
+ }
175
+
176
+ function overlayDataListItems(
177
+ refs: ChartReferenceLine[] | undefined,
178
+ bnds: ChartBand[] | undefined,
179
+ goal: ChartGoalLine | null,
180
+ trendModel: { slope: number } | null
181
+ ): string[] {
182
+ const items: string[] = [];
183
+ for (const r of refs ?? []) {
184
+ if (!Number.isFinite(r.value)) continue;
185
+ items.push(r.label ? `Référence: ${r.label} = ${r.value}` : `Référence: ${r.value}`);
186
+ }
187
+ for (const b of bnds ?? []) {
188
+ if (!Number.isFinite(b.from) || !Number.isFinite(b.to)) continue;
189
+ const lo = Math.min(b.from, b.to);
190
+ const hi = Math.max(b.from, b.to);
191
+ items.push(b.label ? `Bande: ${b.label} (${lo}–${hi})` : `Bande: ${lo}–${hi}`);
192
+ }
193
+ if (goal && Number.isFinite(goal.value)) {
194
+ items.push(goal.label ? `Objectif: ${goal.label} = ${goal.value}` : `Objectif: ${goal.value}`);
195
+ }
196
+ if (trendModel) items.push(`Tendance: pente ${trendModel.slope.toFixed(2)}`);
197
+ return items;
198
+ }
199
+
83
200
  let hoveredIndex: number | null = $state(null);
84
201
 
85
202
  const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
@@ -95,11 +212,16 @@
95
212
  return { kind: "ordinal" as const, values: data.map((d) => d.x) };
96
213
  });
97
214
 
215
+ // A valid goal line needs a finite value; otherwise it is ignored entirely.
216
+ const goal = $derived(goalLine && Number.isFinite(goalLine.value) ? goalLine : null);
217
+
98
218
  const yTicks = $derived.by(() => {
99
- const ys = data.map((d) => d.y);
100
- if (ys.length === 0) return [0];
101
- const minRaw = Math.min(...ys);
102
- const maxRaw = Math.max(...ys);
219
+ const ys = data.map((d) => d.y).filter((y) => Number.isFinite(y));
220
+ if (ys.length === 0 && !referenceLines?.length && !bands?.length && !goal) return [0];
221
+ let minRaw = ys.length ? Math.min(...ys) : 0;
222
+ let maxRaw = ys.length ? Math.max(...ys) : 0;
223
+ // Fold overlay values into the domain so they never fall outside the plot.
224
+ [minRaw, maxRaw] = extendValueDomain(minRaw, maxRaw, referenceLines, bands, goal);
103
225
  const padded = (maxRaw - minRaw) * 0.08 || Math.max(Math.abs(maxRaw), 1) * 0.1;
104
226
  return niceTicks(minRaw - padded, maxRaw + padded, 5);
105
227
  });
@@ -130,7 +252,72 @@
130
252
  });
131
253
  });
132
254
 
133
- const dataValueItems = $derived(data.map((d) => `${d.x}: ${d.y}`));
255
+ // --- Analytical overlays --------------------------------------------------
256
+ // All overlays live in the chart's coordinate space, below the data series
257
+ // (the goal line is the single exception, drawn above for emphasis).
258
+ const bandRects = $derived(
259
+ (bands ?? [])
260
+ .filter((b) => Number.isFinite(b.from) && Number.isFinite(b.to))
261
+ .map((b, i) => {
262
+ const y1 = MARGIN.top + scaleLinear(b.from, yDomain.min, yDomain.max, plotHeight, 0);
263
+ const y2 = MARGIN.top + scaleLinear(b.to, yDomain.min, yDomain.max, plotHeight, 0);
264
+ return {
265
+ key: i,
266
+ x: MARGIN.left,
267
+ y: Math.min(y1, y2),
268
+ width: plotWidth,
269
+ height: Math.max(Math.abs(y2 - y1), 0.5),
270
+ label: b.label,
271
+ tone: b.tone
272
+ };
273
+ })
274
+ );
275
+
276
+ const refLines = $derived(
277
+ (referenceLines ?? [])
278
+ .filter((r) => Number.isFinite(r.value))
279
+ .map((r, i) => {
280
+ const axis = r.axis ?? "y";
281
+ if (axis === "x") {
282
+ if (xDomain.kind !== "numeric") return null;
283
+ const x = MARGIN.left + scaleLinear(r.value, xDomain.min, xDomain.max, 0, plotWidth);
284
+ return { key: i, axis, x1: x, x2: x, y1: MARGIN.top, y2: MARGIN.top + plotHeight, label: r.label, tone: r.tone };
285
+ }
286
+ const y = MARGIN.top + scaleLinear(r.value, yDomain.min, yDomain.max, plotHeight, 0);
287
+ return { key: i, axis, x1: MARGIN.left, x2: MARGIN.left + plotWidth, y1: y, y2: y, label: r.label, tone: r.tone };
288
+ })
289
+ .filter((r): r is NonNullable<typeof r> => r !== null)
290
+ );
291
+
292
+ const goalGeom = $derived(
293
+ goal
294
+ ? {
295
+ y: MARGIN.top + scaleLinear(goal.value, yDomain.min, yDomain.max, plotHeight, 0),
296
+ label: goal.label,
297
+ value: goal.value
298
+ }
299
+ : null
300
+ );
301
+
302
+ const trendModel = $derived(
303
+ trend && xDomain.kind === "numeric"
304
+ ? linearRegression(data.map((d) => ({ x: d.x as number, y: d.y })))
305
+ : null
306
+ );
307
+ const trendLine = $derived.by(() => {
308
+ if (!trendModel || xDomain.kind !== "numeric") return null;
309
+ return {
310
+ x1: MARGIN.left + scaleLinear(trendModel.minX, xDomain.min, xDomain.max, 0, plotWidth),
311
+ y1: MARGIN.top + scaleLinear(trendModel.slope * trendModel.minX + trendModel.intercept, yDomain.min, yDomain.max, plotHeight, 0),
312
+ x2: MARGIN.left + scaleLinear(trendModel.maxX, xDomain.min, xDomain.max, 0, plotWidth),
313
+ y2: MARGIN.top + scaleLinear(trendModel.slope * trendModel.maxX + trendModel.intercept, yDomain.min, yDomain.max, plotHeight, 0)
314
+ };
315
+ });
316
+
317
+ const dataValueItems = $derived([
318
+ ...data.map((d) => `${d.x}: ${d.y}`),
319
+ ...overlayDataListItems(referenceLines, bands, goal, trendModel)
320
+ ]);
134
321
 
135
322
  function buildLinearPath(pts: { x: number; y: number }[]): string {
136
323
  return pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ");
@@ -275,6 +462,31 @@
275
462
  </text>
276
463
  {/each}
277
464
 
465
+ <!-- Analytical overlays — bands + reference lines + trend BELOW the data
466
+ (markers, not series); the goal line is drawn above for emphasis. -->
467
+ {#each bandRects as b (b.key)}
468
+ <rect class={`st-lineChart__band ${overlayToneClass("st-lineChart__band", b.tone)}`} x={b.x} y={b.y} width={b.width} height={b.height} />
469
+ {#if b.label}
470
+ <text class="st-lineChart__overlayLabel" x={b.x + 4} y={b.y + 11}>{b.label}</text>
471
+ {/if}
472
+ {/each}
473
+
474
+ {#each refLines as r (r.key)}
475
+ <line class={`st-lineChart__refLine ${overlayToneClass("st-lineChart__refLine", r.tone)}`} x1={r.x1} x2={r.x2} y1={r.y1} y2={r.y2} />
476
+ {#if r.label}
477
+ <text
478
+ class="st-lineChart__overlayLabel"
479
+ x={r.axis === "x" ? r.x1 + 4 : MARGIN.left + plotWidth - 4}
480
+ y={r.axis === "x" ? MARGIN.top + 11 : r.y1 - 4}
481
+ text-anchor={r.axis === "x" ? "start" : "end"}
482
+ >{r.label}</text>
483
+ {/if}
484
+ {/each}
485
+
486
+ {#if trendLine}
487
+ <line class="st-lineChart__trend" x1={trendLine.x1} y1={trendLine.y1} x2={trendLine.x2} y2={trendLine.y2} />
488
+ {/if}
489
+
278
490
  {#if area && areaPath}
279
491
  <path class="st-lineChart__area" d={areaPath} />
280
492
  {/if}
@@ -298,6 +510,14 @@
298
510
  data-chart-index={p.index}
299
511
  />
300
512
  {/each}
513
+
514
+ <!-- Goal line — emphasised, ABOVE the data. -->
515
+ {#if goalGeom}
516
+ <line class="st-lineChart__goalLine" x1={MARGIN.left} x2={MARGIN.left + plotWidth} y1={goalGeom.y} y2={goalGeom.y} />
517
+ <text class="st-lineChart__goalLabel" x={MARGIN.left + plotWidth - 4} y={goalGeom.y - 4} text-anchor="end">
518
+ {goalGeom.label ?? `Objectif ${goalGeom.value}`}
519
+ </text>
520
+ {/if}
301
521
  </svg>
302
522
  </div>
303
523
 
@@ -406,4 +626,52 @@
406
626
  .st-lineChart__tooltipValue {
407
627
  opacity: 0.85;
408
628
  }
629
+
630
+ /* --- Analytical overlay layer --------------------------------------------
631
+ Bands sit BELOW the data via render order; their fill uses color-mix (a
632
+ semi-transparent tint of the tone token) rather than raw opacity, so the
633
+ data series drawn on top keeps full contrast. */
634
+ .st-lineChart__band {
635
+ fill: color-mix(in srgb, var(--st-overlay-tone, var(--st-semantic-border-strong)) 12%, transparent);
636
+ stroke: none;
637
+ }
638
+ .st-lineChart__band--neutral { --st-overlay-tone: var(--st-semantic-border-strong); }
639
+ .st-lineChart__band--success { --st-overlay-tone: var(--st-semantic-feedback-success); }
640
+ .st-lineChart__band--warning { --st-overlay-tone: var(--st-semantic-feedback-warning); }
641
+ .st-lineChart__band--error { --st-overlay-tone: var(--st-semantic-feedback-error); }
642
+ .st-lineChart__band--info { --st-overlay-tone: var(--st-semantic-feedback-info); }
643
+
644
+ .st-lineChart__refLine {
645
+ stroke: var(--st-overlay-tone, var(--st-semantic-border-strong));
646
+ stroke-width: 1;
647
+ stroke-dasharray: 4 3;
648
+ }
649
+ .st-lineChart__refLine--neutral { --st-overlay-tone: var(--st-semantic-border-strong); }
650
+ .st-lineChart__refLine--success { --st-overlay-tone: var(--st-semantic-feedback-success); }
651
+ .st-lineChart__refLine--warning { --st-overlay-tone: var(--st-semantic-feedback-warning); }
652
+ .st-lineChart__refLine--error { --st-overlay-tone: var(--st-semantic-feedback-error); }
653
+ .st-lineChart__refLine--info { --st-overlay-tone: var(--st-semantic-feedback-info); }
654
+
655
+ .st-lineChart__overlayLabel {
656
+ fill: var(--st-semantic-text-secondary);
657
+ font-size: 0.625rem;
658
+ }
659
+
660
+ .st-lineChart__trend {
661
+ stroke: var(--st-semantic-text-secondary);
662
+ stroke-width: 1.5;
663
+ stroke-dasharray: 6 4;
664
+ opacity: 0.85;
665
+ }
666
+
667
+ /* Goal line — drawn ABOVE the data, emphasised (thicker, solid accent). */
668
+ .st-lineChart__goalLine {
669
+ stroke: var(--st-semantic-feedback-success);
670
+ stroke-width: 2.5;
671
+ }
672
+ .st-lineChart__goalLabel {
673
+ fill: var(--st-semantic-feedback-success);
674
+ font-size: 0.6875rem;
675
+ font-weight: 600;
676
+ }
409
677
  </style>
@@ -3,6 +3,27 @@ export type LineChartDatum = {
3
3
  x: number | string;
4
4
  y: number;
5
5
  };
6
+ /**
7
+ * Semantic tone for an analytical overlay (reference line / band / goal).
8
+ * Maps to the feedback token family — markers, not categorical series.
9
+ */
10
+ export type ChartOverlayTone = "neutral" | "success" | "warning" | "error" | "info";
11
+ export type ChartReferenceLine = {
12
+ value: number;
13
+ label?: string;
14
+ tone?: ChartOverlayTone;
15
+ axis?: "x" | "y";
16
+ };
17
+ export type ChartBand = {
18
+ from: number;
19
+ to: number;
20
+ label?: string;
21
+ tone?: ChartOverlayTone;
22
+ };
23
+ export type ChartGoalLine = {
24
+ value: number;
25
+ label?: string;
26
+ };
6
27
  type LineChartProps = {
7
28
  data: LineChartDatum[];
8
29
  width?: number;
@@ -11,6 +32,14 @@ type LineChartProps = {
11
32
  smooth?: boolean;
12
33
  area?: boolean;
13
34
  label: string;
35
+ /** Reference lines (default `axis: "y"` → horizontal at `value`). */
36
+ referenceLines?: ChartReferenceLine[];
37
+ /** Shaded value-axis bands between `from`..`to`. */
38
+ bands?: ChartBand[];
39
+ /** A single goal line, emphasised above the data. */
40
+ goalLine?: ChartGoalLine;
41
+ /** Least-squares trend line over the data points. */
42
+ trend?: boolean;
14
43
  class?: string;
15
44
  };
16
45
  declare const LineChart: import("svelte").Component<LineChartProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"LineChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/LineChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG;IAC3B,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;CACX,CAAC;AAMF,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,cAAc,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA+OJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"LineChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/LineChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG;IAC3B,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;CACX,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,IAAI,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAMF,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,cAAc,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAC;IACtC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,qDAAqD;IACrD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAoaJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,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>