@sentropic/design-system-svelte 0.34.22 → 0.34.24

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.
@@ -17,6 +17,13 @@
17
17
 
18
18
  <script lang="ts">
19
19
  import ChartDataList from "./ChartDataList.svelte";
20
+ import {
21
+ resolveAnnotations,
22
+ annotationDataListItems,
23
+ polygonPoints,
24
+ type ChartAnnotation
25
+ } from "./chartAnnotations.js";
26
+ import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
20
27
 
21
28
  type AreaChartProps = {
22
29
  data: (number | AreaChartDatum)[];
@@ -25,6 +32,20 @@
25
32
  tone?: AreaChartTone;
26
33
  smooth?: boolean;
27
34
  label: string;
35
+ /**
36
+ * Annotation overlay in DATA space (points, labels, axis lines, regions,
37
+ * polygons), resolved to pixels via the chart scales. Regions render behind
38
+ * the area, every other kind above it. Additive: absent ⇒ unchanged.
39
+ */
40
+ annotations?: ChartAnnotation[];
41
+ /**
42
+ * Per-point value labels. `false`/absent (default) → none. `true` → each
43
+ * point's value with the chart's numeric formatter. Object → `format(value)`
44
+ * and/or a `position` override. Default position is `top` (above the point).
45
+ * Labels are `aria-hidden` — the values already live in the accessible
46
+ * ChartDataList.
47
+ */
48
+ dataLabels?: DataLabelsProp;
28
49
  class?: string;
29
50
  };
30
51
 
@@ -35,6 +56,8 @@
35
56
  tone = "category1",
36
57
  smooth = false,
37
58
  label,
59
+ annotations,
60
+ dataLabels,
38
61
  class: className
39
62
  }: AreaChartProps = $props();
40
63
 
@@ -87,7 +110,10 @@
87
110
  });
88
111
  });
89
112
 
90
- const dataValueItems = $derived(normalizedData.map((d) => `${d.x}: ${d.y}`));
113
+ const dataValueItems = $derived([
114
+ ...normalizedData.map((d) => `${d.x}: ${d.y}`),
115
+ ...annotationDataListItems(annotations)
116
+ ]);
91
117
 
92
118
  let hoveredIndex: number | null = $state(null);
93
119
 
@@ -139,6 +165,50 @@
139
165
  });
140
166
  });
141
167
 
168
+ // --- Annotation overlay ---------------------------------------------------
169
+ // `xScale` honours the ordinal/numeric x domain; `yScale` mirrors the point
170
+ // y mapping. Out-of-domain coordinates yield `null` → the resolver drops them.
171
+ const annotationXScale = $derived((v: number | string): number | null => {
172
+ if (xDomain.kind === "numeric") {
173
+ if (typeof v !== "number" || !Number.isFinite(v)) return null;
174
+ if (v < xDomain.min || v > xDomain.max) return null;
175
+ return scaleLinear(v, xDomain.min, xDomain.max, 0, plotWidth);
176
+ }
177
+ const i = normalizedData.findIndex((d) => d.x === v);
178
+ if (i < 0) return null;
179
+ const denom = Math.max(normalizedData.length - 1, 1);
180
+ return normalizedData.length === 1 ? plotWidth / 2 : (i / denom) * plotWidth;
181
+ });
182
+ const annotationYScale = $derived((v: number): number | null => {
183
+ if (!Number.isFinite(v) || v < yDomain.min || v > yDomain.max) return null;
184
+ return scaleLinear(v, yDomain.min, yDomain.max, plotHeight, 0);
185
+ });
186
+ const resolvedAnnotations = $derived(
187
+ resolveAnnotations(annotations, {
188
+ xScale: annotationXScale,
189
+ yScale: annotationYScale,
190
+ plotLeft: MARGIN.left,
191
+ plotTop: MARGIN.top,
192
+ plotWidth,
193
+ plotHeight
194
+ })
195
+ );
196
+ const annotationRegions = $derived(resolvedAnnotations.filter((a) => a.kind === "region"));
197
+ const annotationAbove = $derived(resolvedAnnotations.filter((a) => a.kind !== "region"));
198
+
199
+ // --- Data labels ----------------------------------------------------------
200
+ // One value label per point. Default `top`: just above the dot. `center` sits
201
+ // on the dot. aria-hidden (values are in the ChartDataList already).
202
+ const dataLabelOpts = $derived(normalizeDataLabels(dataLabels));
203
+ const dataLabelItems = $derived.by(() => {
204
+ if (!dataLabelOpts.enabled) return [] as { key: number; x: number; y: number; text: string; baseline: string }[];
205
+ return points.map((p) => {
206
+ const text = formatDataLabel(p.datum.y, dataLabelOpts, formatTick);
207
+ const center = dataLabelOpts.position === "center" || dataLabelOpts.position === "inside";
208
+ return { key: p.index, x: p.x, y: center ? p.y : p.y - 8, text, baseline: center ? "middle" : "auto" };
209
+ });
210
+ });
211
+
142
212
  function buildLinearPath(pts: { x: number; y: number }[]): string {
143
213
  return pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ");
144
214
  }
