@loidolt/theme-charts 0.7.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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/components/BarChart.svelte +64 -0
- package/dist/components/BarChart.svelte.d.ts +14 -0
- package/dist/components/CandlestickChart.svelte +58 -0
- package/dist/components/CandlestickChart.svelte.d.ts +21 -0
- package/dist/components/Chart.svelte +282 -0
- package/dist/components/Chart.svelte.d.ts +73 -0
- package/dist/components/ChartDataTable.svelte +79 -0
- package/dist/components/ChartDataTable.svelte.d.ts +23 -0
- package/dist/components/FunnelChart.svelte +50 -0
- package/dist/components/FunnelChart.svelte.d.ts +18 -0
- package/dist/components/GaugeChart.svelte +54 -0
- package/dist/components/GaugeChart.svelte.d.ts +18 -0
- package/dist/components/HeatmapChart.svelte +51 -0
- package/dist/components/HeatmapChart.svelte.d.ts +14 -0
- package/dist/components/LineChart.svelte +66 -0
- package/dist/components/LineChart.svelte.d.ts +14 -0
- package/dist/components/PieChart.svelte +62 -0
- package/dist/components/PieChart.svelte.d.ts +18 -0
- package/dist/components/RadarChart.svelte +48 -0
- package/dist/components/RadarChart.svelte.d.ts +14 -0
- package/dist/components/SankeyChart.svelte +48 -0
- package/dist/components/SankeyChart.svelte.d.ts +18 -0
- package/dist/components/ScatterChart.svelte +61 -0
- package/dist/components/ScatterChart.svelte.d.ts +18 -0
- package/dist/components/TreemapChart.svelte +48 -0
- package/dist/components/TreemapChart.svelte.d.ts +17 -0
- package/dist/core/builders/category.d.ts +37 -0
- package/dist/core/builders/category.js +109 -0
- package/dist/core/builders/more.d.ts +119 -0
- package/dist/core/builders/more.js +530 -0
- package/dist/core/builders/slices.d.ts +36 -0
- package/dist/core/builders/slices.js +117 -0
- package/dist/core/describe.d.ts +27 -0
- package/dist/core/describe.js +69 -0
- package/dist/core/format.d.ts +15 -0
- package/dist/core/format.js +39 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.js +12 -0
- package/dist/core/theme.d.ts +30 -0
- package/dist/core/theme.js +137 -0
- package/dist/core/types.d.ts +98 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validate.d.ts +4 -0
- package/dist/core/validate.js +10 -0
- package/dist/echarts/all.d.ts +3 -0
- package/dist/echarts/all.js +23 -0
- package/dist/echarts/bar.d.ts +3 -0
- package/dist/echarts/bar.js +2 -0
- package/dist/echarts/base.d.ts +3 -0
- package/dist/echarts/base.js +13 -0
- package/dist/echarts/candlestick.d.ts +3 -0
- package/dist/echarts/candlestick.js +3 -0
- package/dist/echarts/funnel.d.ts +3 -0
- package/dist/echarts/funnel.js +2 -0
- package/dist/echarts/gauge.d.ts +3 -0
- package/dist/echarts/gauge.js +2 -0
- package/dist/echarts/heatmap.d.ts +3 -0
- package/dist/echarts/heatmap.js +3 -0
- package/dist/echarts/line.d.ts +3 -0
- package/dist/echarts/line.js +2 -0
- package/dist/echarts/pie.d.ts +3 -0
- package/dist/echarts/pie.js +2 -0
- package/dist/echarts/radar.d.ts +3 -0
- package/dist/echarts/radar.js +3 -0
- package/dist/echarts/sankey.d.ts +3 -0
- package/dist/echarts/sankey.js +2 -0
- package/dist/echarts/scatter.d.ts +3 -0
- package/dist/echarts/scatter.js +2 -0
- package/dist/echarts/treemap.d.ts +3 -0
- package/dist/echarts/treemap.js +2 -0
- package/dist/echarts.d.ts +22 -0
- package/dist/echarts.js +24 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/package.json +67 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { chartColors } from '@loidolt/theme-tokens';
|
|
2
|
+
import { joinList } from '../describe.js';
|
|
3
|
+
import { formatCompact, formatNumber } from '../format.js';
|
|
4
|
+
import { seriesColor } from '../theme.js';
|
|
5
|
+
import { invariant } from '../validate.js';
|
|
6
|
+
const formatter = (format) => (value) => formatNumber(value, format);
|
|
7
|
+
/** The least-squares line through `points`, or `null` when it is undefined (all x equal). */
|
|
8
|
+
export function linearFit(points) {
|
|
9
|
+
const n = points.length;
|
|
10
|
+
if (n < 2)
|
|
11
|
+
return null;
|
|
12
|
+
const [sumX, sumY] = points.reduce(([x, y], point) => [x + point[0], y + point[1]], [0, 0]);
|
|
13
|
+
const [meanX, meanY] = [sumX / n, sumY / n];
|
|
14
|
+
let numerator = 0;
|
|
15
|
+
let denominator = 0;
|
|
16
|
+
for (const [x, y] of points) {
|
|
17
|
+
numerator += (x - meanX) * (y - meanY);
|
|
18
|
+
denominator += (x - meanX) ** 2;
|
|
19
|
+
}
|
|
20
|
+
if (denominator === 0)
|
|
21
|
+
return null;
|
|
22
|
+
const slope = numerator / denominator;
|
|
23
|
+
return { slope, intercept: meanY - slope * meanX };
|
|
24
|
+
}
|
|
25
|
+
export function buildScatterOption(data, options = {}, colors = chartColors()) {
|
|
26
|
+
const { showTrendLine = false, symbolSize = 8, showLegend = data.length > 1, showTooltip = true, } = options;
|
|
27
|
+
const [formatX, formatY] = [formatter(options.xFormat), formatter(options.yFormat)];
|
|
28
|
+
const series = [];
|
|
29
|
+
data.forEach((one, index) => {
|
|
30
|
+
const color = one.color ?? seriesColor(colors, index);
|
|
31
|
+
series.push({
|
|
32
|
+
type: 'scatter',
|
|
33
|
+
name: one.name,
|
|
34
|
+
data: one.data,
|
|
35
|
+
symbolSize,
|
|
36
|
+
itemStyle: { color, opacity: 0.85 },
|
|
37
|
+
emphasis: { focus: 'series' },
|
|
38
|
+
});
|
|
39
|
+
const fit = showTrendLine ? linearFit(one.data) : null;
|
|
40
|
+
if (fit) {
|
|
41
|
+
const xs = one.data.map(([x]) => x);
|
|
42
|
+
const [low, high] = [Math.min(...xs), Math.max(...xs)];
|
|
43
|
+
series.push({
|
|
44
|
+
type: 'line',
|
|
45
|
+
name: `${one.name} trend`,
|
|
46
|
+
data: [
|
|
47
|
+
[low, fit.intercept + fit.slope * low],
|
|
48
|
+
[high, fit.intercept + fit.slope * high],
|
|
49
|
+
],
|
|
50
|
+
showSymbol: false,
|
|
51
|
+
lineStyle: { color, type: 'dashed', width: 1.5 },
|
|
52
|
+
tooltip: { show: false },
|
|
53
|
+
silent: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
legend: showLegend ? { type: 'scroll', top: 0, left: 0 } : undefined,
|
|
59
|
+
tooltip: showTooltip
|
|
60
|
+
? {
|
|
61
|
+
trigger: 'item',
|
|
62
|
+
formatter: (params) => `${params.seriesName}<br>${formatX(params.value[0])}, ${formatY(params.value[1])}`,
|
|
63
|
+
}
|
|
64
|
+
: undefined,
|
|
65
|
+
grid: { left: 8, right: 16, top: showLegend ? 40 : 16, bottom: 8, containLabel: true },
|
|
66
|
+
xAxis: {
|
|
67
|
+
type: 'value',
|
|
68
|
+
name: options.xAxisName,
|
|
69
|
+
scale: true,
|
|
70
|
+
axisLabel: { formatter: formatX },
|
|
71
|
+
},
|
|
72
|
+
yAxis: {
|
|
73
|
+
type: 'value',
|
|
74
|
+
name: options.yAxisName,
|
|
75
|
+
scale: true,
|
|
76
|
+
axisLabel: { formatter: formatY },
|
|
77
|
+
},
|
|
78
|
+
series,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function scatterTable(data, { seriesLabel = 'Series', xLabel = 'X', yLabel = 'Y' } = {}) {
|
|
82
|
+
return {
|
|
83
|
+
columns: [seriesLabel, xLabel, yLabel],
|
|
84
|
+
rows: data.flatMap((one) => one.data.map(([x, y]) => [one.name, x, y])),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function describeScatter(data) {
|
|
88
|
+
const points = data.reduce((sum, one) => sum + one.data.length, 0);
|
|
89
|
+
if (!points)
|
|
90
|
+
return 'Empty scatter chart.';
|
|
91
|
+
const trends = data.map((one) => {
|
|
92
|
+
const fit = linearFit(one.data);
|
|
93
|
+
const direction = !fit || fit.slope === 0
|
|
94
|
+
? 'no clear trend'
|
|
95
|
+
: fit.slope > 0
|
|
96
|
+
? 'y rising with x'
|
|
97
|
+
: 'y falling as x rises';
|
|
98
|
+
return `${one.name}: ${one.data.length} points, ${direction}`;
|
|
99
|
+
});
|
|
100
|
+
return `Scatter chart of ${points} points in ${data.length} series. ${trends.join('; ')}.`;
|
|
101
|
+
}
|
|
102
|
+
export function buildRadarOption(data, options = {}, colors = chartColors()) {
|
|
103
|
+
for (const one of data.series) {
|
|
104
|
+
invariant(one.values.length === data.indicators.length, 'buildRadarOption', `series "${one.name}" has ${one.values.length} values for ${data.indicators.length} indicators`);
|
|
105
|
+
}
|
|
106
|
+
const { shape = 'polygon', area = true, showLegend = data.series.length > 1, showTooltip = true, } = options;
|
|
107
|
+
return {
|
|
108
|
+
legend: showLegend ? { type: 'scroll', bottom: 0, left: 'center' } : undefined,
|
|
109
|
+
tooltip: showTooltip ? { trigger: 'item' } : undefined,
|
|
110
|
+
radar: {
|
|
111
|
+
shape,
|
|
112
|
+
radius: '65%',
|
|
113
|
+
indicator: data.indicators.map((indicator, index) => ({
|
|
114
|
+
name: indicator.name,
|
|
115
|
+
min: indicator.min ?? 0,
|
|
116
|
+
max: indicator.max ?? Math.max(1, ...data.series.map((one) => one.values[index] ?? 0)) * 1.1,
|
|
117
|
+
})),
|
|
118
|
+
},
|
|
119
|
+
series: [
|
|
120
|
+
{
|
|
121
|
+
type: 'radar',
|
|
122
|
+
data: data.series.map((one, index) => {
|
|
123
|
+
const color = one.color ?? seriesColor(colors, index);
|
|
124
|
+
return {
|
|
125
|
+
name: one.name,
|
|
126
|
+
value: one.values,
|
|
127
|
+
symbol: 'rect',
|
|
128
|
+
symbolSize: 5,
|
|
129
|
+
itemStyle: { color },
|
|
130
|
+
lineStyle: { color, width: 2 },
|
|
131
|
+
areaStyle: area ? { color, opacity: 0.14 } : undefined,
|
|
132
|
+
};
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function radarTable(data, indicatorLabel = 'Measure') {
|
|
139
|
+
return {
|
|
140
|
+
columns: [indicatorLabel, ...data.series.map((one) => one.name)],
|
|
141
|
+
rows: data.indicators.map((indicator, index) => [
|
|
142
|
+
indicator.name,
|
|
143
|
+
...data.series.map((one) => one.values[index] ?? null),
|
|
144
|
+
]),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export function describeRadar(data) {
|
|
148
|
+
if (!data.series.length)
|
|
149
|
+
return 'Empty radar chart.';
|
|
150
|
+
const strongest = data.series.map((one) => {
|
|
151
|
+
const best = one.values.indexOf(Math.max(...one.values));
|
|
152
|
+
return `${one.name} is strongest in ${data.indicators[best]?.name ?? 'none'}`;
|
|
153
|
+
});
|
|
154
|
+
return `Radar chart comparing ${data.series.length} series across ${data.indicators.length} measures. ${strongest.join('; ')}.`;
|
|
155
|
+
}
|
|
156
|
+
export function buildGaugeOption(data, options = {}, colors = chartColors()) {
|
|
157
|
+
const { value, min = 0, max = 100 } = data;
|
|
158
|
+
invariant(min < max, 'buildGaugeOption', `min (${min}) must be below max (${max})`);
|
|
159
|
+
invariant(Number.isFinite(value), 'buildGaugeOption', 'value must be a finite number');
|
|
160
|
+
const { splitNumber = 5, showProgress = true, valueFormat, label } = options;
|
|
161
|
+
const format = formatter(valueFormat);
|
|
162
|
+
return {
|
|
163
|
+
series: [
|
|
164
|
+
{
|
|
165
|
+
type: 'gauge',
|
|
166
|
+
min,
|
|
167
|
+
max,
|
|
168
|
+
splitNumber,
|
|
169
|
+
radius: '90%',
|
|
170
|
+
progress: { show: showProgress, width: 12, itemStyle: { color: colors.accent } },
|
|
171
|
+
axisLine: { lineStyle: { width: 12, color: [[1, colors.grid]] } },
|
|
172
|
+
axisTick: { distance: -12, length: 4, lineStyle: { color: colors.axis } },
|
|
173
|
+
splitLine: { distance: -12, length: 12, lineStyle: { color: colors.axis, width: 1 } },
|
|
174
|
+
axisLabel: { distance: 18, color: colors.muted, fontSize: 10, formatter: format },
|
|
175
|
+
pointer: {
|
|
176
|
+
show: !showProgress,
|
|
177
|
+
width: 4,
|
|
178
|
+
length: '62%',
|
|
179
|
+
itemStyle: { color: colors.text },
|
|
180
|
+
},
|
|
181
|
+
anchor: {
|
|
182
|
+
show: !showProgress,
|
|
183
|
+
icon: 'rect',
|
|
184
|
+
size: 10,
|
|
185
|
+
itemStyle: { color: colors.text, borderWidth: 0 },
|
|
186
|
+
},
|
|
187
|
+
title: {
|
|
188
|
+
show: Boolean(label),
|
|
189
|
+
offsetCenter: [0, '72%'],
|
|
190
|
+
color: colors.muted,
|
|
191
|
+
fontSize: 11,
|
|
192
|
+
},
|
|
193
|
+
detail: {
|
|
194
|
+
valueAnimation: true,
|
|
195
|
+
offsetCenter: [0, '40%'],
|
|
196
|
+
color: colors.text,
|
|
197
|
+
fontSize: 24,
|
|
198
|
+
fontWeight: 600,
|
|
199
|
+
formatter: format,
|
|
200
|
+
},
|
|
201
|
+
data: [{ value, name: label ?? '' }],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export function gaugeTable(data, { readingLabel = 'Reading', valueLabel = 'Value', minLabel = 'Minimum', maxLabel = 'Maximum', } = {}) {
|
|
207
|
+
return {
|
|
208
|
+
columns: [readingLabel, valueLabel],
|
|
209
|
+
rows: [
|
|
210
|
+
[valueLabel, data.value],
|
|
211
|
+
[minLabel, data.min ?? 0],
|
|
212
|
+
[maxLabel, data.max ?? 100],
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export function describeGauge(data, format) {
|
|
217
|
+
const { value, min = 0, max = 100 } = data;
|
|
218
|
+
const share = Math.round(((value - min) / (max - min)) * 100);
|
|
219
|
+
return `Gauge reading ${formatNumber(value, format)} on a scale of ${formatNumber(min, format)} to ${formatNumber(max, format)} (${share}%).`;
|
|
220
|
+
}
|
|
221
|
+
export function buildHeatmapOption(data, options = {}, colors = chartColors()) {
|
|
222
|
+
for (const [x, y] of data.values) {
|
|
223
|
+
invariant(Number.isInteger(x) &&
|
|
224
|
+
Number.isInteger(y) &&
|
|
225
|
+
x >= 0 &&
|
|
226
|
+
y >= 0 &&
|
|
227
|
+
x < data.x.length &&
|
|
228
|
+
y < data.y.length, 'buildHeatmapOption', `cell [${x}, ${y}] is outside the ${data.x.length} × ${data.y.length} grid`);
|
|
229
|
+
}
|
|
230
|
+
const values = data.values
|
|
231
|
+
.map(([, , value]) => value)
|
|
232
|
+
.filter((value) => value != null);
|
|
233
|
+
const { showValues = false, showTooltip = true, showScale = true, valueFormat } = options;
|
|
234
|
+
const format = formatter(valueFormat);
|
|
235
|
+
const min = options.min ?? Math.min(0, ...values);
|
|
236
|
+
const max = options.max ?? Math.max(min + 1, ...values);
|
|
237
|
+
return {
|
|
238
|
+
tooltip: showTooltip
|
|
239
|
+
? {
|
|
240
|
+
trigger: 'item',
|
|
241
|
+
formatter: (params) => {
|
|
242
|
+
const [x, y, value] = params.value;
|
|
243
|
+
return `${data.y[y]} · ${data.x[x]}<br>${format(value)}`;
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: undefined,
|
|
247
|
+
grid: { left: 8, right: 16, top: 16, bottom: showScale ? 56 : 8, containLabel: true },
|
|
248
|
+
xAxis: { type: 'category', data: data.x, splitArea: { show: false } },
|
|
249
|
+
yAxis: { type: 'category', data: data.y, splitArea: { show: false } },
|
|
250
|
+
visualMap: {
|
|
251
|
+
min,
|
|
252
|
+
max,
|
|
253
|
+
calculable: false,
|
|
254
|
+
show: showScale,
|
|
255
|
+
orient: 'horizontal',
|
|
256
|
+
left: 'center',
|
|
257
|
+
bottom: 0,
|
|
258
|
+
itemWidth: 12,
|
|
259
|
+
formatter: format,
|
|
260
|
+
inRange: { color: colors.sequential },
|
|
261
|
+
},
|
|
262
|
+
series: [
|
|
263
|
+
{
|
|
264
|
+
type: 'heatmap',
|
|
265
|
+
data: data.values,
|
|
266
|
+
itemStyle: { borderColor: colors.surface, borderWidth: 1 },
|
|
267
|
+
label: showValues
|
|
268
|
+
? {
|
|
269
|
+
show: true,
|
|
270
|
+
fontSize: 10,
|
|
271
|
+
formatter: (params) => format(params.value[2]),
|
|
272
|
+
}
|
|
273
|
+
: { show: false },
|
|
274
|
+
emphasis: { itemStyle: { borderColor: colors.text, borderWidth: 1 } },
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function heatmapTable(data, cornerLabel = 'Row') {
|
|
280
|
+
const grid = data.y.map(() => data.x.map(() => null));
|
|
281
|
+
for (const [x, y, value] of data.values)
|
|
282
|
+
grid[y][x] = value;
|
|
283
|
+
return {
|
|
284
|
+
columns: [cornerLabel, ...data.x],
|
|
285
|
+
rows: data.y.map((label, index) => [label, ...grid[index]]),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export function describeHeatmap(data, format) {
|
|
289
|
+
let best = null;
|
|
290
|
+
let worst = null;
|
|
291
|
+
for (const [x, y, value] of data.values) {
|
|
292
|
+
if (value == null)
|
|
293
|
+
continue;
|
|
294
|
+
if (!best || value > best[2])
|
|
295
|
+
best = [x, y, value];
|
|
296
|
+
if (!worst || value < worst[2])
|
|
297
|
+
worst = [x, y, value];
|
|
298
|
+
}
|
|
299
|
+
const where = (cell) => `${formatNumber(cell[2], format)} at ${data.y[cell[1]]} · ${data.x[cell[0]]}`;
|
|
300
|
+
if (!best || !worst)
|
|
301
|
+
return 'Empty heatmap.';
|
|
302
|
+
return `Heatmap of ${data.y.length} rows by ${data.x.length} columns. Highest ${where(best)}; lowest ${where(worst)}.`;
|
|
303
|
+
}
|
|
304
|
+
/** A node's own value, or the sum of its children's. */
|
|
305
|
+
export const nodeValue = (node) => node.value ?? (node.children ?? []).reduce((sum, child) => sum + nodeValue(child), 0);
|
|
306
|
+
export function buildTreemapOption(data, options = {}, colors = chartColors()) {
|
|
307
|
+
const { showTooltip = true, showBreadcrumb = true, valueFormat } = options;
|
|
308
|
+
const format = formatter(valueFormat);
|
|
309
|
+
const colour = (nodes, depth) => nodes.map((node, index) => {
|
|
310
|
+
invariant(node.value === undefined || (Number.isFinite(node.value) && node.value >= 0), 'buildTreemapOption', `"${node.name}" must have a value of 0 or more`);
|
|
311
|
+
const color = node.color ?? (depth === 0 ? seriesColor(colors, index) : undefined);
|
|
312
|
+
return {
|
|
313
|
+
name: node.name,
|
|
314
|
+
value: nodeValue(node),
|
|
315
|
+
itemStyle: color ? { color } : undefined,
|
|
316
|
+
children: node.children ? colour(node.children, depth + 1) : undefined,
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
tooltip: showTooltip ? { trigger: 'item', valueFormatter: format } : undefined,
|
|
321
|
+
series: [
|
|
322
|
+
{
|
|
323
|
+
type: 'treemap',
|
|
324
|
+
roam: false,
|
|
325
|
+
nodeClick: 'zoomToNode',
|
|
326
|
+
top: 8,
|
|
327
|
+
left: 0,
|
|
328
|
+
right: 0,
|
|
329
|
+
bottom: showBreadcrumb ? 32 : 0,
|
|
330
|
+
breadcrumb: {
|
|
331
|
+
show: showBreadcrumb,
|
|
332
|
+
itemStyle: {
|
|
333
|
+
color: colors.surface,
|
|
334
|
+
borderColor: colors.axis,
|
|
335
|
+
textStyle: { color: colors.text },
|
|
336
|
+
},
|
|
337
|
+
emphasis: { itemStyle: { color: colors.background } },
|
|
338
|
+
},
|
|
339
|
+
label: { show: true, color: colors.surface, fontSize: 11 },
|
|
340
|
+
upperLabel: { show: false },
|
|
341
|
+
itemStyle: { borderColor: colors.surface, borderWidth: 1, gapWidth: 1 },
|
|
342
|
+
levels: [
|
|
343
|
+
{ itemStyle: { borderColor: colors.surface, borderWidth: 2, gapWidth: 2 } },
|
|
344
|
+
{ colorSaturation: [0.35, 0.6], itemStyle: { borderColorSaturation: 0.7, gapWidth: 1 } },
|
|
345
|
+
],
|
|
346
|
+
data: colour(data, 0),
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
export function treemapTable(data, { pathLabel = 'Item', valueLabel = 'Value' } = {}) {
|
|
352
|
+
const rows = [];
|
|
353
|
+
const walk = (nodes, trail) => {
|
|
354
|
+
for (const node of nodes) {
|
|
355
|
+
const path = [...trail, node.name];
|
|
356
|
+
rows.push([path.join(' › '), nodeValue(node)]);
|
|
357
|
+
if (node.children)
|
|
358
|
+
walk(node.children, path);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
walk(data, []);
|
|
362
|
+
return { columns: [pathLabel, valueLabel], rows };
|
|
363
|
+
}
|
|
364
|
+
export function describeTreemap(data, format) {
|
|
365
|
+
if (!data.length)
|
|
366
|
+
return 'Empty treemap.';
|
|
367
|
+
const total = data.reduce((sum, node) => sum + nodeValue(node), 0);
|
|
368
|
+
const top = [...data]
|
|
369
|
+
.sort((a, b) => nodeValue(b) - nodeValue(a))
|
|
370
|
+
.slice(0, 3)
|
|
371
|
+
.map((node) => `${node.name} ${formatNumber(nodeValue(node), format)}`);
|
|
372
|
+
return `Treemap of ${data.length} groups totalling ${formatNumber(total, format)}. Largest: ${joinList(top)}.`;
|
|
373
|
+
}
|
|
374
|
+
export function buildCandlestickOption(data, options = {}, colors = chartColors()) {
|
|
375
|
+
for (const day of data) {
|
|
376
|
+
invariant(day.low <= Math.min(day.open, day.close) && day.high >= Math.max(day.open, day.close), 'buildCandlestickOption', `${day.date}: low and high must bracket open and close`);
|
|
377
|
+
}
|
|
378
|
+
const { showVolume = data.some((day) => day.volume != null), zoom = false, showTooltip = true, upColor = colors.positive, downColor = colors.negative, valueFormat, } = options;
|
|
379
|
+
const format = formatter(valueFormat);
|
|
380
|
+
const dates = data.map((day) => day.date);
|
|
381
|
+
// Price takes most of the height; volume a strip under it, above the zoom slider if any.
|
|
382
|
+
const bottom = zoom ? 48 : 8;
|
|
383
|
+
const grids = showVolume
|
|
384
|
+
? [
|
|
385
|
+
{ left: 8, right: 16, top: 16, bottom: zoom ? '38%' : '28%', containLabel: true },
|
|
386
|
+
{ left: 8, right: 16, height: '16%', bottom, containLabel: true },
|
|
387
|
+
]
|
|
388
|
+
: [{ left: 8, right: 16, top: 16, bottom, containLabel: true }];
|
|
389
|
+
const axes = showVolume ? [0, 1] : [0];
|
|
390
|
+
return {
|
|
391
|
+
tooltip: showTooltip
|
|
392
|
+
? { trigger: 'axis', axisPointer: { type: 'cross' }, valueFormatter: format }
|
|
393
|
+
: undefined,
|
|
394
|
+
axisPointer: showVolume ? { link: [{ xAxisIndex: 'all' }] } : undefined,
|
|
395
|
+
grid: grids,
|
|
396
|
+
xAxis: axes.map((index) => ({
|
|
397
|
+
type: 'category',
|
|
398
|
+
gridIndex: index,
|
|
399
|
+
data: dates,
|
|
400
|
+
boundaryGap: true,
|
|
401
|
+
axisLabel: { show: index === axes.length - 1 },
|
|
402
|
+
})),
|
|
403
|
+
yAxis: axes.map((index) => index === 0
|
|
404
|
+
? {
|
|
405
|
+
type: 'value',
|
|
406
|
+
gridIndex: 0,
|
|
407
|
+
scale: true,
|
|
408
|
+
splitNumber: 4,
|
|
409
|
+
axisLabel: { formatter: format },
|
|
410
|
+
}
|
|
411
|
+
: {
|
|
412
|
+
type: 'value',
|
|
413
|
+
gridIndex: 1,
|
|
414
|
+
splitNumber: 1,
|
|
415
|
+
splitLine: { show: false },
|
|
416
|
+
axisLabel: { formatter: (value) => formatCompact(value) },
|
|
417
|
+
}),
|
|
418
|
+
dataZoom: zoom
|
|
419
|
+
? [
|
|
420
|
+
{ type: 'inside', xAxisIndex: axes },
|
|
421
|
+
{ type: 'slider', xAxisIndex: axes, bottom: 8, height: 24 },
|
|
422
|
+
]
|
|
423
|
+
: undefined,
|
|
424
|
+
series: [
|
|
425
|
+
{
|
|
426
|
+
type: 'candlestick',
|
|
427
|
+
name: 'Price',
|
|
428
|
+
data: data.map((day) => [day.open, day.close, day.low, day.high]),
|
|
429
|
+
itemStyle: {
|
|
430
|
+
color: upColor,
|
|
431
|
+
color0: downColor,
|
|
432
|
+
borderColor: upColor,
|
|
433
|
+
borderColor0: downColor,
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
...(showVolume
|
|
437
|
+
? [
|
|
438
|
+
{
|
|
439
|
+
type: 'bar',
|
|
440
|
+
name: 'Volume',
|
|
441
|
+
xAxisIndex: 1,
|
|
442
|
+
yAxisIndex: 1,
|
|
443
|
+
data: data.map((day) => ({
|
|
444
|
+
value: day.volume ?? null,
|
|
445
|
+
itemStyle: { color: day.close >= day.open ? upColor : downColor, opacity: 0.6 },
|
|
446
|
+
})),
|
|
447
|
+
},
|
|
448
|
+
]
|
|
449
|
+
: []),
|
|
450
|
+
],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
export function candlestickTable(data, { dateLabel = 'Date', openLabel = 'Open', highLabel = 'High', lowLabel = 'Low', closeLabel = 'Close', volumeLabel = 'Volume', } = {}) {
|
|
454
|
+
const volume = data.some((day) => day.volume != null);
|
|
455
|
+
return {
|
|
456
|
+
columns: [
|
|
457
|
+
dateLabel,
|
|
458
|
+
openLabel,
|
|
459
|
+
highLabel,
|
|
460
|
+
lowLabel,
|
|
461
|
+
closeLabel,
|
|
462
|
+
...(volume ? [volumeLabel] : []),
|
|
463
|
+
],
|
|
464
|
+
rows: data.map((day) => [
|
|
465
|
+
day.date,
|
|
466
|
+
day.open,
|
|
467
|
+
day.high,
|
|
468
|
+
day.low,
|
|
469
|
+
day.close,
|
|
470
|
+
...(volume ? [day.volume ?? null] : []),
|
|
471
|
+
]),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
export function describeCandlestick(data, format) {
|
|
475
|
+
if (!data.length)
|
|
476
|
+
return 'Empty candlestick chart.';
|
|
477
|
+
const [first, last] = [data[0], data[data.length - 1]];
|
|
478
|
+
const high = Math.max(...data.map((day) => day.high));
|
|
479
|
+
const low = Math.min(...data.map((day) => day.low));
|
|
480
|
+
const change = first.open ? Math.round(((last.close - first.open) / first.open) * 1000) / 10 : 0;
|
|
481
|
+
return `Candlestick chart of ${data.length} periods, ${first.date} to ${last.date}. Opened at ${formatNumber(first.open, format)} and closed at ${formatNumber(last.close, format)} (${change >= 0 ? '+' : ''}${change}%), trading between ${formatNumber(low, format)} and ${formatNumber(high, format)}.`;
|
|
482
|
+
}
|
|
483
|
+
export function buildSankeyOption(data, options = {}, colors = chartColors()) {
|
|
484
|
+
const names = new Set(data.nodes.map((node) => node.name));
|
|
485
|
+
for (const link of data.links) {
|
|
486
|
+
invariant(names.has(link.source) && names.has(link.target), 'buildSankeyOption', `link ${link.source} → ${link.target} names a node that is not in \`nodes\``);
|
|
487
|
+
invariant(link.source !== link.target, 'buildSankeyOption', `${link.source} links to itself`);
|
|
488
|
+
invariant(link.value > 0, 'buildSankeyOption', `link ${link.source} → ${link.target} needs a positive value`);
|
|
489
|
+
}
|
|
490
|
+
const { orientation = 'horizontal', showTooltip = true, valueFormat } = options;
|
|
491
|
+
return {
|
|
492
|
+
tooltip: showTooltip ? { trigger: 'item', valueFormatter: formatter(valueFormat) } : undefined,
|
|
493
|
+
series: [
|
|
494
|
+
{
|
|
495
|
+
type: 'sankey',
|
|
496
|
+
orient: orientation,
|
|
497
|
+
left: 8,
|
|
498
|
+
right: orientation === 'horizontal' ? 96 : 8,
|
|
499
|
+
top: 8,
|
|
500
|
+
bottom: orientation === 'horizontal' ? 8 : 32,
|
|
501
|
+
nodeWidth: 12,
|
|
502
|
+
nodeGap: 10,
|
|
503
|
+
emphasis: { focus: 'adjacency' },
|
|
504
|
+
label: { color: colors.text },
|
|
505
|
+
lineStyle: { color: 'gradient', opacity: 0.35, curveness: 0.5 },
|
|
506
|
+
itemStyle: { borderWidth: 0 },
|
|
507
|
+
data: data.nodes.map((node, index) => ({
|
|
508
|
+
name: node.name,
|
|
509
|
+
itemStyle: { color: node.color ?? seriesColor(colors, index) },
|
|
510
|
+
})),
|
|
511
|
+
links: data.links,
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
export function sankeyTable(data, { sourceLabel = 'From', targetLabel = 'To', valueLabel = 'Value' } = {}) {
|
|
517
|
+
return {
|
|
518
|
+
columns: [sourceLabel, targetLabel, valueLabel],
|
|
519
|
+
rows: data.links.map((link) => [link.source, link.target, link.value]),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
export function describeSankey(data, format) {
|
|
523
|
+
if (!data.links.length)
|
|
524
|
+
return 'Empty flow diagram.';
|
|
525
|
+
const top = [...data.links]
|
|
526
|
+
.sort((a, b) => b.value - a.value)
|
|
527
|
+
.slice(0, 3)
|
|
528
|
+
.map((link) => `${link.source} to ${link.target} (${formatNumber(link.value, format)})`);
|
|
529
|
+
return `Flow diagram of ${data.links.length} flows between ${data.nodes.length} nodes. Largest: ${joinList(top)}.`;
|
|
530
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type FormatOptions } from '../format.js';
|
|
2
|
+
import type { ChartColors, ChartOption, ChartTable, SliceDatum } from '../types.js';
|
|
3
|
+
export interface PieOptions {
|
|
4
|
+
/** Hole size as a percentage of the radius; above 0 draws a donut. */
|
|
5
|
+
innerRadius?: number;
|
|
6
|
+
showLabels?: boolean;
|
|
7
|
+
/** Labels on leader lines outside, or printed on the slices. */
|
|
8
|
+
labelPosition?: 'outside' | 'inside';
|
|
9
|
+
showLegend?: boolean;
|
|
10
|
+
showTooltip?: boolean;
|
|
11
|
+
/** Large text in a donut's hole, e.g. the total. */
|
|
12
|
+
centerValue?: string;
|
|
13
|
+
/** Small caption under `centerValue`. */
|
|
14
|
+
centerLabel?: string;
|
|
15
|
+
valueFormat?: FormatOptions;
|
|
16
|
+
}
|
|
17
|
+
/** A pie, or a donut with `innerRadius`. */
|
|
18
|
+
export declare function buildPieOption(data: SliceDatum[], options?: PieOptions, colors?: ChartColors): ChartOption;
|
|
19
|
+
export interface FunnelOptions {
|
|
20
|
+
/** `descending` puts the largest stage first; `none` keeps the given order. */
|
|
21
|
+
sort?: 'descending' | 'ascending' | 'none';
|
|
22
|
+
orientation?: 'vertical' | 'horizontal';
|
|
23
|
+
showLabels?: boolean;
|
|
24
|
+
showTooltip?: boolean;
|
|
25
|
+
valueFormat?: FormatOptions;
|
|
26
|
+
}
|
|
27
|
+
/** Stages narrowing from first to last — a conversion funnel. */
|
|
28
|
+
export declare function buildFunnelOption(data: SliceDatum[], options?: FunnelOptions, colors?: ChartColors): ChartOption;
|
|
29
|
+
/** The data behind a pie, donut or funnel, with each part's share of the whole. */
|
|
30
|
+
export declare function sliceTable(data: SliceDatum[], { nameLabel, valueLabel, shareLabel }?: {
|
|
31
|
+
nameLabel?: string | undefined;
|
|
32
|
+
valueLabel?: string | undefined;
|
|
33
|
+
shareLabel?: string | undefined;
|
|
34
|
+
}): ChartTable;
|
|
35
|
+
export declare const describePie: (data: SliceDatum[], format?: FormatOptions) => string;
|
|
36
|
+
export declare const describeFunnel: (data: SliceDatum[], format?: FormatOptions) => string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { chartColors } from '@loidolt/theme-tokens';
|
|
2
|
+
import { describeSlices } from '../describe.js';
|
|
3
|
+
import { formatNumber } from '../format.js';
|
|
4
|
+
import { inkOn, seriesColor } from '../theme.js';
|
|
5
|
+
import { invariant } from '../validate.js';
|
|
6
|
+
function checkSlices(data, builder) {
|
|
7
|
+
for (const slice of data) {
|
|
8
|
+
invariant(Number.isFinite(slice.value) && slice.value >= 0, builder, `"${slice.name}" must be a finite value of 0 or more, not ${slice.value}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** A pie, or a donut with `innerRadius`. */
|
|
12
|
+
export function buildPieOption(data, options = {}, colors = chartColors()) {
|
|
13
|
+
checkSlices(data, 'buildPieOption');
|
|
14
|
+
const { innerRadius = 0, showLabels = true, labelPosition = 'outside', showLegend = false, showTooltip = true, centerValue, centerLabel, valueFormat, } = options;
|
|
15
|
+
invariant(innerRadius >= 0 && innerRadius < 70, 'buildPieOption', 'innerRadius is a percentage below the outer radius (0–69)');
|
|
16
|
+
const format = (value) => formatNumber(value, valueFormat);
|
|
17
|
+
const inside = labelPosition === 'inside';
|
|
18
|
+
return {
|
|
19
|
+
tooltip: showTooltip ? { trigger: 'item', valueFormatter: format } : undefined,
|
|
20
|
+
legend: showLegend ? { type: 'scroll', bottom: 0, left: 'center' } : undefined,
|
|
21
|
+
title: innerRadius > 0 && (centerValue || centerLabel)
|
|
22
|
+
? {
|
|
23
|
+
text: centerValue,
|
|
24
|
+
subtext: centerLabel,
|
|
25
|
+
left: 'center',
|
|
26
|
+
top: 'center',
|
|
27
|
+
textStyle: { color: colors.text, fontSize: 22, fontWeight: 600 },
|
|
28
|
+
subtextStyle: { color: colors.muted, fontSize: 11 },
|
|
29
|
+
itemGap: 4,
|
|
30
|
+
}
|
|
31
|
+
: undefined,
|
|
32
|
+
series: [
|
|
33
|
+
{
|
|
34
|
+
type: 'pie',
|
|
35
|
+
radius: [`${innerRadius}%`, '70%'],
|
|
36
|
+
center: ['50%', showLegend ? '45%' : '50%'],
|
|
37
|
+
// Slices are divided by the surface colour, not an outline that competes with the data.
|
|
38
|
+
itemStyle: { borderColor: colors.surface, borderWidth: 2 },
|
|
39
|
+
label: showLabels
|
|
40
|
+
? {
|
|
41
|
+
show: true,
|
|
42
|
+
position: inside ? 'inside' : 'outside',
|
|
43
|
+
color: colors.text,
|
|
44
|
+
formatter: inside ? '{d}%' : '{b}',
|
|
45
|
+
}
|
|
46
|
+
: { show: false },
|
|
47
|
+
labelLine: { show: showLabels && !inside, lineStyle: { color: colors.axis } },
|
|
48
|
+
emphasis: { scale: true, scaleSize: 4 },
|
|
49
|
+
data: data.map((slice, index) => {
|
|
50
|
+
const color = slice.color ?? seriesColor(colors, index);
|
|
51
|
+
return {
|
|
52
|
+
name: slice.name,
|
|
53
|
+
value: slice.value,
|
|
54
|
+
itemStyle: { color },
|
|
55
|
+
// Text printed on a slice takes whichever ink reads on that slice.
|
|
56
|
+
label: inside ? { color: inkOn(color, colors) } : undefined,
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Stages narrowing from first to last — a conversion funnel. */
|
|
64
|
+
export function buildFunnelOption(data, options = {}, colors = chartColors()) {
|
|
65
|
+
checkSlices(data, 'buildFunnelOption');
|
|
66
|
+
const { sort = 'descending', orientation = 'vertical', showLabels = true, showTooltip = true, valueFormat, } = options;
|
|
67
|
+
const format = (value) => formatNumber(value, valueFormat);
|
|
68
|
+
const max = Math.max(0, ...data.map((slice) => slice.value));
|
|
69
|
+
return {
|
|
70
|
+
tooltip: showTooltip ? { trigger: 'item', valueFormatter: format } : undefined,
|
|
71
|
+
series: [
|
|
72
|
+
{
|
|
73
|
+
type: 'funnel',
|
|
74
|
+
sort,
|
|
75
|
+
orient: orientation,
|
|
76
|
+
min: 0,
|
|
77
|
+
max,
|
|
78
|
+
left: '10%',
|
|
79
|
+
right: '10%',
|
|
80
|
+
top: 16,
|
|
81
|
+
bottom: 16,
|
|
82
|
+
gap: 2,
|
|
83
|
+
itemStyle: { borderColor: colors.surface, borderWidth: 1 },
|
|
84
|
+
label: showLabels ? { show: true, position: 'inside', formatter: '{b}' } : { show: false },
|
|
85
|
+
data: data.map((slice, index) => {
|
|
86
|
+
const color = slice.color ?? seriesColor(colors, index);
|
|
87
|
+
return {
|
|
88
|
+
name: slice.name,
|
|
89
|
+
value: slice.value,
|
|
90
|
+
itemStyle: { color },
|
|
91
|
+
label: { color: inkOn(color, colors) },
|
|
92
|
+
};
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** The data behind a pie, donut or funnel, with each part's share of the whole. */
|
|
99
|
+
export function sliceTable(data, { nameLabel = 'Name', valueLabel = 'Value', shareLabel = 'Share' } = {}) {
|
|
100
|
+
const total = data.reduce((sum, slice) => sum + slice.value, 0);
|
|
101
|
+
return {
|
|
102
|
+
columns: [nameLabel, valueLabel, shareLabel],
|
|
103
|
+
rows: data.map((slice) => [
|
|
104
|
+
slice.name,
|
|
105
|
+
slice.value,
|
|
106
|
+
total ? `${Math.round((slice.value / total) * 1000) / 10}%` : null,
|
|
107
|
+
]),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export const describePie = (data, format) => describeSlices(data, { kind: 'pie chart', format });
|
|
111
|
+
export const describeFunnel = (data, format) => {
|
|
112
|
+
if (!data.length)
|
|
113
|
+
return 'Empty funnel chart.';
|
|
114
|
+
const [first, last] = [data[0], data[data.length - 1]];
|
|
115
|
+
const kept = first.value ? Math.round((last.value / first.value) * 100) : 0;
|
|
116
|
+
return `Funnel chart of ${data.length} stages, from ${first.name} (${formatNumber(first.value, format)}) to ${last.name} (${formatNumber(last.value, format)}): ${kept}% carried through.`;
|
|
117
|
+
};
|