@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.
Files changed (54) hide show
  1. package/dist/lib/components/base/ChartContainer.svelte +141 -14
  2. package/dist/lib/components/base/ChartContainer.svelte.d.ts +19 -1
  3. package/dist/lib/components/base/ChartDataTable.svelte +59 -0
  4. package/dist/lib/components/base/ChartDataTable.svelte.d.ts +15 -0
  5. package/dist/lib/components/base/ChartEmpty.svelte +1 -0
  6. package/dist/lib/components/base/ChartSkeleton.svelte +7 -2
  7. package/dist/lib/components/base/ChartSkeleton.svelte.d.ts +2 -0
  8. package/dist/lib/components/base/index.d.ts +3 -0
  9. package/dist/lib/components/base/index.js +1 -0
  10. package/dist/lib/components/core/AreaChart.svelte +115 -70
  11. package/dist/lib/components/core/BarChart.svelte +134 -62
  12. package/dist/lib/components/core/DonutChart.svelte +41 -22
  13. package/dist/lib/components/core/LineChart.svelte +104 -73
  14. package/dist/lib/components/core/PieChart.svelte +41 -22
  15. package/dist/lib/components/core/ScatterChart.svelte +72 -68
  16. package/dist/lib/components/extended/CandlestickChart.svelte +67 -44
  17. package/dist/lib/components/extended/FunnelChart.svelte +41 -27
  18. package/dist/lib/components/extended/GaugeChart.svelte +61 -10
  19. package/dist/lib/components/extended/HeatmapChart.svelte +62 -33
  20. package/dist/lib/components/extended/RadarChart.svelte +39 -19
  21. package/dist/lib/components/extended/SankeyChart.svelte +50 -20
  22. package/dist/lib/components/extended/TreemapChart.svelte +53 -21
  23. package/dist/lib/components/index.d.ts +2 -1
  24. package/dist/lib/components/index.js +3 -1
  25. package/dist/lib/components/report/BoxPlotChart.svelte +253 -0
  26. package/dist/lib/components/report/BoxPlotChart.svelte.d.ts +7 -0
  27. package/dist/lib/components/report/BulletChart.svelte +231 -0
  28. package/dist/lib/components/report/BulletChart.svelte.d.ts +7 -0
  29. package/dist/lib/components/report/ComboChart.svelte +327 -0
  30. package/dist/lib/components/report/ComboChart.svelte.d.ts +8 -0
  31. package/dist/lib/components/report/DotPlotChart.svelte +325 -0
  32. package/dist/lib/components/report/DotPlotChart.svelte.d.ts +8 -0
  33. package/dist/lib/components/report/HistogramChart.svelte +226 -0
  34. package/dist/lib/components/report/HistogramChart.svelte.d.ts +7 -0
  35. package/dist/lib/components/report/Sparkline.svelte +181 -0
  36. package/dist/lib/components/report/Sparkline.svelte.d.ts +4 -0
  37. package/dist/lib/components/report/StatCard.svelte +146 -0
  38. package/dist/lib/components/report/StatCard.svelte.d.ts +4 -0
  39. package/dist/lib/components/report/StatGroup.svelte +35 -0
  40. package/dist/lib/components/report/StatGroup.svelte.d.ts +12 -0
  41. package/dist/lib/components/report/WaterfallChart.svelte +271 -0
  42. package/dist/lib/components/report/WaterfallChart.svelte.d.ts +7 -0
  43. package/dist/lib/components/report/index.d.ts +12 -0
  44. package/dist/lib/components/report/index.js +9 -0
  45. package/dist/lib/composables/index.d.ts +1 -1
  46. package/dist/lib/composables/index.js +1 -1
  47. package/dist/lib/composables/useChart.svelte.d.ts +34 -0
  48. package/dist/lib/composables/useChart.svelte.js +121 -9
  49. package/dist/lib/composables/useChartTheme.svelte.js +14 -11
  50. package/dist/lib/events.d.ts +15 -0
  51. package/dist/lib/events.js +56 -0
  52. package/dist/lib/index.d.ts +4 -3
  53. package/dist/lib/index.js +7 -3
  54. 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
- ...opts.option(),
37
- animation: (opts.animation?.() ?? true) && !reducedMotion.value,
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 registered there are attached to
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
- opts.onReady?.(chart);
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
- let currentTheme = $state(THEME_LIGHT);
21
- function updateTheme() {
20
+ function resolveTheme() {
22
21
  const preference = getPreference();
23
- if (preference !== 'auto') {
24
- currentTheme = preference === 'dark' ? THEME_DARK : THEME_LIGHT;
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
- const isDark = document.documentElement.classList.contains('dark');
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
+ }