@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,226 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as echarts from 'echarts/core';
|
|
3
|
+
import { BarChart as EChartsBarChart } from 'echarts/charts';
|
|
4
|
+
import { GridComponent, TooltipComponent } from 'echarts/components';
|
|
5
|
+
import type { EChartsOption } from 'echarts';
|
|
6
|
+
|
|
7
|
+
import type { HistogramChartProps } from '@classic-homes/charts-core';
|
|
8
|
+
import {
|
|
9
|
+
binValues,
|
|
10
|
+
buildAxis,
|
|
11
|
+
buildDataLabel,
|
|
12
|
+
buildTooltip,
|
|
13
|
+
escapeHtml,
|
|
14
|
+
generateHistogramDescription,
|
|
15
|
+
getChartColors,
|
|
16
|
+
gridInset,
|
|
17
|
+
histogramTable,
|
|
18
|
+
mergeChartOption,
|
|
19
|
+
resolveValueFormatter,
|
|
20
|
+
} from '@classic-homes/charts-core';
|
|
21
|
+
|
|
22
|
+
import { cn } from '../../utils.js';
|
|
23
|
+
import { chartEvents, toDataPointParams } from '../../events.js';
|
|
24
|
+
import { useChart } from '../../composables/useChart.svelte.js';
|
|
25
|
+
import ChartContainer from '../base/ChartContainer.svelte';
|
|
26
|
+
|
|
27
|
+
// Register required ECharts modules
|
|
28
|
+
echarts.use([EChartsBarChart, GridComponent, TooltipComponent]);
|
|
29
|
+
|
|
30
|
+
interface Props extends Omit<HistogramChartProps, 'class'> {
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
values,
|
|
38
|
+
bins,
|
|
39
|
+
binWidth,
|
|
40
|
+
seriesName = 'Count',
|
|
41
|
+
color,
|
|
42
|
+
height = 400,
|
|
43
|
+
minHeight,
|
|
44
|
+
maxHeight,
|
|
45
|
+
loading,
|
|
46
|
+
error,
|
|
47
|
+
onRetry,
|
|
48
|
+
emptyMessage,
|
|
49
|
+
theme = 'auto',
|
|
50
|
+
animation = true,
|
|
51
|
+
showTooltip = true,
|
|
52
|
+
showGrid = true,
|
|
53
|
+
showLabels = false,
|
|
54
|
+
valueFormat,
|
|
55
|
+
locale,
|
|
56
|
+
labelFormatter,
|
|
57
|
+
xAxisConfig,
|
|
58
|
+
yAxisConfig,
|
|
59
|
+
referenceLines,
|
|
60
|
+
referenceBands,
|
|
61
|
+
tooltipFormatter,
|
|
62
|
+
tooltipValueFormatter,
|
|
63
|
+
option: optionOverride,
|
|
64
|
+
onReady,
|
|
65
|
+
events,
|
|
66
|
+
showDataTable,
|
|
67
|
+
exportable,
|
|
68
|
+
patterns,
|
|
69
|
+
onClick,
|
|
70
|
+
class: className,
|
|
71
|
+
}: Props = $props();
|
|
72
|
+
|
|
73
|
+
// `valueFormat` writes the bucket bounds; counts are plain numbers
|
|
74
|
+
const boundFormat = $derived(resolveValueFormatter(valueFormat, locale));
|
|
75
|
+
|
|
76
|
+
const buckets = $derived(binValues(values ?? [], { bins, binWidth, format: boundFormat }));
|
|
77
|
+
const total = $derived(buckets.reduce((sum, bucket) => sum + bucket.count, 0));
|
|
78
|
+
|
|
79
|
+
const isEmpty = $derived(!buckets.length || total === 0);
|
|
80
|
+
|
|
81
|
+
// Per-element colours that follow the resolved chart theme
|
|
82
|
+
const chartColors = $derived.by(() => getChartColors(chart.theme));
|
|
83
|
+
|
|
84
|
+
const option: EChartsOption = $derived.by(() => {
|
|
85
|
+
if (isEmpty) return {};
|
|
86
|
+
|
|
87
|
+
const series: Record<string, unknown> = {
|
|
88
|
+
name: seriesName,
|
|
89
|
+
type: 'bar',
|
|
90
|
+
// Adjacent buckets touch: a histogram's bars are a continuous range
|
|
91
|
+
barCategoryGap: '2%',
|
|
92
|
+
// Lift the theme's bar width cap so buckets fill their range
|
|
93
|
+
barMaxWidth: 1000,
|
|
94
|
+
data: buckets.map((bucket) => bucket.count),
|
|
95
|
+
itemStyle: { color: color ?? chartColors.series[0], borderRadius: [2, 2, 0, 0] },
|
|
96
|
+
label: buildDataLabel({ show: true, position: 'top' }, undefined, {
|
|
97
|
+
show: showLabels,
|
|
98
|
+
formatter: labelFormatter,
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Reference lines mark a count (e.g. a capacity), not a bucket
|
|
103
|
+
if (referenceLines?.length || referenceBands?.length) {
|
|
104
|
+
series.markLine = referenceLines?.length
|
|
105
|
+
? {
|
|
106
|
+
silent: true,
|
|
107
|
+
symbol: 'none',
|
|
108
|
+
animation: false,
|
|
109
|
+
data: referenceLines.map((line) => ({
|
|
110
|
+
...(typeof line.value === 'number'
|
|
111
|
+
? { yAxis: line.value }
|
|
112
|
+
: { type: line.value, valueDim: 'y' }),
|
|
113
|
+
lineStyle: {
|
|
114
|
+
type: line.style ?? 'dashed',
|
|
115
|
+
color: line.color ?? chartColors.reference,
|
|
116
|
+
},
|
|
117
|
+
label: {
|
|
118
|
+
position: 'insideEndTop',
|
|
119
|
+
color: line.color ?? chartColors.reference,
|
|
120
|
+
formatter: line.label ?? '{c}',
|
|
121
|
+
},
|
|
122
|
+
})),
|
|
123
|
+
}
|
|
124
|
+
: undefined;
|
|
125
|
+
series.markArea = referenceBands?.length
|
|
126
|
+
? {
|
|
127
|
+
silent: true,
|
|
128
|
+
data: referenceBands.map((band) => [
|
|
129
|
+
{
|
|
130
|
+
yAxis: band.from,
|
|
131
|
+
name: band.label,
|
|
132
|
+
itemStyle: { color: band.color ?? chartColors.referenceBand },
|
|
133
|
+
},
|
|
134
|
+
{ yAxis: band.to },
|
|
135
|
+
]),
|
|
136
|
+
}
|
|
137
|
+
: undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return mergeChartOption(
|
|
141
|
+
{
|
|
142
|
+
grid: {
|
|
143
|
+
left: '3%',
|
|
144
|
+
right: '4%',
|
|
145
|
+
bottom: '3%',
|
|
146
|
+
containLabel: true,
|
|
147
|
+
...gridInset(undefined, false, xAxisConfig, yAxisConfig),
|
|
148
|
+
},
|
|
149
|
+
tooltip: buildTooltip(
|
|
150
|
+
{
|
|
151
|
+
trigger: 'axis',
|
|
152
|
+
axisPointer: { type: 'shadow' },
|
|
153
|
+
formatter: (params: unknown) => {
|
|
154
|
+
const list = (Array.isArray(params) ? params : [params]) as {
|
|
155
|
+
dataIndex?: number;
|
|
156
|
+
marker?: string;
|
|
157
|
+
}[];
|
|
158
|
+
const bucket = buckets[list[0]?.dataIndex ?? 0];
|
|
159
|
+
if (!bucket) return '';
|
|
160
|
+
const share = total ? ((bucket.count / total) * 100).toFixed(1) : '0';
|
|
161
|
+
const count = tooltipValueFormatter
|
|
162
|
+
? tooltipValueFormatter(bucket.count)
|
|
163
|
+
: bucket.count.toLocaleString(locale ?? 'en-US');
|
|
164
|
+
return `${escapeHtml(bucket.label)}<br/>${list[0]?.marker ?? ''}${escapeHtml(seriesName)}: <strong>${escapeHtml(count)}</strong> (${share}%)`;
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{ show: showTooltip, formatter: tooltipFormatter }
|
|
168
|
+
),
|
|
169
|
+
xAxis: buildAxis(
|
|
170
|
+
{
|
|
171
|
+
type: 'category',
|
|
172
|
+
data: buckets.map((bucket) => bucket.label),
|
|
173
|
+
axisTick: { alignWithLabel: true },
|
|
174
|
+
},
|
|
175
|
+
xAxisConfig
|
|
176
|
+
),
|
|
177
|
+
yAxis: buildAxis(
|
|
178
|
+
{ type: 'value', minInterval: 1, splitLine: { show: showGrid } },
|
|
179
|
+
yAxisConfig
|
|
180
|
+
),
|
|
181
|
+
series: [series],
|
|
182
|
+
} as EChartsOption,
|
|
183
|
+
optionOverride
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const accessibilityDescription = $derived(
|
|
188
|
+
description || (!isEmpty ? generateHistogramDescription(title, buckets, total) : undefined)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const dataTable = $derived(isEmpty ? undefined : histogramTable(buckets));
|
|
192
|
+
|
|
193
|
+
// Manage ECharts instance lifecycle (init, theme, resize, option updates)
|
|
194
|
+
const chart = useChart({
|
|
195
|
+
option: () => option,
|
|
196
|
+
theme: () => theme,
|
|
197
|
+
animation: () => animation,
|
|
198
|
+
isEmpty: () => isEmpty,
|
|
199
|
+
patterns: () => patterns,
|
|
200
|
+
events: () =>
|
|
201
|
+
chartEvents(events, {
|
|
202
|
+
click: onClick && ((params) => onClick(toDataPointParams(params, 'click'))),
|
|
203
|
+
}),
|
|
204
|
+
onCreated: () => onReady,
|
|
205
|
+
});
|
|
206
|
+
</script>
|
|
207
|
+
|
|
208
|
+
<ChartContainer
|
|
209
|
+
{title}
|
|
210
|
+
description={accessibilityDescription}
|
|
211
|
+
{height}
|
|
212
|
+
{minHeight}
|
|
213
|
+
{maxHeight}
|
|
214
|
+
{loading}
|
|
215
|
+
{error}
|
|
216
|
+
{onRetry}
|
|
217
|
+
empty={isEmpty}
|
|
218
|
+
{emptyMessage}
|
|
219
|
+
class={cn(className)}
|
|
220
|
+
{dataTable}
|
|
221
|
+
{showDataTable}
|
|
222
|
+
{exportable}
|
|
223
|
+
exportImage={chart.exportImage}
|
|
224
|
+
>
|
|
225
|
+
<div bind:this={chart.container} class="h-full w-full"></div>
|
|
226
|
+
</ChartContainer>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HistogramChartProps } from '@classic-homes/charts-core';
|
|
2
|
+
interface Props extends Omit<HistogramChartProps, 'class'> {
|
|
3
|
+
class?: string;
|
|
4
|
+
}
|
|
5
|
+
declare const HistogramChart: import("svelte").Component<Props, {}, "">;
|
|
6
|
+
type HistogramChart = ReturnType<typeof HistogramChart>;
|
|
7
|
+
export default HistogramChart;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SparklineProps } from '@classic-homes/charts-core';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
data,
|
|
7
|
+
variant = 'line',
|
|
8
|
+
width = '100%',
|
|
9
|
+
height = 32,
|
|
10
|
+
color,
|
|
11
|
+
showLastPoint,
|
|
12
|
+
referenceLine,
|
|
13
|
+
min,
|
|
14
|
+
max,
|
|
15
|
+
label,
|
|
16
|
+
class: className,
|
|
17
|
+
}: SparklineProps = $props();
|
|
18
|
+
|
|
19
|
+
// Drawn in a 100-wide viewBox stretched to the element's width, with the
|
|
20
|
+
// real pixel height, so strokes keep their width at any size
|
|
21
|
+
const VIEW_WIDTH = 100;
|
|
22
|
+
const PAD = 2;
|
|
23
|
+
|
|
24
|
+
const values = $derived(
|
|
25
|
+
(data ?? []).map((value) =>
|
|
26
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
const present = $derived(values.filter((value): value is number => value !== null));
|
|
30
|
+
|
|
31
|
+
const scale = $derived.by(() => {
|
|
32
|
+
let lo = min ?? Math.min(...present, variant === 'bar' ? 0 : Infinity);
|
|
33
|
+
let hi = max ?? Math.max(...present);
|
|
34
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
|
|
35
|
+
if (hi === lo) {
|
|
36
|
+
// A flat series sits in the middle
|
|
37
|
+
lo -= 1;
|
|
38
|
+
hi += 1;
|
|
39
|
+
}
|
|
40
|
+
return { lo, hi };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const y = (value: number) => {
|
|
44
|
+
if (!scale) return height / 2;
|
|
45
|
+
const ratio = (value - scale.lo) / (scale.hi - scale.lo);
|
|
46
|
+
return PAD + (1 - Math.min(Math.max(ratio, 0), 1)) * (height - PAD * 2);
|
|
47
|
+
};
|
|
48
|
+
const x = (index: number) =>
|
|
49
|
+
values.length <= 1 ? VIEW_WIDTH / 2 : (index / (values.length - 1)) * VIEW_WIDTH;
|
|
50
|
+
|
|
51
|
+
// Runs of consecutive present values; gaps break the line
|
|
52
|
+
const segments = $derived.by(() => {
|
|
53
|
+
const runs: [number, number][][] = [];
|
|
54
|
+
let run: [number, number][] = [];
|
|
55
|
+
values.forEach((value, index) => {
|
|
56
|
+
if (value === null) {
|
|
57
|
+
if (run.length) runs.push(run);
|
|
58
|
+
run = [];
|
|
59
|
+
} else {
|
|
60
|
+
run.push([x(index), y(value)]);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
if (run.length) runs.push(run);
|
|
64
|
+
return runs;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const linePath = $derived(
|
|
68
|
+
segments
|
|
69
|
+
.map((run) =>
|
|
70
|
+
run.map(([px, py], i) => `${i ? 'L' : 'M'}${px.toFixed(2)},${py.toFixed(2)}`).join('')
|
|
71
|
+
)
|
|
72
|
+
.join('')
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const baseline = $derived(scale ? y(Math.max(scale.lo, Math.min(0, scale.hi))) : height - PAD);
|
|
76
|
+
|
|
77
|
+
const areaPath = $derived(
|
|
78
|
+
segments
|
|
79
|
+
.map((run) => {
|
|
80
|
+
if (run.length < 2) return '';
|
|
81
|
+
const top = run.map(([px, py], i) => `${i ? 'L' : 'M'}${px.toFixed(2)},${py.toFixed(2)}`);
|
|
82
|
+
const first = run[0][0].toFixed(2);
|
|
83
|
+
const last = run[run.length - 1][0].toFixed(2);
|
|
84
|
+
return `${top.join('')}L${last},${baseline.toFixed(2)}L${first},${baseline.toFixed(2)}Z`;
|
|
85
|
+
})
|
|
86
|
+
.join('')
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const bars = $derived.by(() => {
|
|
90
|
+
const slot = VIEW_WIDTH / Math.max(values.length, 1);
|
|
91
|
+
return values.flatMap((value, index) => {
|
|
92
|
+
if (value === null) return [];
|
|
93
|
+
const top = Math.min(y(value), baseline);
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
x: index * slot + slot * 0.15,
|
|
97
|
+
width: slot * 0.7,
|
|
98
|
+
y: top,
|
|
99
|
+
height: Math.max(Math.abs(baseline - y(value)), 1),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const lastIndex = $derived.by(() => {
|
|
106
|
+
for (let index = values.length - 1; index >= 0; index--) {
|
|
107
|
+
if (values[index] !== null) return index;
|
|
108
|
+
}
|
|
109
|
+
return -1;
|
|
110
|
+
});
|
|
111
|
+
const showDot = $derived((showLastPoint ?? variant !== 'bar') && lastIndex !== -1);
|
|
112
|
+
|
|
113
|
+
const referenceY = $derived.by(() => {
|
|
114
|
+
if (referenceLine === undefined || !present.length) return null;
|
|
115
|
+
const value =
|
|
116
|
+
referenceLine === 'average'
|
|
117
|
+
? present.reduce((sum, v) => sum + v, 0) / present.length
|
|
118
|
+
: referenceLine;
|
|
119
|
+
return y(value);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const widthStyle = $derived(typeof width === 'number' ? `${width}px` : width);
|
|
123
|
+
</script>
|
|
124
|
+
|
|
125
|
+
<span
|
|
126
|
+
data-testid="sparkline"
|
|
127
|
+
class={cn('relative inline-block align-middle text-chart-1', className)}
|
|
128
|
+
style:width={widthStyle}
|
|
129
|
+
style:height="{height}px"
|
|
130
|
+
style:color
|
|
131
|
+
role={label ? 'img' : undefined}
|
|
132
|
+
aria-label={label}
|
|
133
|
+
aria-hidden={label ? undefined : 'true'}
|
|
134
|
+
>
|
|
135
|
+
<svg
|
|
136
|
+
class="absolute inset-0 h-full w-full overflow-visible"
|
|
137
|
+
viewBox="0 0 {VIEW_WIDTH} {height}"
|
|
138
|
+
preserveAspectRatio="none"
|
|
139
|
+
aria-hidden="true"
|
|
140
|
+
>
|
|
141
|
+
{#if referenceY !== null}
|
|
142
|
+
<line
|
|
143
|
+
x1="0"
|
|
144
|
+
x2={VIEW_WIDTH}
|
|
145
|
+
y1={referenceY}
|
|
146
|
+
y2={referenceY}
|
|
147
|
+
class="stroke-muted-foreground"
|
|
148
|
+
stroke-width="1"
|
|
149
|
+
stroke-dasharray="3 3"
|
|
150
|
+
vector-effect="non-scaling-stroke"
|
|
151
|
+
opacity="0.6"
|
|
152
|
+
/>
|
|
153
|
+
{/if}
|
|
154
|
+
{#if variant === 'bar'}
|
|
155
|
+
{#each bars as bar, index (index)}
|
|
156
|
+
<rect x={bar.x} y={bar.y} width={bar.width} height={bar.height} fill="currentColor" />
|
|
157
|
+
{/each}
|
|
158
|
+
{:else}
|
|
159
|
+
{#if variant === 'area' && areaPath}
|
|
160
|
+
<path d={areaPath} fill="currentColor" opacity="0.15" />
|
|
161
|
+
{/if}
|
|
162
|
+
<path
|
|
163
|
+
d={linePath}
|
|
164
|
+
fill="none"
|
|
165
|
+
stroke="currentColor"
|
|
166
|
+
stroke-width="1.5"
|
|
167
|
+
stroke-linejoin="round"
|
|
168
|
+
stroke-linecap="round"
|
|
169
|
+
vector-effect="non-scaling-stroke"
|
|
170
|
+
/>
|
|
171
|
+
{/if}
|
|
172
|
+
</svg>
|
|
173
|
+
{#if showDot && values[lastIndex] !== null}
|
|
174
|
+
<!-- HTML, not SVG: a circle in the stretched viewBox would be an ellipse -->
|
|
175
|
+
<span
|
|
176
|
+
class="absolute h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current"
|
|
177
|
+
style:left="{(x(lastIndex) / VIEW_WIDTH) * 100}%"
|
|
178
|
+
style:top="{y(values[lastIndex] as number)}px"
|
|
179
|
+
></span>
|
|
180
|
+
{/if}
|
|
181
|
+
</span>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { StatCardProps } from '@classic-homes/charts-core';
|
|
3
|
+
import { computeDelta, formatValue, resolveValueFormatter } from '@classic-homes/charts-core';
|
|
4
|
+
import { cn } from '../../utils.js';
|
|
5
|
+
import Sparkline from './Sparkline.svelte';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
label,
|
|
9
|
+
value,
|
|
10
|
+
valueFormat,
|
|
11
|
+
locale = 'en-US',
|
|
12
|
+
delta,
|
|
13
|
+
hint,
|
|
14
|
+
definition,
|
|
15
|
+
href,
|
|
16
|
+
trend,
|
|
17
|
+
trendVariant = 'line',
|
|
18
|
+
tone = 'default',
|
|
19
|
+
emptyValue = '—',
|
|
20
|
+
loading = false,
|
|
21
|
+
class: className,
|
|
22
|
+
}: StatCardProps = $props();
|
|
23
|
+
|
|
24
|
+
const format = $derived(resolveValueFormatter(valueFormat, locale));
|
|
25
|
+
|
|
26
|
+
const display = $derived(
|
|
27
|
+
typeof value === 'string' ? value : formatValue(value ?? null, format, emptyValue)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const change = $derived(
|
|
31
|
+
delta
|
|
32
|
+
? computeDelta(delta.current ?? (typeof value === 'number' ? value : null), delta.previous)
|
|
33
|
+
: null
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const percentFormat = $derived(
|
|
37
|
+
new Intl.NumberFormat(locale, {
|
|
38
|
+
style: 'percent',
|
|
39
|
+
maximumFractionDigits: 1,
|
|
40
|
+
signDisplay: 'exceptZero',
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const changeText = $derived.by(() => {
|
|
45
|
+
if (!change || !delta) return '';
|
|
46
|
+
const sign = change.change > 0 ? '+' : change.change < 0 ? '−' : '';
|
|
47
|
+
const absolute = `${sign}${formatValue(Math.abs(change.change), format)}`;
|
|
48
|
+
const percent = change.ratio === null ? null : percentFormat.format(change.ratio);
|
|
49
|
+
switch (delta.display ?? 'percent') {
|
|
50
|
+
case 'absolute':
|
|
51
|
+
return absolute;
|
|
52
|
+
case 'both':
|
|
53
|
+
return percent ? `${absolute} (${percent})` : absolute;
|
|
54
|
+
default:
|
|
55
|
+
// A change from zero has no percentage
|
|
56
|
+
return percent ?? absolute;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const changeTone = $derived.by(() => {
|
|
61
|
+
if (!change || change.direction === 'flat' || delta?.goodWhen === 'none') {
|
|
62
|
+
return 'text-muted-foreground';
|
|
63
|
+
}
|
|
64
|
+
const good = (delta?.goodWhen ?? 'up') === change.direction;
|
|
65
|
+
return good ? 'text-success' : 'text-destructive';
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const previousText = $derived(
|
|
69
|
+
delta && typeof delta.previous === 'number' ? formatValue(delta.previous, format) : ''
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const valueTone = $derived(
|
|
73
|
+
{
|
|
74
|
+
default: 'text-foreground',
|
|
75
|
+
success: 'text-success',
|
|
76
|
+
warning: 'text-warning',
|
|
77
|
+
destructive: 'text-destructive',
|
|
78
|
+
}[tone]
|
|
79
|
+
);
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<svelte:element
|
|
83
|
+
this={href ? 'a' : 'div'}
|
|
84
|
+
{href}
|
|
85
|
+
data-testid="stat-card"
|
|
86
|
+
aria-busy={loading || undefined}
|
|
87
|
+
class={cn(
|
|
88
|
+
'flex flex-col rounded-lg border border-border bg-card p-4 text-card-foreground',
|
|
89
|
+
href &&
|
|
90
|
+
'hover:border-ring hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
91
|
+
className
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<div class="flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
|
95
|
+
<span>{label}</span>
|
|
96
|
+
{#if definition}
|
|
97
|
+
<span class="inline-flex cursor-help" title={definition} aria-hidden="true">
|
|
98
|
+
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
|
99
|
+
<path
|
|
100
|
+
fill-rule="evenodd"
|
|
101
|
+
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
102
|
+
clip-rule="evenodd"
|
|
103
|
+
/>
|
|
104
|
+
</svg>
|
|
105
|
+
</span>
|
|
106
|
+
<span class="sr-only">({definition})</span>
|
|
107
|
+
{/if}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{#if loading}
|
|
111
|
+
<div class="mt-2 h-8 w-24 animate-pulse rounded bg-muted"></div>
|
|
112
|
+
<div class="mt-2 h-3 w-32 animate-pulse rounded bg-muted"></div>
|
|
113
|
+
{:else}
|
|
114
|
+
<div class="mt-1 flex items-end justify-between gap-3">
|
|
115
|
+
<div class="min-w-0">
|
|
116
|
+
<div class={cn('text-2xl font-semibold tabular-nums', valueTone)}>{display}</div>
|
|
117
|
+
{#if changeText}
|
|
118
|
+
<div class="mt-1 flex flex-wrap items-baseline gap-x-1 text-xs">
|
|
119
|
+
<span class={cn('font-medium tabular-nums', changeTone)}>
|
|
120
|
+
<span aria-hidden="true">
|
|
121
|
+
{change?.direction === 'up' ? '▲' : change?.direction === 'down' ? '▼' : ''}
|
|
122
|
+
</span>
|
|
123
|
+
{changeText}
|
|
124
|
+
</span>
|
|
125
|
+
<span class="text-muted-foreground">
|
|
126
|
+
{delta?.label ?? (previousText ? `vs ${previousText}` : '')}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
130
|
+
{#if hint}
|
|
131
|
+
<p class="mt-1 text-xs text-muted-foreground">{hint}</p>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
{#if trend?.length}
|
|
135
|
+
<Sparkline
|
|
136
|
+
data={trend}
|
|
137
|
+
variant={trendVariant}
|
|
138
|
+
width={96}
|
|
139
|
+
height={32}
|
|
140
|
+
class="shrink-0"
|
|
141
|
+
label={`${label} trend`}
|
|
142
|
+
/>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
{/if}
|
|
146
|
+
</svelte:element>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Columns at the widest breakpoint (defaults to 4) */
|
|
7
|
+
columns?: 2 | 3 | 4 | 5 | 6;
|
|
8
|
+
/** Accessible name for the group, e.g. 'Key metrics' */
|
|
9
|
+
label?: string;
|
|
10
|
+
class?: string;
|
|
11
|
+
children: Snippet;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { columns = 4, label, class: className, children }: Props = $props();
|
|
15
|
+
|
|
16
|
+
// Full class names, so Tailwind's scanner finds them
|
|
17
|
+
const columnClass = $derived(
|
|
18
|
+
{
|
|
19
|
+
2: 'sm:grid-cols-2',
|
|
20
|
+
3: 'sm:grid-cols-2 lg:grid-cols-3',
|
|
21
|
+
4: 'sm:grid-cols-2 lg:grid-cols-4',
|
|
22
|
+
5: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
|
23
|
+
6: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
|
24
|
+
}[columns]
|
|
25
|
+
);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div
|
|
29
|
+
data-testid="stat-group"
|
|
30
|
+
role={label ? 'group' : undefined}
|
|
31
|
+
aria-label={label}
|
|
32
|
+
class={cn('grid grid-cols-1 gap-4', columnClass, className)}
|
|
33
|
+
>
|
|
34
|
+
{@render children()}
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Columns at the widest breakpoint (defaults to 4) */
|
|
4
|
+
columns?: 2 | 3 | 4 | 5 | 6;
|
|
5
|
+
/** Accessible name for the group, e.g. 'Key metrics' */
|
|
6
|
+
label?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
children: Snippet;
|
|
9
|
+
}
|
|
10
|
+
declare const StatGroup: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type StatGroup = ReturnType<typeof StatGroup>;
|
|
12
|
+
export default StatGroup;
|