@classic-homes/charts-svelte 0.1.37 → 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 (40) hide show
  1. package/dist/lib/components/base/ChartContainer.svelte +51 -11
  2. package/dist/lib/components/base/ChartContainer.svelte.d.ts +8 -0
  3. package/dist/lib/components/base/ChartError.svelte +3 -1
  4. package/dist/lib/components/base/ChartLegend.svelte +59 -0
  5. package/dist/lib/components/base/ChartLegend.svelte.d.ts +10 -0
  6. package/dist/lib/components/base/index.d.ts +12 -0
  7. package/dist/lib/components/base/index.js +1 -0
  8. package/dist/lib/components/core/AreaChart.svelte +126 -61
  9. package/dist/lib/components/core/AreaChart.svelte.d.ts +2 -1
  10. package/dist/lib/components/core/BarChart.svelte +91 -35
  11. package/dist/lib/components/core/BarChart.svelte.d.ts +2 -1
  12. package/dist/lib/components/core/DonutChart.svelte +155 -82
  13. package/dist/lib/components/core/DonutChart.svelte.d.ts +2 -1
  14. package/dist/lib/components/core/LineChart.svelte +118 -48
  15. package/dist/lib/components/core/LineChart.svelte.d.ts +2 -1
  16. package/dist/lib/components/core/PieChart.svelte +100 -38
  17. package/dist/lib/components/core/PieChart.svelte.d.ts +2 -1
  18. package/dist/lib/components/core/ScatterChart.svelte +115 -38
  19. package/dist/lib/components/core/ScatterChart.svelte.d.ts +2 -1
  20. package/dist/lib/components/extended/CandlestickChart.svelte +53 -26
  21. package/dist/lib/components/extended/FunnelChart.svelte +102 -32
  22. package/dist/lib/components/extended/FunnelChart.svelte.d.ts +2 -1
  23. package/dist/lib/components/extended/GaugeChart.svelte +62 -29
  24. package/dist/lib/components/extended/HeatmapChart.svelte +59 -29
  25. package/dist/lib/components/extended/RadarChart.svelte +76 -22
  26. package/dist/lib/components/extended/RadarChart.svelte.d.ts +2 -1
  27. package/dist/lib/components/extended/SankeyChart.svelte +50 -21
  28. package/dist/lib/components/extended/TreemapChart.svelte +71 -26
  29. package/dist/lib/components/index.d.ts +1 -1
  30. package/dist/lib/components/index.js +1 -1
  31. package/dist/lib/composables/index.d.ts +1 -0
  32. package/dist/lib/composables/index.js +1 -0
  33. package/dist/lib/composables/useChart.svelte.js +20 -3
  34. package/dist/lib/composables/useChartLegend.svelte.d.ts +37 -0
  35. package/dist/lib/composables/useChartLegend.svelte.js +54 -0
  36. package/dist/lib/composables/useChartTheme.svelte.d.ts +2 -1
  37. package/dist/lib/composables/useChartTheme.svelte.js +2 -6
  38. package/dist/lib/index.d.ts +3 -3
  39. package/dist/lib/index.js +2 -2
  40. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import type { ChartLegendPosition } from '@classic-homes/charts-core';
3
4
  import { cn } from '../../utils.js';
4
5
  import ChartSkeleton from './ChartSkeleton.svelte';
5
6
  import ChartError from './ChartError.svelte';
@@ -16,6 +17,13 @@
16
17
  onRetry?: () => void;
17
18
  class?: string;
18
19
  wrapperClass?: string;
20
+ /**
21
+ * Custom legend, rendered beside the chart rather than inside the
22
+ * `role="img"` element so its controls stay accessible
23
+ */
24
+ legend?: Snippet;
25
+ /** Where the custom legend sits (defaults to 'bottom') */
26
+ legendPosition?: ChartLegendPosition;
19
27
  children: Snippet;
20
28
  }
21
29
 
@@ -30,28 +38,29 @@
30
38
  onRetry,
31
39
  class: className,
32
40
  wrapperClass,
41
+ legend,
42
+ legendPosition = 'bottom',
33
43
  children,
34
44
  }: Props = $props();
