@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
@@ -1,8 +1,7 @@
1
1
  <script lang="ts">
2
2
  import * as echarts from 'echarts/core';
3
3
  import { TreemapChart as EChartsTreemapChart } from 'echarts/charts';
4
- import { TooltipComponent, TitleComponent } from 'echarts/components';
5
- import { CanvasRenderer } from 'echarts/renderers';
4
+ import { TooltipComponent } from 'echarts/components';
6
5
  import type { EChartsOption } from 'echarts';
7
6
 
8
7
  import type {
@@ -19,13 +18,16 @@
19
18
  mergeChartOption,
20
19
  nameLabelLookup,
21
20
  pointLabel,
21
+ resolveValueFormatter,
22
+ treemapTable,
22
23
  } from '@classic-homes/charts-core';
23
24
 
24
25
  import { cn } from '../../utils.js';
26
+ import { chartEvents } from '../../events.js';
25
27
  import { useChart } from '../../composables/useChart.svelte.js';
26
28
  import ChartContainer from '../base/ChartContainer.svelte';
27
29
 
28
- echarts.use([EChartsTreemapChart, TooltipComponent, TitleComponent, CanvasRenderer]);
30
+ echarts.use([EChartsTreemapChart, TooltipComponent]);
29
31
 
30
32
  interface Props extends Omit<TreemapChartProps, 'class'> {
31
33
  class?: string;
@@ -37,8 +39,11 @@
37
39
  data,
38
40
  levels,
39
41
  height = 400,
42
+ minHeight,
43
+ maxHeight,
40
44
  loading,
41
45
  error,
46
+ onRetry,
42
47
  emptyMessage,
43
48
  theme = 'auto',
44
49
  animation = true,
@@ -47,10 +52,22 @@
47
52
  tooltipFormatter,
48
53
  tooltipValueFormatter,
49
54
  option: optionOverride,
55
+ valueFormat,
56
+ locale,
57
+ onReady,
58
+ events,
59
+ showDataTable,
60
+ exportable,
61
+ patterns,
50
62
  onClick,
51
63
  class: className,
52
64
  }: Props = $props();
53
65
 
66
+ // `valueFormat` writes values in tooltips and the data table
67
+ const valueFormatter = $derived(
68
+ tooltipValueFormatter ?? resolveValueFormatter(valueFormat, locale)
69
+ );
70
+
54
71
  const isEmpty = $derived(!data?.length);
55
72
 
56
73
  // Per-element colours that follow the resolved chart theme
@@ -61,8 +78,10 @@
61
78
 
62
79
  const getLabel = nameLabelLookup(data);
63
80
  // ECharts expects a label object, so swap each node's label text for one
64
- const toNode = (n: TreemapNode): Record<string, unknown> => ({
81
+ // and a colour for a node fill (a node's own `color` is a palette for its children)
82
+ const toNode = ({ color, ...n }: TreemapNode): Record<string, unknown> => ({
65
83
  ...n,
84
+ ...(color && { itemStyle: { color } }),
66
85
  children: n.children?.map(toNode),
67
86
  label: pointLabel(n.label, labelFormatter),
68
87
  });
@@ -73,13 +92,13 @@
73
92
  {
74
93
  formatter: (params: unknown) => {
75
94
  const p = params as { name?: string; value?: number };
76
- return `${escapeHtml(p.name)}: ${escapeHtml(formatTooltipValue(p.value, tooltipValueFormatter))}`;
95
+ return `${escapeHtml(p.name)}: ${escapeHtml(formatTooltipValue(p.value, valueFormatter))}`;
77
96
  },
78
97
  },
79
98
  {
80
99
  show: showTooltip,
81
100
  formatter: tooltipFormatter,
82
- valueFormatter: tooltipValueFormatter,
101
+ valueFormatter,
83
102
  getLabel,
84
103
  }
85
104
  ),
@@ -118,27 +137,32 @@
118
137
  description || (!isEmpty ? `${title}. Treemap chart showing hierarchical data.` : undefined)
119
138
  );