@@ -294,6 +364,20 @@
294
364
  </text>
295
365
  {/each}
296
366
 
367
+ <!-- Annotation regions sit BEHIND the area. -->
368
+ {#if annotationRegions.length > 0}
369
+ <g class="st-areaChart__annotations st-areaChart__annotations--behind">
370
+ {#each annotationRegions as a (a.key)}
371
+ {#if a.kind === "region"}
372
+ <rect class="st-areaChart__annotationRegion" x={a.x} y={a.y} width={a.width} height={a.height} />
373
+ {#if a.label}
374
+ <text class="st-areaChart__annotationLabel" x={a.x + 4} y={a.y + 11}>{a.label}</text>
375
+ {/if}
376
+ {/if}
377
+ {/each}
378
+ </g>
379
+ {/if}
380
+
297
381
  {#if areaPath}
298
382
  <path class="st-areaChart__area" d={areaPath} fill="url(#{gradientId})" />
299
383
  {/if}
@@ -317,6 +401,46 @@
317
401
  data-chart-index={p.index}
318
402
  />
319
403
  {/each}
404
+
405
+ <!-- Annotations ABOVE the area: lines, shapes, points, labels. -->
406
+ {#if annotationAbove.length > 0}
407
+ <g class="st-areaChart__annotations st-areaChart__annotations--above">
408
+ {#each annotationAbove as a (a.key)}
409
+ {#if a.kind === "line"}
410
+ <line class="st-areaChart__annotationLine" x1={a.x1} y1={a.y1} x2={a.x2} y2={a.y2} />
411
+ {#if a.label}
412
+ <text
413
+ class="st-areaChart__annotationLabel"
414
+ x={a.axis === "x" ? a.x1 + 4 : MARGIN.left + plotWidth - 4}
415
+ y={a.axis === "x" ? MARGIN.top + 11 : a.y1 - 4}
416
+ text-anchor={a.axis === "x" ? "start" : "end"}
417
+ >{a.label}</text>
418
+ {/if}
419
+ {:else if a.kind === "shape"}
420
+ <polygon class="st-areaChart__annotationShape" points={polygonPoints(a.points)} />
421
+ {#if a.label}
422
+ <text class="st-areaChart__annotationLabel" x={a.labelX} y={a.labelY} text-anchor="middle">{a.label}</text>
423
+ {/if}
424
+ {:else if a.kind === "point"}
425
+ <circle class="st-areaChart__annotationPoint" cx={a.x} cy={a.y} r="4.5" />
426
+ {#if a.label}
427
+ <text class="st-areaChart__annotationLabel" x={a.x} y={a.y - 8} text-anchor="middle">{a.label}</text>
428
+ {/if}
429
+ {:else}
430
+ <text class="st-areaChart__annotationText" x={a.x} y={a.y} text-anchor={a.anchor}>{a.text}</text>
431
+ {/if}
432
+ {/each}
433
+ </g>
434
+ {/if}
435
+
436
+ <!-- Data labels — one value per point, drawn on top. aria-hidden. -->
437
+ {#if dataLabelItems.length > 0}
438
+ <g class="st-areaChart__dataLabels" aria-hidden="true">
439
+ {#each dataLabelItems as d (d.key)}
440
+ <text class="st-areaChart__dataLabel" x={d.x} y={d.y} text-anchor="middle" dominant-baseline={d.baseline}>{d.text}</text>
441
+ {/each}
442
+ </g>
443
+ {/if}
320
444
  </svg>
321
445
  </div>
322
446
 
@@ -429,4 +553,39 @@
429
553
  .st-areaChart__tooltipValue {
430
554
  opacity: 0.85;
431
555
  }
556
+
557
+ /* --- Annotation layer ----------------------------------------------------
558
+ Regions render BEHIND the area; lines/shapes/points/labels render ABOVE. */
559
+ .st-areaChart__annotationRegion {
560
+ fill: color-mix(in srgb, var(--st-semantic-feedback-info) 12%, transparent);
561
+ stroke: none;
562
+ }
563
+ .st-areaChart__annotationLine {
564
+ stroke: var(--st-semantic-feedback-info);
565
+ stroke-width: 1.5;
566
+ stroke-dasharray: 4 3;
567
+ }
568
+ .st-areaChart__annotationShape {
569
+ fill: color-mix(in srgb, var(--st-semantic-feedback-info) 14%, transparent);
570
+ stroke: var(--st-semantic-feedback-info);
571
+ stroke-width: 1.5;
572
+ }
573
+ .st-areaChart__annotationPoint {
574
+ fill: var(--st-semantic-feedback-info);
575
+ stroke: var(--st-semantic-surface-default);
576
+ stroke-width: 1.5;
577
+ }
578
+ .st-areaChart__annotationLabel,
579
+ .st-areaChart__annotationText {
580
+ fill: var(--st-semantic-text-primary);
581
+ font-size: 0.625rem;
582
+ font-weight: 600;
583
+ }
584
+
585
+ /* Data labels — per-point value, drawn on top. Token-only colour. */
586
+ .st-areaChart__dataLabel {
587
+ fill: var(--st-semantic-text-primary);
588
+ font-size: 0.6875rem;
589
+ font-weight: 600;
590
+ }
432
591
  </style>
@@ -3,6 +3,8 @@ export type AreaChartDatum = {
3
3
  x: number | string;
4
4
  y: number;
5
5
  };
6
+ import { type ChartAnnotation } from "./chartAnnotations.js";
7
+ import { type DataLabelsProp } from "./chartDataLabels.js";
6
8
  type AreaChartProps = {
7
9
  data: (number | AreaChartDatum)[];
8
10
  width?: number;
@@ -10,6 +12,20 @@ type AreaChartProps = {
10
12
  tone?: AreaChartTone;
11
13
  smooth?: boolean;
12
14
  label: string;
15
+ /**
16
+ * Annotation overlay in DATA space (points, labels, axis lines, regions,
17
+ * polygons), resolved to pixels via the chart scales. Regions render behind
18
+ * the area, every other kind above it. Additive: absent ⇒ unchanged.
19
+ */
20
+ annotations?: ChartAnnotation[];
21
+ /**
22
+ * Per-point value labels. `false`/absent (default) → none. `true` → each
23
+ * point's value with the chart's numeric formatter. Object → `format(value)`
24
+ * and/or a `position` override. Default position is `top` (above the point).
25
+ * Labels are `aria-hidden` — the values already live in the accessible
26
+ * ChartDataList.
27
+ */
28
+ dataLabels?: DataLabelsProp;
13
29
  class?: string;
14
30
  };
15
31
  declare const AreaChart: import("svelte").Component<AreaChartProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"AreaChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AreaChart.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,MAAM,GAAG,cAAc,CAAC,EAAE,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAmQJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"AreaChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AreaChart.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;AAIJ,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG/F,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,CAAC,MAAM,GAAG,cAAc,CAAC,EAAE,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAuWJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -50,6 +50,13 @@
50
50
 
51
51
  <script lang="ts">
52
52
  import ChartDataList from "./ChartDataList.svelte";
53
+ import {
54
+ resolveAnnotations,
55
+ annotationDataListItems,
56
+ polygonPoints,
57
+ type ChartAnnotation
58
+ } from "./chartAnnotations.js";
59
+ import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
53
60
 
54
61
  type BarChartProps = {
55
62
  data: BarChartDatum[];
@@ -86,6 +93,22 @@
86
93
  bands?: ChartBand[];
87
94
  /** A single goal line, emphasised above the bars. */
88
95
  goalLine?: ChartGoalLine;
96
+ /**
97
+ * Annotation overlay in DATA space. The x coordinate is CATEGORICAL — it
98
+ * matches a bar by its `label` (centre of band) and is ignored otherwise; the
99
+ * y coordinate (and `value`/`from`/`to`) are value-axis numbers. Regions
100
+ * render behind the bars, every other kind above. Additive: absent ⇒
101
+ * unchanged.
102
+ */
103
+ annotations?: ChartAnnotation[];
104
+ /**
105
+ * Per-bar value labels. `false`/absent (default) → none. `true` → each bar's
106
+ * value with the chart's numeric formatter. Object → `format(value)` and/or
107
+ * a `position` override. Default position is `outside` (above the bar in
108
+ * vertical mode, past the bar end in horizontal mode). Labels are
109
+ * `aria-hidden` — the values already live in the accessible ChartDataList.
110
+ */
111
+ dataLabels?: DataLabelsProp;
89
112
  /**
90
113
  * Value-axis scale. `"linear"` (default) is unchanged. `"log"` switches the
91
114
  * value axis to a base-10 logarithmic scale — values `<= 0` are ignored for
@@ -116,6 +139,8 @@
116
139
  referenceLines,
117
140
  bands,
118
141
  goalLine,
142
+ annotations,
143
+ dataLabels,
119
144
  scale = "linear",
120
145
  invertAxis = false,
121
146
  showLegend,
@@ -469,9 +494,85 @@
469
494
  return out;
470
495
  });
471
496
 
497
+ // --- Annotation overlay ---------------------------------------------------
498
+ // The x coordinate is CATEGORICAL (a bar `label` → centre of band); the y
499
+ // coordinate is a value-axis number. For a HORIZONTAL chart the value axis is
500
+ // x and the category axis is y, so the data-space (x=category, y=value)
501
+ // convention is transposed onto the pixel axes: the resolver's `xScale` maps
502
+ // the value, `yScale` the category, and each annotation's `axis`/coords are
503
+ // swapped so a value reference (`axis: "y"`) still spans the full plot.
504
+ const categoryPixel = $derived((v: number | string): number | null => {
505
+ const bar = bars.find((b) => b.datum.label === String(v));
506
+ if (!bar) return null;
507
+ return isVertical ? bar.cx - MARGIN.left : bar.cy - MARGIN.top;
508
+ });
509
+ const valuePixelRel = $derived((v: number): number | null => {
510
+ if (!Number.isFinite(v)) return null;
511
+ return valuePos(v) - (isVertical ? MARGIN.top : MARGIN.left);
512
+ });
513
+ const transposeAnnotations = $derived((list: ChartAnnotation[] | undefined): ChartAnnotation[] | undefined => {
514
+ if (!list || isVertical) return list;
515
+ // Horizontal: swap the data-space x/y (and the region/line axis) so the
516
+ // generic resolver — which always emits xScale→x-pixel, yScale→y-pixel —
517
+ // lands the value on the x axis and the category on the y axis.
518
+ return list.map((a): ChartAnnotation => {
519
+ switch (a.kind) {
520
+ case "line":
521
+ return { ...a, axis: a.axis === "x" ? "y" : "x" };
522
+ case "region":
523
+ return { ...a, axis: a.axis === "x" ? "y" : "x" };
524
+ case "point":
525
+ return { ...a, x: a.y, y: typeof a.x === "number" ? a.x : NaN } as ChartAnnotation;
526
+ case "label":
527
+ return { ...a, x: a.y, y: typeof a.x === "number" ? a.x : NaN } as ChartAnnotation;
528
+ case "shape":
529
+ return { ...a, points: a.points.map((p) => ({ x: p.y, y: typeof p.x === "number" ? p.x : NaN })) };
530
+ }
531
+ });
532
+ });
533
+ const annXScale = $derived((v: number | string): number | null =>
534
+ isVertical ? categoryPixel(v) : typeof v === "number" ? valuePixelRel(v) : null
535
+ );
536
+ const annYScale = $derived((v: number): number | null => (isVertical ? valuePixelRel(v) : categoryPixel(v)));
537
+ const resolvedAnnotations = $derived(
538
+ resolveAnnotations(transposeAnnotations(annotations), {
539
+ xScale: annXScale,
540
+ yScale: annYScale,
541
+ plotLeft: MARGIN.left,
542
+ plotTop: MARGIN.top,
543
+ plotWidth: scales.plotWidth,
544
+ plotHeight: scales.plotHeight
545
+ })
546
+ );
547
+ const annotationRegions = $derived(resolvedAnnotations.filter((a) => a.kind === "region"));
548
+ const annotationAbove = $derived(resolvedAnnotations.filter((a) => a.kind !== "region"));
549
+
550
+ // --- Data labels ----------------------------------------------------------
551
+ // One value label per bar, placed at the bar's value end. Default `outside`:
552
+ // above the bar (vertical) / past the bar end (horizontal). `inside`/`center`
553
+ // sit at the bar's mid-length. aria-hidden (values are in the ChartDataList).
554
+ const dataLabelOpts = $derived(normalizeDataLabels(dataLabels));
555
+ const dataLabelItems = $derived.by(() => {
556
+ if (!dataLabelOpts.enabled) return [] as { key: string; x: number; y: number; text: string; anchor: "start" | "middle" | "end"; baseline: string }[];
557
+ return bars.map((bar) => {
558
+ const text = formatDataLabel(bar.datum.value, dataLabelOpts, formatTick);
559
+ const pos = dataLabelOpts.position ?? "outside";
560
+ const inside = pos === "inside" || pos === "center";
561
+ if (isVertical) {
562
+ const x = bar.cx;
563
+ const y = inside ? bar.y + bar.height / 2 : bar.cy - 6;
564
+ return { key: bar.datum.label, x, y, text, anchor: "middle" as const, baseline: inside ? "middle" : "auto" };
565
+ }
566
+ const y = bar.cy;
567
+ const x = inside ? bar.x + bar.width / 2 : bar.cx + 4;
568
+ return { key: bar.datum.label, x, y, text, anchor: inside ? ("middle" as const) : ("start" as const), baseline: "middle" };
569
+ });
570
+ });
571
+
472
572
  const dataValueItems = $derived([
473
573
  ...data.map((d) => `${d.label}: ${d.value}`),
474
- ...overlayDataListItems(referenceLines, bands, goal)
574
+ ...overlayDataListItems(referenceLines, bands, goal),
575
+ ...annotationDataListItems(annotations)
475
576
  ]);
476
577
 
477
578
  const valueAxisTicks = $derived.by(() => {
@@ -630,6 +731,20 @@
630
731
  {/if}
631
732
  {/each}
632
733
 
734
+ <!-- Annotation regions sit BEHIND the bars (filled bands). -->
735
+ {#if annotationRegions.length > 0}
736
+ <g class="st-barChart__annotations st-barChart__annotations--behind">
737
+ {#each annotationRegions as a (a.key)}
738
+ {#if a.kind === "region"}
739
+ <rect class="st-barChart__annotationRegion" x={a.x} y={a.y} width={a.width} height={a.height} />
740
+ {#if a.label}
741
+ <text class="st-barChart__annotationLabel" x={a.x + 4} y={a.y + 11}>{a.label}</text>
742
+ {/if}
743
+ {/if}
744
+ {/each}
745
+ </g>
746
+ {/if}
747
+
633
748
  <!-- bars -->
634
749
  <!-- The bars live inside an aria-hidden SVG, so they are NEVER an accessible
635
750
  surface. When `onSelect` is provided they only carry a mouse click
@@ -681,6 +796,46 @@
681
796
  text-anchor={isVertical ? "end" : "start"}
682
797
  >{goalGeom.label ?? `Objectif ${goalGeom.value}`}</text>
683
798
  {/if}
799
+
800
+ <!-- Annotations ABOVE the bars: lines, shapes, points, labels. -->
801
+ {#if annotationAbove.length > 0}
802
+ <g class="st-barChart__annotations st-barChart__annotations--above">
803
+ {#each annotationAbove as a (a.key)}
804
+ {#if a.kind === "line"}
805
+ <line class="st-barChart__annotationLine" x1={a.x1} y1={a.y1} x2={a.x2} y2={a.y2} />
806
+ {#if a.label}
807
+ <text
808
+ class="st-barChart__annotationLabel"
809
+ x={a.axis === "x" ? a.x1 + 4 : MARGIN.left + scales.plotWidth - 4}
810
+ y={a.axis === "x" ? MARGIN.top + 11 : a.y1 - 4}
811
+ text-anchor={a.axis === "x" ? "start" : "end"}
812
+ >{a.label}</text>
813
+ {/if}
814
+ {:else if a.kind === "shape"}
815
+ <polygon class="st-barChart__annotationShape" points={polygonPoints(a.points)} />
816
+ {#if a.label}
817
+ <text class="st-barChart__annotationLabel" x={a.labelX} y={a.labelY} text-anchor="middle">{a.label}</text>
818
+ {/if}
819
+ {:else if a.kind === "point"}
820
+ <circle class="st-barChart__annotationPoint" cx={a.x} cy={a.y} r="4.5" />
821
+ {#if a.label}
822
+ <text class="st-barChart__annotationLabel" x={a.x} y={a.y - 8} text-anchor="middle">{a.label}</text>
823
+ {/if}
824
+ {:else}
825
+ <text class="st-barChart__annotationText" x={a.x} y={a.y} text-anchor={a.anchor}>{a.text}</text>
826
+ {/if}
827
+ {/each}
828
+ </g>
829
+ {/if}
830
+
831
+ <!-- Data labels — one value per bar, drawn on top. aria-hidden. -->
832
+ {#if dataLabelItems.length > 0}
833
+ <g class="st-barChart__dataLabels" aria-hidden="true">
834
+ {#each dataLabelItems as d (d.key)}
835
+ <text class="st-barChart__dataLabel" x={d.x} y={d.y} text-anchor={d.anchor} dominant-baseline={d.baseline}>{d.text}</text>
836
+ {/each}
837
+ </g>
838
+ {/if}
684
839
  </svg>
685
840
  </div>
686
841
 
@@ -934,4 +1089,39 @@
934
1089
  .st-barChart__bar,
935
1090
  .st-barChart__filterChip { transition: none; }
936
1091
  }
1092
+
1093
+ /* --- Annotation layer ----------------------------------------------------
1094
+ Regions render BEHIND the bars; lines/shapes/points/labels render ABOVE. */
1095
+ .st-barChart__annotationRegion {
1096
+ fill: color-mix(in srgb, var(--st-semantic-feedback-info) 12%, transparent);
1097
+ stroke: none;
1098
+ }
1099
+ .st-barChart__annotationLine {
1100
+ stroke: var(--st-semantic-feedback-info);
1101
+ stroke-width: 1.5;
1102
+ stroke-dasharray: 4 3;
1103
+ }
1104
+ .st-barChart__annotationShape {
1105
+ fill: color-mix(in srgb, var(--st-semantic-feedback-info) 14%, transparent);
1106
+ stroke: var(--st-semantic-feedback-info);
1107
+ stroke-width: 1.5;
1108
+ }
1109
+ .st-barChart__annotationPoint {
1110
+ fill: var(--st-semantic-feedback-info);
1111
+ stroke: var(--st-semantic-surface-default);
1112
+ stroke-width: 1.5;
1113
+ }
1114
+ .st-barChart__annotationLabel,
1115
+ .st-barChart__annotationText {
1116
+ fill: var(--st-semantic-text-primary);
1117
+ font-size: 0.625rem;
1118
+ font-weight: 600;
1119
+ }
1120
+
1121
+ /* Data labels — per-bar value, drawn on top. Token-only colour. */
1122
+ .st-barChart__dataLabel {
1123
+ fill: var(--st-semantic-text-primary);
1124
+ font-size: 0.6875rem;
1125
+ font-weight: 600;
1126
+ }
937
1127
  </style>
@@ -31,6 +31,8 @@ export type ChartGoalLine = {
31
31
  };
32
32
  /** Value-axis scale type. `log` requires strictly positive values. */
33
33
  export type ChartScale = "linear" | "log";
34
+ import { type ChartAnnotation } from "./chartAnnotations.js";
35
+ import { type DataLabelsProp } from "./chartDataLabels.js";
34
36
  type BarChartProps = {
35
37
  data: BarChartDatum[];
36
38
  width?: number;
@@ -66,6 +68,22 @@ type BarChartProps = {
66
68
  bands?: ChartBand[];
67
69
  /** A single goal line, emphasised above the bars. */
68
70
  goalLine?: ChartGoalLine;
71
+ /**
72
+ * Annotation overlay in DATA space. The x coordinate is CATEGORICAL — it
73
+ * matches a bar by its `label` (centre of band) and is ignored otherwise; the
74
+ * y coordinate (and `value`/`from`/`to`) are value-axis numbers. Regions
75
+ * render behind the bars, every other kind above. Additive: absent ⇒
76
+ * unchanged.
77
+ */
78
+ annotations?: ChartAnnotation[];
79
+ /**
80
+ * Per-bar value labels. `false`/absent (default) → none. `true` → each bar's
81
+ * value with the chart's numeric formatter. Object → `format(value)` and/or
82
+ * a `position` override. Default position is `outside` (above the bar in
83
+ * vertical mode, past the bar end in horizontal mode). Labels are
84
+ * `aria-hidden` — the values already live in the accessible ChartDataList.
85
+ */
86
+ dataLabels?: DataLabelsProp;
69
87
  /**
70
88
  * Value-axis scale. `"linear"` (default) is unchanged. `"log"` switches the
71
89
  * value axis to a base-10 logarithmic scale — values `<= 0` are ignored for
@@ -1 +1 @@
1
- {"version":3,"file":"BarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/BarChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,YAAY,GACpB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,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;AAEF,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,CAAC;AAM1C,KAAK,aAAa,GAAG;IACnB,IAAI,EAAE,aAAa,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,+DAA+D;IAC/D,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAC;IACtC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAghBJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"BarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/BarChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,YAAY,GACpB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,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;AAEF,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,CAAC;AAI5C,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG/F,KAAK,aAAa,GAAG;IACnB,IAAI,EAAE,aAAa,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,+DAA+D;IAC/D,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAC;IACtC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAipBJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -12,6 +12,7 @@
12
12
 
13
13
  <script lang="ts">
14
14
  import ChartDataList from "./ChartDataList.svelte";
15
+ import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
15
16
 
16
17
  type DonutChartProps = {
17
18
  data: DonutChartDatum[];
@@ -21,6 +22,14 @@
21
22
  thickness?: number;
22
23
  /** Texte au centre (sinon le total). null pour masquer. */
23
24
  centerLabel?: string | null;
25
+ /**
26
+ * Per-slice value labels. `false`/absent (default) → none. `true` → each
27
+ * slice's value with the default formatter. Object → `format(value)` and/or a
28
+ * `position` override (default `center` of the arc). Slices too thin to fit a
29
+ * legible label are skipped. Labels are `aria-hidden` — the values already
30
+ * live in the accessible ChartDataList.
31
+ */
32
+ dataLabels?: DataLabelsProp;
24
33
  label: string;
25
34
  class?: string;
26
35
  };
@@ -30,6 +39,7 @@
30
39
  size = 220,
31
40
  thickness = 34,
32
41
  centerLabel,
42
+ dataLabels,
33
43
  label,
34
44
  class: className
35
45
  }: DonutChartProps = $props();
@@ -39,11 +49,14 @@
39
49
  "category5", "category6", "category7", "category8"
40
50
  ];
41
51
 
52
+ // A slice must span at least this many degrees to host a legible label.
53
+ const DATA_LABEL_MIN_DEG = 18;
54
+
42
55
  let hoveredIndex: number | null = $state(null);
43
56
 
44
57
  const slices = $derived.by(() => {
45
58
  const total = data.reduce((sum, d) => sum + Math.max(d.value, 0), 0);
46
- if (total <= 0) return { total: 0, items: [] as Array<{ d: DonutChartDatum; path: string; tone: DonutChartTone; pct: number }> };
59
+ if (total <= 0) return { total: 0, items: [] as Array<{ d: DonutChartDatum; path: string; tone: DonutChartTone; pct: number; spanDeg: number; labelX: number; labelY: number }> };
47
60
  const cx = size / 2;
48
61
  const cy = size / 2;
49
62
  const rOuter = size / 2 - 2;
@@ -64,7 +77,11 @@
64
77
  const [x1i, y1i] = polar(rInner, a1);
65
78
  const [x0i, y0i] = polar(rInner, a0);
66
79
  const path = `M ${x0o} ${y0o} A ${rOuter} ${rOuter} 0 ${large} 1 ${x1o} ${y1o} L ${x1i} ${y1i} A ${rInner} ${rInner} 0 ${large} 0 ${x0i} ${y0i} Z`;
67
- return { d, path, tone: d.tone ?? TONES[i % TONES.length], pct: frac * 100 };
80
+ // Label anchor: centre of the arc (mid-angle, mid-radius of the ring).
81
+ const aMid = (a0 + a1) / 2;
82
+ const rMid = (rOuter + rInner) / 2;
83
+ const [labelX, labelY] = polar(rMid, aMid);
84
+ return { d, path, tone: d.tone ?? TONES[i % TONES.length], pct: frac * 100, spanDeg: (span * 180) / Math.PI, labelX, labelY };
68
85
  });
69
86
  return { total, items };
70
87
  });
@@ -75,6 +92,24 @@
75
92
  slices.items.map((slice) => `${slice.d.label}: ${slice.d.value} (${fmtPct(slice.pct)})`)
76
93
  );
77
94
 
95
+ // --- Data labels ----------------------------------------------------------
96
+ // One value label centred in each arc (default `center`). Slices thinner than
97
+ // DATA_LABEL_MIN_DEG are skipped so labels stay legible. aria-hidden (values
98
+ // are in the ChartDataList already).
99
+ const dataLabelOpts = $derived(normalizeDataLabels(dataLabels));
100
+ const dataLabelItems = $derived(
101
+ dataLabelOpts.enabled
102
+ ? slices.items
103
+ .filter((slice) => slice.spanDeg >= DATA_LABEL_MIN_DEG)
104
+ .map((slice) => ({
105
+ key: slice.d.label,
106
+ x: slice.labelX,
107
+ y: slice.labelY,
108
+ text: formatDataLabel(slice.d.value, dataLabelOpts, (v) => String(v))
109
+ }))
110
+ : []
111
+ );
112
+
78
113
  function handleVisualPointerMove(event: PointerEvent) {
79
114
  const target = event.target;
80
115
  if (!(target instanceof Element)) {
@@ -109,6 +144,14 @@
109
144
  {centerLabel ?? slices.total}
110
145
  </text>
111
146
  {/if}
147
+ <!-- Data labels — one value per slice, centred in the arc. aria-hidden. -->
148
+ {#if dataLabelItems.length > 0}
149
+ <g class="st-donutChart__dataLabels" aria-hidden="true">
150
+ {#each dataLabelItems as d (d.key)}
151
+ <text class="st-donutChart__dataLabel" x={d.x} y={d.y} text-anchor="middle" dominant-baseline="central">{d.text}</text>
152
+ {/each}
153
+ </g>
154
+ {/if}
112
155
  {/if}
113
156
  </svg>
114
157
  </div>
@@ -160,6 +203,13 @@
160
203
  font-weight: 650;
161
204
  }
162
205
 
206
+ /* Data labels — per-slice value, centred in the arc. Token-only colour. */
207
+ .st-donutChart__dataLabel {
208
+ fill: var(--st-semantic-text-inverse);
209
+ font-size: 0.6875rem;
210
+ font-weight: 600;
211
+ }
212
+
163
213
  .st-donutChart__tooltip {
164
214
  background: var(--st-semantic-surface-inverse);
165
215
  border-radius: var(--st-radius-sm, 0.25rem);
@@ -4,6 +4,7 @@ export type DonutChartDatum = {
4
4
  value: number;
5
5
  tone?: DonutChartTone;
6
6
  };
7
+ import { type DataLabelsProp } from "./chartDataLabels.js";
7
8
  type DonutChartProps = {
8
9
  data: DonutChartDatum[];
9
10
  /** Diamètre du SVG. */
@@ -12,6 +13,14 @@ type DonutChartProps = {
12
13
  thickness?: number;
13
14
  /** Texte au centre (sinon le total). null pour masquer. */
14
15
  centerLabel?: string | null;
16
+ /**
17
+ * Per-slice value labels. `false`/absent (default) → none. `true` → each
18
+ * slice's value with the default formatter. Object → `format(value)` and/or a
19
+ * `position` override (default `center` of the arc). Slices too thin to fit a
20
+ * legible label are skipped. Labels are `aria-hidden` — the values already
21
+ * live in the accessible ChartDataList.
22
+ */
23
+ dataLabels?: DataLabelsProp;
15
24
  label: string;
16
25
  class?: string;
17
26
  };
@@ -1 +1 @@
1
- {"version":3,"file":"DonutChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/DonutChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAMF,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAmGJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"DonutChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/DonutChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAIJ,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG/F,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAsIJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}