@classic-homes/charts-svelte 0.1.38 → 0.2.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 (36) hide show
  1. package/dist/lib/components/base/ChartContainer.svelte +47 -8
  2. package/dist/lib/components/base/ChartContainer.svelte.d.ts +8 -0
  3. package/dist/lib/components/base/ChartLegend.svelte +59 -0
  4. package/dist/lib/components/base/ChartLegend.svelte.d.ts +10 -0
  5. package/dist/lib/components/base/index.d.ts +3 -0
  6. package/dist/lib/components/base/index.js +1 -0
  7. package/dist/lib/components/core/AreaChart.svelte +121 -54
  8. package/dist/lib/components/core/AreaChart.svelte.d.ts +2 -1
  9. package/dist/lib/components/core/BarChart.svelte +91 -35
  10. package/dist/lib/components/core/BarChart.svelte.d.ts +2 -1
  11. package/dist/lib/components/core/DonutChart.svelte +152 -82
  12. package/dist/lib/components/core/DonutChart.svelte.d.ts +2 -1
  13. package/dist/lib/components/core/LineChart.svelte +114 -47
  14. package/dist/lib/components/core/LineChart.svelte.d.ts +2 -1
  15. package/dist/lib/components/core/PieChart.svelte +100 -38
  16. package/dist/lib/components/core/PieChart.svelte.d.ts +2 -1
  17. package/dist/lib/components/core/ScatterChart.svelte +102 -34
  18. package/dist/lib/components/core/ScatterChart.svelte.d.ts +2 -1
  19. package/dist/lib/components/extended/CandlestickChart.svelte +44 -22
  20. package/dist/lib/components/extended/FunnelChart.svelte +102 -33
  21. package/dist/lib/components/extended/FunnelChart.svelte.d.ts +2 -1
  22. package/dist/lib/components/extended/GaugeChart.svelte +59 -38
  23. package/dist/lib/components/extended/HeatmapChart.svelte +47 -27
  24. package/dist/lib/components/extended/RadarChart.svelte +76 -22
  25. package/dist/lib/components/extended/RadarChart.svelte.d.ts +2 -1
  26. package/dist/lib/components/extended/SankeyChart.svelte +50 -21
  27. package/dist/lib/components/extended/TreemapChart.svelte +68 -27
  28. package/dist/lib/components/index.d.ts +1 -1
  29. package/dist/lib/components/index.js +1 -1
  30. package/dist/lib/composables/index.d.ts +1 -0
  31. package/dist/lib/composables/index.js +1 -0
  32. package/dist/lib/composables/useChartLegend.svelte.d.ts +37 -0
  33. package/dist/lib/composables/useChartLegend.svelte.js +54 -0
  34. package/dist/lib/index.d.ts +3 -3
  35. package/dist/lib/index.js +2 -2
  36. package/package.json +1 -1
@@ -6,16 +6,33 @@
6
6
  import type { EChartsOption } from 'echarts';
7
7
 
8
8
  import type { DonutChartProps, SliceEventParams } from '@classic-homes/charts-core';
9
- import { generatePieChartDescription, getChartColors } from '@classic-homes/charts-core';
9
+ import {
10
+ buildDataLabel,
11
+ buildLegend,
12
+ buildTooltip,
13
+ escapeHtml,
14
+ formatTooltipValue,
15
+ generatePieChartDescription,
16
+ getChartColors,
17
+ itemLabelLookup,
18
+ anchorCenterGraphic,
19
+ legendCenter,
20
+ mergeChartOption,
21
+ pointLabel,
22
+ } from '@classic-homes/charts-core';
10
23
 
11
24
  import { cn } from '../../utils.js';
12
25
  import { useChart } from '../../composables/useChart.svelte.js';
26
+ import {
27
+ useChartLegend,
28
+ type CustomLegendProps,
29
+ } from '../../composables/useChartLegend.svelte.js';
13
30
  import ChartContainer from '../base/ChartContainer.svelte';
14
31
 
15
32
  // Register required ECharts modules
16
33
  echarts.use([EChartsPieChart, TooltipComponent, LegendComponent, TitleComponent, CanvasRenderer]);
17
34
 
