@delightstack/components 0.1.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/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- package/package.json +139 -0
|
@@ -0,0 +1,1426 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface ChartData {
|
|
3
|
+
/** Labels for each data point along the x-axis (or each slice for pie/donut) */
|
|
4
|
+
labels: string[];
|
|
5
|
+
/** One or more series of values to plot */
|
|
6
|
+
datasets: Dataset[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Dataset {
|
|
10
|
+
/** Name of this series (shown in the legend) */
|
|
11
|
+
label: string;
|
|
12
|
+
/** The values for this series, one per label */
|
|
13
|
+
data: number[];
|
|
14
|
+
/** Custom color for this series (auto-assigned if omitted) */
|
|
15
|
+
color?: string;
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script lang="ts">
|
|
20
|
+
import { resizeObserver } from '@delightstack/utilities';
|
|
21
|
+
|
|
22
|
+
const propId = $props.id();
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
/** Chart type */
|
|
26
|
+
type = 'line' as 'line' | 'area' | 'bar' | 'horizontal-bar' | 'pie' | 'donut',
|
|
27
|
+
|
|
28
|
+
/** Data to display */
|
|
29
|
+
data,
|
|
30
|
+
|
|
31
|
+
/** Chart height in pixels */
|
|
32
|
+
height = 300,
|
|
33
|
+
|
|
34
|
+
/** Custom color palette */
|
|
35
|
+
colors = undefined as string[] | undefined,
|
|
36
|
+
|
|
37
|
+
/** Show grid lines */
|
|
38
|
+
show_grid = true,
|
|
39
|
+
|
|
40
|
+
/** Show legend */
|
|
41
|
+
show_legend = true,
|
|
42
|
+
|
|
43
|
+
/** Enable tooltips */
|
|
44
|
+
show_tooltip = true,
|
|
45
|
+
|
|
46
|
+
/** Animate on load */
|
|
47
|
+
animate = true,
|
|
48
|
+
|
|
49
|
+
/** Stack datasets */
|
|
50
|
+
stacked = false,
|
|
51
|
+
|
|
52
|
+
/** Smooth curves for line/area */
|
|
53
|
+
curved = true,
|
|
54
|
+
|
|
55
|
+
/** Show data points */
|
|
56
|
+
show_points = false,
|
|
57
|
+
|
|
58
|
+
/** Inner radius for donut (0-1 ratio of outer radius) */
|
|
59
|
+
inner_radius = 0,
|
|
60
|
+
|
|
61
|
+
/** Loading skeleton */
|
|
62
|
+
skeleton = false,
|
|
63
|
+
|
|
64
|
+
/** Element ID */
|
|
65
|
+
id = propId,
|
|
66
|
+
|
|
67
|
+
/** Additional CSS classes */
|
|
68
|
+
class: class_name = '',
|
|
69
|
+
}: {
|
|
70
|
+
type?: 'line' | 'area' | 'bar' | 'horizontal-bar' | 'pie' | 'donut';
|
|
71
|
+
data: ChartData;
|
|
72
|
+
height?: number;
|
|
73
|
+
colors?: string[];
|
|
74
|
+
show_grid?: boolean;
|
|
75
|
+
show_legend?: boolean;
|
|
76
|
+
show_tooltip?: boolean;
|
|
77
|
+
animate?: boolean;
|
|
78
|
+
stacked?: boolean;
|
|
79
|
+
curved?: boolean;
|
|
80
|
+
show_points?: boolean;
|
|
81
|
+
inner_radius?: number;
|
|
82
|
+
skeleton?: boolean;
|
|
83
|
+
id?: string;
|
|
84
|
+
class?: string;
|
|
85
|
+
} = $props();
|
|
86
|
+
|
|
87
|
+
// Defaults pull from the theme's categorical chart palette (tokens.css), so a
|
|
88
|
+
// chart dropped into a delightstack dashboard matches the active theme out of
|
|
89
|
+
// the box. The hex fallbacks only apply when the styles package isn't loaded.
|
|
90
|
+
// Override per chart with the `colors` prop, per series with `Dataset.color`,
|
|
91
|
+
// or globally by setting --chart-N in CSS.
|
|
92
|
+
const DEFAULT_COLORS = [
|
|
93
|
+
'var(--chart-1, #3b82f6)',
|
|
94
|
+
'var(--chart-2, #ef4444)',
|
|
95
|
+
'var(--chart-3, #10b981)',
|
|
96
|
+
'var(--chart-4, #f59e0b)',
|
|
97
|
+
'var(--chart-5, #8b5cf6)',
|
|
98
|
+
'var(--chart-6, #ec4899)',
|
|
99
|
+
'var(--chart-7, #06b6d4)',
|
|
100
|
+
'var(--chart-8, #84cc16)',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
let container_width = $state(0);
|
|
104
|
+
let tooltip_visible = $state(false);
|
|
105
|
+
let tooltip_x = $state(0);
|
|
106
|
+
let tooltip_y = $state(0);
|
|
107
|
+
let tooltip_label = $state('');
|
|
108
|
+
let tooltip_dataset = $state('');
|
|
109
|
+
let tooltip_value = $state('');
|
|
110
|
+
let hidden_datasets = $state(new Set<number>());
|
|
111
|
+
let has_animated = $state(false);
|
|
112
|
+
|
|
113
|
+
$effect(() => {
|
|
114
|
+
if (animate) {
|
|
115
|
+
has_animated = false;
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
has_animated = true;
|
|
118
|
+
}, 50);
|
|
119
|
+
return () => clearTimeout(timer);
|
|
120
|
+
} else {
|
|
121
|
+
has_animated = true;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const palette = $derived(colors ?? DEFAULT_COLORS);
|
|
126
|
+
|
|
127
|
+
function getColor(index: number): string {
|
|
128
|
+
return palette[index % palette.length];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ── Data helpers ─────────────────────────────────────────── */
|
|
132
|
+
|
|
133
|
+
const is_empty = $derived(
|
|
134
|
+
!data ||
|
|
135
|
+
!data.datasets ||
|
|
136
|
+
data.datasets.length === 0 ||
|
|
137
|
+
data.datasets.every((d) => d.data.length === 0),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const visible_datasets = $derived(
|
|
141
|
+
data.datasets.filter((_, i) => !hidden_datasets.has(i)),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const visible_indices = $derived(
|
|
145
|
+
data.datasets.map((_, i) => i).filter((i) => !hidden_datasets.has(i)),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const is_cartesian = $derived(
|
|
149
|
+
type === 'line' || type === 'area' || type === 'bar' || type === 'horizontal-bar',
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/* ── Axis calculations for cartesian charts ───────────────── */
|
|
153
|
+
|
|
154
|
+
const PADDING_LEFT = 50;
|
|
155
|
+
const PADDING_RIGHT = 20;
|
|
156
|
+
const PADDING_TOP = 20;
|
|
157
|
+
const PADDING_BOTTOM = 30;
|
|
158
|
+
|
|
159
|
+
const chart_width = $derived(
|
|
160
|
+
Math.max(0, container_width - PADDING_LEFT - PADDING_RIGHT),
|
|
161
|
+
);
|
|
162
|
+
const chart_height = $derived(Math.max(0, height - PADDING_TOP - PADDING_BOTTOM));
|
|
163
|
+
|
|
164
|
+
function computeYRange(
|
|
165
|
+
datasets: Dataset[],
|
|
166
|
+
indices: number[],
|
|
167
|
+
is_stacked: boolean,
|
|
168
|
+
): { min: number; max: number } {
|
|
169
|
+
if (indices.length === 0) return { min: 0, max: 1 };
|
|
170
|
+
|
|
171
|
+
let min_val = 0;
|
|
172
|
+
let max_val = 0;
|
|
173
|
+
|
|
174
|
+
if (is_stacked) {
|
|
175
|
+
const label_count = datasets[0]?.data.length ?? 0;
|
|
176
|
+
for (let li = 0; li < label_count; li++) {
|
|
177
|
+
let sum = 0;
|
|
178
|
+
for (const di of indices) {
|
|
179
|
+
sum += datasets[di].data[li] ?? 0;
|
|
180
|
+
}
|
|
181
|
+
if (sum > max_val) max_val = sum;
|
|
182
|
+
if (sum < min_val) min_val = sum;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
for (const di of indices) {
|
|
186
|
+
for (const v of datasets[di].data) {
|
|
187
|
+
if (v > max_val) max_val = v;
|
|
188
|
+
if (v < min_val) min_val = v;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (min_val === max_val) {
|
|
194
|
+
return min_val === 0
|
|
195
|
+
? { min: 0, max: 1 }
|
|
196
|
+
: { min: min_val * 0.9, max: max_val * 1.1 };
|
|
197
|
+
}
|
|
198
|
+
return { min: min_val, max: max_val };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function computeNiceTicks(
|
|
202
|
+
min_val: number,
|
|
203
|
+
max_val: number,
|
|
204
|
+
target_count: number = 5,
|
|
205
|
+
): number[] {
|
|
206
|
+
if (min_val === max_val) return [min_val];
|
|
207
|
+
const range = max_val - min_val;
|
|
208
|
+
const rough_step = range / target_count;
|
|
209
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(rough_step)));
|
|
210
|
+
const residual = rough_step / magnitude;
|
|
211
|
+
|
|
212
|
+
let nice_step: number;
|
|
213
|
+
if (residual <= 1.5) nice_step = 1 * magnitude;
|
|
214
|
+
else if (residual <= 3) nice_step = 2 * magnitude;
|
|
215
|
+
else if (residual <= 7) nice_step = 5 * magnitude;
|
|
216
|
+
else nice_step = 10 * magnitude;
|
|
217
|
+
|
|
218
|
+
const nice_min = Math.floor(min_val / nice_step) * nice_step;
|
|
219
|
+
const nice_max = Math.ceil(max_val / nice_step) * nice_step;
|
|
220
|
+
const ticks: number[] = [];
|
|
221
|
+
for (let v = nice_min; v <= nice_max + nice_step * 0.5; v += nice_step) {
|
|
222
|
+
ticks.push(Math.round(v * 1e10) / 1e10);
|
|
223
|
+
}
|
|
224
|
+
return ticks;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const y_range = $derived(
|
|
228
|
+
is_cartesian
|
|
229
|
+
? computeYRange(
|
|
230
|
+
data.datasets,
|
|
231
|
+
visible_indices,
|
|
232
|
+
stacked && type !== 'horizontal-bar',
|
|
233
|
+
)
|
|
234
|
+
: { min: 0, max: 1 },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const y_ticks = $derived(
|
|
238
|
+
is_cartesian ? computeNiceTicks(y_range.min, y_range.max) : [],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const axis_min = $derived(y_ticks.length > 0 ? y_ticks[0] : 0);
|
|
242
|
+
const axis_max = $derived(y_ticks.length > 0 ? y_ticks[y_ticks.length - 1] : 1);
|
|
243
|
+
const axis_range = $derived(Math.max(axis_max - axis_min, 1e-10));
|
|
244
|
+
|
|
245
|
+
/* For horizontal-bar charts we swap axes logic */
|
|
246
|
+
const h_range = $derived(
|
|
247
|
+
type === 'horizontal-bar'
|
|
248
|
+
? computeYRange(data.datasets, visible_indices, stacked)
|
|
249
|
+
: { min: 0, max: 1 },
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const h_ticks = $derived(
|
|
253
|
+
type === 'horizontal-bar' ? computeNiceTicks(h_range.min, h_range.max) : [],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const h_axis_min = $derived(h_ticks.length > 0 ? h_ticks[0] : 0);
|
|
257
|
+
const h_axis_max = $derived(h_ticks.length > 0 ? h_ticks[h_ticks.length - 1] : 1);
|
|
258
|
+
const h_axis_range = $derived(Math.max(h_axis_max - h_axis_min, 1e-10));
|
|
259
|
+
|
|
260
|
+
/* ── Coordinate mapping ───────────────────────────────────── */
|
|
261
|
+
|
|
262
|
+
function mapX(index: number, count: number): number {
|
|
263
|
+
if (count <= 1) return PADDING_LEFT + chart_width / 2;
|
|
264
|
+
return PADDING_LEFT + (index / (count - 1)) * chart_width;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mapY(value: number): number {
|
|
268
|
+
return PADDING_TOP + chart_height - ((value - axis_min) / axis_range) * chart_height;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── Line / Area path generation ──────────────────────────── */
|
|
272
|
+
|
|
273
|
+
interface PointCoord {
|
|
274
|
+
x: number;
|
|
275
|
+
y: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildPoints(values: number[], count: number): PointCoord[] {
|
|
279
|
+
const pts: PointCoord[] = [];
|
|
280
|
+
for (let i = 0; i < count; i++) {
|
|
281
|
+
pts.push({ x: mapX(i, count), y: mapY(values[i] ?? 0) });
|
|
282
|
+
}
|
|
283
|
+
return pts;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function buildStackedValues(dataset_index: number): number[] {
|
|
287
|
+
const label_count = data.labels.length;
|
|
288
|
+
const vis_pos = visible_indices.indexOf(dataset_index);
|
|
289
|
+
const result: number[] = [];
|
|
290
|
+
for (let li = 0; li < label_count; li++) {
|
|
291
|
+
let sum = 0;
|
|
292
|
+
for (let vi = 0; vi <= vis_pos; vi++) {
|
|
293
|
+
sum += data.datasets[visible_indices[vi]].data[li] ?? 0;
|
|
294
|
+
}
|
|
295
|
+
result.push(sum);
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildStackedBaseValues(dataset_index: number): number[] {
|
|
301
|
+
const label_count = data.labels.length;
|
|
302
|
+
const result: number[] = [];
|
|
303
|
+
const vis_pos = visible_indices.indexOf(dataset_index);
|
|
304
|
+
for (let li = 0; li < label_count; li++) {
|
|
305
|
+
let sum = 0;
|
|
306
|
+
for (let vi = 0; vi < vis_pos; vi++) {
|
|
307
|
+
sum += data.datasets[visible_indices[vi]].data[li] ?? 0;
|
|
308
|
+
}
|
|
309
|
+
result.push(sum);
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function buildLinePath(points: PointCoord[], use_curve: boolean): string {
|
|
315
|
+
if (points.length === 0) return '';
|
|
316
|
+
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
|
|
317
|
+
|
|
318
|
+
let d = `M ${points[0].x} ${points[0].y}`;
|
|
319
|
+
|
|
320
|
+
if (use_curve && points.length > 1) {
|
|
321
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
322
|
+
const p0 = points[Math.max(0, i - 1)];
|
|
323
|
+
const p1 = points[i];
|
|
324
|
+
const p2 = points[i + 1];
|
|
325
|
+
const p3 = points[Math.min(points.length - 1, i + 2)];
|
|
326
|
+
|
|
327
|
+
const tension = 0.3;
|
|
328
|
+
const cp1x = p1.x + (p2.x - p0.x) * tension;
|
|
329
|
+
const cp1y = p1.y + (p2.y - p0.y) * tension;
|
|
330
|
+
const cp2x = p2.x - (p3.x - p1.x) * tension;
|
|
331
|
+
const cp2y = p2.y - (p3.y - p1.y) * tension;
|
|
332
|
+
|
|
333
|
+
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
for (let i = 1; i < points.length; i++) {
|
|
337
|
+
d += ` L ${points[i].x} ${points[i].y}`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return d;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function buildAreaPath(
|
|
345
|
+
points: PointCoord[],
|
|
346
|
+
base_y: number | number[],
|
|
347
|
+
use_curve: boolean,
|
|
348
|
+
): string {
|
|
349
|
+
if (points.length === 0) return '';
|
|
350
|
+
|
|
351
|
+
const line = buildLinePath(points, use_curve);
|
|
352
|
+
const base_points = Array.isArray(base_y)
|
|
353
|
+
? points.map((p, i) => ({ x: p.x, y: base_y[i] }))
|
|
354
|
+
: points.map((p) => ({ x: p.x, y: base_y }));
|
|
355
|
+
|
|
356
|
+
const reversed = [...base_points].reverse();
|
|
357
|
+
let close = '';
|
|
358
|
+
if (Array.isArray(base_y)) {
|
|
359
|
+
close = buildLinePath(reversed, use_curve);
|
|
360
|
+
close = close.replace(/^M/, 'L');
|
|
361
|
+
} else {
|
|
362
|
+
close = ` L ${points[points.length - 1].x} ${base_y}`;
|
|
363
|
+
close += ` L ${points[0].x} ${base_y}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return line + close + ' Z';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ── Line/Area chart data ─────────────────────────────────── */
|
|
370
|
+
|
|
371
|
+
interface LineDataset {
|
|
372
|
+
original_index: number;
|
|
373
|
+
color: string;
|
|
374
|
+
label: string;
|
|
375
|
+
line_path: string;
|
|
376
|
+
area_path: string;
|
|
377
|
+
points: PointCoord[];
|
|
378
|
+
values: number[];
|
|
379
|
+
path_length: number;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const line_datasets = $derived.by((): LineDataset[] => {
|
|
383
|
+
if (type !== 'line' && type !== 'area') return [];
|
|
384
|
+
if (is_empty) return [];
|
|
385
|
+
|
|
386
|
+
const count = data.labels.length;
|
|
387
|
+
const result: LineDataset[] = [];
|
|
388
|
+
|
|
389
|
+
for (const di of visible_indices) {
|
|
390
|
+
const ds = data.datasets[di];
|
|
391
|
+
const ds_color = ds.color ?? getColor(di);
|
|
392
|
+
|
|
393
|
+
let values: number[];
|
|
394
|
+
let base_values: number[] | number;
|
|
395
|
+
|
|
396
|
+
if (stacked) {
|
|
397
|
+
values = buildStackedValues(di);
|
|
398
|
+
const base_arr = buildStackedBaseValues(di);
|
|
399
|
+
base_values = base_arr.map((v) => mapY(v));
|
|
400
|
+
} else {
|
|
401
|
+
values = ds.data.slice(0, count);
|
|
402
|
+
base_values = mapY(Math.max(axis_min, 0));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const points = buildPoints(values, count);
|
|
406
|
+
const line_path = buildLinePath(points, curved);
|
|
407
|
+
const area_path = type === 'area' ? buildAreaPath(points, base_values, curved) : '';
|
|
408
|
+
|
|
409
|
+
// Estimate path length for animation
|
|
410
|
+
let path_length = 0;
|
|
411
|
+
for (let i = 1; i < points.length; i++) {
|
|
412
|
+
const dx = points[i].x - points[i - 1].x;
|
|
413
|
+
const dy = points[i].y - points[i - 1].y;
|
|
414
|
+
path_length += Math.sqrt(dx * dx + dy * dy);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
result.push({
|
|
418
|
+
original_index: di,
|
|
419
|
+
color: ds_color,
|
|
420
|
+
label: ds.label,
|
|
421
|
+
line_path,
|
|
422
|
+
area_path,
|
|
423
|
+
points,
|
|
424
|
+
values,
|
|
425
|
+
path_length: Math.ceil(path_length),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return result;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
/* ── Bar chart data ───────────────────────────────────────── */
|
|
433
|
+
|
|
434
|
+
interface BarRect {
|
|
435
|
+
x: number;
|
|
436
|
+
y: number;
|
|
437
|
+
width: number;
|
|
438
|
+
height: number;
|
|
439
|
+
color: string;
|
|
440
|
+
label: string;
|
|
441
|
+
dataset_label: string;
|
|
442
|
+
value: number;
|
|
443
|
+
original_index: number;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const bar_rects = $derived.by((): BarRect[] => {
|
|
447
|
+
if (type !== 'bar') return [];
|
|
448
|
+
if (is_empty) return [];
|
|
449
|
+
|
|
450
|
+
const count = data.labels.length;
|
|
451
|
+
const visible_count = visible_indices.length;
|
|
452
|
+
if (count === 0 || visible_count === 0) return [];
|
|
453
|
+
|
|
454
|
+
const group_width = chart_width / count;
|
|
455
|
+
const bar_padding = group_width * 0.1;
|
|
456
|
+
const available = group_width - bar_padding * 2;
|
|
457
|
+
|
|
458
|
+
const rects: BarRect[] = [];
|
|
459
|
+
|
|
460
|
+
if (stacked) {
|
|
461
|
+
const bar_width = available * 0.7;
|
|
462
|
+
for (let li = 0; li < count; li++) {
|
|
463
|
+
let cumulative = 0;
|
|
464
|
+
for (const di of visible_indices) {
|
|
465
|
+
const ds = data.datasets[di];
|
|
466
|
+
const val = ds.data[li] ?? 0;
|
|
467
|
+
const y_top = mapY(cumulative + val);
|
|
468
|
+
const y_bottom = mapY(cumulative);
|
|
469
|
+
const bar_x =
|
|
470
|
+
PADDING_LEFT + li * group_width + bar_padding + (available - bar_width) / 2;
|
|
471
|
+
rects.push({
|
|
472
|
+
x: bar_x,
|
|
473
|
+
y: Math.min(y_top, y_bottom),
|
|
474
|
+
width: bar_width,
|
|
475
|
+
height: Math.abs(y_bottom - y_top),
|
|
476
|
+
color: ds.color ?? getColor(di),
|
|
477
|
+
label: data.labels[li],
|
|
478
|
+
dataset_label: ds.label,
|
|
479
|
+
value: val,
|
|
480
|
+
original_index: di,
|
|
481
|
+
});
|
|
482
|
+
cumulative += val;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
const bar_width = available / visible_count;
|
|
487
|
+
for (let li = 0; li < count; li++) {
|
|
488
|
+
for (let vi = 0; vi < visible_count; vi++) {
|
|
489
|
+
const di = visible_indices[vi];
|
|
490
|
+
const ds = data.datasets[di];
|
|
491
|
+
const val = ds.data[li] ?? 0;
|
|
492
|
+
const baseline = mapY(Math.max(axis_min, 0));
|
|
493
|
+
const y_top = mapY(val);
|
|
494
|
+
const bar_x = PADDING_LEFT + li * group_width + bar_padding + vi * bar_width;
|
|
495
|
+
|
|
496
|
+
rects.push({
|
|
497
|
+
x: bar_x,
|
|
498
|
+
y: Math.min(y_top, baseline),
|
|
499
|
+
width: bar_width * 0.85,
|
|
500
|
+
height: Math.abs(baseline - y_top),
|
|
501
|
+
color: ds.color ?? getColor(di),
|
|
502
|
+
label: data.labels[li],
|
|
503
|
+
dataset_label: ds.label,
|
|
504
|
+
value: val,
|
|
505
|
+
original_index: di,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return rects;
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
/* ── Horizontal bar chart data ────────────────────────────── */
|
|
515
|
+
|
|
516
|
+
const H_PADDING_LEFT = 80;
|
|
517
|
+
const H_PADDING_RIGHT = 20;
|
|
518
|
+
const H_PADDING_TOP = 20;
|
|
519
|
+
const H_PADDING_BOTTOM = 30;
|
|
520
|
+
|
|
521
|
+
const h_chart_width = $derived(
|
|
522
|
+
Math.max(0, container_width - H_PADDING_LEFT - H_PADDING_RIGHT),
|
|
523
|
+
);
|
|
524
|
+
const h_chart_height = $derived(Math.max(0, height - H_PADDING_TOP - H_PADDING_BOTTOM));
|
|
525
|
+
|
|
526
|
+
function mapHX(value: number): number {
|
|
527
|
+
return H_PADDING_LEFT + ((value - h_axis_min) / h_axis_range) * h_chart_width;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
interface HBarRect {
|
|
531
|
+
x: number;
|
|
532
|
+
y: number;
|
|
533
|
+
width: number;
|
|
534
|
+
height: number;
|
|
535
|
+
color: string;
|
|
536
|
+
label: string;
|
|
537
|
+
dataset_label: string;
|
|
538
|
+
value: number;
|
|
539
|
+
original_index: number;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const hbar_rects = $derived.by((): HBarRect[] => {
|
|
543
|
+
if (type !== 'horizontal-bar') return [];
|
|
544
|
+
if (is_empty) return [];
|
|
545
|
+
|
|
546
|
+
const count = data.labels.length;
|
|
547
|
+
const visible_count = visible_indices.length;
|
|
548
|
+
if (count === 0 || visible_count === 0) return [];
|
|
549
|
+
|
|
550
|
+
const group_height = h_chart_height / count;
|
|
551
|
+
const bar_padding = group_height * 0.1;
|
|
552
|
+
const available = group_height - bar_padding * 2;
|
|
553
|
+
|
|
554
|
+
const rects: HBarRect[] = [];
|
|
555
|
+
const baseline_x = mapHX(Math.max(h_axis_min, 0));
|
|
556
|
+
|
|
557
|
+
if (stacked) {
|
|
558
|
+
const bar_height = available * 0.7;
|
|
559
|
+
for (let li = 0; li < count; li++) {
|
|
560
|
+
let cumulative = 0;
|
|
561
|
+
for (const di of visible_indices) {
|
|
562
|
+
const ds = data.datasets[di];
|
|
563
|
+
const val = ds.data[li] ?? 0;
|
|
564
|
+
const x_left = mapHX(cumulative);
|
|
565
|
+
const x_right = mapHX(cumulative + val);
|
|
566
|
+
const bar_y =
|
|
567
|
+
H_PADDING_TOP +
|
|
568
|
+
li * group_height +
|
|
569
|
+
bar_padding +
|
|
570
|
+
(available - bar_height) / 2;
|
|
571
|
+
|
|
572
|
+
rects.push({
|
|
573
|
+
x: Math.min(x_left, x_right),
|
|
574
|
+
y: bar_y,
|
|
575
|
+
width: Math.abs(x_right - x_left),
|
|
576
|
+
height: bar_height,
|
|
577
|
+
color: ds.color ?? getColor(di),
|
|
578
|
+
label: data.labels[li],
|
|
579
|
+
dataset_label: ds.label,
|
|
580
|
+
value: val,
|
|
581
|
+
original_index: di,
|
|
582
|
+
});
|
|
583
|
+
cumulative += val;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
const bar_height = available / visible_count;
|
|
588
|
+
for (let li = 0; li < count; li++) {
|
|
589
|
+
for (let vi = 0; vi < visible_count; vi++) {
|
|
590
|
+
const di = visible_indices[vi];
|
|
591
|
+
const ds = data.datasets[di];
|
|
592
|
+
const val = ds.data[li] ?? 0;
|
|
593
|
+
const x_val = mapHX(val);
|
|
594
|
+
const bar_y = H_PADDING_TOP + li * group_height + bar_padding + vi * bar_height;
|
|
595
|
+
|
|
596
|
+
rects.push({
|
|
597
|
+
x: Math.min(baseline_x, x_val),
|
|
598
|
+
y: bar_y,
|
|
599
|
+
width: Math.abs(x_val - baseline_x),
|
|
600
|
+
height: bar_height * 0.85,
|
|
601
|
+
color: ds.color ?? getColor(di),
|
|
602
|
+
label: data.labels[li],
|
|
603
|
+
dataset_label: ds.label,
|
|
604
|
+
value: val,
|
|
605
|
+
original_index: di,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return rects;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/* ── Pie / Donut chart data ───────────────────────────────── */
|
|
615
|
+
|
|
616
|
+
interface PieSegment {
|
|
617
|
+
path: string;
|
|
618
|
+
color: string;
|
|
619
|
+
label: string;
|
|
620
|
+
value: number;
|
|
621
|
+
percentage: number;
|
|
622
|
+
mid_angle: number;
|
|
623
|
+
original_index: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const pie_segments = $derived.by((): PieSegment[] => {
|
|
627
|
+
if (type !== 'pie' && type !== 'donut') return [];
|
|
628
|
+
if (is_empty) return [];
|
|
629
|
+
|
|
630
|
+
// For pie, we use the first dataset's data with labels
|
|
631
|
+
const ds = visible_datasets[0];
|
|
632
|
+
if (!ds) return [];
|
|
633
|
+
|
|
634
|
+
const values = ds.data.slice(0, data.labels.length);
|
|
635
|
+
const total = values.reduce((s, v) => s + Math.max(0, v), 0);
|
|
636
|
+
if (total === 0) return [];
|
|
637
|
+
|
|
638
|
+
const cx = container_width / 2;
|
|
639
|
+
const cy = height / 2;
|
|
640
|
+
const outer_r = Math.min(cx, cy) - 30;
|
|
641
|
+
const inner_r =
|
|
642
|
+
type === 'donut' ? outer_r * Math.max(0, Math.min(1, inner_radius || 0.6)) : 0;
|
|
643
|
+
|
|
644
|
+
const segments: PieSegment[] = [];
|
|
645
|
+
let start_angle = -Math.PI / 2;
|
|
646
|
+
|
|
647
|
+
for (let i = 0; i < values.length; i++) {
|
|
648
|
+
const val = Math.max(0, values[i]);
|
|
649
|
+
if (val === 0) continue;
|
|
650
|
+
|
|
651
|
+
const sweep = (val / total) * Math.PI * 2;
|
|
652
|
+
const end_angle = start_angle + sweep;
|
|
653
|
+
const mid_angle = start_angle + sweep / 2;
|
|
654
|
+
|
|
655
|
+
const large_arc = sweep > Math.PI ? 1 : 0;
|
|
656
|
+
|
|
657
|
+
const x1_outer = cx + outer_r * Math.cos(start_angle);
|
|
658
|
+
const y1_outer = cy + outer_r * Math.sin(start_angle);
|
|
659
|
+
const x2_outer = cx + outer_r * Math.cos(end_angle);
|
|
660
|
+
const y2_outer = cy + outer_r * Math.sin(end_angle);
|
|
661
|
+
|
|
662
|
+
let path: string;
|
|
663
|
+
|
|
664
|
+
if (inner_r > 0) {
|
|
665
|
+
const x1_inner = cx + inner_r * Math.cos(start_angle);
|
|
666
|
+
const y1_inner = cy + inner_r * Math.sin(start_angle);
|
|
667
|
+
const x2_inner = cx + inner_r * Math.cos(end_angle);
|
|
668
|
+
const y2_inner = cy + inner_r * Math.sin(end_angle);
|
|
669
|
+
|
|
670
|
+
path = [
|
|
671
|
+
`M ${x1_outer} ${y1_outer}`,
|
|
672
|
+
`A ${outer_r} ${outer_r} 0 ${large_arc} 1 ${x2_outer} ${y2_outer}`,
|
|
673
|
+
`L ${x2_inner} ${y2_inner}`,
|
|
674
|
+
`A ${inner_r} ${inner_r} 0 ${large_arc} 0 ${x1_inner} ${y1_inner}`,
|
|
675
|
+
'Z',
|
|
676
|
+
].join(' ');
|
|
677
|
+
} else {
|
|
678
|
+
path = [
|
|
679
|
+
`M ${cx} ${cy}`,
|
|
680
|
+
`L ${x1_outer} ${y1_outer}`,
|
|
681
|
+
`A ${outer_r} ${outer_r} 0 ${large_arc} 1 ${x2_outer} ${y2_outer}`,
|
|
682
|
+
'Z',
|
|
683
|
+
].join(' ');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
segments.push({
|
|
687
|
+
path,
|
|
688
|
+
color: getColor(i),
|
|
689
|
+
label: data.labels[i],
|
|
690
|
+
value: val,
|
|
691
|
+
percentage: (val / total) * 100,
|
|
692
|
+
mid_angle,
|
|
693
|
+
original_index: i,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
start_angle = end_angle;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return segments;
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
/* ── Tooltip helpers ──────────────────────────────────────── */
|
|
703
|
+
|
|
704
|
+
function showTooltipAt(
|
|
705
|
+
event: MouseEvent,
|
|
706
|
+
label: string,
|
|
707
|
+
dataset: string,
|
|
708
|
+
value: string,
|
|
709
|
+
) {
|
|
710
|
+
if (!show_tooltip) return;
|
|
711
|
+
const rect = (event.currentTarget as Element)
|
|
712
|
+
.closest('.chart')
|
|
713
|
+
?.getBoundingClientRect();
|
|
714
|
+
if (!rect) return;
|
|
715
|
+
tooltip_x = event.clientX - rect.left;
|
|
716
|
+
tooltip_y = event.clientY - rect.top - 40;
|
|
717
|
+
tooltip_label = label;
|
|
718
|
+
tooltip_dataset = dataset;
|
|
719
|
+
tooltip_value = value;
|
|
720
|
+
tooltip_visible = true;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function hideTooltip() {
|
|
724
|
+
tooltip_visible = false;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* ── Legend toggle ─────────────────────────────────────────── */
|
|
728
|
+
|
|
729
|
+
function toggleDataset(index: number) {
|
|
730
|
+
const next = new Set(hidden_datasets);
|
|
731
|
+
if (next.has(index)) {
|
|
732
|
+
next.delete(index);
|
|
733
|
+
} else {
|
|
734
|
+
// Don't hide if it's the last visible dataset
|
|
735
|
+
const total_visible = data.datasets.length - next.size;
|
|
736
|
+
if (total_visible > 1) {
|
|
737
|
+
next.add(index);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
hidden_datasets = next;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/* ── Tick formatting ──────────────────────────────────────── */
|
|
744
|
+
|
|
745
|
+
function formatTick(value: number): string {
|
|
746
|
+
if (Math.abs(value) >= 1_000_000)
|
|
747
|
+
return (value / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
748
|
+
if (Math.abs(value) >= 1_000)
|
|
749
|
+
return (value / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
750
|
+
if (Number.isInteger(value)) return value.toString();
|
|
751
|
+
return value.toFixed(1);
|
|
752
|
+
}
|
|
753
|
+
</script>
|
|
754
|
+
|
|
755
|
+
<div
|
|
756
|
+
{id}
|
|
757
|
+
class={['chart', `chart-${type}`, class_name].filter(Boolean).join(' ')}
|
|
758
|
+
class:skeleton
|
|
759
|
+
class:animate={animate && !has_animated}
|
|
760
|
+
class:animated={animate && has_animated}
|
|
761
|
+
style:height="{height}px"
|
|
762
|
+
{@attach resizeObserver({
|
|
763
|
+
onresize: (el) => {
|
|
764
|
+
container_width = (el as HTMLElement).clientWidth;
|
|
765
|
+
},
|
|
766
|
+
})}>
|
|
767
|
+
{#if skeleton}
|
|
768
|
+
<!-- Skeleton -->
|
|
769
|
+
<div class="skeleton-frame" style:height="{height}px">
|
|
770
|
+
{#if type === 'pie' || type === 'donut'}
|
|
771
|
+
<!-- Same disc the real pie renders: centered, radius = min(w,h)/2 - 30.
|
|
772
|
+
The donut keeps its hole via a radial mask at the real inner radius. -->
|
|
773
|
+
<div
|
|
774
|
+
class="skeleton-circle"
|
|
775
|
+
class:donut={type === 'donut'}
|
|
776
|
+
style:--donut-inner="{Math.max(0, Math.min(1, inner_radius || 0.6)) * 100}%">
|
|
777
|
+
</div>
|
|
778
|
+
{:else}
|
|
779
|
+
<div class="skeleton-bars">
|
|
780
|
+
{#each Array(7) as _, i}
|
|
781
|
+
<div
|
|
782
|
+
class="skeleton-bar"
|
|
783
|
+
style:height="{30 + Math.sin(i * 0.8) * 40 + 30}%"
|
|
784
|
+
style:--shimmer-delay="{i * 120}ms">
|
|
785
|
+
</div>
|
|
786
|
+
{/each}
|
|
787
|
+
</div>
|
|
788
|
+
{/if}
|
|
789
|
+
</div>
|
|
790
|
+
{:else if is_empty}
|
|
791
|
+
<!-- Empty state -->
|
|
792
|
+
<div class="empty">
|
|
793
|
+
<span>No data available</span>
|
|
794
|
+
</div>
|
|
795
|
+
{:else if container_width > 0}
|
|
796
|
+
{#if type === 'line' || type === 'area'}
|
|
797
|
+
<!-- Line / Area Chart -->
|
|
798
|
+
<svg
|
|
799
|
+
width={container_width}
|
|
800
|
+
{height}
|
|
801
|
+
viewBox="0 0 {container_width} {height}"
|
|
802
|
+
role="img"
|
|
803
|
+
aria-label="{type} chart">
|
|
804
|
+
{#if show_grid}
|
|
805
|
+
{#each y_ticks as tick}
|
|
806
|
+
<line
|
|
807
|
+
x1={PADDING_LEFT}
|
|
808
|
+
y1={mapY(tick)}
|
|
809
|
+
x2={PADDING_LEFT + chart_width}
|
|
810
|
+
y2={mapY(tick)}
|
|
811
|
+
class="grid-line" />
|
|
812
|
+
{/each}
|
|
813
|
+
{/if}
|
|
814
|
+
|
|
815
|
+
<!-- Y-axis labels -->
|
|
816
|
+
{#each y_ticks as tick}
|
|
817
|
+
<text
|
|
818
|
+
x={PADDING_LEFT - 8}
|
|
819
|
+
y={mapY(tick)}
|
|
820
|
+
text-anchor="end"
|
|
821
|
+
dominant-baseline="middle">
|
|
822
|
+
{formatTick(tick)}
|
|
823
|
+
</text>
|
|
824
|
+
{/each}
|
|
825
|
+
|
|
826
|
+
<!-- X-axis labels -->
|
|
827
|
+
{#each data.labels as label, i}
|
|
828
|
+
<text
|
|
829
|
+
x={mapX(i, data.labels.length)}
|
|
830
|
+
y={PADDING_TOP + chart_height + 20}
|
|
831
|
+
text-anchor="middle"
|
|
832
|
+
dominant-baseline="auto">
|
|
833
|
+
{label}
|
|
834
|
+
</text>
|
|
835
|
+
{/each}
|
|
836
|
+
|
|
837
|
+
<!-- Area fills -->
|
|
838
|
+
{#if type === 'area'}
|
|
839
|
+
{#each line_datasets as ds}
|
|
840
|
+
<path
|
|
841
|
+
d={ds.area_path}
|
|
842
|
+
style:fill={ds.color}
|
|
843
|
+
fill-opacity="0.15"
|
|
844
|
+
class="area" />
|
|
845
|
+
{/each}
|
|
846
|
+
{/if}
|
|
847
|
+
|
|
848
|
+
<!-- Lines -->
|
|
849
|
+
{#each line_datasets as ds}
|
|
850
|
+
<path
|
|
851
|
+
d={ds.line_path}
|
|
852
|
+
fill="none"
|
|
853
|
+
style:stroke={ds.color}
|
|
854
|
+
stroke-width="2"
|
|
855
|
+
stroke-linecap="round"
|
|
856
|
+
stroke-linejoin="round"
|
|
857
|
+
class="line"
|
|
858
|
+
style:stroke-dasharray={animate ? ds.path_length : 'none'}
|
|
859
|
+
style:stroke-dashoffset={animate && !has_animated ? ds.path_length : 0} />
|
|
860
|
+
{/each}
|
|
861
|
+
|
|
862
|
+
<!-- Data points -->
|
|
863
|
+
{#each line_datasets as ds}
|
|
864
|
+
{#each ds.points as pt, pi}
|
|
865
|
+
{#if show_points || show_tooltip}
|
|
866
|
+
<circle
|
|
867
|
+
cx={pt.x}
|
|
868
|
+
cy={pt.y}
|
|
869
|
+
r={show_points ? 4 : 8}
|
|
870
|
+
style:fill={show_points ? ds.color : 'transparent'}
|
|
871
|
+
style:stroke={show_points ? 'var(--color-bg, white)' : 'none'}
|
|
872
|
+
stroke-width={show_points ? 2 : 0}
|
|
873
|
+
class="point"
|
|
874
|
+
onmouseenter={(e) =>
|
|
875
|
+
showTooltipAt(
|
|
876
|
+
e,
|
|
877
|
+
data.labels[pi],
|
|
878
|
+
ds.label,
|
|
879
|
+
(ds.values[pi] ?? 0).toLocaleString(),
|
|
880
|
+
)}
|
|
881
|
+
onmouseleave={hideTooltip}
|
|
882
|
+
role="presentation" />
|
|
883
|
+
{/if}
|
|
884
|
+
{/each}
|
|
885
|
+
{/each}
|
|
886
|
+
</svg>
|
|
887
|
+
{:else if type === 'bar'}
|
|
888
|
+
<!-- Bar Chart -->
|
|
889
|
+
<svg
|
|
890
|
+
width={container_width}
|
|
891
|
+
{height}
|
|
892
|
+
viewBox="0 0 {container_width} {height}"
|
|
893
|
+
role="img"
|
|
894
|
+
aria-label="bar chart">
|
|
895
|
+
{#if show_grid}
|
|
896
|
+
{#each y_ticks as tick}
|
|
897
|
+
<line
|
|
898
|
+
x1={PADDING_LEFT}
|
|
899
|
+
y1={mapY(tick)}
|
|
900
|
+
x2={PADDING_LEFT + chart_width}
|
|
901
|
+
y2={mapY(tick)}
|
|
902
|
+
class="grid-line" />
|
|
903
|
+
{/each}
|
|
904
|
+
{/if}
|
|
905
|
+
|
|
906
|
+
<!-- Y-axis labels -->
|
|
907
|
+
{#each y_ticks as tick}
|
|
908
|
+
<text
|
|
909
|
+
x={PADDING_LEFT - 8}
|
|
910
|
+
y={mapY(tick)}
|
|
911
|
+
text-anchor="end"
|
|
912
|
+
dominant-baseline="middle">
|
|
913
|
+
{formatTick(tick)}
|
|
914
|
+
</text>
|
|
915
|
+
{/each}
|
|
916
|
+
|
|
917
|
+
<!-- X-axis labels -->
|
|
918
|
+
{#each data.labels as label, i}
|
|
919
|
+
{@const group_width = chart_width / data.labels.length}
|
|
920
|
+
<text
|
|
921
|
+
x={PADDING_LEFT + i * group_width + group_width / 2}
|
|
922
|
+
y={PADDING_TOP + chart_height + 20}
|
|
923
|
+
text-anchor="middle"
|
|
924
|
+
dominant-baseline="auto">
|
|
925
|
+
{label}
|
|
926
|
+
</text>
|
|
927
|
+
{/each}
|
|
928
|
+
|
|
929
|
+
<!-- Bars -->
|
|
930
|
+
{#each bar_rects as bar}
|
|
931
|
+
<rect
|
|
932
|
+
x={bar.x}
|
|
933
|
+
y={bar.y}
|
|
934
|
+
width={Math.max(0, bar.width)}
|
|
935
|
+
height={Math.max(0, bar.height)}
|
|
936
|
+
style:fill={bar.color}
|
|
937
|
+
rx="2"
|
|
938
|
+
class="bar"
|
|
939
|
+
style:transform-origin="{bar.x + bar.width / 2}px {PADDING_TOP +
|
|
940
|
+
chart_height}px"
|
|
941
|
+
onmouseenter={(e) =>
|
|
942
|
+
showTooltipAt(e, bar.label, bar.dataset_label, bar.value.toLocaleString())}
|
|
943
|
+
onmouseleave={hideTooltip}
|
|
944
|
+
role="presentation" />
|
|
945
|
+
{/each}
|
|
946
|
+
</svg>
|
|
947
|
+
{:else if type === 'horizontal-bar'}
|
|
948
|
+
<!-- Horizontal Bar Chart -->
|
|
949
|
+
<svg
|
|
950
|
+
width={container_width}
|
|
951
|
+
{height}
|
|
952
|
+
viewBox="0 0 {container_width} {height}"
|
|
953
|
+
role="img"
|
|
954
|
+
aria-label="horizontal bar chart">
|
|
955
|
+
{#if show_grid}
|
|
956
|
+
{#each h_ticks as tick}
|
|
957
|
+
<line
|
|
958
|
+
x1={mapHX(tick)}
|
|
959
|
+
y1={H_PADDING_TOP}
|
|
960
|
+
x2={mapHX(tick)}
|
|
961
|
+
y2={H_PADDING_TOP + h_chart_height}
|
|
962
|
+
class="grid-line" />
|
|
963
|
+
{/each}
|
|
964
|
+
{/if}
|
|
965
|
+
|
|
966
|
+
<!-- X-axis (value) labels at bottom -->
|
|
967
|
+
{#each h_ticks as tick}
|
|
968
|
+
<text
|
|
969
|
+
x={mapHX(tick)}
|
|
970
|
+
y={H_PADDING_TOP + h_chart_height + 20}
|
|
971
|
+
text-anchor="middle"
|
|
972
|
+
dominant-baseline="auto">
|
|
973
|
+
{formatTick(tick)}
|
|
974
|
+
</text>
|
|
975
|
+
{/each}
|
|
976
|
+
|
|
977
|
+
<!-- Y-axis (category) labels -->
|
|
978
|
+
{#each data.labels as label, i}
|
|
979
|
+
{@const group_height = h_chart_height / data.labels.length}
|
|
980
|
+
<text
|
|
981
|
+
x={H_PADDING_LEFT - 8}
|
|
982
|
+
y={H_PADDING_TOP + i * group_height + group_height / 2}
|
|
983
|
+
text-anchor="end"
|
|
984
|
+
dominant-baseline="middle">
|
|
985
|
+
{label}
|
|
986
|
+
</text>
|
|
987
|
+
{/each}
|
|
988
|
+
|
|
989
|
+
<!-- Bars -->
|
|
990
|
+
{#each hbar_rects as bar}
|
|
991
|
+
<rect
|
|
992
|
+
x={bar.x}
|
|
993
|
+
y={bar.y}
|
|
994
|
+
width={Math.max(0, bar.width)}
|
|
995
|
+
height={Math.max(0, bar.height)}
|
|
996
|
+
style:fill={bar.color}
|
|
997
|
+
rx="2"
|
|
998
|
+
class="hbar"
|
|
999
|
+
style:transform-origin="{H_PADDING_LEFT}px {bar.y + bar.height / 2}px"
|
|
1000
|
+
onmouseenter={(e) =>
|
|
1001
|
+
showTooltipAt(e, bar.label, bar.dataset_label, bar.value.toLocaleString())}
|
|
1002
|
+
onmouseleave={hideTooltip}
|
|
1003
|
+
role="presentation" />
|
|
1004
|
+
{/each}
|
|
1005
|
+
</svg>
|
|
1006
|
+
{:else if type === 'pie' || type === 'donut'}
|
|
1007
|
+
<!-- Pie / Donut Chart -->
|
|
1008
|
+
<svg
|
|
1009
|
+
width={container_width}
|
|
1010
|
+
{height}
|
|
1011
|
+
viewBox="0 0 {container_width} {height}"
|
|
1012
|
+
role="img"
|
|
1013
|
+
aria-label="{type} chart">
|
|
1014
|
+
<g class="pie" style:transform-origin="{container_width / 2}px {height / 2}px">
|
|
1015
|
+
{#each pie_segments as seg}
|
|
1016
|
+
<path
|
|
1017
|
+
d={seg.path}
|
|
1018
|
+
style:fill={seg.color}
|
|
1019
|
+
class="segment"
|
|
1020
|
+
style:transform-origin="{container_width / 2}px {height / 2}px"
|
|
1021
|
+
onmouseenter={(e) =>
|
|
1022
|
+
showTooltipAt(
|
|
1023
|
+
e,
|
|
1024
|
+
seg.label,
|
|
1025
|
+
'',
|
|
1026
|
+
`${seg.value.toLocaleString()} (${seg.percentage.toFixed(1)}%)`,
|
|
1027
|
+
)}
|
|
1028
|
+
onmouseleave={hideTooltip}
|
|
1029
|
+
role="presentation" />
|
|
1030
|
+
{/each}
|
|
1031
|
+
</g>
|
|
1032
|
+
</svg>
|
|
1033
|
+
{/if}
|
|
1034
|
+
|
|
1035
|
+
<!-- Tooltip -->
|
|
1036
|
+
{#if show_tooltip && tooltip_visible}
|
|
1037
|
+
<div class="tooltip" style:left="{tooltip_x}px" style:top="{tooltip_y}px">
|
|
1038
|
+
{#if tooltip_dataset}
|
|
1039
|
+
<span class="dataset">{tooltip_dataset}</span>
|
|
1040
|
+
{/if}
|
|
1041
|
+
<span class="label">{tooltip_label}</span>
|
|
1042
|
+
<span class="value">{tooltip_value}</span>
|
|
1043
|
+
</div>
|
|
1044
|
+
{/if}
|
|
1045
|
+
|
|
1046
|
+
<!-- Legend -->
|
|
1047
|
+
{#if show_legend}
|
|
1048
|
+
<div class="legend">
|
|
1049
|
+
{#if type === 'pie' || type === 'donut'}
|
|
1050
|
+
{#each data.labels as label, i}
|
|
1051
|
+
<button
|
|
1052
|
+
class:hidden={hidden_datasets.has(i)}
|
|
1053
|
+
type="button"
|
|
1054
|
+
onclick={() => toggleDataset(i)}>
|
|
1055
|
+
<span class="dot" style:background-color={getColor(i)}></span>
|
|
1056
|
+
<span class="label">{label}</span>
|
|
1057
|
+
</button>
|
|
1058
|
+
{/each}
|
|
1059
|
+
{:else}
|
|
1060
|
+
{#each data.datasets as ds, i}
|
|
1061
|
+
<button
|
|
1062
|
+
class:hidden={hidden_datasets.has(i)}
|
|
1063
|
+
type="button"
|
|
1064
|
+
onclick={() => toggleDataset(i)}>
|
|
1065
|
+
<span class="dot" style:background-color={ds.color ?? getColor(i)}></span>
|
|
1066
|
+
<span class="label">{ds.label}</span>
|
|
1067
|
+
</button>
|
|
1068
|
+
{/each}
|
|
1069
|
+
{/if}
|
|
1070
|
+
</div>
|
|
1071
|
+
{/if}
|
|
1072
|
+
{/if}
|
|
1073
|
+
</div>
|
|
1074
|
+
|
|
1075
|
+
<style>
|
|
1076
|
+
.chart {
|
|
1077
|
+
position: relative;
|
|
1078
|
+
width: 100%;
|
|
1079
|
+
overflow: hidden;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
svg {
|
|
1083
|
+
display: block;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/* ── Grid ─────────────────────────────────────────────────── */
|
|
1087
|
+
|
|
1088
|
+
.grid-line {
|
|
1089
|
+
stroke: var(--color-border, light-dark(#e5e7eb, #374151));
|
|
1090
|
+
stroke-width: 1;
|
|
1091
|
+
stroke-dasharray: 4 4;
|
|
1092
|
+
opacity: 0.6;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/* ── Axis labels ──────────────────────────────────────────── */
|
|
1096
|
+
|
|
1097
|
+
text {
|
|
1098
|
+
font-size: 11px;
|
|
1099
|
+
fill: var(--color-text-muted, light-dark(#6b7280, #9ca3af));
|
|
1100
|
+
user-select: none;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/* ── Line / Area ──────────────────────────────────────────── */
|
|
1104
|
+
|
|
1105
|
+
.line {
|
|
1106
|
+
transition: stroke-dashoffset 1s ease-out;
|
|
1107
|
+
|
|
1108
|
+
/* Skip animation: show immediately */
|
|
1109
|
+
.chart:not(.animate):not(.animated) & {
|
|
1110
|
+
stroke-dasharray: none !important;
|
|
1111
|
+
stroke-dashoffset: 0 !important;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.area {
|
|
1116
|
+
opacity: 0;
|
|
1117
|
+
transition: opacity 0.6s ease-out;
|
|
1118
|
+
|
|
1119
|
+
.chart.animated & {
|
|
1120
|
+
opacity: 1;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.point {
|
|
1125
|
+
transition: r 0.15s ease;
|
|
1126
|
+
cursor: pointer;
|
|
1127
|
+
|
|
1128
|
+
&:hover {
|
|
1129
|
+
r: 6;
|
|
1130
|
+
fill-opacity: 1;
|
|
1131
|
+
transition: none;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/* ── Bar ──────────────────────────────────────────────────── */
|
|
1136
|
+
|
|
1137
|
+
.bar {
|
|
1138
|
+
transition: opacity 0.15s ease;
|
|
1139
|
+
|
|
1140
|
+
.chart.animate & {
|
|
1141
|
+
animation: chart-bar-grow 0.6s ease-out both;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
&:hover {
|
|
1145
|
+
opacity: 0.8;
|
|
1146
|
+
cursor: pointer;
|
|
1147
|
+
transition: none;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
@keyframes chart-bar-grow {
|
|
1152
|
+
from {
|
|
1153
|
+
transform: scaleY(0);
|
|
1154
|
+
}
|
|
1155
|
+
to {
|
|
1156
|
+
transform: scaleY(1);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/* ── Horizontal Bar ───────────────────────────────────────── */
|
|
1161
|
+
|
|
1162
|
+
.hbar {
|
|
1163
|
+
transition: opacity 0.15s ease;
|
|
1164
|
+
|
|
1165
|
+
.chart.animate & {
|
|
1166
|
+
animation: chart-hbar-grow 0.6s ease-out both;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
&:hover {
|
|
1170
|
+
opacity: 0.8;
|
|
1171
|
+
cursor: pointer;
|
|
1172
|
+
transition: none;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
@keyframes chart-hbar-grow {
|
|
1177
|
+
from {
|
|
1178
|
+
transform: scaleX(0);
|
|
1179
|
+
}
|
|
1180
|
+
to {
|
|
1181
|
+
transform: scaleX(1);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/* ── Pie / Donut ──────────────────────────────────────────── */
|
|
1186
|
+
|
|
1187
|
+
.segment {
|
|
1188
|
+
transition: transform 0.15s ease;
|
|
1189
|
+
cursor: pointer;
|
|
1190
|
+
|
|
1191
|
+
&:hover {
|
|
1192
|
+
transform: scale(1.03);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
.chart.animate .pie {
|
|
1197
|
+
animation: chart-pie-spin 0.8s ease-out;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
@keyframes chart-pie-spin {
|
|
1201
|
+
from {
|
|
1202
|
+
transform: rotate(-90deg);
|
|
1203
|
+
opacity: 0;
|
|
1204
|
+
}
|
|
1205
|
+
to {
|
|
1206
|
+
transform: rotate(0deg);
|
|
1207
|
+
opacity: 1;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/* ── Tooltip ──────────────────────────────────────────────── */
|
|
1212
|
+
|
|
1213
|
+
.tooltip {
|
|
1214
|
+
position: absolute;
|
|
1215
|
+
z-index: 10;
|
|
1216
|
+
background: var(--color-bg, light-dark(#ffffff, #1f2937));
|
|
1217
|
+
border: 1px solid var(--color-border, light-dark(#e5e7eb, #374151));
|
|
1218
|
+
border-radius: var(--radius-lg, 0.5rem);
|
|
1219
|
+
@supports (corner-shape: squircle) {
|
|
1220
|
+
corner-shape: squircle;
|
|
1221
|
+
border-radius: calc(var(--radius-lg, 0.5rem) * var(--squircle-ratio, 2));
|
|
1222
|
+
}
|
|
1223
|
+
padding: 6px 10px;
|
|
1224
|
+
pointer-events: none;
|
|
1225
|
+
white-space: nowrap;
|
|
1226
|
+
display: flex;
|
|
1227
|
+
flex-direction: column;
|
|
1228
|
+
gap: 2px;
|
|
1229
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
|
|
1230
|
+
transform: translateX(-50%);
|
|
1231
|
+
font-size: 12px;
|
|
1232
|
+
|
|
1233
|
+
.dataset {
|
|
1234
|
+
font-weight: 600;
|
|
1235
|
+
color: var(--color-text, light-dark(#111827, #f9fafb));
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
.label {
|
|
1239
|
+
color: var(--color-text-muted, light-dark(#6b7280, #9ca3af));
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.value {
|
|
1243
|
+
font-weight: 600;
|
|
1244
|
+
color: var(--color-text, light-dark(#111827, #f9fafb));
|
|
1245
|
+
font-variant-numeric: tabular-nums;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/* ── Legend ────────────────────────────────────────────────── */
|
|
1250
|
+
|
|
1251
|
+
.legend {
|
|
1252
|
+
display: flex;
|
|
1253
|
+
flex-wrap: wrap;
|
|
1254
|
+
gap: 0.5rem;
|
|
1255
|
+
padding: 8px 0 0;
|
|
1256
|
+
justify-content: center;
|
|
1257
|
+
|
|
1258
|
+
button {
|
|
1259
|
+
display: inline-flex;
|
|
1260
|
+
align-items: center;
|
|
1261
|
+
gap: 6px;
|
|
1262
|
+
background: none;
|
|
1263
|
+
border: none;
|
|
1264
|
+
padding: 2px 6px;
|
|
1265
|
+
cursor: pointer;
|
|
1266
|
+
border-radius: var(--radius-lg, 0.5rem);
|
|
1267
|
+
@supports (corner-shape: squircle) {
|
|
1268
|
+
corner-shape: squircle;
|
|
1269
|
+
border-radius: calc(var(--radius-lg, 0.5rem) * var(--squircle-ratio, 2));
|
|
1270
|
+
}
|
|
1271
|
+
font-size: 12px;
|
|
1272
|
+
color: var(--color-text, light-dark(#111827, #f9fafb));
|
|
1273
|
+
transition: opacity 0.15s ease;
|
|
1274
|
+
|
|
1275
|
+
&:hover {
|
|
1276
|
+
background: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
|
|
1277
|
+
transition: none;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
&.hidden {
|
|
1281
|
+
opacity: 0.4;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.dot {
|
|
1286
|
+
width: 10px;
|
|
1287
|
+
height: 10px;
|
|
1288
|
+
border-radius: 50%;
|
|
1289
|
+
flex-shrink: 0;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
.label {
|
|
1293
|
+
white-space: nowrap;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/* ── Empty state ──────────────────────────────────────────── */
|
|
1298
|
+
|
|
1299
|
+
.empty {
|
|
1300
|
+
display: flex;
|
|
1301
|
+
align-items: center;
|
|
1302
|
+
justify-content: center;
|
|
1303
|
+
height: 100%;
|
|
1304
|
+
color: var(--color-text-muted, light-dark(#6b7280, #9ca3af));
|
|
1305
|
+
font-size: 14px;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/* ── Skeleton ─────────────────────────────────────────────── */
|
|
1309
|
+
|
|
1310
|
+
.chart.skeleton {
|
|
1311
|
+
pointer-events: none;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.skeleton-frame {
|
|
1315
|
+
display: flex;
|
|
1316
|
+
align-items: center;
|
|
1317
|
+
justify-content: center;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/* Mirrors the real svg plot area (PADDING_LEFT/RIGHT/TOP/BOTTOM) so the
|
|
1321
|
+
placeholder bars rise from the same baseline, inside the same box, the
|
|
1322
|
+
real bars/lines will occupy — no shift when data arrives. */
|
|
1323
|
+
.skeleton-bars {
|
|
1324
|
+
display: flex;
|
|
1325
|
+
align-items: flex-end;
|
|
1326
|
+
gap: 8px;
|
|
1327
|
+
height: 100%;
|
|
1328
|
+
width: 100%;
|
|
1329
|
+
padding: 20px 20px 30px 50px;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
.skeleton-bar,
|
|
1333
|
+
.skeleton-circle {
|
|
1334
|
+
position: relative;
|
|
1335
|
+
overflow: hidden;
|
|
1336
|
+
background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
|
|
1337
|
+
|
|
1338
|
+
&::after {
|
|
1339
|
+
content: '';
|
|
1340
|
+
position: absolute;
|
|
1341
|
+
inset: 0;
|
|
1342
|
+
transform: translateX(-100%);
|
|
1343
|
+
background-image: linear-gradient(
|
|
1344
|
+
105deg,
|
|
1345
|
+
transparent 25%,
|
|
1346
|
+
var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
|
|
1347
|
+
transparent 75%
|
|
1348
|
+
);
|
|
1349
|
+
animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
|
|
1350
|
+
infinite;
|
|
1351
|
+
animation-delay: var(--shimmer-delay, 0s);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
.skeleton-bar {
|
|
1356
|
+
flex: 1;
|
|
1357
|
+
border-radius: var(--radius-lg, 0.5rem) var(--radius-lg, 0.5rem) 0 0;
|
|
1358
|
+
@supports (corner-shape: squircle) {
|
|
1359
|
+
corner-shape: squircle;
|
|
1360
|
+
border-radius: calc(var(--radius-lg, 0.5rem) * var(--squircle-ratio, 2))
|
|
1361
|
+
calc(var(--radius-lg, 0.5rem) * var(--squircle-ratio, 2)) 0 0;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/* Same disc the real pie/donut draws: centered, diameter = min(w,h) - 60. */
|
|
1366
|
+
.skeleton-circle {
|
|
1367
|
+
height: calc(100% - 60px);
|
|
1368
|
+
max-width: calc(100% - 60px);
|
|
1369
|
+
aspect-ratio: 1;
|
|
1370
|
+
border-radius: var(--radius-full, 1e5px);
|
|
1371
|
+
|
|
1372
|
+
/* Donut: punch out the real inner radius so the ring (and its shimmer)
|
|
1373
|
+
matches the loaded shape. */
|
|
1374
|
+
&.donut {
|
|
1375
|
+
-webkit-mask: radial-gradient(
|
|
1376
|
+
closest-side,
|
|
1377
|
+
transparent calc(var(--donut-inner, 60%) - 1px),
|
|
1378
|
+
#000 var(--donut-inner, 60%)
|
|
1379
|
+
);
|
|
1380
|
+
mask: radial-gradient(
|
|
1381
|
+
closest-side,
|
|
1382
|
+
transparent calc(var(--donut-inner, 60%) - 1px),
|
|
1383
|
+
#000 var(--donut-inner, 60%)
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
@keyframes -global-delight-skeleton-shimmer {
|
|
1389
|
+
0% {
|
|
1390
|
+
transform: translateX(-100%);
|
|
1391
|
+
}
|
|
1392
|
+
55%,
|
|
1393
|
+
100% {
|
|
1394
|
+
transform: translateX(100%);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/* ── Reduced motion ───────────────────────────────────────── */
|
|
1399
|
+
|
|
1400
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1401
|
+
.line {
|
|
1402
|
+
transition: none;
|
|
1403
|
+
stroke-dasharray: none !important;
|
|
1404
|
+
stroke-dashoffset: 0 !important;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
.area {
|
|
1408
|
+
transition: none;
|
|
1409
|
+
opacity: 1;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
.chart.animate .bar,
|
|
1413
|
+
.chart.animate .hbar {
|
|
1414
|
+
animation: none;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
.chart.animate .pie {
|
|
1418
|
+
animation: none;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.skeleton-bar::after,
|
|
1422
|
+
.skeleton-circle::after {
|
|
1423
|
+
animation: none;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
</style>
|