@juspay/svelte-ui-components 2.75.0 → 2.76.1

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.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { LineChartProperties, LineChartTooltipContext } from './properties';
3
+ import type { ChartHighlightAPI } from '../_chart/highlight';
3
4
  import { onMount } from 'svelte';
4
5
  import ChartContainer from '../_chart/ChartContainer.svelte';
5
6
  import Axis from '../_chart/Axis.svelte';
@@ -25,6 +26,8 @@
25
26
  curve = 'monotone',
26
27
  gradientFill = false,
27
28
  fillOpacity = 0.3,
29
+ showArea = false,
30
+ areaGradient,
28
31
  showDots = true,
29
32
  showValues = false,
30
33
  dotRadius = 4,
@@ -37,11 +40,14 @@
37
40
  yDomain,
38
41
  xAxisLabel,
39
42
  yAxisLabel,
43
+ xAxisCategories,
40
44
  xTickFormat,
41
45
  yTickFormat,
42
46
  aspectRatio = 16 / 9,
43
47
  tooltipSnippet,
44
48
  empty,
49
+ highlightedIndex = null,
50
+ onChartReady,
45
51
  onpointclick,
46
52
  onpointhover,
47
53
  testId,
@@ -54,11 +60,42 @@
54
60
  let chartWidth = $state(0);
55
61
  let chartHeight = $state(0);
56
62
  let hovered = $state<{ si: number; pi: number } | null>(null);
63
+ // internalHighlight holds the index driven by the ChartHighlightAPI.highlight() call.
64
+ // The effective highlighted index merges this with the prop-driven highlightedIndex.
65
+ let internalHighlight = $state<number | null>(null);
57
66
  let mouseX = $state(0);
58
67
  let mouseY = $state(0);
59
68
 
69
+ // Effective highlighted index: the declarative prop takes precedence when it is a non-null
70
+ // number; otherwise the imperative API value (internalHighlight) is used. This lets callers
71
+ // mix both approaches — e.g. default to null so the API drives highlights, then override with
72
+ // a specific prop value when a controlled index is needed.
73
+ let effectiveHighlight = $derived(
74
+ typeof highlightedIndex === 'number' ? highlightedIndex : internalHighlight
75
+ );
76
+
60
77
  onMount(() => {
61
78
  uid = Math.random().toString(36).slice(2, 9);
79
+
80
+ const api: ChartHighlightAPI = {
81
+ type: 'line-chart',
82
+ highlight: (index) => {
83
+ internalHighlight = index;
84
+ },
85
+ getCategories: () => {
86
+ if (xAxisCategories && xAxisCategories.length > 0) {
87
+ return xAxisCategories;
88
+ }
89
+ // Fall back to the x-values of the longest series as string labels.
90
+ const reference = series.reduce(
91
+ (longest, s) => (s.data.length > longest.data.length ? s : longest),
92
+ series[0] ?? { data: [] }
93
+ );
94
+ return reference.data.map((d) => String(d.x));
95
+ }
96
+ };
97
+
98
+ onChartReady?.(api);
62
99
  });
63
100
 
64
101
  // ── Layout ─────────────────────────────────────────────────────
@@ -108,6 +145,23 @@
108
145
  series.map((s, i) => ({ label: s.name, color: s.color ?? getColor(i) }))
109
146
  );
110
147
 
148
+ // ── Category tick formatter ────────────────────────────────────
149
+
150
+ // When xAxisCategories is supplied we build a formatter that maps the numeric
151
+ // x-value (1-based index used in the data) to the corresponding category label.
152
+ let resolvedXTickFormat = $derived.by(() => {
153
+ if (xAxisCategories && xAxisCategories.length > 0) {
154
+ const categories = xAxisCategories;
155
+ return (value: number | string): string => {
156
+ const numericValue = typeof value === 'string' ? parseFloat(value) : value;
157
+ // x values are 1-based indices; category array is 0-based.
158
+ const categoryIndex = Math.round(numericValue) - 1;
159
+ return categories[categoryIndex] ?? String(value);
160
+ };
161
+ }
162
+ return xTickFormat;
163
+ });
164
+
111
165
  // ── Tooltip ────────────────────────────────────────────────────