120
139
 
140
+ const dataTable = $derived(isEmpty ? undefined : treemapTable(data));
141
+
121
142
  // Manage ECharts instance lifecycle (init, theme, resize, option updates)
122
143
  const chart = useChart({
123
144
  option: () => option,
124
145
  theme: () => theme,
125
146
  animation: () => animation,
126
147
  isEmpty: () => isEmpty,
127
- onReady: (instance) => {
128
- if (onClick) {
129
- instance.on('click', (params) => {
130
- const treePathInfo = params.treePathInfo || [];
131
- onClick({
132
- type: params.type || 'click',
133
- componentType: params.componentType || 'series',
134
- name: params.name || '',
135
- value: params.value as number,
136
- path: treePathInfo.map((item: { name?: string }) => item.name || ''),
137
- depth: treePathInfo.length - 1,
138
- } as TreemapEventParams);
139
- });
140
- }
141
- },
148
+ patterns: () => patterns,
149
+ events: () =>
150
+ chartEvents(events, {
151
+ click:
152
+ onClick &&
153
+ ((params) => {
154
+ const treePathInfo = params.treePathInfo || [];
155
+ onClick({
156
+ type: params.type || 'click',
157
+ componentType: params.componentType || 'series',
158
+ name: params.name || '',
159
+ value: params.value as number,
160
+ path: treePathInfo.map((item: { name?: string }) => item.name || ''),
161
+ depth: treePathInfo.length - 1,
162
+ } as TreemapEventParams);
163
+ }),
164
+ }),
165
+ onCreated: () => onReady,
142
166
  });
143
167
  </script>
144
168
 
@@ -146,11 +170,19 @@
146
170
  {title}
147
171
  description={accessibilityDescription}
148
172
  {height}
173
+ {minHeight}
174
+ {maxHeight}
149
175
  {loading}
150
176
  {error}
177
+ {onRetry}
151
178
  empty={isEmpty}
152
179
  {emptyMessage}
153
180
  class={cn(className)}
181
+ {dataTable}
182
+ {showDataTable}
183
+ format={valueFormatter}
184
+ {exportable}
185
+ exportImage={chart.exportImage}
154
186
  >
155
187
  <div bind:this={chart.container} class="h-full w-full"></div>
156
188
  </ChartContainer>
@@ -1,3 +1,4 @@
1
- export { ChartContainer, ChartSkeleton, ChartError, ChartEmpty, ChartLegend, type ChartContainerProps, type ChartLegendProps, type ChartSkeletonProps, type ChartErrorProps, type ChartEmptyProps, } from './base/index.js';
1
+ export { ChartContainer, ChartSkeleton, ChartError, ChartEmpty, ChartLegend, type ChartContainerProps, type ChartLegendProps, type ChartSkeletonProps, type ChartErrorProps, type ChartEmptyProps, ChartDataTable, type ChartDataTableProps, } from './base/index.js';
2
2
  export { LineChart, BarChart, PieChart, AreaChart, ScatterChart, DonutChart, } from './core/index.js';
3
3
  export { RadarChart, FunnelChart, GaugeChart, HeatmapChart, TreemapChart, CandlestickChart, SankeyChart, } from './extended/index.js';
4
+ export { ComboChart, DotPlotChart, BulletChart, HistogramChart, BoxPlotChart, WaterfallChart, Sparkline, StatCard, StatGroup, type StatGroupProps, } from './report/index.js';
@@ -1,6 +1,8 @@
1
1
  // Base components
2
- export { ChartContainer, ChartSkeleton, ChartError, ChartEmpty, ChartLegend, } from './base/index.js';
2
+ export { ChartContainer, ChartSkeleton, ChartError, ChartEmpty, ChartLegend, ChartDataTable, } from './base/index.js';
3
3
  // Core charts
4
4
  export { LineChart, BarChart, PieChart, AreaChart, ScatterChart, DonutChart, } from './core/index.js';
