@juspay/svelte-ui-components 2.31.0 → 2.32.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/AreaChart/AreaChart.svelte +421 -0
- package/dist/AreaChart/AreaChart.svelte.d.ts +4 -0
- package/dist/AreaChart/properties.d.ts +62 -0
- package/dist/AreaChart/properties.js +1 -0
- package/dist/BarChart/BarChart.svelte +372 -0
- package/dist/BarChart/BarChart.svelte.d.ts +4 -0
- package/dist/BarChart/properties.d.ts +44 -0
- package/dist/BarChart/properties.js +1 -0
- package/dist/LineChart/LineChart.svelte +361 -0
- package/dist/LineChart/LineChart.svelte.d.ts +4 -0
- package/dist/LineChart/properties.d.ts +59 -0
- package/dist/LineChart/properties.js +1 -0
- package/dist/PieChart/PieChart.svelte +236 -0
- package/dist/PieChart/PieChart.svelte.d.ts +4 -0
- package/dist/PieChart/properties.d.ts +36 -0
- package/dist/PieChart/properties.js +1 -0
- package/dist/SankeyChart/SankeyChart.svelte +312 -0
- package/dist/SankeyChart/SankeyChart.svelte.d.ts +4 -0
- package/dist/SankeyChart/properties.d.ts +52 -0
- package/dist/SankeyChart/properties.js +1 -0
- package/dist/_chart/Axis.svelte +143 -0
- package/dist/_chart/Axis.svelte.d.ts +4 -0
- package/dist/_chart/ChartContainer.svelte +81 -0
- package/dist/_chart/ChartContainer.svelte.d.ts +4 -0
- package/dist/_chart/ChartTooltip.svelte +81 -0
- package/dist/_chart/ChartTooltip.svelte.d.ts +4 -0
- package/dist/_chart/Legend.svelte +59 -0
- package/dist/_chart/Legend.svelte.d.ts +4 -0
- package/dist/_chart/colors.d.ts +4 -0
- package/dist/_chart/colors.js +36 -0
- package/dist/_chart/format.d.ts +3 -0
- package/dist/_chart/format.js +28 -0
- package/dist/_chart/geometry.d.ts +24 -0
- package/dist/_chart/geometry.js +204 -0
- package/dist/_chart/paths.d.ts +4 -0
- package/dist/_chart/paths.js +138 -0
- package/dist/_chart/scales.d.ts +5 -0
- package/dist/_chart/scales.js +77 -0
- package/dist/_chart/types.d.ts +128 -0
- package/dist/_chart/types.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { LineChartProperties, LineChartTooltipContext } from './properties';
|
|
3
|
+
import ChartContainer from '../_chart/ChartContainer.svelte';
|
|
4
|
+
import Axis from '../_chart/Axis.svelte';
|
|
5
|
+
import ChartTooltip from '../_chart/ChartTooltip.svelte';
|
|
6
|
+
import Legend from '../_chart/Legend.svelte';
|
|
7
|
+
import { createLinearScale, niceLinearDomain } from '../_chart/scales';
|
|
8
|
+
import { computeChartDimensions } from '../_chart/geometry';
|
|
9
|
+
import { linePath } from '../_chart/paths';
|
|
10
|
+
import { getColor } from '../_chart/colors';
|
|
11
|
+
import { formatNumber } from '../_chart/format';
|
|
12
|
+
import type { LegendItem, Point } from '../_chart/types';
|
|
13
|
+
|
|
14
|
+
// ── Props ──────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
series,
|
|
18
|
+
curve = 'monotone',
|
|
19
|
+
showDots = true,
|
|
20
|
+
showValues = false,
|
|
21
|
+
dotRadius = 4,
|
|
22
|
+
strokeWidth = 2,
|
|
23
|
+
showGridlines = true,
|
|
24
|
+
showXAxis = true,
|
|
25
|
+
showYAxis = true,
|
|
26
|
+
showLegend = false,
|
|
27
|
+
xDomain,
|
|
28
|
+
yDomain,
|
|
29
|
+
xAxisLabel,
|
|
30
|
+
yAxisLabel,
|
|
31
|
+
xTickFormat,
|
|
32
|
+
yTickFormat,
|
|
33
|
+
aspectRatio = 16 / 9,
|
|
34
|
+
tooltipSnippet,
|
|
35
|
+
empty,
|
|
36
|
+
onpointclick,
|
|
37
|
+
onpointhover,
|
|
38
|
+
testId,
|
|
39
|
+
classes
|
|
40
|
+
}: LineChartProperties = $props();
|
|
41
|
+
|
|
42
|
+
// ── State ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
let containerEl: HTMLDivElement | null = $state(null);
|
|
45
|
+
let chartWidth = $state(0);
|
|
46
|
+
let chartHeight = $state(0);
|
|
47
|
+
let hovered = $state<{ si: number; pi: number } | null>(null);
|
|
48
|
+
let mouseX = $state(0);
|
|
49
|
+
let mouseY = $state(0);
|
|
50
|
+
|
|
51
|
+
// ── Layout ─────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
let dims = $derived(computeChartDimensions(chartWidth, chartHeight));
|
|
54
|
+
|
|
55
|
+
let isEmpty = $derived(series.length === 0 || series.every((s) => s.data.length === 0));
|
|
56
|
+
|
|
57
|
+
let xExtent = $derived.by<[number, number]>(() => {
|
|
58
|
+
if (xDomain) {
|
|
59
|
+
return xDomain;
|
|
60
|
+
}
|
|
61
|
+
const allX = series.flatMap((s) => s.data.map((d) => d.x));
|
|
62
|
+
if (allX.length === 0) {
|
|
63
|
+
return [0, 1];
|
|
64
|
+
}
|
|
65
|
+
return niceLinearDomain(Math.min(...allX), Math.max(...allX));
|
|
66
|
+
});
|
|
67
|
+
let yExtent = $derived.by<[number, number]>(() => {
|
|
68
|
+
if (yDomain) {
|
|
69
|
+
return yDomain;
|
|
70
|
+
}
|
|
71
|
+
const allY = series.flatMap((s) => s.data.map((d) => d.y));
|
|
72
|
+
if (allY.length === 0) {
|
|
73
|
+
return [0, 1];
|
|
74
|
+
}
|
|
75
|
+
return niceLinearDomain(Math.min(0, ...allY), Math.max(...allY));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let xScale = $derived(createLinearScale(xExtent, [0, dims.innerWidth]));
|
|
79
|
+
let yScale = $derived(createLinearScale(yExtent, [dims.innerHeight, 0]));
|
|
80
|
+
|
|
81
|
+
let lines = $derived(
|
|
82
|
+
series.map((s, si) => {
|
|
83
|
+
const color = s.color ?? getColor(si);
|
|
84
|
+
const points: Point[] = s.data.map((d) => ({ x: xScale(d.x), y: yScale(d.y) }));
|
|
85
|
+
return { color, points, path: linePath(points, curve) };
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
let legendItems = $derived<LegendItem[]>(
|
|
90
|
+
series.map((s, i) => ({ label: s.name, color: s.color ?? getColor(i) }))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// ── Tooltip ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
let hoveredPoint = $derived(
|
|
96
|
+
hovered === null ? null : (series[hovered.si]?.data[hovered.pi] ?? null)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
let hoverLineX = $derived(
|
|
100
|
+
hovered === null ? null : (lines[hovered.si]?.points[hovered.pi]?.x ?? null)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
let tooltipContext = $derived.by<LineChartTooltipContext | null>(() => {
|
|
104
|
+
if (hovered === null || hoveredPoint === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
x: hoveredPoint.x,
|
|
109
|
+
points: series.map((s, si) => {
|
|
110
|
+
const p = s.data[hovered!.pi];
|
|
111
|
+
return {
|
|
112
|
+
name: s.name,
|
|
113
|
+
y: p?.y ?? 0,
|
|
114
|
+
color: s.color ?? getColor(si),
|
|
115
|
+
label: p?.label
|
|
116
|
+
};
|
|
117
|
+
})
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let tooltipData = $derived.by(() => {
|
|
122
|
+
if (tooltipContext === null) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
title: `x: ${formatNumber(tooltipContext.x)}`,
|
|
127
|
+
items: tooltipContext.points.map((p) => ({
|
|
128
|
+
label: p.name,
|
|
129
|
+
value: formatNumber(p.y),
|
|
130
|
+
color: p.color
|
|
131
|
+
}))
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Interactions ───────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function trackMouse(e: MouseEvent) {
|
|
138
|
+
if (containerEl === null) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const rect = containerEl.getBoundingClientRect();
|
|
142
|
+
mouseX = e.clientX - rect.left;
|
|
143
|
+
mouseY = e.clientY - rect.top;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findNearest(plotX: number, plotY: number): { si: number; pi: number } | null {
|
|
147
|
+
if (series.length === 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Use the longest series as the index reference so hover still works when
|
|
151
|
+
// the first series is empty or shorter than the others.
|
|
152
|
+
const reference = series.reduce((a, b) => (b.data.length > a.data.length ? b : a)).data;
|
|
153
|
+
if (reference.length === 0) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
let nearestPi = 0;
|
|
157
|
+
let nearestXDist = Infinity;
|
|
158
|
+
for (let i = 0; i < reference.length; i++) {
|
|
159
|
+
const px = xScale(reference[i].x);
|
|
160
|
+
const dist = Math.abs(px - plotX);
|
|
161
|
+
if (dist < nearestXDist) {
|
|
162
|
+
nearestXDist = dist;
|
|
163
|
+
nearestPi = i;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let nearestSi = 0;
|
|
167
|
+
let nearestYDist = Infinity;
|
|
168
|
+
for (let si = 0; si < series.length; si++) {
|
|
169
|
+
const p = series[si].data[nearestPi];
|
|
170
|
+
if (!p) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const py = yScale(p.y);
|
|
174
|
+
const dist = Math.abs(py - plotY);
|
|
175
|
+
if (dist < nearestYDist) {
|
|
176
|
+
nearestYDist = dist;
|
|
177
|
+
nearestSi = si;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { si: nearestSi, pi: nearestPi };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function handleOverlayMove(e: MouseEvent) {
|
|
184
|
+
trackMouse(e);
|
|
185
|
+
const plotX = mouseX - dims.margin.left;
|
|
186
|
+
const plotY = mouseY - dims.margin.top;
|
|
187
|
+
const next = findNearest(plotX, plotY);
|
|
188
|
+
if (next === null) {
|
|
189
|
+
hovered = null;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (hovered === null || hovered.si !== next.si || hovered.pi !== next.pi) {
|
|
193
|
+
hovered = next;
|
|
194
|
+
const point = series[next.si].data[next.pi];
|
|
195
|
+
onpointhover?.({ seriesIndex: next.si, pointIndex: next.pi, point });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function handleLeave() {
|
|
200
|
+
if (hovered !== null) {
|
|
201
|
+
hovered = null;
|
|
202
|
+
onpointhover?.(null);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleClick() {
|
|
207
|
+
if (hovered === null) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const point = series[hovered.si]?.data[hovered.pi];
|
|
211
|
+
if (point) {
|
|
212
|
+
onpointclick?.({ seriesIndex: hovered.si, pointIndex: hovered.pi, point });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<div
|
|
218
|
+
class="line-chart {classes ?? ''}"
|
|
219
|
+
bind:this={containerEl}
|
|
220
|
+
data-pw={typeof testId === 'string' ? testId : null}
|
|
221
|
+
>
|
|
222
|
+
{#if isEmpty && typeof empty === 'function'}
|
|
223
|
+
<div class="chart-empty">{@render empty()}</div>
|
|
224
|
+
{:else}
|
|
225
|
+
{#if showLegend && series.length > 1}
|
|
226
|
+
<Legend items={legendItems} position="top" />
|
|
227
|
+
{/if}
|
|
228
|
+
|
|
229
|
+
<ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
|
|
230
|
+
<g transform="translate({dims.margin.left}, {dims.margin.top})">
|
|
231
|
+
{#if showYAxis}
|
|
232
|
+
<Axis
|
|
233
|
+
orientation="left"
|
|
234
|
+
scale={yScale}
|
|
235
|
+
{showGridlines}
|
|
236
|
+
gridlineLength={dims.innerWidth}
|
|
237
|
+
label={yAxisLabel}
|
|
238
|
+
tickFormat={yTickFormat}
|
|
239
|
+
/>
|
|
240
|
+
{/if}
|
|
241
|
+
{#if showXAxis}
|
|
242
|
+
<g transform="translate(0, {dims.innerHeight})">
|
|
243
|
+
<Axis orientation="bottom" scale={xScale} label={xAxisLabel} tickFormat={xTickFormat} />
|
|
244
|
+
</g>
|
|
245
|
+
{/if}
|
|
246
|
+
|
|
247
|
+
{#each lines as line, si (si)}
|
|
248
|
+
<path
|
|
249
|
+
class="line-path"
|
|
250
|
+
class:dimmed={hovered !== null && hovered.si !== si}
|
|
251
|
+
d={line.path}
|
|
252
|
+
stroke={line.color}
|
|
253
|
+
stroke-width={strokeWidth}
|
|
254
|
+
fill="none"
|
|
255
|
+
/>
|
|
256
|
+
{#if showDots}
|
|
257
|
+
{#each line.points as point, pi (pi)}
|
|
258
|
+
<circle
|
|
259
|
+
class="dot"
|
|
260
|
+
class:dimmed={hovered !== null && hovered.si !== si}
|
|
261
|
+
cx={point.x}
|
|
262
|
+
cy={point.y}
|
|
263
|
+
r={hovered?.si === si && hovered?.pi === pi ? dotRadius * 1.5 : dotRadius}
|
|
264
|
+
fill={line.color}
|
|
265
|
+
/>
|
|
266
|
+
{/each}
|
|
267
|
+
{/if}
|
|
268
|
+
{#if showValues}
|
|
269
|
+
{#each line.points as point, pi (pi)}
|
|
270
|
+
<text
|
|
271
|
+
class="point-value"
|
|
272
|
+
x={point.x}
|
|
273
|
+
y={point.y - 8}
|
|
274
|
+
text-anchor="middle"
|
|
275
|
+
dominant-baseline="auto">{formatNumber(series[si].data[pi].y)}</text
|
|
276
|
+
>
|
|
277
|
+
{/each}
|
|
278
|
+
{/if}
|
|
279
|
+
{/each}
|
|
280
|
+
|
|
281
|
+
{#if hoverLineX !== null}
|
|
282
|
+
<line class="hover-line" x1={hoverLineX} x2={hoverLineX} y1={0} y2={dims.innerHeight} />
|
|
283
|
+
{/if}
|
|
284
|
+
|
|
285
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
286
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
287
|
+
<rect
|
|
288
|
+
class="hover-overlay"
|
|
289
|
+
x={0}
|
|
290
|
+
y={0}
|
|
291
|
+
width={dims.innerWidth}
|
|
292
|
+
height={dims.innerHeight}
|
|
293
|
+
fill="transparent"
|
|
294
|
+
onmousemove={handleOverlayMove}
|
|
295
|
+
onmouseleave={handleLeave}
|
|
296
|
+
onclick={handleClick}
|
|
297
|
+
/>
|
|
298
|
+
</g>
|
|
299
|
+
</ChartContainer>
|
|
300
|
+
|
|
301
|
+
{#if typeof tooltipSnippet === 'function' && tooltipContext !== null}
|
|
302
|
+
<div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
|
|
303
|
+
{@render tooltipSnippet(tooltipContext)}
|
|
304
|
+
</div>
|
|
305
|
+
{:else}
|
|
306
|
+
<ChartTooltip data={tooltipData} {mouseX} {mouseY} />
|
|
307
|
+
{/if}
|
|
308
|
+
{/if}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<style>
|
|
312
|
+
.line-chart {
|
|
313
|
+
width: 100%;
|
|
314
|
+
position: relative;
|
|
315
|
+
}
|
|
316
|
+
.line-path {
|
|
317
|
+
transition: opacity var(--chart-transition-duration, 0.2s) ease;
|
|
318
|
+
stroke-linecap: round;
|
|
319
|
+
stroke-linejoin: round;
|
|
320
|
+
pointer-events: none;
|
|
321
|
+
}
|
|
322
|
+
.line-path.dimmed {
|
|
323
|
+
opacity: var(--linechart-dimmed-opacity, 0.2);
|
|
324
|
+
}
|
|
325
|
+
.dot {
|
|
326
|
+
transition:
|
|
327
|
+
r var(--chart-transition-duration, 0.2s) ease,
|
|
328
|
+
opacity var(--chart-transition-duration, 0.2s) ease;
|
|
329
|
+
stroke: var(--chart-background, #fff);
|
|
330
|
+
stroke-width: 2;
|
|
331
|
+
pointer-events: none;
|
|
332
|
+
}
|
|
333
|
+
.dot.dimmed {
|
|
334
|
+
opacity: var(--linechart-dimmed-opacity, 0.2);
|
|
335
|
+
}
|
|
336
|
+
.point-value {
|
|
337
|
+
fill: var(--linechart-value-color, #333);
|
|
338
|
+
font-size: var(--linechart-value-font-size, 11px);
|
|
339
|
+
font-family: var(--chart-font-family, inherit);
|
|
340
|
+
pointer-events: none;
|
|
341
|
+
}
|
|
342
|
+
.hover-line {
|
|
343
|
+
stroke: var(--linechart-hover-line-color, #ccc);
|
|
344
|
+
stroke-width: 1;
|
|
345
|
+
stroke-dasharray: var(--linechart-hover-line-dash, 4 4);
|
|
346
|
+
pointer-events: none;
|
|
347
|
+
}
|
|
348
|
+
.hover-overlay {
|
|
349
|
+
cursor: crosshair;
|
|
350
|
+
}
|
|
351
|
+
.chart-tooltip-slot {
|
|
352
|
+
position: absolute;
|
|
353
|
+
z-index: 10;
|
|
354
|
+
pointer-events: none;
|
|
355
|
+
}
|
|
356
|
+
.chart-empty {
|
|
357
|
+
padding: var(--chart-empty-padding, 32px 24px);
|
|
358
|
+
color: var(--chart-empty-color, #9ca3af);
|
|
359
|
+
text-align: center;
|
|
360
|
+
}
|
|
361
|
+
</style>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { CurveType } from '../_chart/types';
|
|
3
|
+
export type LineChartDataPoint = {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
label?: string;
|
|
7
|
+
};
|
|
8
|
+
export type LineChartSeries = {
|
|
9
|
+
name: string;
|
|
10
|
+
data: LineChartDataPoint[];
|
|
11
|
+
color?: string;
|
|
12
|
+
};
|
|
13
|
+
export type LineChartTooltipContext = {
|
|
14
|
+
x: number;
|
|
15
|
+
points: Array<{
|
|
16
|
+
name: string;
|
|
17
|
+
y: number;
|
|
18
|
+
color: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
export type LineChartProperties = MandatoryLineChartProperties & OptionalLineChartProperties & LineChartEventProperties;
|
|
23
|
+
export type MandatoryLineChartProperties = {
|
|
24
|
+
series: LineChartSeries[];
|
|
25
|
+
};
|
|
26
|
+
export type OptionalLineChartProperties = {
|
|
27
|
+
curve?: CurveType;
|
|
28
|
+
showDots?: boolean;
|
|
29
|
+
showValues?: boolean;
|
|
30
|
+
dotRadius?: number;
|
|
31
|
+
strokeWidth?: number;
|
|
32
|
+
showGridlines?: boolean;
|
|
33
|
+
showXAxis?: boolean;
|
|
34
|
+
showYAxis?: boolean;
|
|
35
|
+
showLegend?: boolean;
|
|
36
|
+
xDomain?: [number, number];
|
|
37
|
+
yDomain?: [number, number];
|
|
38
|
+
xAxisLabel?: string;
|
|
39
|
+
yAxisLabel?: string;
|
|
40
|
+
xTickFormat?: (value: number | string) => string;
|
|
41
|
+
yTickFormat?: (value: number | string) => string;
|
|
42
|
+
aspectRatio?: number;
|
|
43
|
+
tooltipSnippet?: Snippet<[LineChartTooltipContext]>;
|
|
44
|
+
empty?: Snippet;
|
|
45
|
+
testId?: string;
|
|
46
|
+
classes?: string;
|
|
47
|
+
};
|
|
48
|
+
export type LineChartEventProperties = {
|
|
49
|
+
onpointclick?: (event: {
|
|
50
|
+
seriesIndex: number;
|
|
51
|
+
pointIndex: number;
|
|
52
|
+
point: LineChartDataPoint;
|
|
53
|
+
}) => void;
|
|
54
|
+
onpointhover?: (event: {
|
|
55
|
+
seriesIndex: number;
|
|
56
|
+
pointIndex: number;
|
|
57
|
+
point: LineChartDataPoint;
|
|
58
|
+
} | null) => void;
|
|
59
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { PieChartProperties } from './properties';
|
|
3
|
+
import ChartContainer from '../_chart/ChartContainer.svelte';
|
|
4
|
+
import ChartTooltip from '../_chart/ChartTooltip.svelte';
|
|
5
|
+
import Legend from '../_chart/Legend.svelte';
|
|
6
|
+
import { arcPath } from '../_chart/paths';
|
|
7
|
+
import { computePieLayout } from '../_chart/geometry';
|
|
8
|
+
import { getColor } from '../_chart/colors';
|
|
9
|
+
import { formatNumber, formatPercent } from '../_chart/format';
|
|
10
|
+
import type { LegendItem } from '../_chart/types';
|
|
11
|
+
|
|
12
|
+
// ── Props ──────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
data,
|
|
16
|
+
innerRadius = 0,
|
|
17
|
+
padAngle = 0.02,
|
|
18
|
+
showLabels = false,
|
|
19
|
+
showValues = false,
|
|
20
|
+
labelPosition = 'outside',
|
|
21
|
+
showLegend = false,
|
|
22
|
+
startAngle = -Math.PI / 2,
|
|
23
|
+
aspectRatio = 1,
|
|
24
|
+
valueFormat,
|
|
25
|
+
tooltipSnippet,
|
|
26
|
+
center,
|
|
27
|
+
empty,
|
|
28
|
+
onsliceclick,
|
|
29
|
+
onslicehover,
|
|
30
|
+
testId,
|
|
31
|
+
classes
|
|
32
|
+
}: PieChartProperties = $props();
|
|
33
|
+
|
|
34
|
+
// ── State ──────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
let containerEl: HTMLDivElement | null = $state(null);
|
|
37
|
+
let chartWidth = $state(0);
|
|
38
|
+
let chartHeight = $state(0);
|
|
39
|
+
let hoveredIndex = $state<number | null>(null);
|
|
40
|
+
let mouseX = $state(0);
|
|
41
|
+
let mouseY = $state(0);
|
|
42
|
+
|
|
43
|
+
// ── Layout ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
let format = $derived(valueFormat ?? formatNumber);
|
|
46
|
+
let total = $derived(data.reduce((sum, d) => sum + Math.max(0, d.value), 0));
|
|
47
|
+
let isEmpty = $derived(data.length === 0 || total === 0);
|
|
48
|
+
|
|
49
|
+
let cx = $derived(chartWidth / 2);
|
|
50
|
+
let cy = $derived(chartHeight / 2);
|
|
51
|
+
let outerR = $derived(
|
|
52
|
+
Math.max(10, Math.min(cx, cy) - (showLabels && labelPosition === 'outside' ? 40 : 10))
|
|
53
|
+
);
|
|
54
|
+
let innerR = $derived(innerRadius > 0 ? outerR * Math.min(0.95, innerRadius) : 0);
|
|
55
|
+
|
|
56
|
+
let slices = $derived(
|
|
57
|
+
computePieLayout(data, startAngle, padAngle).map((s) => {
|
|
58
|
+
const color = s.color ?? data[s.index]?.color ?? getColor(s.index);
|
|
59
|
+
const labelR = labelPosition === 'outside' ? outerR + 16 : (innerR + outerR) / 2;
|
|
60
|
+
return {
|
|
61
|
+
...s,
|
|
62
|
+
color,
|
|
63
|
+
path: arcPath(0, 0, innerR, outerR, s.startAngle, s.endAngle),
|
|
64
|
+
labelX: labelR * Math.cos(s.midAngle),
|
|
65
|
+
labelY: labelR * Math.sin(s.midAngle)
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
let legendItems = $derived<LegendItem[]>(
|
|
71
|
+
data.map((d, i) => ({ label: d.label, color: d.color ?? getColor(i) }))
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
let centerBoxSize = $derived(innerR > 0 ? Math.max(0, innerR * 1.3) : 0);
|
|
75
|
+
|
|
76
|
+
// ── Tooltip ────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
let tooltipData = $derived.by(() => {
|
|
79
|
+
if (hoveredIndex === null || !slices[hoveredIndex]) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const s = slices[hoveredIndex];
|
|
83
|
+
return {
|
|
84
|
+
title: s.label,
|
|
85
|
+
items: [
|
|
86
|
+
{
|
|
87
|
+
label: s.label,
|
|
88
|
+
value: `${format(s.value)} (${formatPercent(s.value, total)})`,
|
|
89
|
+
color: s.color
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Interactions ───────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function trackMouse(e: MouseEvent) {
|
|
98
|
+
if (containerEl === null) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const rect = containerEl.getBoundingClientRect();
|
|
102
|
+
mouseX = e.clientX - rect.left;
|
|
103
|
+
mouseY = e.clientY - rect.top;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleEnter(e: MouseEvent, i: number) {
|
|
107
|
+
hoveredIndex = i;
|
|
108
|
+
trackMouse(e);
|
|
109
|
+
onslicehover?.({ index: i, slice: data[i] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleLeave() {
|
|
113
|
+
hoveredIndex = null;
|
|
114
|
+
onslicehover?.(null);
|
|
115
|
+
}
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<div
|
|
119
|
+
class="pie-chart {classes ?? ''}"
|
|
120
|
+
bind:this={containerEl}
|
|
121
|
+
data-pw={typeof testId === 'string' ? testId : null}
|
|
122
|
+
>
|
|
123
|
+
{#if isEmpty && typeof empty === 'function'}
|
|
124
|
+
<div class="chart-empty">{@render empty()}</div>
|
|
125
|
+
{:else}
|
|
126
|
+
{#if showLegend}
|
|
127
|
+
<Legend items={legendItems} position="top" />
|
|
128
|
+
{/if}
|
|
129
|
+
|
|
130
|
+
<ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
|
|
131
|
+
<g transform="translate({cx}, {cy})">
|
|
132
|
+
{#each slices as slice (slice.index)}
|
|
133
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
134
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
135
|
+
<path
|
|
136
|
+
class="slice"
|
|
137
|
+
class:hovered={hoveredIndex === slice.index}
|
|
138
|
+
class:dimmed={hoveredIndex !== null && hoveredIndex !== slice.index}
|
|
139
|
+
d={slice.path}
|
|
140
|
+
fill={slice.color}
|
|
141
|
+
aria-label="{slice.label}: {format(slice.value)}"
|
|
142
|
+
onmouseenter={(e) => handleEnter(e, slice.index)}
|
|
143
|
+
onmousemove={trackMouse}
|
|
144
|
+
onmouseleave={handleLeave}
|
|
145
|
+
onclick={() => onsliceclick?.({ index: slice.index, slice: data[slice.index] })}
|
|
146
|
+
/>
|
|
147
|
+
{#if showLabels || showValues}
|
|
148
|
+
<text
|
|
149
|
+
class="slice-label"
|
|
150
|
+
class:label-outside={labelPosition === 'outside'}
|
|
151
|
+
x={slice.labelX}
|
|
152
|
+
y={slice.labelY}
|
|
153
|
+
text-anchor="middle"
|
|
154
|
+
dominant-baseline="middle"
|
|
155
|
+
>
|
|
156
|
+
{#if showLabels}{slice.label}{/if}
|
|
157
|
+
{#if showValues}
|
|
158
|
+
{formatPercent(slice.value, total)}{/if}
|
|
159
|
+
</text>
|
|
160
|
+
{/if}
|
|
161
|
+
{/each}
|
|
162
|
+
|
|
163
|
+
{#if innerR > 0 && typeof center === 'function' && centerBoxSize > 0}
|
|
164
|
+
<foreignObject
|
|
165
|
+
x={-centerBoxSize / 2}
|
|
166
|
+
y={-centerBoxSize / 2}
|
|
167
|
+
width={centerBoxSize}
|
|
168
|
+
height={centerBoxSize}
|
|
169
|
+
>
|
|
170
|
+
<div class="pie-center-content" xmlns="http://www.w3.org/1999/xhtml">
|
|
171
|
+
{@render center()}
|
|
172
|
+
</div>
|
|
173
|
+
</foreignObject>
|
|
174
|
+
{/if}
|
|
175
|
+
</g>
|
|
176
|
+
</ChartContainer>
|
|
177
|
+
|
|
178
|
+
{#if typeof tooltipSnippet === 'function' && hoveredIndex !== null && data[hoveredIndex]}
|
|
179
|
+
<div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
|
|
180
|
+
{@render tooltipSnippet(data[hoveredIndex], hoveredIndex)}
|
|
181
|
+
</div>
|
|
182
|
+
{:else}
|
|
183
|
+
<ChartTooltip data={tooltipData} {mouseX} {mouseY} />
|
|
184
|
+
{/if}
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<style>
|
|
189
|
+
.pie-chart {
|
|
190
|
+
width: 100%;
|
|
191
|
+
position: relative;
|
|
192
|
+
}
|
|
193
|
+
.slice {
|
|
194
|
+
stroke: var(--piechart-stroke-color, #fff);
|
|
195
|
+
stroke-width: var(--piechart-stroke-width, 2);
|
|
196
|
+
transition:
|
|
197
|
+
transform var(--chart-transition-duration, 0.2s) ease,
|
|
198
|
+
opacity var(--chart-transition-duration, 0.2s) ease;
|
|
199
|
+
transform-origin: 0 0;
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
}
|
|
202
|
+
.slice.hovered {
|
|
203
|
+
transform: scale(var(--piechart-hover-scale, 1.05));
|
|
204
|
+
opacity: 1;
|
|
205
|
+
}
|
|
206
|
+
.slice.dimmed {
|
|
207
|
+
opacity: var(--piechart-dimmed-opacity, 0.3);
|
|
208
|
+
}
|
|
209
|
+
.slice-label {
|
|
210
|
+
fill: var(--piechart-label-color, #333);
|
|
211
|
+
font-size: var(--piechart-label-font-size, 12px);
|
|
212
|
+
font-family: var(--chart-font-family, inherit);
|
|
213
|
+
pointer-events: none;
|
|
214
|
+
}
|
|
215
|
+
.slice-label.label-outside {
|
|
216
|
+
fill: var(--piechart-label-color, #555);
|
|
217
|
+
}
|
|
218
|
+
.pie-center-content {
|
|
219
|
+
width: 100%;
|
|
220
|
+
height: 100%;
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
justify-content: center;
|
|
224
|
+
text-align: center;
|
|
225
|
+
}
|
|
226
|
+
.chart-tooltip-slot {
|
|
227
|
+
position: absolute;
|
|
228
|
+
z-index: 10;
|
|
229
|
+
pointer-events: none;
|
|
230
|
+
}
|
|
231
|
+
.chart-empty {
|
|
232
|
+
padding: var(--chart-empty-padding, 32px 24px);
|
|
233
|
+
color: var(--chart-empty-color, #9ca3af);
|
|
234
|
+
text-align: center;
|
|
235
|
+
}
|
|
236
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export type PieChartSlice = {
|
|
3
|
+
label: string;
|
|
4
|
+
value: number;
|
|
5
|
+
color?: string;
|
|
6
|
+
};
|
|
7
|
+
export type PieChartProperties = MandatoryPieChartProperties & OptionalPieChartProperties & PieChartEventProperties;
|
|
8
|
+
export type MandatoryPieChartProperties = {
|
|
9
|
+
data: PieChartSlice[];
|
|
10
|
+
};
|
|
11
|
+
export type OptionalPieChartProperties = {
|
|
12
|
+
innerRadius?: number;
|
|
13
|
+
padAngle?: number;
|
|
14
|
+
showLabels?: boolean;
|
|
15
|
+
showValues?: boolean;
|
|
16
|
+
labelPosition?: 'inside' | 'outside';
|
|
17
|
+
showLegend?: boolean;
|
|
18
|
+
startAngle?: number;
|
|
19
|
+
aspectRatio?: number;
|
|
20
|
+
valueFormat?: (value: number) => string;
|
|
21
|
+
tooltipSnippet?: Snippet<[PieChartSlice, number]>;
|
|
22
|
+
center?: Snippet;
|
|
23
|
+
empty?: Snippet;
|
|
24
|
+
testId?: string;
|
|
25
|
+
classes?: string;
|
|
26
|
+
};
|
|
27
|
+
export type PieChartEventProperties = {
|
|
28
|
+
onsliceclick?: (event: {
|
|
29
|
+
index: number;
|
|
30
|
+
slice: PieChartSlice;
|
|
31
|
+
}) => void;
|
|
32
|
+
onslicehover?: (event: {
|
|
33
|
+
index: number;
|
|
34
|
+
slice: PieChartSlice;
|
|
35
|
+
} | null) => void;
|
|
36
|
+
};
|