@classic-homes/charts-svelte 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/components/base/ChartContainer.svelte +141 -14
- package/dist/lib/components/base/ChartContainer.svelte.d.ts +19 -1
- package/dist/lib/components/base/ChartDataTable.svelte +59 -0
- package/dist/lib/components/base/ChartDataTable.svelte.d.ts +15 -0
- package/dist/lib/components/base/ChartEmpty.svelte +1 -0
- package/dist/lib/components/base/ChartSkeleton.svelte +7 -2
- package/dist/lib/components/base/ChartSkeleton.svelte.d.ts +2 -0
- package/dist/lib/components/base/index.d.ts +3 -0
- package/dist/lib/components/base/index.js +1 -0
- package/dist/lib/components/core/AreaChart.svelte +115 -70
- package/dist/lib/components/core/BarChart.svelte +134 -62
- package/dist/lib/components/core/DonutChart.svelte +41 -22
- package/dist/lib/components/core/LineChart.svelte +104 -73
- package/dist/lib/components/core/PieChart.svelte +41 -22
- package/dist/lib/components/core/ScatterChart.svelte +72 -68
- package/dist/lib/components/extended/CandlestickChart.svelte +67 -44
- package/dist/lib/components/extended/FunnelChart.svelte +41 -27
- package/dist/lib/components/extended/GaugeChart.svelte +61 -10
- package/dist/lib/components/extended/HeatmapChart.svelte +62 -33
- package/dist/lib/components/extended/RadarChart.svelte +39 -19
- package/dist/lib/components/extended/SankeyChart.svelte +50 -20
- package/dist/lib/components/extended/TreemapChart.svelte +53 -21
- package/dist/lib/components/index.d.ts +2 -1
- package/dist/lib/components/index.js +3 -1
- package/dist/lib/components/report/BoxPlotChart.svelte +253 -0
- package/dist/lib/components/report/BoxPlotChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/BulletChart.svelte +231 -0
- package/dist/lib/components/report/BulletChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/ComboChart.svelte +327 -0
- package/dist/lib/components/report/ComboChart.svelte.d.ts +8 -0
- package/dist/lib/components/report/DotPlotChart.svelte +325 -0
- package/dist/lib/components/report/DotPlotChart.svelte.d.ts +8 -0
- package/dist/lib/components/report/HistogramChart.svelte +226 -0
- package/dist/lib/components/report/HistogramChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/Sparkline.svelte +181 -0
- package/dist/lib/components/report/Sparkline.svelte.d.ts +4 -0
- package/dist/lib/components/report/StatCard.svelte +146 -0
- package/dist/lib/components/report/StatCard.svelte.d.ts +4 -0
- package/dist/lib/components/report/StatGroup.svelte +35 -0
- package/dist/lib/components/report/StatGroup.svelte.d.ts +12 -0
- package/dist/lib/components/report/WaterfallChart.svelte +271 -0
- package/dist/lib/components/report/WaterfallChart.svelte.d.ts +7 -0
- package/dist/lib/components/report/index.d.ts +12 -0
- package/dist/lib/components/report/index.js +9 -0
- package/dist/lib/composables/index.d.ts +1 -1
- package/dist/lib/composables/index.js +1 -1
- package/dist/lib/composables/useChart.svelte.d.ts +34 -0
- package/dist/lib/composables/useChart.svelte.js +121 -9
- package/dist/lib/composables/useChartTheme.svelte.js +14 -11
- package/dist/lib/events.d.ts +15 -0
- package/dist/lib/events.js +56 -0
- package/dist/lib/index.d.ts +4 -3
- package/dist/lib/index.js +7 -3
- package/package.json +10 -4
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as echarts from 'echarts/core';
|
|
3
|
+
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts';
|
|
4
|
+
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
|
5
|
+
import type { EChartsOption } from 'echarts';
|
|
6
|
+
|
|
7
|
+
import type { ComboChartProps } from '@classic-homes/charts-core';
|
|
8
|
+
import {
|
|
9
|
+
allSeriesEmpty,
|
|
10
|
+
buildAxis,
|
|
11
|
+
buildDataLabel,
|
|
12
|
+
buildDataZoom,
|
|
13
|
+
buildLegend,
|
|
14
|
+
buildTooltip,
|
|
15
|
+
categoryTable,
|
|
16
|
+
chartFormatters,
|
|
17
|
+
colorWithAlpha,
|
|
18
|
+
escapeHtml,
|
|
19
|
+
generateComboChartDescription,
|
|
20
|
+
getChartColors,
|
|
21
|
+
gridInset,
|
|
22
|
+
hasPointLabels,
|
|
23
|
+
mergeChartOption,
|
|
24
|
+
resolveValueFormatter,
|
|
25
|
+
seriesLabelLookup,
|
|
26
|
+
valueAxisConfig,
|
|
27
|
+
valueLabelFormatter,
|
|
28
|
+
withPointLabels,
|
|
29
|
+
withReferenceMarks,
|
|
30
|
+
withZoomInset,
|
|
31
|
+
} from '@classic-homes/charts-core';
|
|
32
|
+
|
|
33
|
+
import { cn } from '../../utils.js';
|
|
34
|
+
import { chartEvents, toDataPointParams } from '../../events.js';
|
|
35
|
+
import { useChart } from '../../composables/useChart.svelte.js';
|
|
36
|
+
import {
|
|
37
|
+
useChartLegend,
|
|
38
|
+
type CustomLegendProps,
|
|
39
|
+
} from '../../composables/useChartLegend.svelte.js';
|
|
40
|
+
import ChartContainer from '../base/ChartContainer.svelte';
|
|
41
|
+
|
|
42
|
+
// Register required ECharts modules
|
|
43
|
+
echarts.use([
|
|
44
|
+
EChartsBarChart,
|
|
45
|
+
EChartsLineChart,
|
|
46
|
+
GridComponent,
|
|
47
|
+
TooltipComponent,
|
|
48
|
+
LegendComponent,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
interface Props extends Omit<ComboChartProps, 'class'>, CustomLegendProps {
|
|
52
|
+
class?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let {
|
|
56
|
+
title,
|
|
57
|
+
description,
|
|
58
|
+
data,
|
|
59
|
+
height = 400,
|
|
60
|
+
minHeight,
|
|
61
|
+
maxHeight,
|
|
62
|
+
loading,
|
|
63
|
+
error,
|
|
64
|
+
onRetry,
|
|
65
|
+
emptyMessage,
|
|
66
|
+
theme = 'auto',
|
|
67
|
+
animation = true,
|
|
68
|
+
showLegend = true,
|
|
69
|
+
showTooltip = true,
|
|
70
|
+
showGrid = true,
|
|
71
|
+
stacked = false,
|
|
72
|
+
smooth = false,
|
|
73
|
+
showLabels = false,
|
|
74
|
+
connectNulls = false,
|
|
75
|
+
valueFormat,
|
|
76
|
+
secondaryValueFormat,
|
|
77
|
+
locale,
|
|
78
|
+
labelFormatter,
|
|
79
|
+
xAxisConfig,
|
|
80
|
+
yAxisConfig,
|
|
81
|
+
secondaryYAxisConfig,
|
|
82
|
+
referenceLines,
|
|
83
|
+
referenceBands,
|
|
84
|
+
dataZoom,
|
|
85
|
+
tooltipFormatter,
|
|
86
|
+
tooltipValueFormatter,
|
|
87
|
+
legend: legendConfig,
|
|
88
|
+
renderLegend,
|
|
89
|
+
onLegendChange,
|
|
90
|
+
option: optionOverride,
|
|
91
|
+
onReady,
|
|
92
|
+
events,
|
|
93
|
+
showDataTable,
|
|
94
|
+
exportable,
|
|
95
|
+
patterns,
|
|
96
|
+
onClick,
|
|
97
|
+
onHover,
|
|
98
|
+
class: className,
|
|
99
|
+
}: Props = $props();
|
|
100
|
+
|
|
101
|
+
const isEmpty = $derived(!data?.categories?.length || allSeriesEmpty(data?.series));
|
|
102
|
+
|
|
103
|
+
const formatters = $derived(
|
|
104
|
+
chartFormatters({ valueFormat, locale, tooltipValueFormatter, labelFormatter })
|
|
105
|
+
);
|
|
106
|
+
const secondaryFormat = $derived(
|
|
107
|
+
resolveValueFormatter(secondaryValueFormat, locale) ?? formatters.format
|
|
108
|
+
);
|
|
109
|
+
const secondaryTitled = $derived(
|
|
110
|
+
secondaryYAxisConfig?.name !== undefined &&
|
|
111
|
+
(secondaryYAxisConfig.nameLocation ?? 'middle') === 'middle'
|
|
112
|
+
);
|
|
113
|
+
const hasSecondary = $derived(data?.series?.some((s) => s.axis === 'secondary') ?? false);
|
|
114
|
+
|
|
115
|
+
// Per-element colours that follow the resolved chart theme
|
|
116
|
+
const chartColors = $derived.by(() => getChartColors(chart.theme));
|
|
117
|
+
|
|
118
|
+
const builtInLegend = $derived(showLegend && !renderLegend);
|
|
119
|
+
|
|
120
|
+
const option: EChartsOption = $derived.by(() => {
|
|
121
|
+
if (isEmpty) return {};
|
|
122
|
+
|
|
123
|
+
const getLabel = seriesLabelLookup(data.series);
|
|
124
|
+
const legendBottom = builtInLegend && (legendConfig?.position ?? 'bottom') === 'bottom';
|
|
125
|
+
const anyBars = data.series.some((s) => (s.type ?? 'bar') === 'bar');
|
|
126
|
+
|
|
127
|
+
const series = data.series.map((source, index) => {
|
|
128
|
+
const type = source.type ?? 'bar';
|
|
129
|
+
const secondary = source.axis === 'secondary';
|
|
130
|
+
// Each axis has its own format, so each series labels with its own
|
|
131
|
+
const label =
|
|
132
|
+
labelFormatter ??
|
|
133
|
+
(secondary && secondaryFormat ? valueLabelFormatter(secondaryFormat) : formatters.label);
|
|
134
|
+
const color = source.color || chartColors.series[index % chartColors.series.length];
|
|
135
|
+
const common = {
|
|
136
|
+
name: source.name,
|
|
137
|
+
yAxisIndex: secondary ? 1 : 0,
|
|
138
|
+
data: withPointLabels(source.data, source.labels, label, { position: 'top' }),
|
|
139
|
+
itemStyle: source.color ? { color: source.color } : undefined,
|
|
140
|
+
label: buildDataLabel({ show: true, position: 'top' }, undefined, {
|
|
141
|
+
show: showLabels,
|
|
142
|
+
formatter: label,
|
|
143
|
+
getLabel,
|
|
144
|
+
}),
|
|
145
|
+
emphasis: { focus: 'series' },
|
|
146
|
+
};
|
|
147
|
+
if (type === 'bar') {
|
|
148
|
+
return { ...common, type: 'bar', stack: source.stack ?? (stacked ? 'Total' : undefined) };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...common,
|
|
152
|
+
type: 'line',
|
|
153
|
+
smooth,
|
|
154
|
+
connectNulls,
|
|
155
|
+
showSymbol: true,
|
|
156
|
+
symbolSize: showLabels || hasPointLabels([source]) ? 6 : 5,
|
|
157
|
+
// Lines sit above bars
|
|
158
|
+
z: 3,
|
|
159
|
+
stack: source.stack,
|
|
160
|
+
areaStyle: type === 'area' ? { color: colorWithAlpha(color, 0.25) } : undefined,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A per-series value formatter, so tooltips write each value in its axis' format
|
|
165
|
+
const tooltipValue = (value: number, seriesIndex: number) => {
|
|
166
|
+
if (tooltipValueFormatter) return tooltipValueFormatter(value);
|
|
167
|
+
const fmt =
|
|
168
|
+
data.series[seriesIndex]?.axis === 'secondary' ? secondaryFormat : formatters.format;
|
|
169
|
+
return fmt ? fmt(value) : String(value);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return mergeChartOption(
|
|
173
|
+
{
|
|
174
|
+
grid: {
|
|
175
|
+
left: '3%',
|
|
176
|
+
right: '4%',
|
|
177
|
+
bottom: '3%',
|
|
178
|
+
containLabel: true,
|
|
179
|
+
...withZoomInset(
|
|
180
|
+
{
|
|
181
|
+
...gridInset(legendConfig, builtInLegend, xAxisConfig, yAxisConfig),
|
|
182
|
+
// Room for a centred secondary axis title beside its tick labels
|
|
183
|
+
...(secondaryTitled && { right: 36 }),
|
|
184
|
+
},
|
|
185
|
+
dataZoom,
|
|
186
|
+
'x'
|
|
187
|
+
),
|
|
188
|
+
},
|
|
189
|
+
tooltip: buildTooltip(
|
|
190
|
+
{
|
|
191
|
+
trigger: 'axis',
|
|
192
|
+
axisPointer: { type: anyBars ? 'shadow' : 'line' },
|
|
193
|
+
formatter: tooltipFormatter
|
|
194
|
+
? undefined
|
|
195
|
+
: (params: unknown) => {
|
|
196
|
+
const list = (Array.isArray(params) ? params : [params]) as {
|
|
197
|
+
axisValueLabel?: string;
|
|
198
|
+
marker?: string;
|
|
199
|
+
seriesName?: string;
|
|
200
|
+
seriesIndex?: number;
|
|
201
|
+
value?: unknown;
|
|
202
|
+
}[];
|
|
203
|
+
const rows = list.map((p) => {
|
|
204
|
+
// Missing values show as a dash
|
|
205
|
+
const value =
|
|
206
|
+
typeof p.value === 'number' ? tooltipValue(p.value, p.seriesIndex ?? 0) : '-';
|
|
207
|
+
return `${p.marker ?? ''}${escapeHtml(p.seriesName ?? '')}<span style="float:right;margin-left:16px;font-weight:600">${escapeHtml(value)}</span>`;
|
|
208
|
+
});
|
|
209
|
+
return [escapeHtml(list[0]?.axisValueLabel ?? ''), ...rows].join('<br/>');
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{ show: showTooltip, formatter: tooltipFormatter, getLabel }
|
|
213
|
+
),
|
|
214
|
+
legend: buildLegend(
|
|
215
|
+
data.series.map((s) => s.name),
|
|
216
|
+
legendConfig,
|
|
217
|
+
{ show: showLegend, custom: !!renderLegend }
|
|
218
|
+
),
|
|
219
|
+
dataZoom: buildDataZoom(dataZoom, 'x', legendBottom ? 36 : 0),
|
|
220
|
+
xAxis: buildAxis(
|
|
221
|
+
{
|
|
222
|
+
type: 'category',
|
|
223
|
+
data: data.categories,
|
|
224
|
+
axisTick: { alignWithLabel: true },
|
|
225
|
+
},
|
|
226
|
+
xAxisConfig
|
|
227
|
+
),
|
|
228
|
+
yAxis: [
|
|
229
|
+
buildAxis({ type: 'value', splitLine: { show: showGrid } }, formatters.axis(yAxisConfig)),
|
|
230
|
+
...(hasSecondary
|
|
231
|
+
? [
|
|
232
|
+
buildAxis(
|
|
233
|
+
// The secondary axis' grid lines would not line up with the primary's
|
|
234
|
+
{ type: 'value', position: 'right', splitLine: { show: false } },
|
|
235
|
+
valueAxisConfig(
|
|
236
|
+
secondaryTitled
|
|
237
|
+
? // Clear wide tick labels such as "$500,000"
|
|
238
|
+
{ nameGap: 64, ...secondaryYAxisConfig }
|
|
239
|
+
: secondaryYAxisConfig,
|
|
240
|
+
secondaryFormat
|
|
241
|
+
)
|
|
242
|
+
),
|
|
243
|
+
]
|
|
244
|
+
: []),
|
|
245
|
+
],
|
|
246
|
+
series: withReferenceMarks(series, referenceLines, referenceBands, {
|
|
247
|
+
valueAxis: 'y',
|
|
248
|
+
format: formatters.format,
|
|
249
|
+
color: chartColors.reference,
|
|
250
|
+
bandColor: chartColors.referenceBand,
|
|
251
|
+
}),
|
|
252
|
+
} as EChartsOption,
|
|
253
|
+
optionOverride
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const accessibilityDescription = $derived(
|
|
258
|
+
description ||
|
|
259
|
+
(!isEmpty ? generateComboChartDescription(title, data, formatters.format) : undefined)
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Secondary-axis columns are written as plain numbers when their format differs
|
|
263
|
+
const dataTable = $derived.by(() => {
|
|
264
|
+
if (isEmpty) return undefined;
|
|
265
|
+
const table = categoryTable(data);
|
|
266
|
+
if (!secondaryValueFormat) return table;
|
|
267
|
+
return {
|
|
268
|
+
...table,
|
|
269
|
+
columns: table.columns?.map((column, index) =>
|
|
270
|
+
index > 0 && data.series[index - 1]?.axis === 'secondary' ? ('number' as const) : column
|
|
271
|
+
),
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Legend selection state, shared with a custom legend
|
|
276
|
+
const legend = useChartLegend({
|
|
277
|
+
names: () => data?.series?.map((s) => s.name) ?? [],
|
|
278
|
+
colors: () => data?.series?.map((s) => s.color) ?? [],
|
|
279
|
+
palette: () => getChartColors(chart.theme).series,
|
|
280
|
+
config: () => legendConfig,
|
|
281
|
+
title: () => title,
|
|
282
|
+
onLegendChange: () => onLegendChange,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Manage ECharts instance lifecycle (init, theme, resize, option updates)
|
|
286
|
+
const chart = useChart({
|
|
287
|
+
option: () => option,
|
|
288
|
+
theme: () => theme,
|
|
289
|
+
animation: () => animation,
|
|
290
|
+
isEmpty: () => isEmpty,
|
|
291
|
+
patterns: () => patterns,
|
|
292
|
+
events: () =>
|
|
293
|
+
chartEvents(events, {
|
|
294
|
+
click: onClick && ((params) => onClick(toDataPointParams(params, 'click'))),
|
|
295
|
+
mouseover: onHover && ((params) => onHover(toDataPointParams(params, 'mouseover'))),
|
|
296
|
+
}),
|
|
297
|
+
onReady: (instance) => legend.attach(instance),
|
|
298
|
+
onCreated: () => onReady,
|
|
299
|
+
});
|
|
300
|
+
</script>
|
|
301
|
+
|
|
302
|
+
{#snippet customLegend()}
|
|
303
|
+
{@render renderLegend?.(legend.renderProps)}
|
|
304
|
+
{/snippet}
|
|
305
|
+
|
|
306
|
+
<ChartContainer
|
|
307
|
+
{title}
|
|
308
|
+
description={accessibilityDescription}
|
|
309
|
+
{height}
|
|
310
|
+
{minHeight}
|
|
311
|
+
{maxHeight}
|
|
312
|
+
{loading}
|
|
313
|
+
{error}
|
|
314
|
+
{onRetry}
|
|
315
|
+
empty={isEmpty}
|
|
316
|
+
{emptyMessage}
|
|
317
|
+
class={cn(className)}
|
|
318
|
+
legend={renderLegend ? customLegend : undefined}
|
|
319
|
+
legendPosition={legendConfig?.position}
|
|
320
|
+
{dataTable}
|
|
321
|
+
{showDataTable}
|
|
322
|
+
format={formatters.format}
|
|
323
|
+
{exportable}
|
|
324
|
+
exportImage={chart.exportImage}
|
|
325
|
+
>
|
|
326
|
+
<div bind:this={chart.container} class="h-full w-full"></div>
|
|
327
|
+
</ChartContainer>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ComboChartProps } from '@classic-homes/charts-core';
|
|
2
|
+
import { type CustomLegendProps } from '../../composables/useChartLegend.svelte.js';
|
|
3
|
+
interface Props extends Omit<ComboChartProps, 'class'>, CustomLegendProps {
|
|
4
|
+
class?: string;
|
|
5
|
+
}
|
|
6
|
+
declare const ComboChart: import("svelte").Component<Props, {}, "">;
|
|
7
|
+
type ComboChart = ReturnType<typeof ComboChart>;
|
|
8
|
+
export default ComboChart;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as echarts from 'echarts/core';
|
|
3
|
+
import { ScatterChart as EChartsScatterChart, LinesChart } from 'echarts/charts';
|
|
4
|
+
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
|
5
|
+
import type { EChartsOption } from 'echarts';
|
|
6
|
+
|
|
7
|
+
import type { DotPlotChartProps } from '@classic-homes/charts-core';
|
|
8
|
+
import {
|
|
9
|
+
allSeriesEmpty,
|
|
10
|
+
buildAxis,
|
|
11
|
+
buildDataLabel,
|
|
12
|
+
buildLegend,
|
|
13
|
+
buildTooltip,
|
|
14
|
+
categoryTable,
|
|
15
|
+
chartFormatters,
|
|
16
|
+
escapeHtml,
|
|
17
|
+
formatValue,
|
|
18
|
+
generateDotPlotDescription,
|
|
19
|
+
getChartColors,
|
|
20
|
+
gridInset,
|
|
21
|
+
mergeChartOption,
|
|
22
|
+
pointLabel,
|
|
23
|
+
resolveChartHeight,
|
|
24
|
+
seriesLabelLookup,
|
|
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 {
|
|
32
|
+
useChartLegend,
|
|
33
|
+
type CustomLegendProps,
|
|
34
|
+
} from '../../composables/useChartLegend.svelte.js';
|
|
35
|
+
import ChartContainer from '../base/ChartContainer.svelte';
|
|
36
|
+
|
|
37
|
+
// Register required ECharts modules
|
|
38
|
+
echarts.use([EChartsScatterChart, LinesChart, GridComponent, TooltipComponent, LegendComponent]);
|
|
39
|
+
|
|
40
|
+
interface Props extends Omit<DotPlotChartProps, 'class'>, CustomLegendProps {
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
title,
|
|
46
|
+
description,
|
|
47
|
+
data,
|
|
48
|
+
height = 'auto',
|
|
49
|
+
minHeight,
|
|
50
|
+
maxHeight,
|
|
51
|
+
rowHeight,
|
|
52
|
+
loading,
|
|
53
|
+
error,
|
|
54
|
+
onRetry,
|
|
55
|
+
emptyMessage,
|
|
56
|
+
theme = 'auto',
|
|
57
|
+
animation = true,
|
|
58
|
+
showLegend = true,
|
|
59
|
+
showTooltip = true,
|
|
60
|
+
showGrid = true,
|
|
61
|
+
dotSize = 12,
|
|
62
|
+
connect = false,
|
|
63
|
+
showLabels = false,
|
|
64
|
+
valueFormat,
|
|
65
|
+
locale,
|
|
66
|
+
labelFormatter,
|
|
67
|
+
valueAxisConfig,
|
|
68
|
+
categoryAxisConfig,
|
|
69
|
+
referenceLines,
|
|
70
|
+
referenceBands,
|
|
71
|
+
tooltipFormatter,
|
|
72
|
+
tooltipValueFormatter,
|
|
73
|
+
legend: legendConfig,
|
|
74
|
+
renderLegend,
|
|
75
|
+
onLegendChange,
|
|
76
|
+
option: optionOverride,
|
|
77
|
+
onReady,
|
|
78
|
+
events,
|
|
79
|
+
showDataTable,
|
|
80
|
+
exportable,
|
|
81
|
+
patterns,
|
|
82
|
+
onClick,
|
|
83
|
+
class: className,
|
|
84
|
+
}: Props = $props();
|
|
85
|
+
|
|
86
|
+
const isEmpty = $derived(!data?.categories?.length || allSeriesEmpty(data?.series));
|
|
87
|
+
|
|
88
|
+
const formatters = $derived(
|
|
89
|
+
chartFormatters({ valueFormat, locale, tooltipValueFormatter, labelFormatter })
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Points are [value, row index], so labels format the first element
|
|
93
|
+
const dotLabel = $derived<DotPlotChartProps['labelFormatter']>(
|
|
94
|
+
labelFormatter ??
|
|
95
|
+
((params) =>
|
|
96
|
+
params.label ??
|
|
97
|
+
formatValue(
|
|
98
|
+
Array.isArray(params.value) ? params.value[0] : params.value,
|
|
99
|
+
formatters.format
|
|
100
|
+
))
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Per-element colours that follow the resolved chart theme
|
|
104
|
+
const chartColors = $derived.by(() => getChartColors(chart.theme));
|
|
105
|
+
|
|
106
|
+
const builtInLegend = $derived(showLegend && !renderLegend);
|
|
107
|
+
|
|
108
|
+
// Dot diameter for a weight: dot area grows with the weight
|
|
109
|
+
const sizeScale = $derived.by(() => {
|
|
110
|
+
const [smallest, largest] = Array.isArray(dotSize) ? dotSize : [dotSize, dotSize];
|
|
111
|
+
let maxWeight = 0;
|
|
112
|
+
for (const s of data?.series ?? []) {
|
|
113
|
+
for (const weight of s.sizes ?? []) {
|
|
114
|
+
if (typeof weight === 'number' && weight > maxWeight) maxWeight = weight;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return (weight: number | null | undefined) =>
|
|
118
|
+
typeof weight === 'number' && maxWeight > 0 && smallest !== largest
|
|
119
|
+
? smallest + Math.sqrt(Math.max(weight, 0) / maxWeight) * (largest - smallest)
|
|
120
|
+
: largest;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const option: EChartsOption = $derived.by(() => {
|
|
124
|
+
if (isEmpty) return {};
|
|
125
|
+
|
|
126
|
+
const getLabel = seriesLabelLookup(data.series);
|
|
127
|
+
|
|
128
|
+
const series: Record<string, unknown>[] = data.series.map((source) => ({
|
|
129
|
+
name: source.name,
|
|
130
|
+
type: 'scatter',
|
|
131
|
+
// Missing values leave the row empty
|
|
132
|
+
data: source.data.flatMap((value, index) =>
|
|
133
|
+
value == null
|
|
134
|
+
? []
|
|
135
|
+
: [
|
|
136
|
+
{
|
|
137
|
+
name: data.categories[index],
|
|
138
|
+
value: [value, index],
|
|
139
|
+
symbolSize: sizeScale(source.sizes?.[index]),
|
|
140
|
+
weight: source.sizes?.[index],
|
|
141
|
+
label: pointLabel(source.labels?.[index], dotLabel, { position: 'right' }),
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
),
|
|
145
|
+
itemStyle: source.color ? { color: source.color } : undefined,
|
|
146
|
+
label: buildDataLabel({ show: true, position: 'right', distance: 8 }, undefined, {
|
|
147
|
+
show: showLabels,
|
|
148
|
+
formatter: dotLabel,
|
|
149
|
+
getLabel: (seriesIndex, _dataIndex, name) =>
|
|
150
|
+
getLabel(seriesIndex, data.categories.indexOf(name), name),
|
|
151
|
+
}),
|
|
152
|
+
emphasis: { scale: 1.3 },
|
|
153
|
+
z: 3,
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
// A line through each row's dots, for comparing series (a dumbbell plot)
|
|
157
|
+
if (connect && data.series.length > 1) {
|
|
158
|
+
const segments = data.categories.flatMap((_, index) => {
|
|
159
|
+
const values = data.series
|
|
160
|
+
.map((s) => s.data[index])
|
|
161
|
+
.filter((value): value is number => typeof value === 'number');
|
|
162
|
+
if (values.length < 2) return [];
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
coords: [
|
|
166
|
+
[Math.min(...values), index],
|
|
167
|
+
[Math.max(...values), index],
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
});
|
|
172
|
+
series.push({
|
|
173
|
+
type: 'lines',
|
|
174
|
+
coordinateSystem: 'cartesian2d',
|
|
175
|
+
silent: true,
|
|
176
|
+
data: segments,
|
|
177
|
+
lineStyle: { color: chartColors.mutedForeground, width: 2, opacity: 0.5 },
|
|
178
|
+
z: 2,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return mergeChartOption(
|
|
183
|
+
{
|
|
184
|
+
grid: {
|
|
185
|
+
left: '3%',
|
|
186
|
+
right: '6%',
|
|
187
|
+
bottom: '3%',
|
|
188
|
+
// Reference line labels sit above the plot
|
|
189
|
+
top: referenceLines?.length ? 32 : 16,
|
|
190
|
+
containLabel: true,
|
|
191
|
+
...gridInset(legendConfig, builtInLegend, valueAxisConfig, categoryAxisConfig),
|
|
192
|
+
},
|
|
193
|
+
tooltip: buildTooltip(
|
|
194
|
+
{
|
|
195
|
+
trigger: 'item',
|
|
196
|
+
formatter: (params: unknown) => {
|
|
197
|
+
const p = params as {
|
|
198
|
+
name?: string;
|
|
199
|
+
marker?: string;
|
|
200
|
+
seriesName?: string;
|
|
201
|
+
value?: [number, number];
|
|
202
|
+
data?: { weight?: number | null };
|
|
203
|
+
};
|
|
204
|
+
if (!p.value) return '';
|
|
205
|
+
const value = formatValue(p.value[0], formatters.tooltip);
|
|
206
|
+
const weight =
|
|
207
|
+
typeof p.data?.weight === 'number' ? ` (n = ${formatValue(p.data.weight)})` : '';
|
|
208
|
+
return `${escapeHtml(p.name ?? '')}<br/>${p.marker ?? ''}${escapeHtml(p.seriesName ?? '')}: <strong>${escapeHtml(value)}</strong>${escapeHtml(weight)}`;
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{ show: showTooltip, formatter: tooltipFormatter, getLabel }
|
|
212
|
+
),
|
|
213
|
+
legend: buildLegend(
|
|
214
|
+
data.series.map((s) => s.name),
|
|
215
|
+
legendConfig,
|
|
216
|
+
{ show: showLegend && data.series.length > 1, custom: !!renderLegend }
|
|
217
|
+
),
|
|
218
|
+
xAxis: buildAxis(
|
|
219
|
+
{
|
|
220
|
+
type: 'value',
|
|
221
|
+
// A zero baseline would hide the differences a dot plot is for
|
|
222
|
+
scale: true,
|
|
223
|
+
splitLine: { show: showGrid },
|
|
224
|
+
},
|
|
225
|
+
formatters.axis(valueAxisConfig)
|
|
226
|
+
),
|
|
227
|
+
yAxis: buildAxis(
|
|
228
|
+
{
|
|
229
|
+
type: 'category',
|
|
230
|
+
data: data.categories,
|
|
231
|
+
// First category at the top
|
|
232
|
+
inverse: true,
|
|
233
|
+
axisTick: { show: false },
|
|
234
|
+
axisLine: { show: false },
|
|
235
|
+
splitLine: { show: showGrid, lineStyle: { type: 'dotted' } },
|
|
236
|
+
},
|
|
237
|
+
categoryAxisConfig
|
|
238
|
+
),
|
|
239
|
+
series: withReferenceMarks(series, referenceLines, referenceBands, {
|
|
240
|
+
valueAxis: 'x',
|
|
241
|
+
inverse: true,
|
|
242
|
+
format: formatters.format,
|
|
243
|
+
color: chartColors.reference,
|
|
244
|
+
bandColor: chartColors.referenceBand,
|
|
245
|
+
}),
|
|
246
|
+
} as EChartsOption,
|
|
247
|
+
optionOverride
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const accessibilityDescription = $derived(
|
|
252
|
+
description ||
|
|
253
|
+
(!isEmpty ? generateDotPlotDescription(title, data, formatters.format) : undefined)
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const dataTable = $derived(isEmpty ? undefined : categoryTable(data));
|
|
257
|
+
|
|
258
|
+
const resolvedHeight = $derived(
|
|
259
|
+
resolveChartHeight(height, data?.categories?.length ?? 0, {
|
|
260
|
+
rowHeight,
|
|
261
|
+
minHeight,
|
|
262
|
+
maxHeight,
|
|
263
|
+
chrome: 56 + (builtInLegend && (data?.series?.length ?? 0) > 1 ? 36 : 0),
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Legend selection state, shared with a custom legend
|
|
268
|
+
const legend = useChartLegend({
|
|
269
|
+
names: () => data?.series?.map((s) => s.name) ?? [],
|
|
270
|
+
colors: () => data?.series?.map((s) => s.color) ?? [],
|
|
271
|
+
palette: () => getChartColors(chart.theme).series,
|
|
272
|
+
config: () => legendConfig,
|
|
273
|
+
title: () => title,
|
|
274
|
+
onLegendChange: () => onLegendChange,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Manage ECharts instance lifecycle (init, theme, resize, option updates)
|
|
278
|
+
const chart = useChart({
|
|
279
|
+
option: () => option,
|
|
280
|
+
theme: () => theme,
|
|
281
|
+
animation: () => animation,
|
|
282
|
+
isEmpty: () => isEmpty,
|
|
283
|
+
patterns: () => patterns,
|
|
284
|
+
events: () =>
|
|
285
|
+
chartEvents(events, {
|
|
286
|
+
click:
|
|
287
|
+
onClick &&
|
|
288
|
+
((params) => {
|
|
289
|
+
const mapped = toDataPointParams(params, 'click');
|
|
290
|
+
// Report the category's index and the plain value
|
|
291
|
+
const value = mapped.value as unknown as [number, number];
|
|
292
|
+
onClick({ ...mapped, dataIndex: value[1], value: value[0] });
|
|
293
|
+
}),
|
|
294
|
+
}),
|
|
295
|
+
onReady: (instance) => legend.attach(instance),
|
|
296
|
+
onCreated: () => onReady,
|
|
297
|
+
});
|
|
298
|
+
</script>
|
|
299
|
+
|
|
300
|
+
{#snippet customLegend()}
|
|
301
|
+
{@render renderLegend?.(legend.renderProps)}
|
|
302
|
+
{/snippet}
|
|
303
|
+
|
|
304
|
+
<ChartContainer
|
|
305
|
+
{title}
|
|
306
|
+
description={accessibilityDescription}
|
|
307
|
+
height={resolvedHeight}
|
|
308
|
+
{minHeight}
|
|
309
|
+
{maxHeight}
|
|
310
|
+
{loading}
|
|
311
|
+
{error}
|
|
312
|
+
{onRetry}
|
|
313
|
+
empty={isEmpty}
|
|
314
|
+
{emptyMessage}
|
|
315
|
+
class={cn(className)}
|
|
316
|
+
legend={renderLegend ? customLegend : undefined}
|
|
317
|
+
legendPosition={legendConfig?.position}
|
|
318
|
+
{dataTable}
|
|
319
|
+
{showDataTable}
|
|
320
|
+
format={formatters.format}
|
|
321
|
+
{exportable}
|
|
322
|
+
exportImage={chart.exportImage}
|
|
323
|
+
>
|
|
324
|
+
<div bind:this={chart.container} class="h-full w-full"></div>
|
|
325
|
+
</ChartContainer>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DotPlotChartProps } from '@classic-homes/charts-core';
|
|
2
|
+
import { type CustomLegendProps } from '../../composables/useChartLegend.svelte.js';
|
|
3
|
+
interface Props extends Omit<DotPlotChartProps, 'class'>, CustomLegendProps {
|
|
4
|
+
class?: string;
|
|
5
|
+
}
|
|
6
|
+
declare const DotPlotChart: import("svelte").Component<Props, {}, "">;
|
|
7
|
+
type DotPlotChart = ReturnType<typeof DotPlotChart>;
|
|
8
|
+
export default DotPlotChart;
|