5
5
  // Extended charts
6
6
  export { RadarChart, FunnelChart, GaugeChart, HeatmapChart, TreemapChart, CandlestickChart, SankeyChart, } from './extended/index.js';
7
+ // Report charts and stats
8
+ export { ComboChart, DotPlotChart, BulletChart, HistogramChart, BoxPlotChart, WaterfallChart, Sparkline, StatCard, StatGroup, } from './report/index.js';
@@ -0,0 +1,253 @@
1
+ <script lang="ts">
2
+ import * as echarts from 'echarts/core';
3
+ import {
4
+ BoxplotChart as EChartsBoxplotChart,
5
+ ScatterChart as EChartsScatterChart,
6
+ } from 'echarts/charts';
7
+ import { GridComponent, TooltipComponent } from 'echarts/components';
8
+ import type { EChartsOption } from 'echarts';
9
+
10
+ import type { BoxPlotChartProps } from '@classic-homes/charts-core';
11
+ import {
12
+ boxPlotTable,
13
+ boxplotStats,
14
+ buildAxis,
15
+ buildTooltip,
16
+ chartFormatters,
17
+ colorWithAlpha,
18
+ escapeHtml,
19
+ formatValue,
20
+ generateBoxPlotDescription,
21
+ getChartColors,
22
+ gridInset,
23
+ mergeChartOption,
24
+ quantile,
25
+ withReferenceMarks,
26
+ } from '@classic-homes/charts-core';
27
+
28
+ import { cn } from '../../utils.js';
29
+ import { chartEvents, toDataPointParams } from '../../events.js';
30
+ import { useChart } from '../../composables/useChart.svelte.js';
31
+ import ChartContainer from '../base/ChartContainer.svelte';
32
+
33
+ // Register required ECharts modules
34
+ echarts.use([EChartsBoxplotChart, EChartsScatterChart, GridComponent, TooltipComponent]);
35
+
36
+ interface Props extends Omit<BoxPlotChartProps, 'class'> {
37
+ class?: string;
38
+ }
39
+
40
+ let {
41
+ title,
42
+ description,
43
+ data,
44
+ orientation = 'vertical',
45
+ showOutliers = true,
46
+ color,
47
+ height = 400,
48
+ minHeight,
49
+ maxHeight,
50
+ loading,
51
+ error,
52
+ onRetry,
53
+ emptyMessage,
54
+ theme = 'auto',
55
+ animation = true,
56
+ showTooltip = true,
57
+ showGrid = true,
58
+ valueFormat,
59
+ locale,
60
+ xAxisConfig,
61
+ yAxisConfig,
62
+ referenceLines,
63
+ referenceBands,
64
+ tooltipFormatter,
65
+ tooltipValueFormatter,
66
+ option: optionOverride,
67
+ onReady,
68
+ events,
69
+ showDataTable,
70
+ exportable,
71
+ patterns,
72
+ onClick,
73
+ class: className,
74
+ }: Props = $props();
75
+
76
+ const groups = $derived(
77
+ (data ?? []).map((group) => ({ name: group.name, stats: boxplotStats(group.values) }))
78
+ );
79
+
80
+ const isEmpty = $derived(!groups.some((group) => group.stats));
81
+
82
+ // A statistic of every observation, for reference lines
83
+ const statistic = (kind: 'average' | 'median' | 'min' | 'max') => {
84
+ const all = (data ?? [])
85
+ .flatMap((group) => group.values)
86
+ .filter((value) => Number.isFinite(value))
87
+ .sort((a, b) => a - b);
88
+ if (!all.length) return 0;
89
+ if (kind === 'min') return all[0];
90
+ if (kind === 'max') return all[all.length - 1];
91
+ if (kind === 'median') return quantile(all, 0.5);
92
+ return all.reduce((sum, value) => sum + value, 0) / all.length;
93
+ };
94
+
95
+ const isHorizontal = $derived(orientation === 'horizontal');
96
+
97
+ const formatters = $derived(chartFormatters({ valueFormat, locale, tooltipValueFormatter }));
98
+
99
+ // Per-element colours that follow the resolved chart theme
100
+ const chartColors = $derived.by(() => getChartColors(chart.theme));
101
+
102
+ const option: EChartsOption = $derived.by(() => {
103
+ if (isEmpty) return {};
104
+
105
+ const base = color ?? chartColors.series[0];
106
+ const point = (index: number, value: number) =>
107
+ isHorizontal ? [value, index] : [index, value];
108
+
109
+ const series: Record<string, unknown>[] = [
110
+ {
111
+ name: title,
112
+ type: 'boxplot',
113
+ data: groups.map(({ stats }) =>
114
+ stats ? [stats.low, stats.q1, stats.median, stats.q3, stats.high] : []
115
+ ),
116
+ itemStyle: { color: colorWithAlpha(base, 0.2), borderColor: base, borderWidth: 1.5 },
117
+ emphasis: { itemStyle: { borderWidth: 2.5 } },
118
+ },
119
+ ];
120
+
121
+ if (showOutliers) {
122
+ series.push({
123
+ name: 'Outliers',
124
+ type: 'scatter',
125
+ symbolSize: 6,
126
+ itemStyle: { color: 'transparent', borderColor: base, borderWidth: 1.5 },
127
+ data: groups.flatMap(({ stats }, index) =>
128
+ (stats?.outliers ?? []).map((value) => point(index, value))
129
+ ),
130
+ });
131
+ }
132
+
133
+ const categoryAxis = {
134
+ type: 'category' as const,
135
+ data: groups.map((group) => group.name),
136
+ // Horizontal boxes list the first group at the top
137
+ inverse: isHorizontal,
138
+ axisTick: { alignWithLabel: true },
139
+ };
140
+ const valueAxis = { type: 'value' as const, scale: true, splitLine: { show: showGrid } };
141
+
142
+ return mergeChartOption(
143
+ {
144
+ grid: {
145
+ left: '3%',
146
+ right: '4%',
147
+ bottom: '3%',
148
+ containLabel: true,
149
+ ...gridInset(undefined, false, xAxisConfig, yAxisConfig),
150
+ },
151
+ tooltip: buildTooltip(
152
+ {
153
+ trigger: 'item',
154
+ formatter: (params: unknown) => {
155
+ const p = params as { seriesType?: string; dataIndex?: number; value?: number[] };
156
+ const fmt = (value: number) => escapeHtml(formatValue(value, formatters.tooltip));
157
+ if (p.seriesType === 'scatter') {
158
+ const [x, y] = p.value ?? [];
159
+ const index = isHorizontal ? y : x;
160
+ return `${escapeHtml(groups[index]?.name ?? '')}<br/>Outlier: <strong>${fmt(isHorizontal ? x : y)}</strong>`;
161
+ }
162
+ const group = groups[p.dataIndex ?? 0];
163
+ if (!group?.stats) return '';
164
+ const { stats } = group;
165
+ return [
166
+ `<strong>${escapeHtml(group.name)}</strong> (n = ${stats.count})`,
167
+ `High: ${fmt(stats.high)}`,
168
+ `Q3: ${fmt(stats.q3)}`,
169
+ `Median: <strong>${fmt(stats.median)}</strong>`,
170
+ `Q1: ${fmt(stats.q1)}`,
171
+ `Low: ${fmt(stats.low)}`,
172
+ ].join('<br/>');
173
+ },
174
+ },
175
+ { show: showTooltip, formatter: tooltipFormatter }
176
+ ),
177
+ xAxis: buildAxis(
178
+ isHorizontal ? valueAxis : categoryAxis,
179
+ isHorizontal ? formatters.axis(xAxisConfig) : xAxisConfig
180
+ ),
181
+ yAxis: buildAxis(
182
+ isHorizontal ? categoryAxis : valueAxis,
183
+ isHorizontal ? yAxisConfig : formatters.axis(yAxisConfig)
184
+ ),
185
+ series: withReferenceMarks(
186
+ series,
187
+ // ECharts would compute statistics from the box series' five-number
188
+ // rows, so resolve them from the raw observations instead
189
+ referenceLines?.map((line) =>
190
+ typeof line.value === 'number' ? line : { ...line, value: statistic(line.value) }
191
+ ),
192
+ referenceBands,
193
+ {
194
+ valueAxis: isHorizontal ? 'x' : 'y',
195
+ inverse: isHorizontal,
196
+ format: formatters.format,
197
+ color: chartColors.reference,
198
+ bandColor: chartColors.referenceBand,
199
+ }
200
+ ),
201
+ } as EChartsOption,
202
+ optionOverride
203
+ );
204
+ });
205
+
206
+ const accessibilityDescription = $derived(
207
+ description ||
208
+ (!isEmpty ? generateBoxPlotDescription(title, groups, formatters.format) : undefined)
209
+ );
210
+
211
+ const dataTable = $derived(isEmpty ? undefined : boxPlotTable(groups));
212
+
213
+ // Manage ECharts instance lifecycle (init, theme, resize, option updates)
214
+ const chart = useChart({
215
+ option: () => option,
216
+ theme: () => theme,
217
+ animation: () => animation,
218
+ isEmpty: () => isEmpty,
219
+ patterns: () => patterns,
220
+ events: () =>
221
+ chartEvents(events, {
222
+ click:
223
+ onClick &&
224
+ ((params) => {
225
+ const mapped = toDataPointParams(params, 'click');
226
+ if ((params as { seriesType?: string }).seriesType !== 'boxplot') return;
227
+ onClick({ ...mapped, name: groups[mapped.dataIndex]?.name ?? mapped.name });
228
+ }),
229
+ }),
230
+ onCreated: () => onReady,
231
+ });
232
+ </script>
233
+
234
+ <ChartContainer
235
+ {title}
236
+ description={accessibilityDescription}
237
+ {height}
238
+ {minHeight}
239
+ {maxHeight}
240
+ {loading}
241
+ {error}
242
+ {onRetry}
243
+ empty={isEmpty}
244
+ {emptyMessage}
245
+ class={cn(className)}
246
+ {dataTable}
247
+ {showDataTable}
248
+ format={formatters.format}
249
+ {exportable}
250
+ exportImage={chart.exportImage}
251
+ >
252
+ <div bind:this={chart.container} class="h-full w-full"></div>
253
+ </ChartContainer>
@@ -0,0 +1,7 @@
1
+ import type { BoxPlotChartProps } from '@classic-homes/charts-core';
2
+ interface Props extends Omit<BoxPlotChartProps, 'class'> {
3
+ class?: string;
4
+ }
5
+ declare const BoxPlotChart: import("svelte").Component<Props, {}, "">;
6
+ type BoxPlotChart = ReturnType<typeof BoxPlotChart>;
7
+ export default BoxPlotChart;
@@ -0,0 +1,231 @@
1
+ <script lang="ts">
2
+ import * as echarts from 'echarts/core';
3
+ import { BarChart as EChartsBarChart, ScatterChart as EChartsScatterChart } from 'echarts/charts';
4
+ import { GridComponent, TooltipComponent } from 'echarts/components';
5
+ import type { EChartsOption } from 'echarts';
6
+
7
+ import type { BulletChartProps } from '@classic-homes/charts-core';
8
+ import {
9
+ buildAxis,
10
+ buildDataLabel,
11
+ buildTooltip,
12
+ bulletTable,
13
+ chartFormatters,
14
+ escapeHtml,
15
+ formatValue,
16
+ generateBulletDescription,
17
+ getChartColors,
18
+ mergeChartOption,
19
+ pointLabel,
20
+ resolveChartHeight,
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, EChartsScatterChart, GridComponent, TooltipComponent]);
30
+
31
+ interface Props extends Omit<BulletChartProps, 'class'> {
32
+ class?: string;
33
+ }
34
+
35
+ let {
36
+ title,
37
+ description,
38
+ data,
39
+ height = 'auto',
40
+ minHeight,
41
+ maxHeight,
42
+ rowHeight = 36,
43
+ loading,
44
+ error,
45
+ onRetry,
46
+ emptyMessage,
47
+ theme = 'auto',
48
+ animation = true,
49
+ showTooltip = true,
50
+ showGrid = false,
51
+ showLabels = true,
52
+ valueFormat,
53
+ locale,
54
+ labelFormatter,
55
+ valueAxisConfig,
56
+ tooltipFormatter,
57
+ tooltipValueFormatter,
58
+ option: optionOverride,
59
+ onReady,
60
+ events,
61
+ showDataTable,
62
+ exportable,
63
+ patterns,
64
+ onClick,
65
+ class: className,
66
+ }: Props = $props();
67
+
68
+ const isEmpty = $derived(!data?.length);
69
+
70
+ const formatters = $derived(
71
+ chartFormatters({ valueFormat, locale, tooltipValueFormatter, labelFormatter })
72
+ );
73
+
74
+ // Per-element colours that follow the resolved chart theme
75
+ const chartColors = $derived.by(() => getChartColors(chart.theme));
76
+
77
+ const option: EChartsOption = $derived.by(() => {
78
+ if (isEmpty) return {};
79
+
80
+ const names = data.map((item) => item.name);
81
+ const rangeCount = Math.max(0, ...data.map((item) => item.ranges?.length ?? 0));
82
+ const shades = chartColors.ranges;
83
+
84
+ // Qualitative ranges, stacked as bands behind each row; widths are the
85
+ // gaps between consecutive boundaries
86
+ const rangeSeries = Array.from({ length: rangeCount }, (_, band) => ({
87
+ name: `Range ${band + 1}`,
88
+ type: 'bar',
89
+ stack: 'ranges',
90
+ silent: true,
91
+ barWidth: '70%',
92
+ yAxisIndex: 0,
93
+ itemStyle: { color: shades[Math.min(band, shades.length - 1)], borderRadius: 0 },
94
+ tooltip: { show: false },
95
+ data: data.map((item) => {
96
+ const ranges = item.ranges ?? [];
97
+ if (band >= ranges.length) return null;
98
+ return ranges[band] - (band > 0 ? ranges[band - 1] : 0);
99
+ }),
100
+ }));
101
+
102
+ const measure = {
103
+ name: 'Value',
104
+ type: 'bar',
105
+ // A second, hidden category axis, so the narrower measure bar centres
106
+ // on the range bands instead of sitting beside them
107
+ yAxisIndex: 1,
108
+ barWidth: '30%',
109
+ z: 3,
110
+ data: data.map((item, index) => ({
111
+ value: item.value,
112
+ name: item.name,
113
+ itemStyle: item.color ? { color: item.color } : undefined,
114
+ label: pointLabel(item.label, formatters.label, { position: 'right' }),
115
+ })),
116
+ itemStyle: { color: chartColors.foreground, borderRadius: 0 },
117
+ label: buildDataLabel({ show: true, position: 'right' }, undefined, {
118
+ show: showLabels,
119
+ formatter: formatters.label,
120
+ getLabel: (_series, index) => data[index]?.label,
121
+ }),
122
+ };
123
+
124
+ const targets = {
125
+ name: 'Target',
126
+ type: 'scatter',
127
+ yAxisIndex: 0,
128
+ symbol: 'rect',
129
+ symbolSize: [3, Math.round(rowHeight * 0.6)],
130
+ itemStyle: { color: chartColors.foreground },
131
+ z: 4,
132
+ data: data.flatMap((item, index) =>
133
+ typeof item.target === 'number' ? [{ value: [item.target, index], name: item.name }] : []
134
+ ),
135
+ };
136
+
137
+ const categoryAxis = {
138
+ type: 'category' as const,
139
+ data: names,
140
+ // First row at the top
141
+ inverse: true,
142
+ axisTick: { show: false },
143
+ axisLine: { show: false },
144
+ };
145
+
146
+ return mergeChartOption(
147
+ {
148
+ grid: { left: '3%', right: '8%', top: 8, bottom: '3%', containLabel: true },
149
+ tooltip: buildTooltip(
150
+ {
151
+ trigger: 'axis',
152
+ axisPointer: { type: 'none' },
153
+ formatter: (params: unknown) => {
154
+ const list = (Array.isArray(params) ? params : [params]) as { dataIndex?: number }[];
155
+ const item = data[list[0]?.dataIndex ?? 0];
156
+ if (!item) return '';
157
+ const value = formatValue(item.value, formatters.tooltip);
158
+ const target =
159
+ typeof item.target === 'number'
160
+ ? `<br/>Target: ${escapeHtml(formatValue(item.target, formatters.tooltip))}`
161
+ : '';
162
+ return `${escapeHtml(item.name)}<br/>Value: <strong>${escapeHtml(value)}</strong>${target}`;
163
+ },
164
+ },
165
+ { show: showTooltip, formatter: tooltipFormatter }
166
+ ),
167
+ xAxis: buildAxis(
168
+ { type: 'value', min: 0, splitLine: { show: showGrid } },
169
+ formatters.axis(valueAxisConfig)
170
+ ),
171
+ yAxis: [categoryAxis, { ...categoryAxis, show: false }],
172
+ series: [...rangeSeries, measure, targets],
173
+ } as EChartsOption,
174
+ optionOverride
175
+ );
176
+ });
177
+
178
+ const accessibilityDescription = $derived(
179
+ description ||
180
+ (!isEmpty ? generateBulletDescription(title, data, formatters.format) : undefined)
181
+ );
182
+
183
+ const dataTable = $derived(isEmpty ? undefined : bulletTable(data));
184
+
185
+ const resolvedHeight = $derived(
186
+ resolveChartHeight(height, data?.length ?? 0, { rowHeight, minHeight, maxHeight, chrome: 48 })
187
+ );
188
+
189
+ // Manage ECharts instance lifecycle (init, theme, resize, option updates)
190
+ const chart = useChart({
191
+ option: () => option,
192
+ theme: () => theme,
193
+ animation: () => animation,
194
+ isEmpty: () => isEmpty,
195
+ patterns: () => patterns,
196
+ events: () =>
197
+ chartEvents(events, {
198
+ click:
199
+ onClick &&
200
+ ((params) => {
201
+ // Rows are reported by their item, whichever mark was clicked
202
+ const mapped = toDataPointParams(params, 'click');
203
+ const index = data.findIndex((item) => item.name === mapped.name);
204
+ if (index === -1) return;
205
+ onClick({ ...mapped, dataIndex: index, value: data[index].value });
206
+ }),
207
+ }),
208
+ onCreated: () => onReady,
209
+ });
210
+ </script>
211
+
212
+ <ChartContainer
213
+ {title}
214
+ description={accessibilityDescription}
215
+ height={resolvedHeight}
216
+ {minHeight}
217
+ {maxHeight}
218
+ {loading}
219
+ {error}
220
+ {onRetry}
221
+ empty={isEmpty}
222
+ {emptyMessage}
223
+ class={cn(className)}
224
+ {dataTable}
225
+ {showDataTable}
226
+ format={formatters.format}
227
+ {exportable}
228
+ exportImage={chart.exportImage}
229
+ >
230
+ <div bind:this={chart.container} class="h-full w-full"></div>
231
+ </ChartContainer>
@@ -0,0 +1,7 @@
1
+ import type { BulletChartProps } from '@classic-homes/charts-core';
2
+ interface Props extends Omit<BulletChartProps, 'class'> {
3
+ class?: string;
4
+ }
5
+ declare const BulletChart: import("svelte").Component<Props, {}, "">;
6
+ type BulletChart = ReturnType<typeof BulletChart>;
7
+ export default BulletChart;