35
45
 
46
+ // Unique per instance: two charts may share a title
47
+ const uid = $props.id();
48
+
36
49
  const heightStyle = $derived(typeof height === 'number' ? `${height}px` : height);
37
- const descriptionId = $derived(
38
- description ? `${title.replace(/\s+/g, '-').toLowerCase()}-desc` : undefined
39
- );
50
+ const descriptionId = $derived(description ? `${uid}-desc` : undefined);
51
+
52
+ // The legend's DOM order follows its visual position
53
+ const side = $derived(legendPosition === 'left' || legendPosition === 'right');
54
+ const leading = $derived(legendPosition === 'top' || legendPosition === 'left');
40
55
  </script>
41
56
 
42
- {#if loading}
43
- <ChartSkeleton {height} class={wrapperClass} />
44
- {:else if error}
45
- <ChartError message={error} {height} {onRetry} class={wrapperClass} />
46
- {:else if empty}
47
- <ChartEmpty message={emptyMessage} {height} class={wrapperClass} />
48
- {:else}
57
+ {#snippet chart(chartClass: string)}
49
58
  <div
50
59
  role="img"
51
60
  aria-label={title}
52
61
  aria-describedby={descriptionId}
53
62
  data-testid="chart-container"
54
- class={cn('rounded-lg border border-border bg-card', wrapperClass)}
63
+ class={chartClass}
55
64
  >
56
65
  {#if description}
57
66
  <span id={descriptionId} class="sr-only">{description}</span>
@@ -60,4 +69,35 @@
60
69
  {@render children()}
61
70
  </div>
62
71
  </div>
72
+ {/snippet}
73
+
74
+ {#snippet legendArea(content: Snippet)}
75
+ <div
76
+ data-testid="chart-legend"
77
+ class={cn('flex shrink-0 p-2', side ? 'items-center' : 'justify-center')}
78
+ >
79
+ {@render content()}
80
+ </div>
81
+ {/snippet}
82
+
83
+ {#if loading}
84
+ <ChartSkeleton {height} class={wrapperClass} />
85
+ {:else if error}
86
+ <ChartError message={error} {height} {onRetry} class={wrapperClass} />
87
+ {:else if empty}
88
+ <ChartEmpty message={emptyMessage} {height} class={wrapperClass} />
89
+ {:else if legend}
90
+ <div
91
+ class={cn(
92
+ 'flex rounded-lg border border-border bg-card',
93
+ side ? 'flex-row' : 'flex-col',
94
+ wrapperClass
95
+ )}
96
+ >
97
+ {#if leading}{@render legendArea(legend)}{/if}
98
+ {@render chart('min-h-0 min-w-0 flex-1')}
99
+ {#if !leading}{@render legendArea(legend)}{/if}
100
+ </div>
101
+ {:else}
102
+ {@render chart(cn('rounded-lg border border-border bg-card', wrapperClass))}
63
103
  {/if}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { ChartLegendPosition } from '@classic-homes/charts-core';
2
3
  interface Props {
3
4
  title: string;
4
5
  description?: string;
@@ -10,6 +11,13 @@ interface Props {
10
11
  onRetry?: () => void;
11
12
  class?: string;
12
13
  wrapperClass?: string;
14
+ /**
15
+ * Custom legend, rendered beside the chart rather than inside the
16
+ * `role="img"` element so its controls stay accessible
17
+ */
18
+ legend?: Snippet;
19
+ /** Where the custom legend sits (defaults to 'bottom') */
20
+ legendPosition?: ChartLegendPosition;
13
21
  children: Snippet;
14
22
  }
15
23
  declare const ChartContainer: import("svelte").Component<Props, {}, "">;
@@ -37,9 +37,11 @@
37
37
  d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
38
38
  />
39
39
  </svg>
40
- <p class="mb-2 text-sm font-medium text-destructive">{message}</p>
40
+ <!-- text-destructive on the destructive tint fails AA; the icon and border carry the colour -->
41
+ <p class="mb-2 text-sm font-medium text-foreground">{message}</p>
41
42
  {#if onRetry}
42
43
  <button
44
+ type="button"
43
45
  onclick={onRetry}
44
46
  class="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
45
47
  >
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import type { LegendRenderProps } from '@classic-homes/charts-core';
3
+ import { cn } from '../../utils.js';
4
+
5
+ interface Props
6
+ extends
7
+ Pick<LegendRenderProps, 'items' | 'toggle'>,
8
+ Partial<Omit<LegendRenderProps, 'items' | 'toggle'>> {
9
+ /** Lay items out in a column (defaults to vertical for left/right positions) */
10
+ orientation?: 'horizontal' | 'vertical';
11
+ /** Additional classes for the list */
12
+ class?: string;
13
+ }
14
+
15
+ let {
16
+ items,
17
+ toggle,
18
+ position = 'bottom',
19
+ chartTitle,
20
+ orientation,
21
+ class: className,
22
+ }: Props = $props();
23
+
24
+ const vertical = $derived(
25
+ (orientation ?? (position === 'left' || position === 'right' ? 'vertical' : 'horizontal')) ===
26
+ 'vertical'
27
+ );
28
+ </script>
29
+
30
+ <!--
31
+ Accessible HTML legend for use with a chart's `renderLegend` snippet. Each
32
+ item is a toggle button that shows or hides its series.
33
+ -->
34
+ <ul
35
+ aria-label={chartTitle ? `${chartTitle} legend` : 'Chart legend'}
36
+ class={cn('flex gap-1 text-sm', vertical ? 'flex-col' : 'flex-wrap justify-center', className)}
37
+ >
38
+ {#each items as item (item.name)}
39
+ <li>
40
+ <button
41
+ type="button"
42
+ aria-pressed={item.visible}
43
+ onclick={() => toggle(item.name)}
44
+ class={cn(
45
+ 'inline-flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
46
+ item.visible ? 'text-foreground' : 'text-muted-foreground line-through'
47
+ )}
48
+ >
49
+ <span
50
+ aria-hidden="true"
51
+ class="h-3 w-3 shrink-0 rounded-sm"
52
+ style:background-color={item.color}
53
+ style:opacity={item.visible ? 1 : 0.35}
54
+ ></span>
55
+ {item.name}
56
+ </button>
57
+ </li>
58
+ {/each}
59
+ </ul>
@@ -0,0 +1,10 @@
1
+ import type { LegendRenderProps } from '@classic-homes/charts-core';
2
+ interface Props extends Pick<LegendRenderProps, 'items' | 'toggle'>, Partial<Omit<LegendRenderProps, 'items' | 'toggle'>> {
3
+ /** Lay items out in a column (defaults to vertical for left/right positions) */
4
+ orientation?: 'horizontal' | 'vertical';
5
+ /** Additional classes for the list */
6
+ class?: string;
7
+ }
8
+ declare const ChartLegend: import("svelte").Component<Props, {}, "">;
9
+ type ChartLegend = ReturnType<typeof ChartLegend>;
10
+ export default ChartLegend;
@@ -2,3 +2,15 @@ export { default as ChartContainer } from './ChartContainer.svelte';
2
2
  export { default as ChartSkeleton } from './ChartSkeleton.svelte';
3
3
  export { default as ChartError } from './ChartError.svelte';
4
4
  export { default as ChartEmpty } from './ChartEmpty.svelte';
5
+ export { default as ChartLegend } from './ChartLegend.svelte';
6
+ import type { ComponentProps } from 'svelte';
7
+ import type ChartContainer from './ChartContainer.svelte';
8
+ import type ChartSkeleton from './ChartSkeleton.svelte';
9
+ import type ChartError from './ChartError.svelte';
10
+ import type ChartEmpty from './ChartEmpty.svelte';
11
+ import type ChartLegend from './ChartLegend.svelte';
12
+ export type ChartContainerProps = ComponentProps<typeof ChartContainer>;
13
+ export type ChartSkeletonProps = ComponentProps<typeof ChartSkeleton>;
14
+ export type ChartErrorProps = ComponentProps<typeof ChartError>;
15
+ export type ChartEmptyProps = ComponentProps<typeof ChartEmpty>;
16
+ export type ChartLegendProps = ComponentProps<typeof ChartLegend>;
@@ -2,3 +2,4 @@ export { default as ChartContainer } from './ChartContainer.svelte';
2
2
  export { default as ChartSkeleton } from './ChartSkeleton.svelte';
3
3
  export { default as ChartError } from './ChartError.svelte';
4
4
  export { default as ChartEmpty } from './ChartEmpty.svelte';
5
+ export { default as ChartLegend } from './ChartLegend.svelte';
@@ -11,9 +11,26 @@
11
11
  import type { EChartsOption } from 'echarts';
12
12
 
13
13
  import type { AreaChartProps, DataPointEventParams } from '@classic-homes/charts-core';
14
+ import {
15
+ buildAxis,
16
+ buildDataLabel,
17
+ buildLegend,
18
+ buildTooltip,
19
+ colorWithAlpha,
20
+ getChartColors,
21
+ hasPointLabels,
22
+ gridInset,
23
+ mergeChartOption,
24
+ seriesLabelLookup,
25
+ withPointLabels,
26
+ } from '@classic-homes/charts-core';
14
27
 
15
28
  import { cn } from '../../utils.js';
16
29
  import { useChart } from '../../composables/useChart.svelte.js';
30
+ import {
31
+ useChartLegend,
32
+ type CustomLegendProps,
33
+ } from '../../composables/useChartLegend.svelte.js';
17
34
  import ChartContainer from '../base/ChartContainer.svelte';
18
35
 
19
36
  // Register required ECharts modules
@@ -26,7 +43,7 @@
26
43
  CanvasRenderer,
27
44
  ]);
28
45
 
29
- interface Props extends Omit<AreaChartProps, 'class'> {
46
+ interface Props extends Omit<AreaChartProps, 'class'>, CustomLegendProps {
30
47
  class?: string;
31
48
  }
32
49
 
@@ -46,6 +63,16 @@
46
63
  smooth = false,
47
64
  stacked = false,
48
65
  gradient = true,
66
+ showLabels = false,
67
+ labelFormatter,
68
+ xAxisConfig,
69
+ yAxisConfig,
70
+ tooltipFormatter,
71
+ tooltipValueFormatter,
72
+ legend: legendConfig,
73
+ renderLegend,
74
+ onLegendChange,
75
+ option: optionOverride,
49
76
  onClick,
50
77
  class: className,
51
78
  }: Props = $props();
@@ -54,76 +81,97 @@
54
81
  !data?.categories?.length || !data?.series?.length || data.series.every((s) => !s.data?.length)
55
82
  );
56
83
 
84
+ // Per-element colours that follow the resolved chart theme
85
+ const chartColors = $derived.by(() => getChartColors(chart.theme));
86
+
57
87
  const option: EChartsOption = $derived.by(() => {
58
88
  if (isEmpty) return {};
59
89
 
60
- return {
61
- grid: {
62
- left: '3%',
63
- right: '4%',
64
- bottom: '3%',
65
- containLabel: true,
66
- },
67
- tooltip: showTooltip
68
- ? {
90
+ const getLabel = seriesLabelLookup(data.series);
91
+ // ECharts hides line labels along with the symbol
92
+ const showSymbol = showLabels || hasPointLabels(data.series);
93
+
94
+ return mergeChartOption(
95
+ {
96
+ grid: {
97
+ left: '3%',
98
+ right: '4%',
99
+ bottom: '3%',
100
+ containLabel: true,
101
+ ...gridInset(legendConfig, showLegend && !renderLegend, xAxisConfig, yAxisConfig),
102
+ },
103
+ tooltip: buildTooltip(
104
+ {
69
105
  trigger: 'axis',
70
106
  axisPointer: {
71
107
  type: 'cross',
72
108
  label: {
73
- backgroundColor: '#6a7985',
109
+ backgroundColor: chartColors.axisPointerLabel,
74
110
  },
75
111
  },
112
+ },
113
+ {
114
+ show: showTooltip,
115
+ formatter: tooltipFormatter,
116
+ valueFormatter: tooltipValueFormatter,
117
+ getLabel,
76
118
  }
77
- : undefined,
78
- legend: showLegend
79
- ? {
80
- data: data.series.map((s) => s.name),
81
- bottom: 0,
82
- }
83
- : undefined,
84
- xAxis: {
85
- type: 'category',
86
- boundaryGap: false,
87
- data: data.categories,
88
- },
89
- yAxis: {
90
- type: 'value',
91
- splitLine: {
92
- show: showGrid,
93
- },
94
- },
95
- series: data.series.map((series, index) => ({
96
- name: series.name,
97
- type: 'line',
98
- data: series.data,
99
- smooth,
100
- stack: stacked ? 'Total' : undefined,
101
- areaStyle: gradient
102
- ? {
103
- opacity: 0.8,
104
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
105
- {
106
- offset: 0,
107
- color:
108
- series.color ||
109
- `rgba(${60 + index * 40}, ${160 - index * 20}, ${180 - index * 30}, 0.8)`,
110
- },
111
- {
112
- offset: 1,
113
- color:
114
- series.color ||
115
- `rgba(${60 + index * 40}, ${160 - index * 20}, ${180 - index * 30}, 0.1)`,
116
- },
117
- ]),
118
- }
119
- : { opacity: 0.5 },
120
- itemStyle: series.color ? { color: series.color } : undefined,
121
- emphasis: {
122
- focus: 'series',
123
- },
124
- showSymbol: false,
125
- })),
126
- };
119
+ ),
120
+ legend: buildLegend(
121
+ data.series.map((s) => s.name),
122
+ legendConfig,
123
+ { show: showLegend, custom: !!renderLegend }
124
+ ),
125
+ xAxis: buildAxis(
126
+ {
127
+ type: 'category',
128
+ boundaryGap: false,
129
+ data: data.categories,
130
+ },
131
+ xAxisConfig
132
+ ),
133
+ yAxis: buildAxis(
134
+ {
135
+ type: 'value',
136
+ splitLine: {
137
+ show: showGrid,
138
+ },
139
+ },
140
+ yAxisConfig
141
+ ),
142
+ series: data.series.map((series, index) => {
143
+ // Fade the series colour (theme palette unless overridden) downwards
144
+ const base = series.color || chartColors.series[index % chartColors.series.length];
145
+ return {
146
+ name: series.name,
147
+ type: 'line',
148
+ data: withPointLabels(series.data, series.labels, labelFormatter, { position: 'top' }),
149
+ smooth,
150
+ stack: stacked ? 'Total' : undefined,
151
+ areaStyle: gradient
152
+ ? {
153
+ opacity: 0.8,
154
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
155
+ { offset: 0, color: colorWithAlpha(base, 0.8) },
156
+ { offset: 1, color: colorWithAlpha(base, 0.1) },
157
+ ]),
158
+ }
159
+ : { opacity: 0.5 },
160
+ itemStyle: series.color ? { color: series.color } : undefined,
161
+ label: buildDataLabel({ show: true, position: 'top' }, undefined, {
162
+ show: showLabels,
163
+ formatter: labelFormatter,
164
+ getLabel,
165
+ }),
166
+ emphasis: {
167
+ focus: 'series',
168
+ },
169
+ showSymbol,
170
+ };
171
+ }),
172
+ } as EChartsOption,
173
+ optionOverride
174
+ );
127
175
  });
128
176
 
129
177
  const accessibilityDescription = $derived(
@@ -133,6 +181,16 @@
133
181
  : undefined)
134
182
  );
135
183
 
184
+ // Legend selection state, shared with a custom legend
185
+ const legend = useChartLegend({
186
+ names: () => data?.series?.map((s) => s.name) ?? [],
187
+ colors: () => data?.series?.map((s) => s.color) ?? [],
188
+ palette: () => getChartColors(chart.theme).series,
189
+ config: () => legendConfig,
190
+ title: () => title,
191
+ onLegendChange: () => onLegendChange,
192
+ });
193
+
136
194
  // Manage ECharts instance lifecycle (init, theme, resize, option updates)
137
195
  const chart = useChart({
138
196
  option: () => option,
@@ -140,6 +198,7 @@
140
198
  animation: () => animation,
141
199
  isEmpty: () => isEmpty,
142
200
  onReady: (instance) => {
201
+ legend.attach(instance);
143
202
  if (onClick) {
144
203
  instance.on('click', (params) => {
145
204
  onClick({
@@ -158,6 +217,10 @@
158
217
  });
159
218
  </script>
160
219
 
220
+ {#snippet customLegend()}
221
+ {@render renderLegend?.(legend.renderProps)}
222
+ {/snippet}
223
+
161
224
  <ChartContainer
162
225
  {title}
163
226
  description={accessibilityDescription}
@@ -167,6 +230,8 @@
167
230
  empty={isEmpty}
168
231
  {emptyMessage}
169
232
  class={cn(className)}
233
+ legend={renderLegend ? customLegend : undefined}
234
+ legendPosition={legendConfig?.position}
170
235
  >
171
236
  <div bind:this={chart.container} class="h-full w-full"></div>
172
237
  </ChartContainer>
@@ -1,5 +1,6 @@
1
1
  import type { AreaChartProps } from '@classic-homes/charts-core';
2
- interface Props extends Omit<AreaChartProps, 'class'> {
2
+ import { type CustomLegendProps } from '../../composables/useChartLegend.svelte.js';
3
+ interface Props extends Omit<AreaChartProps, 'class'>, CustomLegendProps {
3
4
  class?: string;
4
5
  }
5
6
  declare const AreaChart: import("svelte").Component<Props, {}, "">;
@@ -11,10 +11,25 @@
11
11
  import type { EChartsOption } from 'echarts';
12
12
 
13
13
  import type { BarChartProps, DataPointEventParams } from '@classic-homes/charts-core';
14
- import { generateBarChartDescription } from '@classic-homes/charts-core';
14
+ import {
15
+ buildAxis,
16
+ buildDataLabel,
17
+ buildLegend,
18
+ buildTooltip,
19
+ generateBarChartDescription,
20
+ getChartColors,
21
+ gridInset,
22
+ mergeChartOption,
23
+ seriesLabelLookup,
24
+ withPointLabels,
25
+ } from '@classic-homes/charts-core';
15
26
 
16
27
  import { cn } from '../../utils.js';
17
28
  import { useChart } from '../../composables/useChart.svelte.js';
29
+ import {
30
+ useChartLegend,
31
+ type CustomLegendProps,
32
+ } from '../../composables/useChartLegend.svelte.js';
18
33
  import ChartContainer from '../base/ChartContainer.svelte';
19
34
 
20
35
  // Register required ECharts modules
@@ -27,7 +42,7 @@
27
42
  CanvasRenderer,
28
43
  ]);
29
44
 
30
- interface Props extends Omit<BarChartProps, 'class'> {
45
+ interface Props extends Omit<BarChartProps, 'class'>, CustomLegendProps {
31
46
  class?: string;
32
47
  }
33
48
 
@@ -47,6 +62,15 @@
47
62
  orientation = 'vertical',
48
63
  stacked = false,
49
64
  showValues = false,
65
+ labelFormatter,
66
+ xAxisConfig,
67
+ yAxisConfig,
68
+ tooltipFormatter,
69
+ tooltipValueFormatter,
70
+ legend: legendConfig,
71
+ renderLegend,
72
+ onLegendChange,
73
+ option: optionOverride,
50
74
  onClick,
51
75
  class: className,
52
76
  }: Props = $props();
@@ -75,52 +99,77 @@
75
99
  },
76
100
  };
77
101
 
78
- return {
79
- grid: {
80
- left: '3%',
81
- right: '4%',
82
- bottom: '3%',
83
- containLabel: true,
84
- },
85
- tooltip: showTooltip
86
- ? {
102
+ const getLabel = seriesLabelLookup(data.series);
103
+
104
+ return mergeChartOption(
105
+ {
106
+ grid: {
107
+ left: '3%',
108
+ right: '4%',
109
+ bottom: '3%',
110
+ containLabel: true,
111
+ ...gridInset(legendConfig, showLegend && !renderLegend, xAxisConfig, yAxisConfig),
112
+ },
113
+ tooltip: buildTooltip(
114
+ {
87
115
  trigger: 'axis',
88
116
  axisPointer: {
89
117
  type: 'shadow',
90
118
  },
119
+ },
120
+ {
121
+ show: showTooltip,
122
+ formatter: tooltipFormatter,
123
+ valueFormatter: tooltipValueFormatter,
124
+ getLabel,
91
125
  }
92
- : undefined,
93
- legend: showLegend
94
- ? {
95
- data: data.series.map((s) => s.name),
96
- bottom: 0,
97
- }
98
- : undefined,
99
- xAxis: isHorizontal ? valueAxis : categoryAxis,
100
- yAxis: isHorizontal ? categoryAxis : valueAxis,
101
- series: data.series.map((series) => ({
102
- name: series.name,
103
- type: 'bar',
104
- data: series.data,
105
- stack: stacked ? 'Total' : undefined,
106
- itemStyle: series.color ? { color: series.color } : undefined,
107
- label: showValues
108
- ? {
126
+ ),
127
+ legend: buildLegend(
128
+ data.series.map((s) => s.name),
129
+ legendConfig,
130
+ { show: showLegend, custom: !!renderLegend }
131
+ ),
132
+ xAxis: buildAxis(isHorizontal ? valueAxis : categoryAxis, xAxisConfig),
133
+ yAxis: buildAxis(isHorizontal ? categoryAxis : valueAxis, yAxisConfig),
134
+ series: data.series.map((series) => ({
135
+ name: series.name,
136
+ type: 'bar',
137
+ data: withPointLabels(series.data, series.labels, labelFormatter, {
138
+ position: isHorizontal ? 'right' : 'top',
139
+ }),
140
+ stack: stacked ? 'Total' : undefined,
141
+ itemStyle: series.color ? { color: series.color } : undefined,
142
+ label: buildDataLabel(
143
+ {
109
144
  show: true,
110
145
  position: isHorizontal ? 'right' : 'top',
111
- }
112
- : undefined,
113
- emphasis: {
114
- focus: 'series',
115
- },
116
- })),
117
- };
146
+ },
147
+ undefined,
148
+ { show: showValues, formatter: labelFormatter, getLabel }
149
+ ),
150
+ emphasis: {
151
+ focus: 'series',
152
+ },
153
+ })),
154
+ } as EChartsOption,
155
+ optionOverride
156
+ );
118
157
  });
119
158
 
120
159
  const accessibilityDescription = $derived(
121
160
  description || (!isEmpty ? generateBarChartDescription(title, data) : undefined)
122
161
  );
123
162
 
163
+ // Legend selection state, shared with a custom legend
164
+ const legend = useChartLegend({
165
+ names: () => data?.series?.map((s) => s.name) ?? [],
166
+ colors: () => data?.series?.map((s) => s.color) ?? [],
167
+ palette: () => getChartColors(chart.theme).series,
168
+ config: () => legendConfig,
169
+ title: () => title,
170
+ onLegendChange: () => onLegendChange,
171
+ });
172
+
124
173
  // Manage ECharts instance lifecycle (init, theme, resize, option updates)
125
174
  const chart = useChart({
126
175
  option: () => option,
@@ -128,6 +177,7 @@
128
177
  animation: () => animation,
129
178
  isEmpty: () => isEmpty,
130
179
  onReady: (instance) => {
180
+ legend.attach(instance);
131
181
  if (onClick) {
132
182
  instance.on('click', (params) => {
133
183
  onClick({
@@ -146,6 +196,10 @@
146
196
  });
147
197
  </script>
148
198
 
199
+ {#snippet customLegend()}
200
+ {@render renderLegend?.(legend.renderProps)}
201
+ {/snippet}
202
+
149
203
  <ChartContainer
150
204
  {title}
151
205
  description={accessibilityDescription}
@@ -155,6 +209,8 @@
155
209
  empty={isEmpty}
156
210
  {emptyMessage}
157
211
  class={cn(className)}
212
+ legend={renderLegend ? customLegend : undefined}
213
+ legendPosition={legendConfig?.position}
158
214
  >
159
215
  <div bind:this={chart.container} class="h-full w-full"></div>
160
216
  </ChartContainer>