@juspay/svelte-ui-components 2.75.0 → 2.76.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/BarChart/BarChart.svelte +163 -48
- package/dist/BarChart/properties.d.ts +41 -0
- package/dist/DeltaIndicator/DeltaIndicator.svelte +87 -0
- package/dist/DeltaIndicator/DeltaIndicator.svelte.d.ts +4 -0
- package/dist/DeltaIndicator/properties.d.ts +26 -0
- package/dist/DeltaIndicator/properties.js +1 -0
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte +521 -0
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte.d.ts +4 -0
- package/dist/DualAxisBarChart/properties.d.ts +108 -0
- package/dist/DualAxisBarChart/properties.js +1 -0
- package/dist/FunnelChart/FunnelChart.svelte +335 -0
- package/dist/FunnelChart/FunnelChart.svelte.d.ts +4 -0
- package/dist/FunnelChart/properties.d.ts +69 -0
- package/dist/FunnelChart/properties.js +1 -0
- package/dist/LineChart/LineChart.svelte +190 -42
- package/dist/LineChart/properties.d.ts +69 -0
- package/dist/PieChart/PieChart.svelte +52 -20
- package/dist/PieChart/properties.d.ts +26 -0
- package/dist/SankeyChart/SankeyChart.svelte +15 -5
- package/dist/SankeyChart/properties.d.ts +19 -0
- package/dist/_chart/geometry.d.ts +1 -1
- package/dist/_chart/geometry.js +5 -4
- package/dist/_chart/highlight.d.ts +15 -0
- package/dist/_chart/highlight.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
+
const handleLeave = (): void => {
|
|
218
326
|
if (hovered !== null) {
|
|
219
327
|
hovered = null;
|
|
220
328
|
onpointhover?.(null);
|
|
221
329
|
}
|
|
222
|
-
}
|
|
330
|
+
};
|
|
223
331
|
|
|
224
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
|
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={
|
|
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={
|
|
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={
|
|
467
|
+
class:dimmed={isDotDimmed(si, pi)}
|
|
468
|
+
class:highlighted={isHighlightedDot(si, pi)}
|
|
325
469
|
cx={point.x}
|
|
326
470
|
cy={point.y}
|
|
327
|
-
r={
|
|
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
|
|
346
|
-
<line class="hover-line" x1={
|
|
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
|
-
//
|
|
47
|
-
|
|
48
|
-
//
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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={
|
|
213
|
-
class:dimmed={
|
|
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: {
|