112
166
 
113
167
  let hoveredPoint = $derived(
@@ -118,6 +172,24 @@
118
172
  hovered === null ? null : (lines[hovered.si]?.points[hovered.pi]?.x ?? null)
119
173
  );
120
174
 
175
+ // When a highlight index is active (imperative or prop), show the vertical
176
+ // crosshair at that point even without a mouse hover.
177
+ let highlightLineX = $derived.by<number | null>(() => {
178
+ if (effectiveHighlight === null) {
179
+ return null;
180
+ }
181
+ // Use the first series that has a point at this index.
182
+ for (const line of lines) {
183
+ const point = line.points[effectiveHighlight];
184
+ if (point) {
185
+ return point.x;
186
+ }
187
+ }
188
+ return null;
189
+ });
190
+
191
+ let activeLineX = $derived(hoverLineX ?? highlightLineX);
192
+
121
193
  let tooltipContext = $derived.by<LineChartTooltipContext | null>(() => {
122
194
  if (hovered === null || hoveredPoint === null) {
123
195
  return null;
@@ -140,8 +212,11 @@
140
212
  if (tooltipContext === null) {
141
213
  return null;
142
214
  }
215
+ const xLabel = resolvedXTickFormat
216
+ ? resolvedXTickFormat(tooltipContext.x)
217
+ : `x: ${formatNumber(tooltipContext.x)}`;
143
218
  return {
144
- title: xTickFormat ? xTickFormat(tooltipContext.x) : `x: ${formatNumber(tooltipContext.x)}`,
219
+ title: xLabel,
145
220
  items: tooltipContext.points.map((p) => ({
146
221
  label: p.name,
147
222
  value: formatNumber(p.y),
@@ -150,18 +225,51 @@
150
225
  };
151
226
  });
152
227
 
228
+ // ── Highlight dim logic ────────────────────────────────────────
229
+
230
+ // A point index is "dimmed" when the highlight system is active (hover or
231
+ // imperative highlight) and the point is not the active one.
232
+ const isDotDimmed = (si: number, pi: number): boolean => {
233
+ // Hover interaction takes precedence over imperative highlight.
234
+ if (hovered !== null) {
235
+ return hovered.si !== si || hovered.pi !== pi;
236
+ }
237
+ if (effectiveHighlight !== null) {
238
+ return pi !== effectiveHighlight;
239
+ }
240
+ return false;
241
+ };
242
+
243
+ const isLineDimmed = (si: number): boolean => {
244
+ if (hovered !== null) {
245
+ return hovered.si !== si;
246
+ }
247
+ // When only a point index is highlighted (no series index), dim no lines.
248
+ return false;
249
+ };
250
+
251
+ const isHighlightedDot = (si: number, pi: number): boolean => {
252
+ if (hovered !== null) {
253
+ return hovered.si === si && hovered.pi === pi;
254
+ }
255
+ if (effectiveHighlight !== null) {
256
+ return pi === effectiveHighlight;
257
+ }
258
+ return false;
259
+ };
260
+
153
261
  // ── Interactions ───────────────────────────────────────────────
154
262
 
155
- function trackMouse(e: MouseEvent) {
263
+ const trackMouse = (e: MouseEvent): void => {
156
264
  if (containerEl === null) {
157
265
  return;
158
266
  }
159
267
  const rect = containerEl.getBoundingClientRect();
160
268
  mouseX = e.clientX - rect.left;
161
269
  mouseY = e.clientY - rect.top;
162
- }
270
+ };
163
271
 
164
- function findNearest(plotX: number, plotY: number): { si: number; pi: number } | null {
272
+ const findNearest = (plotX: number, plotY: number): { si: number; pi: number } | null => {
165
273
  if (series.length === 0) {
166
274
  return null;
167
275
  }
@@ -196,9 +304,9 @@
196
304
  }
197
305
  }
198
306
  return { si: nearestSi, pi: nearestPi };
199
- }
307
+ };
200
308
 
201
- function handleOverlayMove(e: MouseEvent) {
309
+ const handleOverlayMove = (e: MouseEvent): void => {
202
310
  trackMouse(e);
203
311
  const plotX = mouseX - dims.margin.left;
204
312
  const plotY = mouseY - dims.margin.top;
@@ -212,16 +320,16 @@
212
320
  const point = series[next.si].data[next.pi];
213
321
  onpointhover?.({ seriesIndex: next.si, pointIndex: next.pi, point });
214
322
  }
215
- }
323
+ };
216
324
 
217
- function handleLeave() {
325
+ const handleLeave = (): void => {
218
326
  if (hovered !== null) {
219
327
  hovered = null;
220
328
  onpointhover?.(null);
221
329
  }
222
- }
330
+ };
223
331
 
224
- function handleClick() {
332
+ const handleClick = (): void => {
225
333
  if (hovered === null) {
226
334
  return;
227
335
  }
@@ -229,7 +337,7 @@
229
337
  if (point) {
230
338
  onpointclick?.({ seriesIndex: hovered.si, pointIndex: hovered.pi, point });
231
339
  }
232
- }
340
+ };
233
341
  </script>
234
342
 
235
343
  <div
@@ -245,31 +353,54 @@
245
353
  {/if}
246
354
 
247
355
  <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
248
- {#if gradientFill}
356
+ {#if gradientFill || showArea}
249
357
  <defs>
250
358
  {#each lines as line, si (si)}
251
- <linearGradient
252
- id="line-grad-{uid}-{si}"
253
- x1="0"
254
- y1="0"
255
- x2="0"
256
- y2={dims.innerHeight}
257
- gradientUnits="userSpaceOnUse"
258
- >
259
- <!-- The gradient top stop is fillOpacity + 0.3 (clamped to 1), giving a richer
260
- anchor at the top that fades to transparent at the bottom. This intentionally
261
- exceeds the base fillOpacity so that gradient-fill areas appear more vivid
262
- than a flat solid-fill at fillOpacity alone. -->
263
- <stop
264
- offset="0%"
265
- stop-color={line.color}
266
- stop-opacity={Math.min(
267
- (hovered?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
268
- 1
269
- )}
270
- />
271
- <stop offset="100%" stop-color={line.color} stop-opacity={0} />
272
- </linearGradient>
359
+ {#if gradientFill}
360
+ <linearGradient
361
+ id="line-grad-{uid}-{si}"
362
+ x1="0"
363
+ y1="0"
364
+ x2="0"
365
+ y2={dims.innerHeight}
366
+ gradientUnits="userSpaceOnUse"
367
+ >
368
+ <!-- The gradient top stop is fillOpacity + 0.3 (clamped to 1), giving a richer
369
+ anchor at the top that fades to transparent at the bottom. This intentionally
370
+ exceeds the base fillOpacity so that gradient-fill areas appear more vivid
371
+ than a flat solid-fill at fillOpacity alone. -->
372
+ <stop
373
+ offset="0%"
374
+ stop-color={line.color}
375
+ stop-opacity={Math.min(
376
+ (hovered?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
377
+ 1
378
+ )}
379
+ />
380
+ <stop offset="100%" stop-color={line.color} stop-opacity={0} />
381
+ </linearGradient>
382
+ {/if}
383
+ {#if showArea}
384
+ <linearGradient
385
+ id="line-area-{uid}-{si}"
386
+ x1="0"
387
+ y1="0"
388
+ x2="0"
389
+ y2={dims.innerHeight}
390
+ gradientUnits="userSpaceOnUse"
391
+ >
392
+ <stop
393
+ offset="0%"
394
+ stop-color={areaGradient ? areaGradient.from : line.color}
395
+ stop-opacity={areaGradient ? 1 : 0.35}
396
+ />
397
+ <stop
398
+ offset="100%"
399
+ stop-color={areaGradient ? areaGradient.to : line.color}
400
+ stop-opacity={areaGradient ? 1 : 0}
401
+ />
402
+ </linearGradient>
403
+ {/if}
273
404
  {/each}
274
405
  </defs>
275
406
  {/if}
@@ -286,12 +417,24 @@
286
417
  {/if}
287
418
  {#if showXAxis}
288
419
  <g transform="translate(0, {dims.innerHeight})">
289
- <Axis orientation="bottom" scale={xScale} label={xAxisLabel} tickFormat={xTickFormat} />
420
+ <Axis
421
+ orientation="bottom"
422
+ scale={xScale}
423
+ label={xAxisLabel}
424
+ tickFormat={resolvedXTickFormat}
425
+ />
290
426
  </g>
291
427
  {/if}
292
428
 
293
429
  {#each lines as line, si (si)}
294
- {#if gradientFill}
430
+ {#if showArea}
431
+ <path
432
+ class="line-area-fill"
433
+ class:dimmed={isLineDimmed(si)}
434
+ d={line.areaD}
435
+ fill="url(#line-area-{uid}-{si})"
436
+ />
437
+ {:else if gradientFill}
295
438
  <path
296
439
  class="line-area-fill"
297
440
  class:dimmed={hovered !== null && hovered.si !== si}
@@ -301,7 +444,7 @@
301
444
  {/if}
302
445
  <path
303
446
  class="line-path"
304
- class:dimmed={hovered !== null && hovered.si !== si}
447
+ class:dimmed={isLineDimmed(si)}
305
448
  d={line.path}
306
449
  stroke={line.color}
307
450
  stroke-width={strokeWidth}
@@ -310,7 +453,7 @@
310
453
  {#if line.points.length === 1 && !showDots}
311
454
  <circle
312
455
  class="single-point"
313
- class:dimmed={hovered !== null && hovered.si !== si}
456
+ class:dimmed={isLineDimmed(si)}
314
457
  cx={line.points[0].x}
315
458
  cy={line.points[0].y}
316
459
  r={dotRadius * 1.5}
@@ -321,10 +464,11 @@
321
464
  {#each line.points as point, pi (pi)}
322
465
  <circle
323
466
  class="dot"
324
- class:dimmed={hovered !== null && hovered.si !== si}
467
+ class:dimmed={isDotDimmed(si, pi)}
468
+ class:highlighted={isHighlightedDot(si, pi)}
325
469
  cx={point.x}
326
470
  cy={point.y}
327
- r={hovered?.si === si && hovered?.pi === pi ? dotRadius * 1.5 : dotRadius}
471
+ r={isHighlightedDot(si, pi) ? dotRadius * 1.5 : dotRadius}
328
472
  fill={line.color}
329
473
  />
330
474
  {/each}
@@ -342,8 +486,8 @@
342
486
  {/if}
343
487
  {/each}
344
488
 
345
- {#if hoverLineX !== null}
346
- <line class="hover-line" x1={hoverLineX} x2={hoverLineX} y1={0} y2={dims.innerHeight} />
489
+ {#if activeLineX !== null}
490
+ <line class="hover-line" x1={activeLineX} x2={activeLineX} y1={0} y2={dims.innerHeight} />
347
491
  {/if}
348
492
 
349
493
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -412,6 +556,10 @@
412
556
  .dot.dimmed {
413
557
  opacity: var(--linechart-dimmed-opacity, 0.2);
414
558
  }
559
+ .dot.highlighted {
560
+ stroke: var(--linechart-highlight-ring-color, #fff);
561
+ stroke-width: var(--linechart-highlight-ring-width, 2.5);
562
+ }
415
563
  .point-value {
416
564
  fill: var(--linechart-value-color, #333);
417
565
  font-size: var(--linechart-value-font-size, 11px);
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { CurveType } from '../_chart/types';
3
+ import type { ChartHighlightAPI } from '../_chart/highlight';
3
4
  export type LineChartDataPoint = {
4
5
  x: number;
5
6
  y: number;
@@ -19,33 +20,95 @@ export type LineChartTooltipContext = {
19
20
  label?: string;
20
21
  }>;
21
22
  };
23
+ /**
24
+ * Colours used for the vertical gradient fill rendered under the line when
25
+ * `showArea` is true. Both values accept any valid CSS colour string.
26
+ */
27
+ export type LineChartAreaGradient = {
28
+ /** Colour at the top of the gradient (closest to the line). */
29
+ from: string;
30
+ /** Colour at the bottom of the gradient (at the baseline). */
31
+ to: string;
32
+ };
22
33
  export type LineChartProperties = MandatoryLineChartProperties & OptionalLineChartProperties & LineChartEventProperties;
23
34
  export type MandatoryLineChartProperties = {
35
+ /** Array of `{name, data, color?}`. Each series renders as a separate line. */
24
36
  series: LineChartSeries[];
25
37
  };
26
38
  export type OptionalLineChartProperties = {
39
+ /** Interpolation between points. `'spline'` is an alias for `'monotone'`. */
27
40
  curve?: CurveType;
41
+ /**
42
+ * When true, renders a gradient fill under each line using the series colour,
43
+ * fading from `fillOpacity+0.3` at the top to transparent at the bottom.
44
+ * For per-series custom gradient colours use `showArea` + `areaGradient` instead.
45
+ */
28
46
  gradientFill?: boolean;
47
+ /** Base opacity of the gradient fill (0–1). Only used when `gradientFill` is true. */
29
48
  fillOpacity?: number;
49
+ /**
50
+ * When true, renders a solid filled area under each line. The fill colour is
51
+ * taken from `areaGradient` when provided, otherwise falls back to a
52
+ * transparent-to-series-colour vertical gradient.
53
+ */
54
+ showArea?: boolean;
55
+ /**
56
+ * Custom colours for the filled area rendered when `showArea` is true.
57
+ * Applies the same gradient to every series; supply a per-series colour via
58
+ * each series' own `color` field if distinct fills are needed.
59
+ */
60
+ areaGradient?: LineChartAreaGradient;
61
+ /** Whether to render dots at each data point. Hover still works via overlay when `false`. */
30
62
  showDots?: boolean;
63
+ /** Whether to render text labels with the y-value at each data point. */
31
64
  showValues?: boolean;
65
+ /** Radius of data point dots in pixels. Hovered or highlighted dot is 1.5× this. */
32
66
  dotRadius?: number;
67
+ /** Width of line strokes in pixels. */
33
68
  strokeWidth?: number;
69
+ /** Whether to show dashed gridlines across the Y axis. */
34
70
  showGridlines?: boolean;
71
+ /** Whether to render the X axis. */
35
72
  showXAxis?: boolean;
73
+ /** Whether to render the Y axis. */
36
74
  showYAxis?: boolean;
75
+ /** Whether to render the legend. Only shown when there are multiple series. */
37
76
  showLegend?: boolean;
77
+ /** Fixed `[min, max]` for the X axis. When omitted the domain is derived from data. */
38
78
  xDomain?: [number, number];
79
+ /** Fixed `[min, max]` for the Y axis. When omitted the domain is derived from data. */
39
80
  yDomain?: [number, number];
81
+ /** Text label below the X axis. */
40
82
  xAxisLabel?: string;
83
+ /** Text label beside the Y axis (rotated). */
41
84
  yAxisLabel?: string;
85
+ /**
86
+ * String category labels, one per unique x-index (parallel array to the
87
+ * numeric x values used in `series[n].data`). When provided these labels
88
+ * replace the raw numeric x values in the X-axis tick labels and tooltips.
89
+ * Index 0 maps to x=1, index 1 maps to x=2, and so on.
90
+ */
91
+ xAxisCategories?: string[];
92
+ /** Formatter for X axis tick labels. */
42
93
  xTickFormat?: (value: number | string) => string;
94
+ /** Formatter for Y axis tick labels. */
43
95
  yTickFormat?: (value: number | string) => string;
96
+ /** Width-to-height ratio for the chart. */
44
97
  aspectRatio?: number;
98
+ /** Custom tooltip. Receives `{x, points: [{name, y, color, label?}]}`. */
45
99
  tooltipSnippet?: Snippet<[LineChartTooltipContext]>;
100
+ /** Content rendered when all series are empty. */
46
101
  empty?: Snippet;
102
+ /** Value for the data-pw attribute on the chart container. */
47
103
  testId?: string;
104
+ /** CSS class string applied to the top-level element. */
48
105
  classes?: string;
106
+ /**
107
+ * Zero-based point index to highlight imperatively. When set, the marker at
108
+ * that index is emphasised and all others are dimmed. Pass `null` to clear.
109
+ * Use `onChartReady` for the imperative API equivalent.
110
+ */
111
+ highlightedIndex?: number | null;
49
112
  };
50
113
  export type LineChartEventProperties = {
51
114
  onpointclick?: (event: {
@@ -58,4 +121,10 @@ export type LineChartEventProperties = {
58
121
  pointIndex: number;
59
122
  point: LineChartDataPoint;
60
123
  } | null) => void;
124
+ /**
125
+ * Called once after the chart mounts, handing back a `ChartHighlightAPI`
126
+ * so an external orchestrator (e.g. voice narration sync) can drive point
127
+ * highlighting without knowledge of the chart's internal state.
128
+ */
129
+ onChartReady?: (api: ChartHighlightAPI) => void;
61
130
  };
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
+ import { onMount } from 'svelte';
2
3
  import type { PieChartProperties } from './properties';
3
4
  import ChartContainer from '../_chart/ChartContainer.svelte';
4
5
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
5
6
  import Legend from '../_chart/Legend.svelte';
7
+ import DeltaIndicator from '../DeltaIndicator/DeltaIndicator.svelte';
6
8
  import { arcPath } from '../_chart/paths';
7
9
  import { computePieLayout } from '../_chart/geometry';
8
10
  import { getColor } from '../_chart/colors';
@@ -31,7 +33,11 @@
31
33
  classes,
32
34
  semiCircle = false,
33
35
  legendShowValues = false,
34
- percentDecimals = 0
36
+ percentDecimals = 0,
37
+ onChartReady,
38
+ highlightedIndex = null,
39
+ changePercentage,
40
+ changeInvertColors = false
35
41
  }: PieChartProperties = $props();
36
42
 
37
43
  // ── State ──────────────────────────────────────────────────────
@@ -40,31 +46,40 @@
40
46
  let chartWidth = $state(0);
41
47
  let chartHeight = $state(0);
42
48
  let hoveredIndex = $state<number | null>(null);
49
+ let programmaticIndex = $state<number | null>(null);
43
50
  let mouseX = $state(0);
44
51
  let mouseY = $state(0);
45
52
 
46
- // Aspect ratio read from --piechart-semi-aspect-ratio CSS variable.
47
- // Uses $effect so it re-reads whenever containerEl binds or semiCircle changes
48
- // (e.g. media-query theme switch), not just on initial mount.
53
+ // ── Highlight API ──────────────────────────────────────────────
54
+
55
+ // Aspect ratio read from the --piechart-semi-aspect-ratio CSS variable on mount.
49
56
  let semiAspectRatioCssVar = $state(2);
50
57
 
51
- // eslint-disable-next-line no-restricted-syntax
52
- $effect(() => {
53
- // Track semiCircle and containerEl so the effect re-runs when either changes.
54
- void semiCircle;
55
- void containerEl;
56
- if (typeof window === 'undefined' || containerEl === null) {
57
- return;
58
- }
59
- const rawValue = getComputedStyle(containerEl)
60
- .getPropertyValue('--piechart-semi-aspect-ratio')
61
- .trim();
62
- const parsed = parseFloat(rawValue);
63
- if (!Number.isNaN(parsed) && parsed > 0) {
64
- semiAspectRatioCssVar = parsed;
58
+ onMount(() => {
59
+ onChartReady?.({
60
+ highlight: (index) => {
61
+ programmaticIndex = index;
62
+ },
63
+ getCategories: () => data.map((d) => d.label),
64
+ type: 'donut-chart'
65
+ });
66
+
67
+ if (typeof window !== 'undefined' && containerEl !== null) {
68
+ const rawValue = getComputedStyle(containerEl)
69
+ .getPropertyValue('--piechart-semi-aspect-ratio')
70
+ .trim();
71
+ const parsed = parseFloat(rawValue);
72
+ if (!Number.isNaN(parsed) && parsed > 0) {
73
+ semiAspectRatioCssVar = parsed;
74
+ }
65
75
  }
66
76
  });
67
77
 
78
+ // ── Active index ───────────────────────────────────────────────
79
+ // Precedence: mouse hover > declarative highlightedIndex prop > imperative API.
80
+
81
+ let activeIndex = $derived<number | null>(hoveredIndex ?? highlightedIndex ?? programmaticIndex);
82
+
68
83
  // ── Layout ─────────────────────────────────────────────────────
69
84
 
70
85
  let format = $derived(valueFormat ?? formatNumber);
@@ -146,6 +161,10 @@
146
161
 
147
162
  // ── Tooltip ────────────────────────────────────────────────────
148
163
 
164
+ // Tooltip visibility tracks hover only. activeIndex also covers declarative/imperative
165
+ // highlights, but those arrive without mouse coordinates, so a tooltip driven by them
166
+ // would render at the top-left (mouseX/mouseY still 0). Highlight styling uses
167
+ // activeIndex; the tooltip stays gated on hoveredIndex.
149
168
  let tooltipData = $derived.by(() => {
150
169
  if (hoveredIndex === null || !slices[hoveredIndex]) {
151
170
  return null;
@@ -209,8 +228,8 @@
209
228
  <!-- svelte-ignore a11y_click_events_have_key_events -->
210
229
  <path
211
230
  class="slice"
212
- class:hovered={hoveredIndex === slice.index}
213
- class:dimmed={hoveredIndex !== null && hoveredIndex !== slice.index}
231
+ class:hovered={activeIndex === slice.index}
232
+ class:dimmed={activeIndex !== null && activeIndex !== slice.index}
214
233
  d={slice.path}
215
234
  fill={slice.color}
216
235
  aria-label="{slice.label}: {format(slice.value)}"
@@ -250,6 +269,12 @@
250
269
  </g>
251
270
  </ChartContainer>
252
271
 
272
+ {#if typeof changePercentage === 'number'}
273
+ <div class="pie-delta-badge">
274
+ <DeltaIndicator value={changePercentage} invertColors={changeInvertColors} />
275
+ </div>
276
+ {/if}
277
+
253
278
  {#if showLegend && legendShowValues}
254
279
  <ul class="pie-legend-values">
255
280
  {#each data as d, i (i)}
@@ -312,6 +337,13 @@
312
337
  justify-content: center;
313
338
  text-align: center;
314
339
  }
340
+ .pie-delta-badge {
341
+ position: absolute;
342
+ top: var(--piechart-delta-top, 8px);
343
+ right: var(--piechart-delta-right, 8px);
344
+ z-index: 2;
345
+ pointer-events: none;
346
+ }
315
347
  .chart-tooltip-slot {
316
348
  position: absolute;
317
349
  z-index: 10;
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { ChartHighlightAPI } from '../_chart/highlight';
2
3
  export type PieChartSlice = {
3
4
  label: string;
4
5
  value: number;
@@ -26,6 +27,31 @@ export type OptionalPieChartProperties = {
26
27
  semiCircle?: boolean;
27
28
  legendShowValues?: boolean;
28
29
  percentDecimals?: number;
30
+ /**
31
+ * Called once on mount with the imperative highlight API. Pass the returned
32
+ * object to an external orchestrator (e.g. voice narration) so it can drive
33
+ * slice highlighting without touching internal state. The `type` field is
34
+ * always `'donut-chart'` regardless of whether `innerRadius` is set.
35
+ */
36
+ onChartReady?: (api: ChartHighlightAPI) => void;
37
+ /**
38
+ * Index of the slice to highlight programmatically. The highlighted slice
39
+ * scales out and all others dim, exactly as if the user were hovering it.
40
+ * Pass `null` (or omit the prop) to clear all highlights.
41
+ */
42
+ highlightedIndex?: number | null;
43
+ /**
44
+ * When provided, renders a `DeltaIndicator` badge anchored to the top-right
45
+ * corner of the chart container. Positive values show green ↑, negative
46
+ * values show red ↓ (unless `changeInvertColors` is `true`).
47
+ */
48
+ changePercentage?: number;
49
+ /**
50
+ * Swap the up/down colors on the delta badge for lower-is-better metrics
51
+ * (e.g. RTO rate, bounce rate). Has no effect when `changePercentage` is
52
+ * not provided.
53
+ */
54
+ changeInvertColors?: boolean;
29
55
  };
30
56
  export type PieChartEventProperties = {
31
57
  onsliceclick?: (event: {