18
- interface Props extends Omit<DonutChartProps, 'class'> {
35
+ interface Props extends Omit<DonutChartProps, 'class'>, CustomLegendProps {
19
36
  class?: string;
20
37
  }
21
38
 
@@ -36,6 +53,13 @@
36
53
  innerRadius = '50%',
37
54
  centerLabel,
38
55
  centerValue,
56
+ labelFormatter,
57
+ tooltipFormatter,
58
+ tooltipValueFormatter,
59
+ legend: legendConfig,
60
+ renderLegend,
61
+ onLegendChange,
62
+ option: optionOverride,
39
63
  onClick,
40
64
  class: className,
41
65
  }: Props = $props();
@@ -48,105 +72,144 @@
48
72
  const option: EChartsOption = $derived.by(() => {
49
73
  if (isEmpty) return {};
50
74
 
75
+ const getLabel = itemLabelLookup(data);
76
+
77
+ const pieCenter = legendCenter(legendConfig, showLegend && !renderLegend, ['50%', '45%']);
51
78
  const graphicElements: unknown[] = [];
52
79
 
53
80
  if (centerLabel || centerValue) {
54
- graphicElements.push({
55
- type: 'group',
56
- left: 'center',
57
- top: '40%',
58
- children: [
59
- ...(centerValue !== undefined
60
- ? [
61
- {
62
- type: 'text',
63
- style: {
64
- text: String(centerValue),
65
- fontSize: 28,
66
- fontWeight: 'bold',
67
- fill: chartColors.foreground,
68
- align: 'center',
69
- },
70
- top: 0,
71
- left: 'center',
72
- },
73
- ]
74
- : []),
75
- ...(centerLabel
76
- ? [
77
- {
78
- type: 'text',
79
- style: {
80
- text: centerLabel,
81
- fontSize: 14,
82
- fill: chartColors.mutedForeground,
83
- align: 'center',
84
- },
85
- top: centerValue !== undefined ? 35 : 0,
86
- left: 'center',
87
- },
88
- ]
89
- : []),
90
- ],
91
- });
81
+ graphicElements.push(
82
+ anchorCenterGraphic(
83
+ {
84
+ type: 'group',
85
+ left: 'center',
86
+ top: '40%',
87
+ children: [
88
+ ...(centerValue !== undefined
89
+ ? [
90
+ {
91
+ type: 'text',
92
+ style: {
93
+ text: String(centerValue),
94
+ fontSize: 28,
95
+ fontWeight: 'bold',
96
+ fill: chartColors.foreground,
97
+ align: 'center',
98
+ },
99
+ top: 0,
100
+ left: 'center',
101
+ },
102
+ ]
103
+ : []),
104
+ ...(centerLabel
105
+ ? [
106
+ {
107
+ type: 'text',
108
+ style: {
109
+ text: centerLabel,
110
+ fontSize: 14,
111
+ fill: chartColors.mutedForeground,
112
+ align: 'center',
113
+ },
114
+ top: centerValue !== undefined ? 35 : 0,
115
+ left: 'center',
116
+ },
117
+ ]
118
+ : []),
119
+ ],
120
+ },
121
+ pieCenter,
122
+ ['50%', '45%']
123
+ )
124
+ );
92
125
  }
93
126
 
94
- return {
95
- tooltip: showTooltip
96
- ? {
127
+ return mergeChartOption(
128
+ {
129
+ tooltip: buildTooltip(
130
+ {
97
131
  trigger: 'item',
98
- formatter: '{a} <br/>{b}: {c} ({d}%)',
99
- }
100
- : undefined,
101
- legend: showLegend
102
- ? {
103
- orient: 'horizontal',
104
- bottom: 0,
105
- data: data.map((d) => d.name),
132
+ // A string template ignores valueFormatter, so format the value here
133
+ formatter:
134
+ tooltipValueFormatter && !tooltipFormatter
135
+ ? (params: unknown) => {
136
+ const p = params as {
137
+ seriesName?: string;
138
+ name?: string;
139
+ value?: unknown;
140
+ percent?: number;
141
+ };
142
+ return `${escapeHtml(p.seriesName)} <br/>${escapeHtml(p.name)}: ${escapeHtml(formatTooltipValue(p.value, tooltipValueFormatter))} (${p.percent}%)`;
143
+ }
144
+ : '{a} <br/>{b}: {c} ({d}%)',
145
+ },
146
+ {
147
+ show: showTooltip,
148
+ formatter: tooltipFormatter,
149
+ valueFormatter: tooltipValueFormatter,
150
+ getLabel,
106
151
  }
107
- : undefined,
108
- graphic:
109
- graphicElements.length > 0 ? (graphicElements as EChartsOption['graphic']) : undefined,
110
- series: [
111
- {
112
- name: title,
113
- type: 'pie',
114
- radius: [innerRadius, '70%'],
115
- center: ['50%', '45%'],
116
- avoidLabelOverlap: true,
117
- data: data.map((item) => ({
118
- name: item.name,
119
- value: item.value,
120
- itemStyle: item.color ? { color: item.color } : undefined,
121
- })),
122
- label: showLabels
123
- ? {
152
+ ),
153
+ legend: buildLegend(
154
+ data.map((d) => d.name),
155
+ legendConfig,
156
+ { show: showLegend, custom: !!renderLegend, defaults: { orient: 'horizontal' } }
157
+ ),
158
+ graphic:
159
+ graphicElements.length > 0 ? (graphicElements as EChartsOption['graphic']) : undefined,
160
+ series: [
161
+ {
162
+ name: title,
163
+ type: 'pie',
164
+ radius: [innerRadius, '70%'],
165
+ center: pieCenter,
166
+ avoidLabelOverlap: true,
167
+ data: data.map((item) => ({
168
+ name: item.name,
169
+ value: item.value,
170
+ itemStyle: item.color ? { color: item.color } : undefined,
171
+ label: pointLabel(item.label, labelFormatter, { position: labelPosition }),
172
+ })),
173
+ label: buildDataLabel(
174
+ {
124
175
  show: true,
125
176
  position: labelPosition,
126
177
  formatter: labelPosition === 'inside' ? '{d}%' : '{b}: {d}%',
127
- }
128
- : {
129
- show: false,
130
178
  },
131
- labelLine: {
132
- show: showLabels && labelPosition === 'outside',
133
- },
134
- emphasis: {
135
- itemStyle: {
136
- shadowBlur: 10,
137
- shadowOffsetX: 0,
138
- shadowColor: 'rgba(0, 0, 0, 0.5)',
179
+ { show: false },
180
+ { show: showLabels, formatter: labelFormatter, getLabel }
181
+ ),
182
+ labelLine: {
183
+ show: showLabels && labelPosition === 'outside',
184
+ },
185
+ emphasis: {
186
+ itemStyle: {
187
+ shadowBlur: 10,
188
+ shadowOffsetX: 0,
189
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
190
+ },
139
191
  },
140
192
  },
141
- },
142
- ],
143
- };
193
+ ],
194
+ } as EChartsOption,
195
+ optionOverride
196
+ );
144
197
  });
145
198
 
146
199
  const accessibilityDescription = $derived(
147
200
  description || (!isEmpty ? generatePieChartDescription(title, data, true) : undefined)
148
201
  );
149
202
 
203
+ // Legend selection state, shared with a custom legend
204
+ const legend = useChartLegend({
205
+ names: () => data?.map((d) => d.name) ?? [],
206
+ colors: () => data?.map((d) => d.color) ?? [],
207
+ palette: () => getChartColors(chart.theme).series,
208
+ config: () => legendConfig,
209
+ title: () => title,
210
+ onLegendChange: () => onLegendChange,
211
+ });
212
+
150
213
  // Manage ECharts instance lifecycle (init, theme, resize, option updates)
151
214
  const chart = useChart({
152
215
  option: () => option,
@@ -154,6 +217,7 @@
154
217
  animation: () => animation,
155
218
  isEmpty: () => isEmpty,
156
219
  onReady: (instance) => {
220
+ legend.attach(instance);
157
221
  if (onClick) {
158
222
  instance.on('click', (params) => {
159
223
  onClick({
@@ -170,6 +234,10 @@
170
234
  });
171
235
  </script>
172
236
 
237
+ {#snippet customLegend()}
238
+ {@render renderLegend?.(legend.renderProps)}
239
+ {/snippet}
240
+
173
241
  <ChartContainer
174
242
  {title}
175
243
  description={accessibilityDescription}
@@ -179,6 +247,8 @@
179
247
  empty={isEmpty}
180
248
  {emptyMessage}
181
249
  class={cn(className)}
250
+ legend={renderLegend ? customLegend : undefined}
251
+ legendPosition={legendConfig?.position}
182
252
  >
183
253
  <div bind:this={chart.container} class="h-full w-full"></div>
184
254
  </ChartContainer>
@@ -1,5 +1,6 @@
1
1
  import type { DonutChartProps } from '@classic-homes/charts-core';
2
- interface Props extends Omit<DonutChartProps, 'class'> {
2
+ import { type CustomLegendProps } from '../../composables/useChartLegend.svelte.js';
3
+ interface Props extends Omit<DonutChartProps, 'class'>, CustomLegendProps {
3
4
  class?: string;
4
5
  }
5
6
  declare const DonutChart: import("svelte").Component<Props, {}, "">;
@@ -11,10 +11,26 @@
11
11
  import type { EChartsOption } from 'echarts';
12
12
 
13
13
  import type { LineChartProps, DataPointEventParams } from '@classic-homes/charts-core';
14
- import { generateLineChartDescription, getChartColors } from '@classic-homes/charts-core';
14
+ import {
15
+ buildAxis,
16
+ buildDataLabel,
17
+ buildLegend,
18
+ buildTooltip,
19
+ generateLineChartDescription,
20
+ getChartColors,
21
+ hasPointLabels,
22
+ gridInset,
23
+ mergeChartOption,
24
+ seriesLabelLookup,
25
+ withPointLabels,
26
+ } from '@classic-homes/charts-core';
15
27
 
16
28
  import { cn } from '../../utils.js';
17
29
  import { useChart } from '../../composables/useChart.svelte.js';
30
+ import {
31
+ useChartLegend,
32
+ type CustomLegendProps,
33
+ } from '../../composables/useChartLegend.svelte.js';
18
34
  import ChartContainer from '../base/ChartContainer.svelte';
19
35
 
20
36
  // Register required ECharts modules
@@ -27,7 +43,7 @@
27
43
  CanvasRenderer,
28
44
  ]);
29
45
 
30
- interface Props extends Omit<LineChartProps, 'class'> {
46
+ interface Props extends Omit<LineChartProps, 'class'>, CustomLegendProps {
31
47
  class?: string;
32
48
  }
33
49
 
@@ -48,6 +64,16 @@
48
64
  areaFilled = false,
49
65
  showDataPoints = false,
50
66
  stacked = false,
67
+ showLabels = false,
68
+ labelFormatter,
69
+ xAxisConfig,
70
+ yAxisConfig,
71
+ tooltipFormatter,
72
+ tooltipValueFormatter,
73
+ legend: legendConfig,
74
+ renderLegend,
75
+ onLegendChange,
76
+ option: optionOverride,
51
77
  onClick,
52
78
  onHover,
53
79
  class: className,
@@ -63,15 +89,21 @@
63
89
  const option: EChartsOption = $derived.by(() => {
64
90
  if (isEmpty) return {};
65
91
 
66
- return {
67
- grid: {
68
- left: '3%',
69
- right: '4%',
70
- bottom: '3%',
71
- containLabel: true,
72
- },
73
- tooltip: showTooltip
74
- ? {
92
+ const getLabel = seriesLabelLookup(data.series);
93
+ // ECharts hides line labels along with the symbol
94
+ const showSymbol = showDataPoints || showLabels || hasPointLabels(data.series);
95
+
96
+ return mergeChartOption(
97
+ {
98
+ grid: {
99
+ left: '3%',
100
+ right: '4%',
101
+ bottom: '3%',
102
+ containLabel: true,
103
+ ...gridInset(legendConfig, showLegend && !renderLegend, xAxisConfig, yAxisConfig),
104
+ },
105
+ tooltip: buildTooltip(
106
+ {
75
107
  trigger: 'axis',
76
108
  axisPointer: {
77
109
  type: 'cross',
@@ -79,49 +111,77 @@
79
111
  backgroundColor: chartColors.axisPointerLabel,
80
112
  },
81
113
  },
114
+ },
115
+ {
116
+ show: showTooltip,
117
+ formatter: tooltipFormatter,
118
+ valueFormatter: tooltipValueFormatter,
119
+ getLabel,
82
120
  }
83
- : undefined,
84
- legend: showLegend
85
- ? {
86
- data: data.series.map((s) => s.name),
87
- bottom: 0,
88
- }
89
- : undefined,
90
- xAxis: {
91
- type: 'category',
92
- boundaryGap: false,
93
- data: data.categories,
94
- splitLine: {
95
- show: showGrid,
96
- },
97
- },
98
- yAxis: {
99
- type: 'value',
100
- splitLine: {
101
- show: showGrid,
102
- },
103
- },
104
- series: data.series.map((series) => ({
105
- name: series.name,
106
- type: 'line',
107
- data: series.data,
108
- smooth,
109
- showSymbol: showDataPoints,
110
- symbolSize: showDataPoints ? 6 : 0,
111
- stack: stacked ? 'Total' : undefined,
112
- areaStyle: areaFilled ? { opacity: 0.3 } : undefined,
113
- itemStyle: series.color ? { color: series.color } : undefined,
114
- emphasis: {
115
- focus: 'series',
116
- },
117
- })),
118
- };
121
+ ),
122
+ legend: buildLegend(
123
+ data.series.map((s) => s.name),
124
+ legendConfig,
125
+ { show: showLegend, custom: !!renderLegend }
126
+ ),
127
+ xAxis: buildAxis(
128
+ {
129
+ type: 'category',
130
+ boundaryGap: false,
131
+ data: data.categories,
132
+ splitLine: {
133
+ show: showGrid,
134
+ },
135
+ },
136
+ xAxisConfig
137
+ ),
138
+ yAxis: buildAxis(
139
+ {
140
+ type: 'value',
141
+ splitLine: {
142
+ show: showGrid,
143
+ },
144
+ },
145
+ yAxisConfig
146
+ ),
147
+ series: data.series.map((series) => ({
148
+ name: series.name,
149
+ type: 'line',
150
+ data: withPointLabels(series.data, series.labels, labelFormatter, { position: 'top' }),
151
+ smooth,
152
+ showSymbol,
153
+ symbolSize: showDataPoints ? 6 : 0,
154
+ stack: stacked ? 'Total' : undefined,
155
+ areaStyle: areaFilled ? { opacity: 0.3 } : undefined,
156
+ itemStyle: series.color ? { color: series.color } : undefined,
157
+ label: buildDataLabel({ show: true, position: 'top' }, undefined, {
158
+ show: showLabels,
159
+ formatter: labelFormatter,
160
+ getLabel,
161
+ }),
162
+ emphasis: {
163
+ focus: 'series',
164
+ },
165
+ })),
166
+ } as EChartsOption,
167
+ optionOverride
168
+ );
119
169
  });
120
170
 
121
171
  const accessibilityDescription = $derived(
122
172
  description || (!isEmpty ? generateLineChartDescription(title, data) : undefined)
123
173
  );
124
174
 
175
+ // Legend selection state, shared with a custom legend
176
+ const legend = useChartLegend({
177
+ names: () => data?.series?.map((s) => s.name) ?? [],
178
+ colors: () => data?.series?.map((s) => s.color) ?? [],
179
+ palette: () => getChartColors(chart.theme).series,
180
+ config: () => legendConfig,
181
+ title: () => title,
182
+ onLegendChange: () => onLegendChange,
183
+ });
184
+
125
185
  // Manage ECharts instance lifecycle (init, theme, resize, option updates)
126
186
  const chart = useChart({
127
187
  option: () => option,
@@ -129,6 +189,7 @@
129
189
  animation: () => animation,
130
190
  isEmpty: () => isEmpty,
131
191
  onReady: (instance) => {
192
+ legend.attach(instance);
132
193
  if (onClick) {
133
194
  instance.on('click', (params) => {
134
195
  onClick({
@@ -162,6 +223,10 @@
162
223
  });
163
224
  </script>
164
225
 
226
+ {#snippet customLegend()}
227
+ {@render renderLegend?.(legend.renderProps)}
228
+ {/snippet}
229
+
165
230
  <ChartContainer
166
231
  {title}
167
232
  description={accessibilityDescription}
@@ -171,6 +236,8 @@
171
236
  empty={isEmpty}
172
237
  {emptyMessage}
173
238
  class={cn(className)}
239
+ legend={renderLegend ? customLegend : undefined}
240
+ legendPosition={legendConfig?.position}
174
241
  >
175
242
  <div bind:this={chart.container} class="h-full w-full"></div>
176
243
  </ChartContainer>
@@ -1,5 +1,6 @@
1
1
  import type { LineChartProps } from '@classic-homes/charts-core';
2
- interface Props extends Omit<LineChartProps, 'class'> {
2
+ import { type CustomLegendProps } from '../../composables/useChartLegend.svelte.js';
3
+ interface Props extends Omit<LineChartProps, 'class'>, CustomLegendProps {
3
4
  class?: string;
4
5
  }
5
6
  declare const LineChart: import("svelte").Component<Props, {}, "">;