@classic-homes/charts-svelte 0.2.0 → 0.3.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/lib/components/base/ChartContainer.svelte +141 -14
- package/dist/lib/components/base/ChartContainer.svelte.d.ts +19 -1
- package/dist/lib/components/base/ChartDataTable.svelte +59 -0
- package/dist/lib/components/base/ChartDataTable.svelte.d.ts +15 -0
- package/dist/lib/components/base/ChartEmpty.svelte +1 -0
- package/dist/lib/components/base/ChartSkeleton.svelte +7 -2
- package/dist/lib/components/base/ChartSkeleton.svelte.d.ts +2 -0
- package/dist/lib/components/base/index.d.ts +3 -0
- package/dist/lib/components/base/index.js +1 -0
- package/dist/lib/components/core/AreaChart.svelte +115 -70
- package/dist/lib/components/core/BarChart.svelte +134 -62
- package/dist/lib/components/core/DonutChart.svelte +41 -22
- package/dist/lib/components/core/LineChart.svelte +104 -73
- package/dist/lib/components/core/PieChart.svelte +41 -22
- package/dist/lib/components/core/ScatterChart.svelte +72 -68
- package/dist/lib/components/extended/CandlestickChart.svelte +67 -44
- package/dist/lib/components/extended/FunnelChart.svelte +41 -27
- package/dist/lib/components/extended/GaugeChart.svelte +61 -10
- package/dist/lib/components/extended/HeatmapChart.svelte +62 -33
- package/dist/lib/components/extended/RadarChart.svelte +39 -19
- package/dist/lib/components/extended/SankeyChart.svelte +50 -20
- package/dist/lib/components/extended/TreemapChart.svelte +53 -21
- package/dist/lib/components/index.d.ts +2 -1
- package/dist/lib/components/index.js +3 -1
- package/dist/lib/components/report/BoxPlotChart.svelte +253 -0
- package/dist/lib/components/report/BoxPlotChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/BulletChart.svelte +231 -0
- package/dist/lib/components/report/BulletChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/ComboChart.svelte +327 -0
- package/dist/lib/components/report/ComboChart.svelte.d.ts +8 -0
- package/dist/lib/components/report/DotPlotChart.svelte +325 -0
- package/dist/lib/components/report/DotPlotChart.svelte.d.ts +8 -0
- package/dist/lib/components/report/HistogramChart.svelte +226 -0
- package/dist/lib/components/report/HistogramChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/Sparkline.svelte +181 -0
- package/dist/lib/components/report/Sparkline.svelte.d.ts +4 -0
- package/dist/lib/components/report/StatCard.svelte +146 -0
- package/dist/lib/components/report/StatCard.svelte.d.ts +4 -0
- package/dist/lib/components/report/StatGroup.svelte +35 -0
- package/dist/lib/components/report/StatGroup.svelte.d.ts +12 -0
- package/dist/lib/components/report/WaterfallChart.svelte +271 -0
- package/dist/lib/components/report/WaterfallChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/index.d.ts +12 -0
- package/dist/lib/components/report/index.js +9 -0
- package/dist/lib/composables/index.d.ts +1 -1
- package/dist/lib/composables/index.js +1 -1
- package/dist/lib/composables/useChart.svelte.d.ts +34 -0
- package/dist/lib/composables/useChart.svelte.js +121 -9
- package/dist/lib/composables/useChartTheme.svelte.js +14 -11
- package/dist/lib/events.d.ts +15 -0
- package/dist/lib/events.js +56 -0
- package/dist/lib/index.d.ts +4 -3
- package/dist/lib/index.js +7 -3
- package/package.json +10 -4
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as echarts from 'echarts/core';
|
|
3
|
+
import { BarChart as EChartsBarChart } from 'echarts/charts';
|
|
4
|
+
import { GridComponent, TooltipComponent } from 'echarts/components';
|
|
5
|
+
import type { EChartsOption } from 'echarts';
|
|
6
|
+
|
|
7
|
+
import type { WaterfallChartProps } from '@classic-homes/charts-core';
|
|
8
|
+
import {
|
|
9
|
+
buildAxis,
|
|
10
|
+
buildTooltip,
|
|
11
|
+
chartFormatters,
|
|
12
|
+
escapeHtml,
|
|
13
|
+
formatValue,
|
|
14
|
+
generateWaterfallDescription,
|
|
15
|
+
getChartColors,
|
|
16
|
+
gridInset,
|
|
17
|
+
mergeChartOption,
|
|
18
|
+
waterfallLayout,
|
|
19
|
+
waterfallTable,
|
|
20
|
+
withReferenceMarks,
|
|
21
|
+
} from '@classic-homes/charts-core';
|
|
22
|
+
|
|
23
|
+
import { cn } from '../../utils.js';
|
|
24
|
+
import { chartEvents, toDataPointParams } from '../../events.js';
|
|
25
|
+
import { useChart } from '../../composables/useChart.svelte.js';
|
|
26
|
+
import ChartContainer from '../base/ChartContainer.svelte';
|
|
27
|
+
|
|
28
|
+
// Register required ECharts modules
|
|
29
|
+
echarts.use([EChartsBarChart, GridComponent, TooltipComponent]);
|
|
30
|
+
|
|
31
|
+
interface Props extends Omit<WaterfallChartProps, 'class'> {
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
data,
|
|
39
|
+
increaseColor,
|
|
40
|
+
decreaseColor,
|
|
41
|
+
totalColor,
|
|
42
|
+
height = 400,
|
|
43
|
+
minHeight,
|
|
44
|
+
maxHeight,
|
|
45
|
+
loading,
|
|
46
|
+
error,
|
|
47
|
+
onRetry,
|
|
48
|
+
emptyMessage,
|
|
49
|
+
theme = 'auto',
|
|
50
|
+
animation = true,
|
|
51
|
+
showTooltip = true,
|
|
52
|
+
showGrid = true,
|
|
53
|
+
showLabels = true,
|
|
54
|
+
valueFormat,
|
|
55
|
+
locale,
|
|
56
|
+
labelFormatter,
|
|
57
|
+
xAxisConfig,
|
|
58
|
+
yAxisConfig,
|
|
59
|
+
referenceLines,
|
|
60
|
+
referenceBands,
|
|
61
|
+
tooltipFormatter,
|
|
62
|
+
tooltipValueFormatter,
|
|
63
|
+
option: optionOverride,
|
|
64
|
+
onReady,
|
|
65
|
+
events,
|
|
66
|
+
showDataTable,
|
|
67
|
+
exportable,
|
|
68
|
+
patterns,
|
|
69
|
+
onClick,
|
|
70
|
+
class: className,
|
|
71
|
+
}: Props = $props();
|
|
72
|
+
|
|
73
|
+
const isEmpty = $derived(!data?.length);
|
|
74
|
+
|
|
75
|
+
const bars = $derived(waterfallLayout(data ?? []));
|
|
76
|
+
|
|
77
|
+
const formatters = $derived(
|
|
78
|
+
chartFormatters({ valueFormat, locale, tooltipValueFormatter, labelFormatter })
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Per-element colours that follow the resolved chart theme
|
|
82
|
+
const chartColors = $derived.by(() => getChartColors(chart.theme));
|
|
83
|
+
|
|
84
|
+
const option: EChartsOption = $derived.by(() => {
|
|
85
|
+
if (isEmpty) return {};
|
|
86
|
+
|
|
87
|
+
const colorOf = (index: number) => {
|
|
88
|
+
const bar = bars[index];
|
|
89
|
+
return (
|
|
90
|
+
data[index].color ??
|
|
91
|
+
(bar.kind === 'total'
|
|
92
|
+
? (totalColor ?? chartColors.series[0])
|
|
93
|
+
: bar.kind === 'increase'
|
|
94
|
+
? (increaseColor ?? chartColors.success)
|
|
95
|
+
: (decreaseColor ?? chartColors.destructive))
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// The step's own label: a signed change, or the total
|
|
100
|
+
const labelText = (index: number) => {
|
|
101
|
+
const step = data[index];
|
|
102
|
+
const bar = bars[index];
|
|
103
|
+
if (step.label) return step.label;
|
|
104
|
+
if (typeof labelFormatter === 'function') {
|
|
105
|
+
return labelFormatter({
|
|
106
|
+
name: step.name,
|
|
107
|
+
value: bar.value,
|
|
108
|
+
seriesName: title,
|
|
109
|
+
seriesIndex: 0,
|
|
110
|
+
dataIndex: index,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const text = formatValue(Math.abs(bar.value), formatters.format);
|
|
114
|
+
if (bar.kind === 'total') return formatValue(bar.value, formatters.format);
|
|
115
|
+
return `${bar.value < 0 ? '−' : '+'}${text}`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Each bar is an invisible base plus its parts above and below zero
|
|
119
|
+
// (see waterfallLayout); the label sits on whichever part is drawn
|
|
120
|
+
const piece = (part: 'positive' | 'negative') => ({
|
|
121
|
+
name: part === 'positive' ? 'Increase' : 'Decrease',
|
|
122
|
+
type: 'bar',
|
|
123
|
+
stack: 'waterfall',
|
|
124
|
+
barMaxWidth: 64,
|
|
125
|
+
data: bars.map((bar, index) => {
|
|
126
|
+
const value = bar[part];
|
|
127
|
+
const labelled =
|
|
128
|
+
part === 'positive' ? bar.positive !== 0 || bar.negative === 0 : bar.positive === 0;
|
|
129
|
+
return {
|
|
130
|
+
value,
|
|
131
|
+
name: data[index].name,
|
|
132
|
+
itemStyle: { color: colorOf(index), borderRadius: 0 },
|
|
133
|
+
label: {
|
|
134
|
+
show: showLabels && labelled,
|
|
135
|
+
position: part === 'positive' ? 'top' : 'bottom',
|
|
136
|
+
// A template string is ECharts' to fill; anything else is our text
|
|
137
|
+
formatter: typeof labelFormatter === 'string' ? labelFormatter : () => labelText(index),
|
|
138
|
+
color: chartColors.foreground,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const series = [
|
|
145
|
+
{
|
|
146
|
+
name: 'Base',
|
|
147
|
+
type: 'bar',
|
|
148
|
+
stack: 'waterfall',
|
|
149
|
+
silent: true,
|
|
150
|
+
itemStyle: { color: 'transparent', borderColor: 'transparent' },
|
|
151
|
+
emphasis: { disabled: true },
|
|
152
|
+
tooltip: { show: false },
|
|
153
|
+
data: bars.map((bar) => bar.base),
|
|
154
|
+
},
|
|
155
|
+
piece('positive'),
|
|
156
|
+
piece('negative'),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
return mergeChartOption(
|
|
160
|
+
{
|
|
161
|
+
grid: {
|
|
162
|
+
left: '3%',
|
|
163
|
+
right: '4%',
|
|
164
|
+
bottom: '3%',
|
|
165
|
+
containLabel: true,
|
|
166
|
+
...gridInset(undefined, false, xAxisConfig, yAxisConfig),
|
|
167
|
+
},
|
|
168
|
+
tooltip: buildTooltip(
|
|
169
|
+
{
|
|
170
|
+
trigger: 'axis',
|
|
171
|
+
axisPointer: { type: 'shadow' },
|
|
172
|
+
formatter: (params: unknown) => {
|
|
173
|
+
const list = (Array.isArray(params) ? params : [params]) as { dataIndex?: number }[];
|
|
174
|
+
const index = list[0]?.dataIndex ?? 0;
|
|
175
|
+
const step = data[index];
|
|
176
|
+
const bar = bars[index];
|
|
177
|
+
if (!step || !bar) return '';
|
|
178
|
+
const fmt = (value: number) => escapeHtml(formatValue(value, formatters.tooltip));
|
|
179
|
+
if (bar.kind === 'total') {
|
|
180
|
+
return `${escapeHtml(step.name)}<br/>Total: <strong>${fmt(bar.end)}</strong>`;
|
|
181
|
+
}
|
|
182
|
+
const sign = bar.value < 0 ? '−' : '+';
|
|
183
|
+
return `${escapeHtml(step.name)}<br/>Change: <strong>${sign}${fmt(Math.abs(bar.value))}</strong><br/>Running total: ${fmt(bar.end)}`;
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{ show: showTooltip, formatter: tooltipFormatter }
|
|
187
|
+
),
|
|
188
|
+
xAxis: buildAxis(
|
|
189
|
+
{
|
|
190
|
+
type: 'category',
|
|
191
|
+
data: data.map((step) => step.name),
|
|
192
|
+
axisTick: { alignWithLabel: true },
|
|
193
|
+
},
|
|
194
|
+
xAxisConfig
|
|
195
|
+
),
|
|
196
|
+
yAxis: buildAxis(
|
|
197
|
+
{ type: 'value', splitLine: { show: showGrid } },
|
|
198
|
+
formatters.axis(yAxisConfig)
|
|
199
|
+
),
|
|
200
|
+
series: withReferenceMarks(
|
|
201
|
+
series,
|
|
202
|
+
referenceLines?.filter((line) => typeof line.value === 'number'),
|
|
203
|
+
referenceBands,
|
|
204
|
+
{
|
|
205
|
+
valueAxis: 'y',
|
|
206
|
+
format: formatters.format,
|
|
207
|
+
color: chartColors.reference,
|
|
208
|
+
bandColor: chartColors.referenceBand,
|
|
209
|
+
}
|
|
210
|
+
),
|
|
211
|
+
} as EChartsOption,
|
|
212
|
+
optionOverride
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const accessibilityDescription = $derived(
|
|
217
|
+
description ||
|
|
218
|
+
(!isEmpty
|
|
219
|
+
? generateWaterfallDescription(
|
|
220
|
+
title,
|
|
221
|
+
data,
|
|
222
|
+
bars[bars.length - 1]?.end ?? 0,
|
|
223
|
+
formatters.format
|
|
224
|
+
)
|
|
225
|
+
: undefined)
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const dataTable = $derived(isEmpty ? undefined : waterfallTable(data, bars));
|
|
229
|
+
|
|
230
|
+
// Manage ECharts instance lifecycle (init, theme, resize, option updates)
|
|
231
|
+
const chart = useChart({
|
|
232
|
+
option: () => option,
|
|
233
|
+
theme: () => theme,
|
|
234
|
+
animation: () => animation,
|
|
235
|
+
isEmpty: () => isEmpty,
|
|
236
|
+
patterns: () => patterns,
|
|
237
|
+
events: () =>
|
|
238
|
+
chartEvents(events, {
|
|
239
|
+
click:
|
|
240
|
+
onClick &&
|
|
241
|
+
((params) => {
|
|
242
|
+
const mapped = toDataPointParams(params, 'click');
|
|
243
|
+
const bar = bars[mapped.dataIndex];
|
|
244
|
+
if (!bar) return;
|
|
245
|
+
onClick({ ...mapped, seriesName: title, value: bar.value });
|
|
246
|
+
}),
|
|
247
|
+
}),
|
|
248
|
+
onCreated: () => onReady,
|
|
249
|
+
});
|
|
250
|
+
</script>
|
|
251
|
+
|
|
252
|
+
<ChartContainer
|
|
253
|
+
{title}
|
|
254
|
+
description={accessibilityDescription}
|
|
255
|
+
{height}
|
|
256
|
+
{minHeight}
|
|
257
|
+
{maxHeight}
|
|
258
|
+
{loading}
|
|
259
|
+
{error}
|
|
260
|
+
{onRetry}
|
|
261
|
+
empty={isEmpty}
|
|
262
|
+
{emptyMessage}
|
|
263
|
+
class={cn(className)}
|
|
264
|
+
{dataTable}
|
|
265
|
+
{showDataTable}
|
|
266
|
+
format={formatters.format}
|
|
267
|
+
{exportable}
|
|
268
|
+
exportImage={chart.exportImage}
|
|
269
|
+
>
|
|
270
|
+
<div bind:this={chart.container} class="h-full w-full"></div>
|
|
271
|
+
</ChartContainer>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { WaterfallChartProps } from '@classic-homes/charts-core';
|
|
2
|
+
interface Props extends Omit<WaterfallChartProps, 'class'> {
|
|
3
|
+
class?: string;
|
|
4
|
+
}
|
|
5
|
+
declare const WaterfallChart: import("svelte").Component<Props, {}, "">;
|
|
6
|
+
type WaterfallChart = ReturnType<typeof WaterfallChart>;
|
|
7
|
+
export default WaterfallChart;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as ComboChart } from './ComboChart.svelte';
|
|
2
|
+
export { default as DotPlotChart } from './DotPlotChart.svelte';
|
|
3
|
+
export { default as BulletChart } from './BulletChart.svelte';
|
|
4
|
+
export { default as HistogramChart } from './HistogramChart.svelte';
|
|
5
|
+
export { default as BoxPlotChart } from './BoxPlotChart.svelte';
|
|
6
|
+
export { default as WaterfallChart } from './WaterfallChart.svelte';
|
|
7
|
+
export { default as Sparkline } from './Sparkline.svelte';
|
|
8
|
+
export { default as StatCard } from './StatCard.svelte';
|
|
9
|
+
export { default as StatGroup } from './StatGroup.svelte';
|
|
10
|
+
import type { ComponentProps } from 'svelte';
|
|
11
|
+
import type StatGroup from './StatGroup.svelte';
|
|
12
|
+
export type StatGroupProps = ComponentProps<typeof StatGroup>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default as ComboChart } from './ComboChart.svelte';
|
|
2
|
+
export { default as DotPlotChart } from './DotPlotChart.svelte';
|
|
3
|
+
export { default as BulletChart } from './BulletChart.svelte';
|
|
4
|
+
export { default as HistogramChart } from './HistogramChart.svelte';
|
|
5
|
+
export { default as BoxPlotChart } from './BoxPlotChart.svelte';
|
|
6
|
+
export { default as WaterfallChart } from './WaterfallChart.svelte';
|
|
7
|
+
export { default as Sparkline } from './Sparkline.svelte';
|
|
8
|
+
export { default as StatCard } from './StatCard.svelte';
|
|
9
|
+
export { default as StatGroup } from './StatGroup.svelte';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { useChart, type UseChartOptions, type UseChartReturn } from './useChart.svelte.js';
|
|
1
|
+
export { useChart, exportChartImage, registerEchartsModules, type ExportImageOptions, type UseChartOptions, type UseChartReturn, } from './useChart.svelte.js';
|
|
2
2
|
export { useChartTheme, registerClassicThemes } from './useChartTheme.svelte.js';
|
|
3
3
|
export { useReducedMotion } from './useReducedMotion.svelte.js';
|
|
4
4
|
export { useChartResize } from './useChartResize.svelte.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { useChart } from './useChart.svelte.js';
|
|
1
|
+
export { useChart, exportChartImage, registerEchartsModules, } from './useChart.svelte.js';
|
|
2
2
|
export { useChartTheme, registerClassicThemes } from './useChartTheme.svelte.js';
|
|
3
3
|
export { useReducedMotion } from './useReducedMotion.svelte.js';
|
|
4
4
|
export { useChartResize } from './useChartResize.svelte.js';
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import * as echarts from 'echarts/core';
|
|
2
2
|
import type { EChartsOption } from 'echarts';
|
|
3
|
+
import { type ChartEventHandlers } from '@classic-homes/charts-core';
|
|
4
|
+
/**
|
|
5
|
+
* Register extra ECharts modules (series types, components or renderers)
|
|
6
|
+
* that an `option` override needs, e.g. `registerEchartsModules([ToolboxComponent])`
|
|
7
|
+
*/
|
|
8
|
+
export declare const registerEchartsModules: typeof echarts.use;
|
|
9
|
+
export interface ExportImageOptions {
|
|
10
|
+
/** Image format (defaults to 'png') */
|
|
11
|
+
type?: 'png' | 'jpeg';
|
|
12
|
+
/** Resolution multiplier (defaults to 2) */
|
|
13
|
+
pixelRatio?: number;
|
|
14
|
+
/** Background colour (defaults to the card colour, so the image is not transparent) */
|
|
15
|
+
backgroundColor?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Render a chart instance to an image data URL, or null when it has no
|
|
19
|
+
* live instance
|
|
20
|
+
*/
|
|
21
|
+
export declare function exportChartImage(chart: echarts.ECharts | null | undefined, { type, pixelRatio, backgroundColor }?: ExportImageOptions): string | null;
|
|
3
22
|
export interface UseChartOptions {
|
|
4
23
|
/** Getter returning the ECharts option (animation is resolved by the composable) */
|
|
5
24
|
option: () => EChartsOption;
|
|
@@ -9,6 +28,14 @@ export interface UseChartOptions {
|
|
|
9
28
|
animation?: () => boolean;
|
|
10
29
|
/** Getter returning whether the chart has no data (skips option updates) */
|
|
11
30
|
isEmpty?: () => boolean;
|
|
31
|
+
/** Getter returning whether series are filled with decal patterns */
|
|
32
|
+
patterns?: () => boolean | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Getter returning ECharts event handlers by event name. Listeners are
|
|
35
|
+
* bound for each name with a handler and call the current handler at event
|
|
36
|
+
* time, so handlers set or replaced after mount work.
|
|
37
|
+
*/
|
|
38
|
+
events?: () => ChartEventHandlers;
|
|
12
39
|
/**
|
|
13
40
|
* Callback when the chart instance is (re)created. Register event handlers
|
|
14
41
|
* here; a theme change re-runs it so handlers are attached to the live
|
|
@@ -17,6 +44,11 @@ export interface UseChartOptions {
|
|
|
17
44
|
* the chart.
|
|
18
45
|
*/
|
|
19
46
|
onReady?: (chart: echarts.ECharts) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Getter returning a consumer callback, called with each new instance once
|
|
49
|
+
* its option is applied (the chart's `onReady` prop)
|
|
50
|
+
*/
|
|
51
|
+
onCreated?: () => ((chart: echarts.ECharts) => void) | undefined;
|
|
20
52
|
}
|
|
21
53
|
export interface UseChartReturn {
|
|
22
54
|
/** Container element to attach via bind:this */
|
|
@@ -27,6 +59,8 @@ export interface UseChartReturn {
|
|
|
27
59
|
readonly theme: string;
|
|
28
60
|
/** Whether the user prefers reduced motion */
|
|
29
61
|
readonly reducedMotion: boolean;
|
|
62
|
+
/** Render the chart to an image data URL */
|
|
63
|
+
exportImage: (options?: ExportImageOptions) => string | null;
|
|
30
64
|
}
|
|
31
65
|
/**
|
|
32
66
|
* Composable to manage ECharts instance lifecycle
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { untrack } from 'svelte';
|
|
2
2
|
import * as echarts from 'echarts/core';
|
|
3
|
+
import { AriaComponent, DataZoomInsideComponent, DataZoomSliderComponent, DatasetComponent, GraphicComponent, MarkAreaComponent, MarkLineComponent, } from 'echarts/components';
|
|
4
|
+
import { CanvasRenderer } from 'echarts/renderers';
|
|
3
5
|
import { getReplaceMerge } from '@classic-homes/charts-core';
|
|
4
6
|
import { useChartTheme } from './useChartTheme.svelte.js';
|
|
5
7
|
import { useChartResize } from './useChartResize.svelte.js';
|
|
6
8
|
import { useReducedMotion } from './useReducedMotion.svelte.js';
|
|
9
|
+
// Components every chart can use through props or an `option` override, so
|
|
10
|
+
// overrides do not depend on which other charts an app happens to import
|
|
11
|
+
echarts.use([
|
|
12
|
+
AriaComponent,
|
|
13
|
+
DataZoomInsideComponent,
|
|
14
|
+
DataZoomSliderComponent,
|
|
15
|
+
DatasetComponent,
|
|
16
|
+
GraphicComponent,
|
|
17
|
+
MarkAreaComponent,
|
|
18
|
+
MarkLineComponent,
|
|
19
|
+
CanvasRenderer,
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* Register extra ECharts modules (series types, components or renderers)
|
|
23
|
+
* that an `option` override needs, e.g. `registerEchartsModules([ToolboxComponent])`
|
|
24
|
+
*/
|
|
25
|
+
export const registerEchartsModules = (modules) => echarts.use(modules);
|
|
7
26
|
const isComponentType = (mainType) => echarts.ComponentModel.hasClass(mainType);
|
|
8
27
|
// The option last applied to each instance; a fresh instance has none
|
|
9
28
|
const appliedOptions = new WeakMap();
|
|
@@ -21,6 +40,46 @@ function applyOption(chart, next) {
|
|
|
21
40
|
});
|
|
22
41
|
appliedOptions.set(chart, next);
|
|
23
42
|
}
|
|
43
|
+
function captureState(chart) {
|
|
44
|
+
if (chart.isDisposed())
|
|
45
|
+
return null;
|
|
46
|
+
const option = chart.getOption();
|
|
47
|
+
const selected = option.legend?.[0]?.selected ?? {};
|
|
48
|
+
return {
|
|
49
|
+
hidden: Object.keys(selected).filter((name) => selected[name] === false),
|
|
50
|
+
zoom: (option.dataZoom ?? []).map(({ start, end }) => ({ start, end })),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function restoreState(chart, state) {
|
|
54
|
+
if (!state)
|
|
55
|
+
return;
|
|
56
|
+
for (const name of state.hidden)
|
|
57
|
+
chart.dispatchAction({ type: 'legendUnSelect', name });
|
|
58
|
+
state.zoom.forEach(({ start, end }, dataZoomIndex) => {
|
|
59
|
+
if (start !== undefined && end !== undefined && (start !== 0 || end !== 100)) {
|
|
60
|
+
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex, start, end });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Render a chart instance to an image data URL, or null when it has no
|
|
66
|
+
* live instance
|
|
67
|
+
*/
|
|
68
|
+
export function exportChartImage(chart, { type = 'png', pixelRatio = 2, backgroundColor } = {}) {
|
|
69
|
+
if (!chart || chart.isDisposed())
|
|
70
|
+
return null;
|
|
71
|
+
const background = backgroundColor ??
|
|
72
|
+
(typeof document !== 'undefined'
|
|
73
|
+
? getComputedStyle(chart.getDom()).getPropertyValue('--card').trim()
|
|
74
|
+
: '');
|
|
75
|
+
// Token variables hold bare HSL channels ("0 0% 100%")
|
|
76
|
+
const color = !background
|
|
77
|
+
? '#ffffff'
|
|
78
|
+
: /^[\d.]+\s/.test(background)
|
|
79
|
+
? `hsl(${background})`
|
|
80
|
+
: background;
|
|
81
|
+
return chart.getDataURL({ type, pixelRatio, backgroundColor: color });
|
|
82
|
+
}
|
|
24
83
|
/**
|
|
25
84
|
* Composable to manage ECharts instance lifecycle
|
|
26
85
|
* Matches behavior of React useChart hook
|
|
@@ -31,15 +90,24 @@ export function useChart(opts) {
|
|
|
31
90
|
const chartTheme = useChartTheme(() => opts.theme?.() ?? 'auto');
|
|
32
91
|
const reducedMotion = useReducedMotion();
|
|
33
92
|
const chartResize = useChartResize();
|
|
34
|
-
// Resolve the option with reduced-motion-aware animation
|
|
35
|
-
const resolveOption = () =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
93
|
+
// Resolve the option with reduced-motion-aware animation and patterns
|
|
94
|
+
const resolveOption = () => {
|
|
95
|
+
const option = {
|
|
96
|
+
...opts.option(),
|
|
97
|
+
animation: (opts.animation?.() ?? true) && !reducedMotion.value,
|
|
98
|
+
};
|
|
99
|
+
if (opts.patterns?.()) {
|
|
100
|
+
// Labels stay off: the container already describes the chart
|
|
101
|
+
option.aria = { enabled: true, label: { enabled: false }, decal: { show: true } };
|
|
102
|
+
}
|
|
103
|
+
return option;
|
|
104
|
+
};
|
|
105
|
+
// State to carry over to the instance a theme change re-creates
|
|
106
|
+
let carried = null;
|
|
39
107
|
// Initialize chart instance (only depends on container and theme). A theme
|
|
40
|
-
// change disposes and re-creates the instance, restoring the current option
|
|
41
|
-
// and re-running onReady so event handlers
|
|
42
|
-
// the live instance.
|
|
108
|
+
// change disposes and re-creates the instance, restoring the current option,
|
|
109
|
+
// legend selection and zoom, and re-running onReady so event handlers
|
|
110
|
+
// registered there are attached to the live instance.
|
|
43
111
|
$effect(() => {
|
|
44
112
|
const container = containerEl;
|
|
45
113
|
const themeName = chartTheme.theme;
|
|
@@ -50,14 +118,21 @@ export function useChart(opts) {
|
|
|
50
118
|
// Apply the current option and callbacks without tracking them, so data
|
|
51
119
|
// or callback changes update the chart instead of re-initializing it
|
|
52
120
|
untrack(() => {
|
|
121
|
+
opts.onReady?.(chart);
|
|
53
122
|
if (!(opts.isEmpty?.() ?? false)) {
|
|
54
123
|
applyOption(chart, resolveOption());
|
|
124
|
+
// Flush the lazy update so restored actions apply to the new option
|
|
125
|
+
if (carried)
|
|
126
|
+
chart.setOption({}, { lazyUpdate: false });
|
|
127
|
+
restoreState(chart, carried);
|
|
55
128
|
}
|
|
56
|
-
|
|
129
|
+
carried = null;
|
|
130
|
+
opts.onCreated?.()?.(chart);
|
|
57
131
|
});
|
|
58
132
|
// Set up ResizeObserver for container-aware resizing
|
|
59
133
|
chartResize.observe(container, chart);
|
|
60
134
|
return () => {
|
|
135
|
+
carried = captureState(chart);
|
|
61
136
|
chartResize.cleanup(container);
|
|
62
137
|
chart.dispose();
|
|
63
138
|
if (chartInstance === chart) {
|
|
@@ -74,6 +149,42 @@ export function useChart(opts) {
|
|
|
74
149
|
return;
|
|
75
150
|
applyOption(chart, option);
|
|
76
151
|
});
|
|
152
|
+
// Bind event listeners per event name; each calls the current handler
|
|
153
|
+
$effect(() => {
|
|
154
|
+
const chart = chartInstance;
|
|
155
|
+
const handlers = opts.events?.() ?? {};
|
|
156
|
+
if (!chart || chart.isDisposed())
|
|
157
|
+
return;
|
|
158
|
+
const bound = Object.keys(handlers)
|
|
159
|
+
.filter((name) => handlers[name])
|
|
160
|
+
.map((name) => {
|
|
161
|
+
const listener = (params) => untrack(() => opts.events?.()[name])?.(params);
|
|
162
|
+
chart.on(name, listener);
|
|
163
|
+
return [name, listener];
|
|
164
|
+
});
|
|
165
|
+
return () => {
|
|
166
|
+
if (chart.isDisposed())
|
|
167
|
+
return;
|
|
168
|
+
for (const [name, listener] of bound)
|
|
169
|
+
chart.off(name, listener);
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
// Charts are drawn to a canvas sized to the screen layout; resize for print
|
|
173
|
+
$effect(() => {
|
|
174
|
+
if (typeof window === 'undefined')
|
|
175
|
+
return;
|
|
176
|
+
const resize = () => {
|
|
177
|
+
const chart = chartInstance;
|
|
178
|
+
if (chart && !chart.isDisposed())
|
|
179
|
+
chart.resize();
|
|
180
|
+
};
|
|
181
|
+
window.addEventListener('beforeprint', resize);
|
|
182
|
+
window.addEventListener('afterprint', resize);
|
|
183
|
+
return () => {
|
|
184
|
+
window.removeEventListener('beforeprint', resize);
|
|
185
|
+
window.removeEventListener('afterprint', resize);
|
|
186
|
+
};
|
|
187
|
+
});
|
|
77
188
|
return {
|
|
78
189
|
get container() {
|
|
79
190
|
return containerEl;
|
|
@@ -90,5 +201,6 @@ export function useChart(opts) {
|
|
|
90
201
|
get reducedMotion() {
|
|
91
202
|
return reducedMotion.value;
|
|
92
203
|
},
|
|
204
|
+
exportImage: (options) => exportChartImage(chartInstance, options),
|
|
93
205
|
};
|
|
94
206
|
}
|
|
@@ -17,22 +17,25 @@ export function registerClassicThemes() {
|
|
|
17
17
|
* @param getPreference - A getter function that returns the current theme preference
|
|
18
18
|
*/
|
|
19
19
|
export function useChartTheme(getPreference) {
|
|
20
|
-
|
|
21
|
-
function updateTheme() {
|
|
20
|
+
function resolveTheme() {
|
|
22
21
|
const preference = getPreference();
|
|
23
|
-
if (preference !== 'auto')
|
|
24
|
-
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
22
|
+
if (preference !== 'auto')
|
|
23
|
+
return preference === 'dark' ? THEME_DARK : THEME_LIGHT;
|
|
27
24
|
// Check for dark class on html element
|
|
28
|
-
if (typeof document !== 'undefined') {
|
|
29
|
-
|
|
30
|
-
currentTheme = isDark ? THEME_DARK : THEME_LIGHT;
|
|
25
|
+
if (typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) {
|
|
26
|
+
return THEME_DARK;
|
|
31
27
|
}
|
|
28
|
+
return THEME_LIGHT;
|
|
29
|
+
}
|
|
30
|
+
// Resolved up front so a chart on a dark page is created dark, rather than
|
|
31
|
+
// created light and then re-created
|
|
32
|
+
let currentTheme = $state(resolveTheme());
|
|
33
|
+
function updateTheme() {
|
|
34
|
+
currentTheme = resolveTheme();
|
|
32
35
|
}
|
|
36
|
+
// Themes must exist before the first chart initialises with one
|
|
37
|
+
registerClassicThemes();
|
|
33
38
|
$effect(() => {
|
|
34
|
-
// Register themes on first use
|
|
35
|
-
registerClassicThemes();
|
|
36
39
|
// Initial theme check
|
|
37
40
|
updateTheme();
|
|
38
41
|
const preference = getPreference();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ChartEventHandlers, DataPointEventParams, SliceEventParams } from '@classic-homes/charts-core';
|
|
2
|
+
/**
|
|
3
|
+
* Map ECharts event parameters for a data point (line, bar, area, scatter)
|
|
4
|
+
*/
|
|
5
|
+
export declare function toDataPointParams(params: unknown, fallbackType: string): DataPointEventParams;
|
|
6
|
+
/**
|
|
7
|
+
* Map ECharts event parameters for a slice (pie, donut, funnel)
|
|
8
|
+
*/
|
|
9
|
+
export declare function toSliceParams(params: unknown, fallbackType: string): SliceEventParams;
|
|
10
|
+
/**
|
|
11
|
+
* Combine a chart's mapped `onClick`/`onHover` props with its raw `events`
|
|
12
|
+
* prop into one handler map. When both handle an event, both run, the
|
|
13
|
+
* mapped handler first.
|
|
14
|
+
*/
|
|
15
|
+
export declare function chartEvents(events: ChartEventHandlers | undefined, mapped: ChartEventHandlers): ChartEventHandlers;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const raw = (params) => (params ?? {});
|
|
2
|
+
/**
|
|
3
|
+
* Map ECharts event parameters for a data point (line, bar, area, scatter)
|
|
4
|
+
*/
|
|
5
|
+
export function toDataPointParams(params, fallbackType) {
|
|
6
|
+
const p = raw(params);
|
|
7
|
+
return {
|
|
8
|
+
type: p.type || fallbackType,
|
|
9
|
+
componentType: p.componentType || 'series',
|
|
10
|
+
seriesIndex: p.seriesIndex,
|
|
11
|
+
seriesName: p.seriesName || '',
|
|
12
|
+
dataIndex: p.dataIndex ?? 0,
|
|
13
|
+
name: p.name || '',
|
|
14
|
+
value: p.value,
|
|
15
|
+
color: typeof p.color === 'string' ? p.color : undefined,
|
|
16
|
+
event: p.event?.event,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Map ECharts event parameters for a slice (pie, donut, funnel)
|
|
21
|
+
*/
|
|
22
|
+
export function toSliceParams(params, fallbackType) {
|
|
23
|
+
const p = raw(params);
|
|
24
|
+
return {
|
|
25
|
+
type: p.type || fallbackType,
|
|
26
|
+
componentType: p.componentType || 'series',
|
|
27
|
+
seriesIndex: p.seriesIndex,
|
|
28
|
+
seriesName: p.seriesName,
|
|
29
|
+
dataIndex: p.dataIndex,
|
|
30
|
+
name: p.name || '',
|
|
31
|
+
value: p.value,
|
|
32
|
+
percent: p.percent,
|
|
33
|
+
color: typeof p.color === 'string' ? p.color : undefined,
|
|
34
|
+
event: p.event?.event,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Combine a chart's mapped `onClick`/`onHover` props with its raw `events`
|
|
39
|
+
* prop into one handler map. When both handle an event, both run, the
|
|
40
|
+
* mapped handler first.
|
|
41
|
+
*/
|
|
42
|
+
export function chartEvents(events, mapped) {
|
|
43
|
+
const merged = { ...events };
|
|
44
|
+
for (const [name, handler] of Object.entries(mapped)) {
|
|
45
|
+
if (!handler)
|
|
46
|
+
continue;
|
|
47
|
+
const own = events?.[name];
|
|
48
|
+
merged[name] = own
|
|
49
|
+
? (params) => {
|
|
50
|
+
handler(params);
|
|
51
|
+
own(params);
|
|
52
|
+
}
|
|
53
|
+
: handler;
|
|
54
|
+
}
|
|
55
|
+
return merged;
|
|
56
|
+
}
|