@pixerate/editor 0.45.0 → 0.46.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/README.md +2 -1
- package/dist/chart/index.cjs +1984 -0
- package/dist/chart/index.cjs.map +1 -0
- package/dist/chart/index.d.cts +646 -0
- package/dist/chart/index.d.ts +646 -0
- package/dist/chart/index.js +1984 -0
- package/dist/chart/index.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,1984 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class;
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
var _chunkTNNJXF46cjs = require('../chunk-TNNJXF46.cjs');
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
var _chunkQKLRAA4Tcjs = require('../chunk-QKLRAA4T.cjs');
|
|
8
|
+
|
|
9
|
+
// src/chart/spec.ts
|
|
10
|
+
var CHART_TYPES = ["bar", "line", "area", "scatter", "pie", "donut"];
|
|
11
|
+
var CHART_SERIES_TYPES = ["bar", "line", "area", "scatter"];
|
|
12
|
+
var CHART_STACKINGS = ["none", "stacked", "percent"];
|
|
13
|
+
var CHART_CURVES = ["linear", "smooth", "step"];
|
|
14
|
+
var CHART_ORIENTATIONS = ["vertical", "horizontal"];
|
|
15
|
+
var CHART_LEGEND_POSITIONS = ["top", "bottom", "left", "right"];
|
|
16
|
+
var CHART_NUMBER_STYLES = ["number", "percent", "currency", "compact"];
|
|
17
|
+
var CHART_TYPE_LABELS = {
|
|
18
|
+
bar: "Bar",
|
|
19
|
+
line: "Line",
|
|
20
|
+
area: "Area",
|
|
21
|
+
scatter: "Scatter",
|
|
22
|
+
pie: "Pie",
|
|
23
|
+
donut: "Donut"
|
|
24
|
+
};
|
|
25
|
+
var DEFAULT_CHART_PALETTE = ["#4269d0", "#efb118", "#ff725c", "#6cc5b0", "#3ca951", "#ff8ab7", "#a463f2", "#97bbf5", "#9c6b4e", "#9498a0"];
|
|
26
|
+
var DEFAULT_CHART_THEME = {
|
|
27
|
+
background: "#ffffff",
|
|
28
|
+
surface: "#ffffff",
|
|
29
|
+
text: "#0f172a",
|
|
30
|
+
mutedText: "#64748b",
|
|
31
|
+
grid: "#e2e8f0",
|
|
32
|
+
axis: "#94a3b8",
|
|
33
|
+
selection: "#2563eb",
|
|
34
|
+
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
35
|
+
fontSize: 11,
|
|
36
|
+
titleSize: 15
|
|
37
|
+
};
|
|
38
|
+
var CSS_VARIABLE_CHART_THEME = {
|
|
39
|
+
...DEFAULT_CHART_THEME,
|
|
40
|
+
background: "transparent",
|
|
41
|
+
surface: "var(--background, #ffffff)",
|
|
42
|
+
text: "var(--foreground, #0f172a)",
|
|
43
|
+
mutedText: "var(--muted-foreground, #64748b)",
|
|
44
|
+
grid: "var(--border, #e2e8f0)",
|
|
45
|
+
axis: "var(--muted-foreground, #94a3b8)",
|
|
46
|
+
selection: "var(--primary, #2563eb)",
|
|
47
|
+
fontFamily: "inherit"
|
|
48
|
+
};
|
|
49
|
+
function isCartesianChart(type) {
|
|
50
|
+
return type !== "pie" && type !== "donut";
|
|
51
|
+
}
|
|
52
|
+
function defaultChartAxis(value) {
|
|
53
|
+
return { show: true, title: "", min: null, max: null, gridlines: value, format: {} };
|
|
54
|
+
}
|
|
55
|
+
function createChartSpec(partial = {}) {
|
|
56
|
+
return normalizeChartSpec(partial).spec;
|
|
57
|
+
}
|
|
58
|
+
function chartSeriesColor(spec, seriesIndex) {
|
|
59
|
+
const series = spec.series[seriesIndex];
|
|
60
|
+
if (_optionalChain([series, 'optionalAccess', _2 => _2.color])) return series.color;
|
|
61
|
+
const palette = spec.palette.length ? spec.palette : DEFAULT_CHART_PALETTE;
|
|
62
|
+
return palette[seriesIndex % palette.length];
|
|
63
|
+
}
|
|
64
|
+
function chartSliceColor(spec, index) {
|
|
65
|
+
const palette = spec.palette.length ? spec.palette : DEFAULT_CHART_PALETTE;
|
|
66
|
+
return palette[index % palette.length];
|
|
67
|
+
}
|
|
68
|
+
function chartSeriesType(spec, series) {
|
|
69
|
+
if (series.type) return series.type;
|
|
70
|
+
return isCartesianChart(spec.type) ? spec.type : "bar";
|
|
71
|
+
}
|
|
72
|
+
function uniqueChartSeriesId(series, base = "series") {
|
|
73
|
+
const used = new Set(series.map((s) => s.id));
|
|
74
|
+
for (let i = series.length + 1; ; i++) {
|
|
75
|
+
const id = `${base}-${i}`;
|
|
76
|
+
if (!used.has(id)) return id;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parseChartValue(input) {
|
|
80
|
+
if (input === null || input === void 0) return null;
|
|
81
|
+
if (typeof input === "number") return Number.isFinite(input) ? input : void 0;
|
|
82
|
+
if (typeof input !== "string") return void 0;
|
|
83
|
+
let text2 = input.trim();
|
|
84
|
+
if (!text2 || text2 === "-" || text2 === "\u2014") return null;
|
|
85
|
+
let negative = false;
|
|
86
|
+
if (/^\(.*\)$/.test(text2)) {
|
|
87
|
+
negative = true;
|
|
88
|
+
text2 = text2.slice(1, -1).trim();
|
|
89
|
+
}
|
|
90
|
+
const percent = text2.endsWith("%");
|
|
91
|
+
if (percent) text2 = text2.slice(0, -1).trim();
|
|
92
|
+
text2 = text2.replace(/^[$€£¥]/, "").replace(/,/g, "").replace(/\s+/g, "");
|
|
93
|
+
if (!/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(text2)) return void 0;
|
|
94
|
+
let value = Number(text2);
|
|
95
|
+
if (percent) value /= 100;
|
|
96
|
+
if (negative) value = -value;
|
|
97
|
+
return Number.isFinite(value) ? value : void 0;
|
|
98
|
+
}
|
|
99
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
function pick(value, options, fallback, path, issues) {
|
|
101
|
+
if (value === void 0) return fallback;
|
|
102
|
+
if (typeof value === "string" && options.includes(value)) return value;
|
|
103
|
+
issues.push({ path, message: `${path} must be one of ${options.map((o) => `"${o}"`).join(", ")}; got ${JSON.stringify(value)}.` });
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
function text(value, fallback, path, issues) {
|
|
107
|
+
if (value === void 0 || value === null) return fallback;
|
|
108
|
+
if (typeof value === "string") return value;
|
|
109
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
110
|
+
issues.push({ path, message: `${path} must be text.` });
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
function flag(value, fallback, path, issues) {
|
|
114
|
+
if (value === void 0) return fallback;
|
|
115
|
+
if (typeof value === "boolean") return value;
|
|
116
|
+
issues.push({ path, message: `${path} must be true or false.` });
|
|
117
|
+
return fallback;
|
|
118
|
+
}
|
|
119
|
+
function bound(value, path, issues) {
|
|
120
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
121
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
122
|
+
if (typeof n === "number" && Number.isFinite(n)) return n;
|
|
123
|
+
issues.push({ path, message: `${path} must be a number or null.` });
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function normalizeFormat(value, path, issues) {
|
|
127
|
+
if (value === void 0 || value === null) return {};
|
|
128
|
+
if (!isRecord(value)) {
|
|
129
|
+
issues.push({ path, message: `${path} must be an object.` });
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
const out = {};
|
|
133
|
+
if (value.style !== void 0) out.style = pick(value.style, CHART_NUMBER_STYLES, "number", `${path}.style`, issues);
|
|
134
|
+
if (value.decimals !== void 0) {
|
|
135
|
+
const d = Number(value.decimals);
|
|
136
|
+
if (Number.isInteger(d) && d >= 0 && d <= 10) out.decimals = d;
|
|
137
|
+
else issues.push({ path: `${path}.decimals`, message: `${path}.decimals must be a whole number from 0 to 10.` });
|
|
138
|
+
}
|
|
139
|
+
for (const key of ["currency", "prefix", "suffix"]) {
|
|
140
|
+
if (value[key] !== void 0) out[key] = text(value[key], "", `${path}.${key}`, issues);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function normalizeChartAxis(value, fallback, path, issues = []) {
|
|
145
|
+
if (value === void 0) return { ...fallback, format: { ...fallback.format } };
|
|
146
|
+
if (!isRecord(value)) {
|
|
147
|
+
issues.push({ path, message: `${path} must be an object.` });
|
|
148
|
+
return { ...fallback, format: { ...fallback.format } };
|
|
149
|
+
}
|
|
150
|
+
let min = "min" in value ? bound(value.min, `${path}.min`, issues) : fallback.min;
|
|
151
|
+
let max = "max" in value ? bound(value.max, `${path}.max`, issues) : fallback.max;
|
|
152
|
+
if (min !== null && max !== null && min >= max) {
|
|
153
|
+
issues.push({ path: `${path}.max`, message: `${path}.max (${max}) must be greater than ${path}.min (${min}).` });
|
|
154
|
+
min = fallback.min;
|
|
155
|
+
max = fallback.max;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
show: flag(value.show, fallback.show, `${path}.show`, issues),
|
|
159
|
+
title: text(value.title, fallback.title, `${path}.title`, issues),
|
|
160
|
+
min,
|
|
161
|
+
max,
|
|
162
|
+
gridlines: flag(value.gridlines, fallback.gridlines, `${path}.gridlines`, issues),
|
|
163
|
+
format: "format" in value ? normalizeFormat(value.format, `${path}.format`, issues) : { ...fallback.format }
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function normalizeLegend(value, issues) {
|
|
167
|
+
const fallback = { show: true, position: "bottom" };
|
|
168
|
+
if (value === void 0) return fallback;
|
|
169
|
+
if (!isRecord(value)) {
|
|
170
|
+
issues.push({ path: "legend", message: "legend must be an object." });
|
|
171
|
+
return fallback;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
show: flag(value.show, fallback.show, "legend.show", issues),
|
|
175
|
+
position: pick(value.position, CHART_LEGEND_POSITIONS, fallback.position, "legend.position", issues)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function normalizeChartValues(value, length, path, issues = []) {
|
|
179
|
+
if (value === void 0) return length ? Array(length).fill(null) : [];
|
|
180
|
+
if (!Array.isArray(value)) {
|
|
181
|
+
issues.push({ path, message: `${path} must be an array of numbers.` });
|
|
182
|
+
return length ? Array(length).fill(null) : [];
|
|
183
|
+
}
|
|
184
|
+
const out = value.map((v, i) => {
|
|
185
|
+
const parsed = parseChartValue(v);
|
|
186
|
+
if (parsed === void 0) {
|
|
187
|
+
issues.push({ path: `${path}[${i}]`, message: `${path}[${i}] is not a number: ${JSON.stringify(v)}.` });
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return parsed;
|
|
191
|
+
});
|
|
192
|
+
if (length === void 0) return out;
|
|
193
|
+
if (out.length > length) return out.slice(0, length);
|
|
194
|
+
while (out.length < length) out.push(null);
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
function normalizeSeries(value, length, used, index, issues) {
|
|
198
|
+
const path = `series[${index}]`;
|
|
199
|
+
if (!isRecord(value)) {
|
|
200
|
+
issues.push({ path, message: `${path} must be an object with a name and values.` });
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
let id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : "";
|
|
204
|
+
if (!id || used.has(id)) {
|
|
205
|
+
if (id) issues.push({ path: `${path}.id`, message: `Series id "${id}" is used twice; the second was renamed.` });
|
|
206
|
+
for (let n = index + 1; ; n++) {
|
|
207
|
+
id = `series-${n}`;
|
|
208
|
+
if (!used.has(id)) break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
used.add(id);
|
|
212
|
+
const series = {
|
|
213
|
+
id,
|
|
214
|
+
name: text(value.name, `Series ${index + 1}`, `${path}.name`, issues),
|
|
215
|
+
values: normalizeChartValues(value.values, length, `${path}.values`, issues)
|
|
216
|
+
};
|
|
217
|
+
if (typeof value.color === "string" && value.color.trim()) series.color = value.color.trim();
|
|
218
|
+
if (value.type !== void 0 && value.type !== null) series.type = pick(value.type, CHART_SERIES_TYPES, "bar", `${path}.type`, issues);
|
|
219
|
+
if (value.hidden === true) series.hidden = true;
|
|
220
|
+
return series;
|
|
221
|
+
}
|
|
222
|
+
function normalizeChartSpec(input) {
|
|
223
|
+
const issues = [];
|
|
224
|
+
const value = isRecord(input) ? input : {};
|
|
225
|
+
if (input !== void 0 && !isRecord(input)) issues.push({ path: "", message: "A chart must be an object." });
|
|
226
|
+
const type = pick(value.type, CHART_TYPES, "bar", "type", issues);
|
|
227
|
+
const rawSeries = Array.isArray(value.series) ? value.series : [];
|
|
228
|
+
if (value.series !== void 0 && !Array.isArray(value.series)) issues.push({ path: "series", message: "series must be an array." });
|
|
229
|
+
let categories;
|
|
230
|
+
if (Array.isArray(value.categories)) categories = value.categories.map((c, i) => text(c, "", `categories[${i}]`, issues));
|
|
231
|
+
else {
|
|
232
|
+
if (value.categories !== void 0) issues.push({ path: "categories", message: "categories must be an array of labels." });
|
|
233
|
+
const longest = rawSeries.reduce((n, s) => Math.max(n, isRecord(s) && Array.isArray(s.values) ? s.values.length : 0), 0);
|
|
234
|
+
categories = Array.from({ length: longest }, (_, i) => `${i + 1}`);
|
|
235
|
+
}
|
|
236
|
+
const used = /* @__PURE__ */ new Set();
|
|
237
|
+
const series = rawSeries.map((s, i) => normalizeSeries(s, categories.length, used, i, issues)).filter((s) => s !== null);
|
|
238
|
+
let palette = [...DEFAULT_CHART_PALETTE];
|
|
239
|
+
if (Array.isArray(value.palette)) {
|
|
240
|
+
const colors = value.palette.filter((c) => typeof c === "string" && c.trim() !== "");
|
|
241
|
+
if (colors.length) palette = colors;
|
|
242
|
+
else issues.push({ path: "palette", message: "palette must list at least one colour." });
|
|
243
|
+
} else if (value.palette !== void 0) issues.push({ path: "palette", message: "palette must be an array of colours." });
|
|
244
|
+
let innerRadius = 0.55;
|
|
245
|
+
if (value.innerRadius !== void 0) {
|
|
246
|
+
const r = Number(value.innerRadius);
|
|
247
|
+
if (Number.isFinite(r) && r >= 0 && r <= 0.9) innerRadius = r;
|
|
248
|
+
else issues.push({ path: "innerRadius", message: "innerRadius must be a number from 0 to 0.9." });
|
|
249
|
+
}
|
|
250
|
+
const spec = {
|
|
251
|
+
version: 1,
|
|
252
|
+
type,
|
|
253
|
+
title: text(value.title, "", "title", issues),
|
|
254
|
+
subtitle: text(value.subtitle, "", "subtitle", issues),
|
|
255
|
+
categories,
|
|
256
|
+
series,
|
|
257
|
+
xAxis: normalizeChartAxis(value.xAxis, defaultChartAxis(false), "xAxis", issues),
|
|
258
|
+
yAxis: normalizeChartAxis(value.yAxis, defaultChartAxis(true), "yAxis", issues),
|
|
259
|
+
legend: normalizeLegend(value.legend, issues),
|
|
260
|
+
stacking: pick(value.stacking, CHART_STACKINGS, "none", "stacking", issues),
|
|
261
|
+
orientation: pick(value.orientation, CHART_ORIENTATIONS, "vertical", "orientation", issues),
|
|
262
|
+
curve: pick(value.curve, CHART_CURVES, "linear", "curve", issues),
|
|
263
|
+
points: flag(value.points, true, "points", issues),
|
|
264
|
+
dataLabels: flag(value.dataLabels, false, "dataLabels", issues),
|
|
265
|
+
palette,
|
|
266
|
+
innerRadius
|
|
267
|
+
};
|
|
268
|
+
return { spec, issues };
|
|
269
|
+
}
|
|
270
|
+
function cloneChartSpec(spec) {
|
|
271
|
+
return {
|
|
272
|
+
...spec,
|
|
273
|
+
categories: [...spec.categories],
|
|
274
|
+
series: spec.series.map(cloneChartSeries),
|
|
275
|
+
xAxis: { ...spec.xAxis, format: { ...spec.xAxis.format } },
|
|
276
|
+
yAxis: { ...spec.yAxis, format: { ...spec.yAxis.format } },
|
|
277
|
+
legend: { ...spec.legend },
|
|
278
|
+
palette: [...spec.palette]
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function cloneChartSeries(series) {
|
|
282
|
+
return { ...series, values: [...series.values] };
|
|
283
|
+
}
|
|
284
|
+
function chartDataFromTable(rows, options = {}) {
|
|
285
|
+
const header = _nullishCoalesce(options.header, () => ( true));
|
|
286
|
+
let grid = rows.map((r) => [...r]).filter((r) => r.some((c) => c !== null && c !== void 0 && String(c).trim() !== ""));
|
|
287
|
+
if (options.seriesIn === "rows") {
|
|
288
|
+
const width2 = Math.max(0, ...grid.map((r) => r.length));
|
|
289
|
+
grid = Array.from({ length: width2 }, (_, c) => grid.map((r) => r[c]));
|
|
290
|
+
}
|
|
291
|
+
const issues = [];
|
|
292
|
+
if (!grid.length) return { categories: [], series: [], issues };
|
|
293
|
+
const width = Math.max(...grid.map((r) => r.length));
|
|
294
|
+
const names = header ? grid[0] : [];
|
|
295
|
+
const body = header ? grid.slice(1) : grid;
|
|
296
|
+
const categories = body.map((r, i) => {
|
|
297
|
+
const c = r[0];
|
|
298
|
+
return c === null || c === void 0 || String(c).trim() === "" ? `${i + 1}` : String(c).trim();
|
|
299
|
+
});
|
|
300
|
+
const series = [];
|
|
301
|
+
for (let col = 1; col < width; col++) {
|
|
302
|
+
const name = names[col] !== void 0 && String(names[col]).trim() ? String(names[col]).trim() : `Series ${col}`;
|
|
303
|
+
const values = body.map((r, i) => {
|
|
304
|
+
const parsed = parseChartValue(_nullishCoalesce(r[col], () => ( null)));
|
|
305
|
+
if (parsed === void 0) {
|
|
306
|
+
issues.push({ path: `${name}[${i}]`, message: `"${String(r[col])}" in ${name} (${categories[i]}) is not a number; it was left blank.` });
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return parsed;
|
|
310
|
+
});
|
|
311
|
+
series.push({ id: `series-${col}`, name, values });
|
|
312
|
+
}
|
|
313
|
+
return { categories, series, issues };
|
|
314
|
+
}
|
|
315
|
+
function chartDataToTable(spec, options = {}) {
|
|
316
|
+
return [
|
|
317
|
+
[_nullishCoalesce(options.categoryHeader, () => ( "")), ...spec.series.map((s) => s.name)],
|
|
318
|
+
...spec.categories.map((category, i) => [category, ...spec.series.map((s) => _nullishCoalesce(s.values[i], () => ( null)))])
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
function chartColorInputValue(color) {
|
|
322
|
+
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
|
|
323
|
+
if (/^#[0-9a-f]{3}$/i.test(color)) return `#${[...color.slice(1)].map((c) => c + c).join("")}`.toLowerCase();
|
|
324
|
+
return "#000000";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/chart/scale.ts
|
|
328
|
+
function niceStep(span, count) {
|
|
329
|
+
if (!(span > 0) || !(count > 0)) return 1;
|
|
330
|
+
const raw = span / count;
|
|
331
|
+
const power = Math.pow(10, Math.floor(Math.log10(raw)));
|
|
332
|
+
const fraction = raw / power;
|
|
333
|
+
const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10;
|
|
334
|
+
return nice * power;
|
|
335
|
+
}
|
|
336
|
+
var clean = (n) => Math.abs(n) < 1e-12 ? 0 : Number(n.toPrecision(12));
|
|
337
|
+
function niceScale(min, max, count = 5, fixedMin = null, fixedMax = null) {
|
|
338
|
+
let lo = _nullishCoalesce(fixedMin, () => ( min));
|
|
339
|
+
let hi = _nullishCoalesce(fixedMax, () => ( max));
|
|
340
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
|
|
341
|
+
lo = 0;
|
|
342
|
+
hi = 1;
|
|
343
|
+
}
|
|
344
|
+
if (lo > hi) [lo, hi] = [hi, lo];
|
|
345
|
+
if (lo === hi) {
|
|
346
|
+
const pad = lo === 0 ? 1 : Math.abs(lo) * 0.1;
|
|
347
|
+
if (fixedMin === null && lo !== 0) lo -= pad;
|
|
348
|
+
if (fixedMax === null || lo === hi) hi += pad;
|
|
349
|
+
}
|
|
350
|
+
const step = niceStep(hi - lo, count);
|
|
351
|
+
const niceMin = _nullishCoalesce(fixedMin, () => ( clean(Math.floor(lo / step + 1e-9) * step)));
|
|
352
|
+
const niceMax = _nullishCoalesce(fixedMax, () => ( clean(Math.ceil(hi / step - 1e-9) * step)));
|
|
353
|
+
const ticks = [];
|
|
354
|
+
const first = Math.ceil(niceMin / step - 1e-9) * step;
|
|
355
|
+
for (let t = first, i = 0; t <= niceMax + step * 1e-9 && i < 1e3; t += step, i++) ticks.push(clean(t));
|
|
356
|
+
return { min: niceMin, max: niceMax, step, ticks };
|
|
357
|
+
}
|
|
358
|
+
function stepDecimals(step) {
|
|
359
|
+
if (!(step > 0) || step >= 1) return 0;
|
|
360
|
+
return Math.min(6, Math.max(0, Math.ceil(-Math.log10(step) - 1e-9)));
|
|
361
|
+
}
|
|
362
|
+
var COMPACT = [
|
|
363
|
+
{ value: 1e12, suffix: "T" },
|
|
364
|
+
{ value: 1e9, suffix: "B" },
|
|
365
|
+
{ value: 1e6, suffix: "M" },
|
|
366
|
+
{ value: 1e3, suffix: "K" }
|
|
367
|
+
];
|
|
368
|
+
function grouped(value, decimals) {
|
|
369
|
+
const fixed = Math.abs(value).toFixed(decimals);
|
|
370
|
+
const [int, frac] = fixed.split(".");
|
|
371
|
+
const withCommas = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
372
|
+
return `${value < 0 && Number(fixed) !== 0 ? "-" : ""}${withCommas}${frac ? `.${frac}` : ""}`;
|
|
373
|
+
}
|
|
374
|
+
var CURRENCY_SYMBOLS = { USD: "$", EUR: "\u20AC", GBP: "\xA3", JPY: "\xA5", CNY: "\xA5", INR: "\u20B9", AUD: "A$", CAD: "C$" };
|
|
375
|
+
function formatChartNumber(value, format = {}, fallbackDecimals) {
|
|
376
|
+
if (value === null || value === void 0 || !Number.isFinite(value)) return "";
|
|
377
|
+
const style = _nullishCoalesce(format.style, () => ( "number"));
|
|
378
|
+
let body;
|
|
379
|
+
const decimalsFor = (v) => {
|
|
380
|
+
if (format.decimals !== void 0) return format.decimals;
|
|
381
|
+
if (fallbackDecimals !== void 0) return fallbackDecimals;
|
|
382
|
+
return Number.isInteger(v) ? 0 : Math.abs(v) < 1 ? Math.min(4, Math.max(2, stepDecimals(Math.abs(v)) + 1)) : 2;
|
|
383
|
+
};
|
|
384
|
+
const trim = (s) => format.decimals === void 0 && fallbackDecimals === void 0 && s.includes(".") ? s.replace(/\.?0+$/, "") : s;
|
|
385
|
+
if (style === "percent") {
|
|
386
|
+
const v = value * 100;
|
|
387
|
+
const d = _nullishCoalesce(format.decimals, () => ( (fallbackDecimals !== void 0 ? Math.max(0, fallbackDecimals - 2) : Number.isInteger(clean(v)) ? 0 : 1)));
|
|
388
|
+
body = `${grouped(clean(v), d)}%`;
|
|
389
|
+
} else if (style === "compact") {
|
|
390
|
+
const unit = COMPACT.find((u) => Math.abs(value) >= u.value);
|
|
391
|
+
if (unit) {
|
|
392
|
+
const v = value / unit.value;
|
|
393
|
+
const d = _nullishCoalesce(format.decimals, () => ( (Math.abs(v) >= 100 || Number.isInteger(clean(v)) ? 0 : 1)));
|
|
394
|
+
body = `${trim(grouped(v, d))}${unit.suffix}`;
|
|
395
|
+
} else body = trim(grouped(value, decimalsFor(value)));
|
|
396
|
+
} else if (style === "currency") {
|
|
397
|
+
const code = (format.currency || "USD").toUpperCase();
|
|
398
|
+
const symbol = _nullishCoalesce(CURRENCY_SYMBOLS[code], () => ( `${code} `));
|
|
399
|
+
const d = _nullishCoalesce(format.decimals, () => ( (fallbackDecimals !== void 0 ? fallbackDecimals : Number.isInteger(value) ? 0 : 2)));
|
|
400
|
+
const s = grouped(Math.abs(value), d);
|
|
401
|
+
body = `${value < 0 ? "-" : ""}${symbol}${s}`;
|
|
402
|
+
} else body = trim(grouped(value, decimalsFor(value)));
|
|
403
|
+
return `${_nullishCoalesce(format.prefix, () => ( ""))}${body}${_nullishCoalesce(format.suffix, () => ( ""))}`;
|
|
404
|
+
}
|
|
405
|
+
function linearMap(value, domain, range) {
|
|
406
|
+
const [d0, d1] = domain;
|
|
407
|
+
const [r0, r1] = range;
|
|
408
|
+
if (d1 === d0) return (r0 + r1) / 2;
|
|
409
|
+
return r0 + (value - d0) / (d1 - d0) * (r1 - r0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/chart/layout.ts
|
|
413
|
+
var estimateChartTextWidth = (text2, size, weight = 400) => text2.length * size * (weight >= 600 ? 0.6 : 0.56);
|
|
414
|
+
function sameChartSelection(a, b) {
|
|
415
|
+
if (!a || !b || a.kind !== b.kind) return false;
|
|
416
|
+
switch (a.kind) {
|
|
417
|
+
case "axis":
|
|
418
|
+
return a.axis === b.axis;
|
|
419
|
+
case "series":
|
|
420
|
+
return a.seriesId === b.seriesId;
|
|
421
|
+
case "point":
|
|
422
|
+
return a.seriesId === b.seriesId && a.index === b.index;
|
|
423
|
+
case "category":
|
|
424
|
+
return a.index === b.index;
|
|
425
|
+
default:
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
var r2 = (n) => Math.round(n * 100) / 100;
|
|
430
|
+
function curvePath(points, curve, map, moveFirst = true) {
|
|
431
|
+
if (!points.length) return "";
|
|
432
|
+
const xy = points.map(map);
|
|
433
|
+
let d = `${moveFirst ? "M" : "L"}${r2(xy[0][0])},${r2(xy[0][1])}`;
|
|
434
|
+
if (points.length === 1) return d;
|
|
435
|
+
if (curve === "step") {
|
|
436
|
+
for (let i = 1; i < points.length; i++) {
|
|
437
|
+
const mid = (points[i - 1].c + points[i].c) / 2;
|
|
438
|
+
const a = map({ c: mid, v: points[i - 1].v });
|
|
439
|
+
const b = map({ c: mid, v: points[i].v });
|
|
440
|
+
d += `L${r2(a[0])},${r2(a[1])}L${r2(b[0])},${r2(b[1])}L${r2(xy[i][0])},${r2(xy[i][1])}`;
|
|
441
|
+
}
|
|
442
|
+
return d;
|
|
443
|
+
}
|
|
444
|
+
if (curve === "smooth" && points.length > 2) {
|
|
445
|
+
const n = points.length;
|
|
446
|
+
const dc = points.slice(1).map((p, i) => p.c - points[i].c);
|
|
447
|
+
const slope = points.slice(1).map((p, i) => dc[i] === 0 ? 0 : (p.v - points[i].v) / dc[i]);
|
|
448
|
+
const tangent = points.map((_, i) => {
|
|
449
|
+
if (i === 0) return slope[0];
|
|
450
|
+
if (i === n - 1) return slope[n - 2];
|
|
451
|
+
const a = slope[i - 1];
|
|
452
|
+
const b = slope[i];
|
|
453
|
+
if (a * b <= 0) return 0;
|
|
454
|
+
const wa = 2 * dc[i] + dc[i - 1];
|
|
455
|
+
const wb = dc[i] + 2 * dc[i - 1];
|
|
456
|
+
return (wa + wb) / (wa / a + wb / b);
|
|
457
|
+
});
|
|
458
|
+
for (let i = 1; i < n; i++) {
|
|
459
|
+
const h = dc[i - 1] / 3;
|
|
460
|
+
const p0 = points[i - 1];
|
|
461
|
+
const p1 = points[i];
|
|
462
|
+
const a = map({ c: p0.c + h, v: p0.v + tangent[i - 1] * h });
|
|
463
|
+
const b = map({ c: p1.c - h, v: p1.v - tangent[i] * h });
|
|
464
|
+
d += `C${r2(a[0])},${r2(a[1])} ${r2(b[0])},${r2(b[1])} ${r2(xy[i][0])},${r2(xy[i][1])}`;
|
|
465
|
+
}
|
|
466
|
+
return d;
|
|
467
|
+
}
|
|
468
|
+
for (let i = 1; i < xy.length; i++) d += `L${r2(xy[i][0])},${r2(xy[i][1])}`;
|
|
469
|
+
return d;
|
|
470
|
+
}
|
|
471
|
+
function arcPath(cx, cy, outer, inner, start, end) {
|
|
472
|
+
const full = end - start >= Math.PI * 2 - 1e-9;
|
|
473
|
+
if (full) end = start + Math.PI * 2 - 1e-4;
|
|
474
|
+
const p = (r, a) => `${r2(cx + r * Math.sin(a))},${r2(cy - r * Math.cos(a))}`;
|
|
475
|
+
const large = end - start > Math.PI ? 1 : 0;
|
|
476
|
+
let d = `M${p(outer, start)}A${r2(outer)},${r2(outer)} 0 ${large} 1 ${p(outer, end)}`;
|
|
477
|
+
if (inner > 0) d += `L${p(inner, end)}A${r2(inner)},${r2(inner)} 0 ${large} 0 ${p(inner, start)}Z`;
|
|
478
|
+
else d += `L${r2(cx)},${r2(cy)}Z`;
|
|
479
|
+
return d;
|
|
480
|
+
}
|
|
481
|
+
function layoutChart(spec, options) {
|
|
482
|
+
const width = Math.max(1, options.width);
|
|
483
|
+
const height = Math.max(1, options.height);
|
|
484
|
+
const theme = { ...DEFAULT_CHART_THEME, ...options.theme };
|
|
485
|
+
const measure = _nullishCoalesce(options.measureText, () => ( estimateChartTextWidth));
|
|
486
|
+
const selection = _nullishCoalesce(options.selection, () => ( null));
|
|
487
|
+
const fs = theme.fontSize;
|
|
488
|
+
const marks = [];
|
|
489
|
+
const isSelected = (target) => sameChartSelection(selection, target);
|
|
490
|
+
const seriesSelected = (id) => _optionalChain([selection, 'optionalAccess', _3 => _3.kind]) === "series" && selection.seriesId === id;
|
|
491
|
+
const pad = 12;
|
|
492
|
+
marks.push({ kind: "rect", key: "background", role: "background", x: 0, y: 0, width, height, fill: theme.background, target: { kind: "chart" } });
|
|
493
|
+
let top = pad;
|
|
494
|
+
const outline = (key, box) => marks.push({ kind: "rect", key, role: "selection", ...box, fill: "none", stroke: theme.selection, strokeWidth: 1, radius: 3 });
|
|
495
|
+
const titleMark = (kind, label, size, weight, fill) => {
|
|
496
|
+
const target = { kind };
|
|
497
|
+
const selected = isSelected(target);
|
|
498
|
+
marks.push({ kind: "text", key: kind, role: kind, x: width / 2, y: top, text: label, anchor: "middle", baseline: "top", size, weight, fill, target, selected });
|
|
499
|
+
if (selected) {
|
|
500
|
+
const w = measure(label, size, weight);
|
|
501
|
+
outline(`${kind}-selection`, { x: width / 2 - w / 2 - 4, y: top - 3, width: w + 8, height: size * 1.25 + 6 });
|
|
502
|
+
}
|
|
503
|
+
top += size * 1.35;
|
|
504
|
+
};
|
|
505
|
+
if (spec.title) titleMark("title", spec.title, theme.titleSize, 600, theme.text);
|
|
506
|
+
if (spec.subtitle) titleMark("subtitle", spec.subtitle, fs + 1, 400, theme.mutedText);
|
|
507
|
+
if (spec.title || spec.subtitle) top += 6;
|
|
508
|
+
const cartesian = isCartesianChart(spec.type);
|
|
509
|
+
const visible = spec.series.filter((s) => !s.hidden);
|
|
510
|
+
const legendItems = cartesian ? spec.series.map((s, i) => ({
|
|
511
|
+
label: s.name,
|
|
512
|
+
color: chartSeriesColor(spec, i),
|
|
513
|
+
target: { kind: "series", seriesId: s.id },
|
|
514
|
+
faded: !!s.hidden,
|
|
515
|
+
selected: seriesSelected(s.id),
|
|
516
|
+
shape: chartSeriesType(spec, s) === "line" ? "line" : "square"
|
|
517
|
+
})) : spec.categories.map((c, i) => ({
|
|
518
|
+
label: c,
|
|
519
|
+
color: chartSliceColor(spec, i),
|
|
520
|
+
target: { kind: "category", index: i },
|
|
521
|
+
faded: false,
|
|
522
|
+
selected: isSelected({ kind: "category", index: i }),
|
|
523
|
+
shape: "square"
|
|
524
|
+
}));
|
|
525
|
+
let area = { x: pad, y: top, width: width - pad * 2, height: height - top - pad };
|
|
526
|
+
const showLegend = spec.legend.show && legendItems.length > 0 && (!cartesian || spec.series.length > 1 || spec.series.some((s) => s.hidden));
|
|
527
|
+
if (showLegend) area = layoutLegend(legendItems, spec.legend.position, area, theme, measure, marks, isSelected({ kind: "legend" }));
|
|
528
|
+
const empty = (message) => {
|
|
529
|
+
marks.push({ kind: "text", key: "empty", role: "empty", x: area.x + area.width / 2, y: area.y + area.height / 2, text: message, anchor: "middle", baseline: "middle", size: fs + 1, fill: theme.mutedText });
|
|
530
|
+
return { width, height, plot: { x: area.x, y: area.y, width: 0, height: 0 }, marks, percent: false, theme };
|
|
531
|
+
};
|
|
532
|
+
const hasData = visible.some((s) => s.values.some((v) => v !== null));
|
|
533
|
+
if (!spec.categories.length || !visible.length || !hasData) return empty(_nullishCoalesce(options.emptyMessage, () => ( "No data")));
|
|
534
|
+
if (!cartesian) return layoutPie(spec, visible[0], area, theme, marks, selection, width, height);
|
|
535
|
+
const horizontal = spec.orientation === "horizontal";
|
|
536
|
+
const n = spec.categories.length;
|
|
537
|
+
const types = new Map(visible.map((s) => [s.id, chartSeriesType(spec, s)]));
|
|
538
|
+
const stacked = spec.stacking !== "none";
|
|
539
|
+
const percent = spec.stacking === "percent";
|
|
540
|
+
const stacks = /* @__PURE__ */ new Map();
|
|
541
|
+
if (stacked) {
|
|
542
|
+
for (const kind of ["bar", "area"]) {
|
|
543
|
+
const group = visible.filter((s) => types.get(s.id) === kind);
|
|
544
|
+
if (!group.length) continue;
|
|
545
|
+
const totals = Array.from({ length: n }, (_, i) => group.reduce((t, s) => t + Math.abs(_nullishCoalesce(s.values[i], () => ( 0))), 0));
|
|
546
|
+
const pos = Array(n).fill(0);
|
|
547
|
+
const neg = Array(n).fill(0);
|
|
548
|
+
for (const s of group) {
|
|
549
|
+
const segments = s.values.map((raw, i) => {
|
|
550
|
+
if (raw === null && kind === "bar") return null;
|
|
551
|
+
let v = _nullishCoalesce(raw, () => ( 0));
|
|
552
|
+
if (percent) v = totals[i] ? v / totals[i] : 0;
|
|
553
|
+
const base = v >= 0 ? pos[i] : neg[i];
|
|
554
|
+
const seg = [base, base + v];
|
|
555
|
+
if (v >= 0) pos[i] += v;
|
|
556
|
+
else neg[i] += v;
|
|
557
|
+
return seg;
|
|
558
|
+
});
|
|
559
|
+
stacks.set(s.id, { segments });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
let lo = Infinity;
|
|
564
|
+
let hi = -Infinity;
|
|
565
|
+
const see = (v) => {
|
|
566
|
+
lo = Math.min(lo, v);
|
|
567
|
+
hi = Math.max(hi, v);
|
|
568
|
+
};
|
|
569
|
+
for (const s of visible) {
|
|
570
|
+
const stack = stacks.get(s.id);
|
|
571
|
+
if (stack) stack.segments.forEach((seg) => seg && (see(seg[0]), see(seg[1])));
|
|
572
|
+
else s.values.forEach((v) => v !== null && see(v));
|
|
573
|
+
}
|
|
574
|
+
if (!Number.isFinite(lo)) {
|
|
575
|
+
lo = 0;
|
|
576
|
+
hi = 1;
|
|
577
|
+
}
|
|
578
|
+
if ([...types.values()].some((t) => t === "bar" || t === "area")) {
|
|
579
|
+
lo = Math.min(lo, 0);
|
|
580
|
+
hi = Math.max(hi, 0);
|
|
581
|
+
}
|
|
582
|
+
const valueFormat = percent && !spec.yAxis.format.style ? { ...spec.yAxis.format, style: "percent" } : spec.yAxis.format;
|
|
583
|
+
const xAxis = spec.xAxis;
|
|
584
|
+
const yAxis = spec.yAxis;
|
|
585
|
+
const titleBand = fs * 1.3 + 6;
|
|
586
|
+
const valueLen0 = horizontal ? area.width : area.height;
|
|
587
|
+
let scale = niceScale(lo, hi, Math.max(2, Math.min(10, Math.round(valueLen0 / 56))), yAxis.min, yAxis.max);
|
|
588
|
+
const tickText = (t) => formatChartNumber(t, valueFormat, _nullishCoalesce(valueFormat.decimals, () => ( stepDecimals(scale.step))));
|
|
589
|
+
const valueLabelWidth = Math.max(0, ...scale.ticks.map((t) => measure(tickText(t), fs)));
|
|
590
|
+
const catLabelWidth = Math.max(0, ...spec.categories.map((c) => measure(c, fs)));
|
|
591
|
+
let left;
|
|
592
|
+
let bottom;
|
|
593
|
+
let rotate = 0;
|
|
594
|
+
if (horizontal) {
|
|
595
|
+
left = (xAxis.show ? Math.min(catLabelWidth, area.width * 0.35) + 8 : 2) + (xAxis.title ? titleBand : 0);
|
|
596
|
+
bottom = (yAxis.show ? fs * 1.6 : 2) + (yAxis.title ? titleBand : 0);
|
|
597
|
+
} else {
|
|
598
|
+
left = (yAxis.show ? valueLabelWidth + 8 : 2) + (yAxis.title ? titleBand : 0);
|
|
599
|
+
const band2 = (area.width - left - 8) / n;
|
|
600
|
+
if (xAxis.show && catLabelWidth > band2 * 0.92) rotate = -40;
|
|
601
|
+
const catHeight = rotate ? Math.min(catLabelWidth * Math.sin(Math.PI / 4.5) + fs, area.height * 0.4) : fs * 1.6;
|
|
602
|
+
bottom = (xAxis.show ? catHeight : 2) + (xAxis.title ? titleBand : 0);
|
|
603
|
+
}
|
|
604
|
+
const rightPad = horizontal ? Math.max(8, valueLabelWidth / 2) : 8;
|
|
605
|
+
const plot = {
|
|
606
|
+
x: area.x + left,
|
|
607
|
+
y: area.y + (horizontal ? 4 : fs / 2 + 2),
|
|
608
|
+
width: Math.max(1, area.width - left - rightPad),
|
|
609
|
+
height: Math.max(1, area.height - bottom - (horizontal ? 4 : fs / 2 + 2))
|
|
610
|
+
};
|
|
611
|
+
scale = niceScale(lo, hi, Math.max(2, Math.min(10, Math.round((horizontal ? plot.width : plot.height) / 56))), yAxis.min, yAxis.max);
|
|
612
|
+
const domain = [scale.min, scale.max];
|
|
613
|
+
const valueRange = horizontal ? [plot.x, plot.x + plot.width] : [plot.y + plot.height, plot.y];
|
|
614
|
+
const valuePx = (v) => linearMap(Math.min(scale.max, Math.max(scale.min, v)), domain, valueRange);
|
|
615
|
+
const catStart = horizontal ? plot.y : plot.x;
|
|
616
|
+
const catLen = horizontal ? plot.height : plot.width;
|
|
617
|
+
const band = catLen / n;
|
|
618
|
+
const catCenter = (i) => catStart + band * (i + 0.5);
|
|
619
|
+
const map = (p) => horizontal ? [p.v, p.c] : [p.c, p.v];
|
|
620
|
+
const zero = valuePx(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
621
|
+
const axisSelected = (axis) => isSelected({ kind: "axis", axis });
|
|
622
|
+
if (yAxis.gridlines) {
|
|
623
|
+
scale.ticks.forEach((t, i) => {
|
|
624
|
+
const p = valuePx(t);
|
|
625
|
+
const [x1, y1] = map({ c: catStart, v: p });
|
|
626
|
+
const [x2, y2] = map({ c: catStart + catLen, v: p });
|
|
627
|
+
marks.push({ kind: "line", key: `grid-y-${i}`, role: "grid", x1, y1, x2, y2, stroke: theme.grid, strokeWidth: 1 });
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
if (xAxis.gridlines) {
|
|
631
|
+
for (let i = 1; i < n; i++) {
|
|
632
|
+
const c = catStart + band * i;
|
|
633
|
+
const [x1, y1] = map({ c, v: valueRange[0] });
|
|
634
|
+
const [x2, y2] = map({ c, v: valueRange[1] });
|
|
635
|
+
marks.push({ kind: "line", key: `grid-x-${i}`, role: "grid", x1, y1, x2, y2, stroke: theme.grid, strokeWidth: 1 });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const yTarget = { kind: "axis", axis: "y" };
|
|
639
|
+
const xTarget = { kind: "axis", axis: "x" };
|
|
640
|
+
if (yAxis.show) {
|
|
641
|
+
scale.ticks.forEach((t, i) => {
|
|
642
|
+
const p = valuePx(t);
|
|
643
|
+
const label = tickText(t);
|
|
644
|
+
if (horizontal)
|
|
645
|
+
marks.push({ kind: "text", key: `tick-y-${i}`, role: "tick-label", x: p, y: plot.y + plot.height + 6, text: label, anchor: "middle", baseline: "top", size: fs, fill: theme.mutedText, target: yTarget });
|
|
646
|
+
else marks.push({ kind: "text", key: `tick-y-${i}`, role: "tick-label", x: plot.x - 8, y: p, text: label, anchor: "end", baseline: "middle", size: fs, fill: theme.mutedText, target: yTarget });
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (xAxis.show) {
|
|
650
|
+
const [x1, y1] = map({ c: catStart, v: zero });
|
|
651
|
+
const [x2, y2] = map({ c: catStart + catLen, v: zero });
|
|
652
|
+
marks.push({ kind: "line", key: "axis-x", role: "axis", x1, y1, x2, y2, stroke: theme.axis, strokeWidth: 1, target: xTarget });
|
|
653
|
+
const every = horizontal ? Math.max(1, Math.ceil(fs * 1.3 / band)) : rotate ? Math.max(1, Math.ceil(fs * 1.4 / band)) : 1;
|
|
654
|
+
spec.categories.forEach((c, i) => {
|
|
655
|
+
if (i % every) return;
|
|
656
|
+
const target = { kind: "category", index: i };
|
|
657
|
+
const selected = isSelected(target);
|
|
658
|
+
const fill = selected ? theme.selection : theme.mutedText;
|
|
659
|
+
if (horizontal) {
|
|
660
|
+
const maxW = left - (xAxis.title ? titleBand : 0) - 8;
|
|
661
|
+
marks.push({ kind: "text", key: `tick-x-${i}`, role: "tick-label", x: plot.x - 8, y: catCenter(i), text: truncate(c, maxW, fs, measure), anchor: "end", baseline: "middle", size: fs, fill, target, selected });
|
|
662
|
+
} else if (rotate)
|
|
663
|
+
marks.push({ kind: "text", key: `tick-x-${i}`, role: "tick-label", x: catCenter(i), y: plot.y + plot.height + 8, text: c, anchor: "end", baseline: "middle", size: fs, fill, rotate, target, selected });
|
|
664
|
+
else marks.push({ kind: "text", key: `tick-x-${i}`, role: "tick-label", x: catCenter(i), y: plot.y + plot.height + 6, text: c, anchor: "middle", baseline: "top", size: fs, fill, target, selected });
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
const bottomTitle = horizontal ? yAxis.title : xAxis.title;
|
|
668
|
+
const leftTitle = horizontal ? xAxis.title : yAxis.title;
|
|
669
|
+
if (bottomTitle)
|
|
670
|
+
marks.push({ kind: "text", key: `axis-title-${horizontal ? "y" : "x"}`, role: "axis-title", x: plot.x + plot.width / 2, y: area.y + area.height, text: bottomTitle, anchor: "middle", baseline: "bottom", size: fs + 1, weight: 500, fill: theme.text, target: horizontal ? yTarget : xTarget });
|
|
671
|
+
if (leftTitle)
|
|
672
|
+
marks.push({ kind: "text", key: `axis-title-${horizontal ? "x" : "y"}`, role: "axis-title", x: area.x, y: plot.y + plot.height / 2, text: leftTitle, anchor: "middle", baseline: "top", size: fs + 1, weight: 500, fill: theme.text, rotate: -90, target: horizontal ? xTarget : yTarget });
|
|
673
|
+
for (const axis of ["x", "y"]) {
|
|
674
|
+
if (!axisSelected(axis)) continue;
|
|
675
|
+
const onLeft = axis === "y" !== horizontal;
|
|
676
|
+
const box = onLeft ? { x: area.x, y: plot.y - fs / 2, width: left - 2, height: plot.height + fs } : { x: plot.x - 4, y: plot.y + plot.height + 2, width: plot.width + 8, height: area.y + area.height - plot.y - plot.height - 2 };
|
|
677
|
+
outline(`axis-${axis}-selection`, box);
|
|
678
|
+
}
|
|
679
|
+
const bars = visible.filter((s) => types.get(s.id) === "bar");
|
|
680
|
+
const slots = stacked ? 1 : bars.length;
|
|
681
|
+
const groupWidth = band * (bars.length ? 0.72 : 0);
|
|
682
|
+
const barWidth = slots ? groupWidth / slots : 0;
|
|
683
|
+
const labelMarks = [];
|
|
684
|
+
const decimals = _nullishCoalesce(valueFormat.decimals, () => ( void 0));
|
|
685
|
+
const dataLabel = (v) => formatChartNumber(v, valueFormat, decimals);
|
|
686
|
+
const drawOrder = [...visible].sort((a, b) => order(types.get(a.id)) - order(types.get(b.id)));
|
|
687
|
+
for (const s of drawOrder) {
|
|
688
|
+
const si = spec.series.indexOf(s);
|
|
689
|
+
const color = chartSeriesColor(spec, si);
|
|
690
|
+
const type = types.get(s.id);
|
|
691
|
+
const stack = stacks.get(s.id);
|
|
692
|
+
const selSeries = seriesSelected(s.id);
|
|
693
|
+
const seriesTarget = { kind: "series", seriesId: s.id };
|
|
694
|
+
if (type === "bar") {
|
|
695
|
+
const slot = stacked ? 0 : bars.indexOf(s);
|
|
696
|
+
s.values.forEach((raw, i) => {
|
|
697
|
+
const seg = stack ? stack.segments[i] : raw === null ? null : [0, raw];
|
|
698
|
+
if (!seg || raw === null) return;
|
|
699
|
+
const a = valuePx(seg[0]);
|
|
700
|
+
const b = valuePx(seg[1]);
|
|
701
|
+
const c0 = catCenter(i) - groupWidth / 2 + slot * barWidth + barWidth * 0.06;
|
|
702
|
+
const cw = barWidth * 0.88;
|
|
703
|
+
const target = { kind: "point", seriesId: s.id, index: i };
|
|
704
|
+
const selected = isSelected(target);
|
|
705
|
+
const [x, y] = map({ c: c0, v: Math.min(a, b) });
|
|
706
|
+
const rect = horizontal ? { x: Math.min(a, b), y: c0, width: Math.abs(b - a), height: cw } : { x, y, width: cw, height: Math.abs(b - a) };
|
|
707
|
+
marks.push({
|
|
708
|
+
kind: "rect",
|
|
709
|
+
key: `bar-${s.id}-${i}`,
|
|
710
|
+
role: "bar",
|
|
711
|
+
...rect,
|
|
712
|
+
fill: color,
|
|
713
|
+
radius: Math.min(2, cw / 4),
|
|
714
|
+
target,
|
|
715
|
+
selected: selected || selSeries,
|
|
716
|
+
...selSeries ? { stroke: theme.selection, strokeWidth: 1.5 } : {}
|
|
717
|
+
});
|
|
718
|
+
if (selected) outline(`bar-${s.id}-${i}-selection`, { x: rect.x - 2, y: rect.y - 2, width: rect.width + 4, height: rect.height + 4 });
|
|
719
|
+
if (spec.dataLabels) {
|
|
720
|
+
const v = percent ? seg[1] - seg[0] : raw;
|
|
721
|
+
const end = valuePx(seg[1]);
|
|
722
|
+
const outward = (seg[1] >= seg[0] ? 1 : -1) * (horizontal ? 1 : -1);
|
|
723
|
+
const [lx, ly] = map({ c: c0 + cw / 2, v: stacked ? (a + b) / 2 : end + outward * 4 });
|
|
724
|
+
labelMarks.push({
|
|
725
|
+
kind: "text",
|
|
726
|
+
key: `label-${s.id}-${i}`,
|
|
727
|
+
role: "data-label",
|
|
728
|
+
x: lx,
|
|
729
|
+
y: ly,
|
|
730
|
+
text: dataLabel(v),
|
|
731
|
+
anchor: horizontal && !stacked ? outward > 0 ? "start" : "end" : "middle",
|
|
732
|
+
baseline: horizontal || stacked ? "middle" : outward < 0 ? "bottom" : "top",
|
|
733
|
+
size: fs - 1,
|
|
734
|
+
fill: stacked ? "#ffffff" : theme.text,
|
|
735
|
+
target
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const pointValue = (i) => stack ? _nullishCoalesce(_optionalChain([stack, 'access', _4 => _4.segments, 'access', _5 => _5[i], 'optionalAccess', _6 => _6[1]]), () => ( null)) : s.values[i];
|
|
742
|
+
const runs = [];
|
|
743
|
+
let run = [];
|
|
744
|
+
for (let i = 0; i < n; i++) {
|
|
745
|
+
const v = pointValue(i);
|
|
746
|
+
if (v === null) {
|
|
747
|
+
if (run.length) runs.push(run);
|
|
748
|
+
run = [];
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
run.push({ c: catCenter(i), v: valuePx(v) });
|
|
752
|
+
}
|
|
753
|
+
if (run.length) runs.push(run);
|
|
754
|
+
if (type === "area") {
|
|
755
|
+
const parts = runs.map((r) => {
|
|
756
|
+
const baseline = stack ? r.map((p) => ({ c: p.c, v: valuePx(_nullishCoalesce(_optionalChain([stack, 'access', _7 => _7.segments, 'access', _8 => _8[Math.round((p.c - catStart) / band - 0.5)], 'optionalAccess', _9 => _9[0]]), () => ( 0))) })).reverse() : [
|
|
757
|
+
{ c: r[r.length - 1].c, v: zero },
|
|
758
|
+
{ c: r[0].c, v: zero }
|
|
759
|
+
];
|
|
760
|
+
return `${curvePath(r, spec.curve, map)}${stack ? curvePath(baseline, spec.curve, map, false) : baseline.map((p) => `L${map(p).map(r2).join(",")}`).join("")}Z`;
|
|
761
|
+
});
|
|
762
|
+
marks.push({ kind: "path", key: `area-${s.id}`, role: "area", d: parts.join(""), fill: color, opacity: 0.22, target: seriesTarget, selected: selSeries });
|
|
763
|
+
}
|
|
764
|
+
if (type === "line" || type === "area") {
|
|
765
|
+
marks.push({
|
|
766
|
+
kind: "path",
|
|
767
|
+
key: `line-${s.id}`,
|
|
768
|
+
role: "line",
|
|
769
|
+
d: runs.map((r) => curvePath(r, spec.curve, map)).join(""),
|
|
770
|
+
fill: "none",
|
|
771
|
+
stroke: color,
|
|
772
|
+
strokeWidth: selSeries ? 3.5 : 2,
|
|
773
|
+
target: seriesTarget,
|
|
774
|
+
selected: selSeries
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const showDots = type === "scatter" || spec.points;
|
|
778
|
+
for (let i = 0; i < n; i++) {
|
|
779
|
+
const v = pointValue(i);
|
|
780
|
+
if (v === null || s.values[i] === null) continue;
|
|
781
|
+
const [cx, cy] = map({ c: catCenter(i), v: valuePx(v) });
|
|
782
|
+
const target = { kind: "point", seriesId: s.id, index: i };
|
|
783
|
+
const selected = isSelected(target);
|
|
784
|
+
const r = type === "scatter" ? 4.5 : 3.5;
|
|
785
|
+
marks.push({
|
|
786
|
+
kind: "circle",
|
|
787
|
+
key: `point-${s.id}-${i}`,
|
|
788
|
+
role: "point",
|
|
789
|
+
cx,
|
|
790
|
+
cy,
|
|
791
|
+
r: showDots ? r : 6,
|
|
792
|
+
fill: showDots ? type === "scatter" ? color : theme.surface : "transparent",
|
|
793
|
+
...showDots ? { stroke: color, strokeWidth: 2 } : {},
|
|
794
|
+
opacity: type === "scatter" ? 0.85 : void 0,
|
|
795
|
+
target,
|
|
796
|
+
selected: selected || selSeries
|
|
797
|
+
});
|
|
798
|
+
if (selected) marks.push({ kind: "circle", key: `point-${s.id}-${i}-selection`, role: "selection", cx, cy, r: r + 3.5, fill: "none", stroke: theme.selection, strokeWidth: 1.5 });
|
|
799
|
+
if (spec.dataLabels) {
|
|
800
|
+
const raw = s.values[i];
|
|
801
|
+
const shown = percent && stack ? stack.segments[i][1] - stack.segments[i][0] : raw;
|
|
802
|
+
labelMarks.push({
|
|
803
|
+
kind: "text",
|
|
804
|
+
key: `label-${s.id}-${i}`,
|
|
805
|
+
role: "data-label",
|
|
806
|
+
x: horizontal ? cx + 8 : cx,
|
|
807
|
+
y: horizontal ? cy : cy - 8,
|
|
808
|
+
text: dataLabel(shown),
|
|
809
|
+
anchor: horizontal ? "start" : "middle",
|
|
810
|
+
baseline: horizontal ? "middle" : "bottom",
|
|
811
|
+
size: fs - 1,
|
|
812
|
+
fill: theme.text,
|
|
813
|
+
target
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
marks.push(...labelMarks);
|
|
819
|
+
const valueScale = { axis: horizontal ? "x" : "y", domain, range: valueRange, ticks: scale.ticks };
|
|
820
|
+
return { width, height, plot, marks, valueScale, percent, theme };
|
|
821
|
+
}
|
|
822
|
+
var order = (t) => t === "area" ? 0 : t === "bar" ? 1 : t === "line" ? 2 : 3;
|
|
823
|
+
function truncate(text2, maxWidth, size, measure) {
|
|
824
|
+
if (measure(text2, size) <= maxWidth) return text2;
|
|
825
|
+
let out = text2;
|
|
826
|
+
while (out.length > 1 && measure(`${out}\u2026`, size) > maxWidth) out = out.slice(0, -1);
|
|
827
|
+
return `${out}\u2026`;
|
|
828
|
+
}
|
|
829
|
+
function layoutLegend(items, position, area, theme, measure, marks, legendSelected) {
|
|
830
|
+
const fs = theme.fontSize;
|
|
831
|
+
const swatch = 10;
|
|
832
|
+
const rowH = fs * 1.7;
|
|
833
|
+
const itemW = (item) => swatch + 6 + measure(item.label, fs) + 14;
|
|
834
|
+
const placed = [];
|
|
835
|
+
let box;
|
|
836
|
+
let rest;
|
|
837
|
+
if (position === "left" || position === "right") {
|
|
838
|
+
const w = Math.min(area.width * 0.35, Math.max(...items.map(itemW)));
|
|
839
|
+
const h = Math.min(items.length * rowH, area.height);
|
|
840
|
+
const x = position === "left" ? area.x : area.x + area.width - w;
|
|
841
|
+
const y = area.y + (area.height - h) / 2;
|
|
842
|
+
items.forEach((item, i) => {
|
|
843
|
+
if ((i + 1) * rowH <= area.height + 0.5) placed.push({ item, x, y: y + i * rowH + rowH / 2 });
|
|
844
|
+
});
|
|
845
|
+
box = { x, y, width: w, height: h };
|
|
846
|
+
rest = position === "left" ? { ...area, x: area.x + w + 8, width: area.width - w - 8 } : { ...area, width: area.width - w - 8 };
|
|
847
|
+
} else {
|
|
848
|
+
const rows = [[]];
|
|
849
|
+
let used = 0;
|
|
850
|
+
for (const item of items) {
|
|
851
|
+
const w = itemW(item);
|
|
852
|
+
if (used + w > area.width && rows[rows.length - 1].length) {
|
|
853
|
+
rows.push([]);
|
|
854
|
+
used = 0;
|
|
855
|
+
}
|
|
856
|
+
rows[rows.length - 1].push(item);
|
|
857
|
+
used += w;
|
|
858
|
+
}
|
|
859
|
+
const shown = rows.slice(0, 3);
|
|
860
|
+
const h = shown.length * rowH;
|
|
861
|
+
const y0 = position === "top" ? area.y : area.y + area.height - h;
|
|
862
|
+
shown.forEach((row, r) => {
|
|
863
|
+
const total = row.reduce((t, item) => t + itemW(item), 0) - 14;
|
|
864
|
+
let x = area.x + (area.width - total) / 2;
|
|
865
|
+
for (const item of row) {
|
|
866
|
+
placed.push({ item, x, y: y0 + r * rowH + rowH / 2 });
|
|
867
|
+
x += itemW(item);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
box = { x: area.x, y: y0, width: area.width, height: h };
|
|
871
|
+
rest = position === "top" ? { ...area, y: area.y + h + 6, height: area.height - h - 6 } : { ...area, height: area.height - h - 6 };
|
|
872
|
+
}
|
|
873
|
+
placed.forEach(({ item, x, y }, i) => {
|
|
874
|
+
const opacity = item.faded ? 0.4 : void 0;
|
|
875
|
+
if (item.shape === "line")
|
|
876
|
+
marks.push({ kind: "line", key: `legend-swatch-${i}`, role: "legend-swatch", x1: x, y1: y, x2: x + swatch, y2: y, stroke: item.color, strokeWidth: 2.5, opacity, target: item.target, selected: item.selected });
|
|
877
|
+
else
|
|
878
|
+
marks.push({ kind: "rect", key: `legend-swatch-${i}`, role: "legend-swatch", x, y: y - swatch / 2, width: swatch, height: swatch, radius: 2, fill: item.color, opacity, target: item.target, selected: item.selected });
|
|
879
|
+
marks.push({
|
|
880
|
+
kind: "text",
|
|
881
|
+
key: `legend-label-${i}`,
|
|
882
|
+
role: "legend-label",
|
|
883
|
+
x: x + swatch + 6,
|
|
884
|
+
y,
|
|
885
|
+
text: item.label,
|
|
886
|
+
anchor: "start",
|
|
887
|
+
baseline: "middle",
|
|
888
|
+
size: fs,
|
|
889
|
+
fill: item.selected ? theme.selection : theme.text,
|
|
890
|
+
weight: item.selected ? 600 : void 0,
|
|
891
|
+
opacity,
|
|
892
|
+
target: item.target,
|
|
893
|
+
selected: item.selected
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
if (legendSelected) marks.push({ kind: "rect", key: "legend-selection", role: "selection", x: box.x - 4, y: box.y - 2, width: box.width + 8, height: box.height + 4, fill: "none", stroke: theme.selection, strokeWidth: 1, radius: 3 });
|
|
897
|
+
return rest;
|
|
898
|
+
}
|
|
899
|
+
function layoutPie(spec, series, area, theme, marks, selection, width, height) {
|
|
900
|
+
const fs = theme.fontSize;
|
|
901
|
+
const values = spec.categories.map((_, i) => Math.max(0, _nullishCoalesce(series.values[i], () => ( 0))));
|
|
902
|
+
const total = values.reduce((a, b) => a + b, 0);
|
|
903
|
+
const labelRoom = spec.dataLabels ? fs * 2.2 : 4;
|
|
904
|
+
const outer = Math.max(4, Math.min(area.width, area.height) / 2 - labelRoom);
|
|
905
|
+
const inner = spec.type === "donut" ? outer * spec.innerRadius : 0;
|
|
906
|
+
const cx = area.x + area.width / 2;
|
|
907
|
+
const cy = area.y + area.height / 2;
|
|
908
|
+
const plot = { x: cx - outer, y: cy - outer, width: outer * 2, height: outer * 2 };
|
|
909
|
+
if (total <= 0) {
|
|
910
|
+
marks.push({ kind: "text", key: "empty", role: "empty", x: cx, y: cy, text: "No positive values", anchor: "middle", baseline: "middle", size: fs + 1, fill: theme.mutedText });
|
|
911
|
+
return { width, height, plot, marks, percent: false, theme };
|
|
912
|
+
}
|
|
913
|
+
let angle = 0;
|
|
914
|
+
const format = spec.yAxis.format;
|
|
915
|
+
values.forEach((v, i) => {
|
|
916
|
+
if (v <= 0) return;
|
|
917
|
+
const sweep = v / total * Math.PI * 2;
|
|
918
|
+
const start = angle;
|
|
919
|
+
const end = angle + sweep;
|
|
920
|
+
angle = end;
|
|
921
|
+
const target = { kind: "point", seriesId: series.id, index: i };
|
|
922
|
+
const selected = sameChartSelection(selection, target) || sameChartSelection(selection, { kind: "category", index: i });
|
|
923
|
+
const seriesSel = _optionalChain([selection, 'optionalAccess', _10 => _10.kind]) === "series" && selection.seriesId === series.id;
|
|
924
|
+
const mid = (start + end) / 2;
|
|
925
|
+
const off = selected ? 6 : 0;
|
|
926
|
+
const ox = cx + off * Math.sin(mid);
|
|
927
|
+
const oy = cy - off * Math.cos(mid);
|
|
928
|
+
marks.push({
|
|
929
|
+
kind: "path",
|
|
930
|
+
key: `slice-${i}`,
|
|
931
|
+
role: "slice",
|
|
932
|
+
d: arcPath(ox, oy, outer, inner, start, end),
|
|
933
|
+
fill: chartSliceColor(spec, i),
|
|
934
|
+
stroke: selected || seriesSel ? theme.selection : theme.surface,
|
|
935
|
+
strokeWidth: selected || seriesSel ? 2 : 1,
|
|
936
|
+
target,
|
|
937
|
+
selected: selected || seriesSel
|
|
938
|
+
});
|
|
939
|
+
if (spec.dataLabels && sweep > 0.12) {
|
|
940
|
+
const share = v / total;
|
|
941
|
+
const text2 = format.style ? formatChartNumber(v, format) : `${Math.round(share * 100)}%`;
|
|
942
|
+
const lr = outer + fs * 1.1;
|
|
943
|
+
const lx = ox + lr * Math.sin(mid);
|
|
944
|
+
const ly = oy - lr * Math.cos(mid);
|
|
945
|
+
const anchor = Math.abs(Math.sin(mid)) < 0.2 ? "middle" : Math.sin(mid) > 0 ? "start" : "end";
|
|
946
|
+
marks.push({ kind: "text", key: `label-slice-${i}`, role: "data-label", x: lx, y: ly, text: text2, anchor, baseline: "middle", size: fs, fill: theme.text, target });
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
if (spec.type === "donut" && inner > fs * 2) {
|
|
950
|
+
marks.push({ kind: "text", key: "donut-total", role: "data-label", x: cx, y: cy, text: formatChartNumber(total, format), anchor: "middle", baseline: "middle", size: Math.min(theme.titleSize + 4, inner / 2), weight: 600, fill: theme.text, target: { kind: "series", seriesId: series.id } });
|
|
951
|
+
}
|
|
952
|
+
return { width, height, plot, marks, percent: false, theme };
|
|
953
|
+
}
|
|
954
|
+
function chartValueAtPoint(scene, point) {
|
|
955
|
+
const scale = scene.valueScale;
|
|
956
|
+
if (!scale || scene.percent) return void 0;
|
|
957
|
+
const px = scale.axis === "y" ? point.y : point.x;
|
|
958
|
+
return linearMap(px, scale.range, scale.domain);
|
|
959
|
+
}
|
|
960
|
+
function snapChartValue(scene, value, step) {
|
|
961
|
+
const ticks = _nullishCoalesce(_optionalChain([scene, 'access', _11 => _11.valueScale, 'optionalAccess', _12 => _12.ticks]), () => ( []));
|
|
962
|
+
const unit = _nullishCoalesce(step, () => ( (ticks.length > 1 ? (ticks[1] - ticks[0]) / 10 : 1)));
|
|
963
|
+
if (!(unit > 0)) return value;
|
|
964
|
+
const snapped = Math.round(value / unit) * unit;
|
|
965
|
+
return Number(snapped.toPrecision(12));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// src/chart/svg.ts
|
|
969
|
+
function chartTextBaseline(baseline) {
|
|
970
|
+
return baseline === "top" ? "hanging" : baseline === "middle" ? "central" : "text-after-edge";
|
|
971
|
+
}
|
|
972
|
+
function chartTextTransform(mark) {
|
|
973
|
+
return mark.rotate ? `rotate(${mark.rotate} ${mark.x} ${mark.y})` : void 0;
|
|
974
|
+
}
|
|
975
|
+
var escape = (text2) => text2.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
976
|
+
function attrs(values) {
|
|
977
|
+
return Object.entries(values).filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => ` ${k}="${escape(String(v))}"`).join("");
|
|
978
|
+
}
|
|
979
|
+
function markToSvg(mark, fontFamily) {
|
|
980
|
+
const common = { opacity: mark.opacity, "data-role": mark.role };
|
|
981
|
+
switch (mark.kind) {
|
|
982
|
+
case "rect":
|
|
983
|
+
return `<rect${attrs({ x: mark.x, y: mark.y, width: mark.width, height: mark.height, rx: mark.radius, fill: mark.fill, stroke: mark.stroke, "stroke-width": mark.strokeWidth, ...common })}/>`;
|
|
984
|
+
case "path":
|
|
985
|
+
return `<path${attrs({ d: mark.d, fill: _nullishCoalesce(mark.fill, () => ( "none")), stroke: mark.stroke, "stroke-width": mark.strokeWidth, "stroke-dasharray": mark.dash, "stroke-linejoin": "round", "stroke-linecap": "round", ...common })}/>`;
|
|
986
|
+
case "circle":
|
|
987
|
+
return `<circle${attrs({ cx: mark.cx, cy: mark.cy, r: mark.r, fill: mark.fill, stroke: mark.stroke, "stroke-width": mark.strokeWidth, ...common })}/>`;
|
|
988
|
+
case "line":
|
|
989
|
+
return `<line${attrs({ x1: mark.x1, y1: mark.y1, x2: mark.x2, y2: mark.y2, stroke: mark.stroke, "stroke-width": mark.strokeWidth, "stroke-dasharray": mark.dash, ...common })}/>`;
|
|
990
|
+
case "text":
|
|
991
|
+
return `<text${attrs({
|
|
992
|
+
x: mark.x,
|
|
993
|
+
y: mark.y,
|
|
994
|
+
"text-anchor": mark.anchor,
|
|
995
|
+
"dominant-baseline": chartTextBaseline(mark.baseline),
|
|
996
|
+
"font-size": mark.size,
|
|
997
|
+
"font-weight": mark.weight,
|
|
998
|
+
"font-family": fontFamily,
|
|
999
|
+
fill: mark.fill,
|
|
1000
|
+
transform: chartTextTransform(mark),
|
|
1001
|
+
...common
|
|
1002
|
+
})}>${escape(mark.text)}</text>`;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function chartSceneToSvg(scene, options = {}) {
|
|
1006
|
+
const hide = _nullishCoalesce(options.hideSelection, () => ( true));
|
|
1007
|
+
const font = scene.theme.fontFamily;
|
|
1008
|
+
const body = scene.marks.filter((m) => !(hide && m.role === "selection")).map((m) => markToSvg(m, font)).join("");
|
|
1009
|
+
const label = options.ariaLabel;
|
|
1010
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${scene.width}" height="${scene.height}" viewBox="0 0 ${scene.width} ${scene.height}" role="img"${label ? ` aria-label="${escape(label)}"` : ""}>${body}</svg>`;
|
|
1011
|
+
}
|
|
1012
|
+
function chartToSvg(spec, options) {
|
|
1013
|
+
const scene = layoutChart(spec, { ...options, selection: null });
|
|
1014
|
+
return chartSceneToSvg(scene, { ...options, ariaLabel: _nullishCoalesce(options.ariaLabel, () => ( (spec.title || void 0))) });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/chart/controller.ts
|
|
1018
|
+
var fail = (message, issues) => ({ ok: false, message, ...issues ? { issues } : {} });
|
|
1019
|
+
var ChartController = (_class = class {
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
__init() {this.selection = null}
|
|
1023
|
+
__init2() {this.issues = []}
|
|
1024
|
+
__init3() {this.gestureKey = null}
|
|
1025
|
+
__init4() {this.gestureCount = 0}
|
|
1026
|
+
__init5() {this.revision = 0}
|
|
1027
|
+
__init6() {this.applying = false}
|
|
1028
|
+
|
|
1029
|
+
__init7() {this.listeners = /* @__PURE__ */ new Set()}
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
constructor(options = {}) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);_class.prototype.__init7.call(this);_class.prototype.__init8.call(this);_class.prototype.__init9.call(this);
|
|
1033
|
+
this.history = _nullishCoalesce(options.history, () => ( new (0, _chunkQKLRAA4Tcjs.HistoryManager)()));
|
|
1034
|
+
this.onChange = options.onChange;
|
|
1035
|
+
const { spec, issues } = normalizeChartSpec(_nullishCoalesce(options.spec, () => ( {})));
|
|
1036
|
+
this.spec = spec;
|
|
1037
|
+
this.issues = issues;
|
|
1038
|
+
this.state = this.computeState();
|
|
1039
|
+
this.connect();
|
|
1040
|
+
}
|
|
1041
|
+
connect() {
|
|
1042
|
+
_optionalChain([this, 'access', _13 => _13.unsubscribeHistory, 'optionalCall', _14 => _14()]);
|
|
1043
|
+
this.unsubscribeHistory = this.history.subscribe((h) => {
|
|
1044
|
+
const s = this.state;
|
|
1045
|
+
if (this.applying || s.canUndo === h.canUndo && s.canRedo === h.canRedo && s.undoLabel === h.undoName && s.redoLabel === h.redoName) return;
|
|
1046
|
+
this.notify(false);
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
// -------------------------------------------------------------------------
|
|
1050
|
+
// State
|
|
1051
|
+
// -------------------------------------------------------------------------
|
|
1052
|
+
/** The current snapshot (stable until the next change). */
|
|
1053
|
+
__init8() {this.getState = () => this.state}
|
|
1054
|
+
/** Calls `listener` after every change. Returns an unsubscribe function. */
|
|
1055
|
+
__init9() {this.subscribe = (listener) => {
|
|
1056
|
+
if (!this.unsubscribeHistory) this.connect();
|
|
1057
|
+
this.listeners.add(listener);
|
|
1058
|
+
return () => {
|
|
1059
|
+
this.listeners.delete(listener);
|
|
1060
|
+
};
|
|
1061
|
+
}}
|
|
1062
|
+
/** The current spec (do not mutate; edit through the controller). */
|
|
1063
|
+
getSpec() {
|
|
1064
|
+
return this.spec;
|
|
1065
|
+
}
|
|
1066
|
+
getSeries(id) {
|
|
1067
|
+
return this.spec.series.find((s) => s.id === id);
|
|
1068
|
+
}
|
|
1069
|
+
computeState() {
|
|
1070
|
+
const h = this.history.getState();
|
|
1071
|
+
const sel = this.selection;
|
|
1072
|
+
const seriesId = _optionalChain([sel, 'optionalAccess', _15 => _15.kind]) === "series" || _optionalChain([sel, 'optionalAccess', _16 => _16.kind]) === "point" ? sel.seriesId : null;
|
|
1073
|
+
return {
|
|
1074
|
+
spec: this.spec,
|
|
1075
|
+
selection: sel,
|
|
1076
|
+
selectedSeries: seriesId ? _nullishCoalesce(this.spec.series.find((s) => s.id === seriesId), () => ( null)) : null,
|
|
1077
|
+
selectedIndex: _optionalChain([sel, 'optionalAccess', _17 => _17.kind]) === "point" || _optionalChain([sel, 'optionalAccess', _18 => _18.kind]) === "category" ? sel.index : null,
|
|
1078
|
+
issues: this.issues,
|
|
1079
|
+
gesture: this.gestureKey,
|
|
1080
|
+
canUndo: h.canUndo,
|
|
1081
|
+
canRedo: h.canRedo,
|
|
1082
|
+
undoLabel: h.undoName,
|
|
1083
|
+
redoLabel: h.redoName,
|
|
1084
|
+
revision: this.revision
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
notify(specChanged) {
|
|
1088
|
+
this.revision++;
|
|
1089
|
+
this.state = this.computeState();
|
|
1090
|
+
if (specChanged) _optionalChain([this, 'access', _19 => _19.onChange, 'optionalCall', _20 => _20(this.spec)]);
|
|
1091
|
+
const state = this.state;
|
|
1092
|
+
this.listeners.forEach((listener) => {
|
|
1093
|
+
try {
|
|
1094
|
+
listener(state);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
console.error("[ChartController] Error in state listener:", error);
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
// -------------------------------------------------------------------------
|
|
1101
|
+
// Changes: every edit records a before / after snapshot of the spec
|
|
1102
|
+
// -------------------------------------------------------------------------
|
|
1103
|
+
/** Drops a selection that no longer points at anything. */
|
|
1104
|
+
validSelection(sel, spec) {
|
|
1105
|
+
if (!sel) return null;
|
|
1106
|
+
switch (sel.kind) {
|
|
1107
|
+
case "series":
|
|
1108
|
+
return spec.series.some((s) => s.id === sel.seriesId) ? sel : null;
|
|
1109
|
+
case "point":
|
|
1110
|
+
return spec.series.some((s) => s.id === sel.seriesId) && sel.index < spec.categories.length ? sel : null;
|
|
1111
|
+
case "category":
|
|
1112
|
+
return sel.index < spec.categories.length ? sel : null;
|
|
1113
|
+
case "title":
|
|
1114
|
+
return spec.title ? sel : null;
|
|
1115
|
+
case "subtitle":
|
|
1116
|
+
return spec.subtitle ? sel : null;
|
|
1117
|
+
default:
|
|
1118
|
+
return sel;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
applySpec(spec) {
|
|
1122
|
+
this.applying = true;
|
|
1123
|
+
try {
|
|
1124
|
+
this.spec = spec;
|
|
1125
|
+
this.selection = this.validSelection(this.selection, spec);
|
|
1126
|
+
} finally {
|
|
1127
|
+
this.applying = false;
|
|
1128
|
+
}
|
|
1129
|
+
this.notify(true);
|
|
1130
|
+
}
|
|
1131
|
+
commit(label, next, select) {
|
|
1132
|
+
const before = this.spec;
|
|
1133
|
+
this.issues = [];
|
|
1134
|
+
const command = {
|
|
1135
|
+
name: label,
|
|
1136
|
+
tag: "chart",
|
|
1137
|
+
execute: () => this.applySpec(next),
|
|
1138
|
+
undo: () => this.applySpec(before),
|
|
1139
|
+
redo: () => this.applySpec(next)
|
|
1140
|
+
};
|
|
1141
|
+
if (select !== void 0) this.selection = select;
|
|
1142
|
+
if (this.gestureKey !== null) this.history.executeMerged(command, { key: `chart.gesture:${this.gestureKey}:${this.gestureCount}` });
|
|
1143
|
+
else this.history.execute(command);
|
|
1144
|
+
}
|
|
1145
|
+
reject(message, issues = []) {
|
|
1146
|
+
this.issues = issues.length ? issues : [{ path: "", message }];
|
|
1147
|
+
this.notify(false);
|
|
1148
|
+
return fail(message, issues.length ? issues : void 0);
|
|
1149
|
+
}
|
|
1150
|
+
/** A copy of the spec with `edit` applied (series and categories copied as needed). */
|
|
1151
|
+
edited(edit) {
|
|
1152
|
+
const draft = cloneChartSpec(this.spec);
|
|
1153
|
+
edit(draft);
|
|
1154
|
+
return draft;
|
|
1155
|
+
}
|
|
1156
|
+
unknownSeries(id) {
|
|
1157
|
+
const ids = this.spec.series.map((s) => `"${s.id}"`);
|
|
1158
|
+
return fail(`There is no series "${id}"; ${ids.length ? `expected one of ${ids.join(", ")}` : "the chart has no series yet"}.`);
|
|
1159
|
+
}
|
|
1160
|
+
badIndex(index) {
|
|
1161
|
+
const n = this.spec.categories.length;
|
|
1162
|
+
return fail(n ? `Category index ${index} is out of range; expected 0 to ${n - 1}.` : "The chart has no categories yet.");
|
|
1163
|
+
}
|
|
1164
|
+
// -------------------------------------------------------------------------
|
|
1165
|
+
// Presentation
|
|
1166
|
+
// -------------------------------------------------------------------------
|
|
1167
|
+
/**
|
|
1168
|
+
* Changes any presentation or data fields as one undo step. All-or-nothing: if anything
|
|
1169
|
+
* is invalid, nothing changes and every problem is returned (and shown in `state.issues`).
|
|
1170
|
+
*/
|
|
1171
|
+
update(patch, options = {}) {
|
|
1172
|
+
const s = this.spec;
|
|
1173
|
+
const merged = {
|
|
1174
|
+
...s,
|
|
1175
|
+
...patch,
|
|
1176
|
+
xAxis: patch.xAxis ? { ...s.xAxis, ...patch.xAxis } : s.xAxis,
|
|
1177
|
+
yAxis: patch.yAxis ? { ...s.yAxis, ...patch.yAxis } : s.yAxis,
|
|
1178
|
+
legend: patch.legend ? { ...s.legend, ...patch.legend } : s.legend,
|
|
1179
|
+
version: 1
|
|
1180
|
+
};
|
|
1181
|
+
const { spec, issues } = normalizeChartSpec(merged);
|
|
1182
|
+
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1183
|
+
if (JSON.stringify(spec) === JSON.stringify(s)) return { ok: true };
|
|
1184
|
+
this.commit(_nullishCoalesce(options.label, () => ( updateLabel(patch))), spec);
|
|
1185
|
+
return { ok: true };
|
|
1186
|
+
}
|
|
1187
|
+
setType(type) {
|
|
1188
|
+
return this.update({ type }, { label: `Change chart type` });
|
|
1189
|
+
}
|
|
1190
|
+
setTitle(title) {
|
|
1191
|
+
return this.update({ title }, { label: "Edit title" });
|
|
1192
|
+
}
|
|
1193
|
+
setSubtitle(subtitle) {
|
|
1194
|
+
return this.update({ subtitle }, { label: "Edit subtitle" });
|
|
1195
|
+
}
|
|
1196
|
+
/** Changes the category (`x`) or value (`y`) axis. */
|
|
1197
|
+
setAxis(axis, patch) {
|
|
1198
|
+
return this.update(axis === "x" ? { xAxis: patch } : { yAxis: patch }, { label: `Change ${axis === "x" ? "category" : "value"} axis` });
|
|
1199
|
+
}
|
|
1200
|
+
setLegend(patch) {
|
|
1201
|
+
return this.update({ legend: patch }, { label: "Change legend" });
|
|
1202
|
+
}
|
|
1203
|
+
// -------------------------------------------------------------------------
|
|
1204
|
+
// Data
|
|
1205
|
+
// -------------------------------------------------------------------------
|
|
1206
|
+
/**
|
|
1207
|
+
* Replaces categories and / or series as one undo step. Values are validated and padded
|
|
1208
|
+
* to the categories; all-or-nothing like `update`.
|
|
1209
|
+
*/
|
|
1210
|
+
setData(data, options = {}) {
|
|
1211
|
+
return this.update({ ...data.categories ? { categories: data.categories } : {}, ...data.series ? { series: data.series } : {} }, { label: _nullishCoalesce(options.label, () => ( "Edit chart data")) });
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Sets one value (numbers, numeric text such as `1,200` or `12%`, or blank for a gap).
|
|
1215
|
+
* One undo step, or part of the current gesture.
|
|
1216
|
+
*/
|
|
1217
|
+
setValue(seriesId, index, value, options = {}) {
|
|
1218
|
+
const series = this.getSeries(seriesId);
|
|
1219
|
+
if (!series) return this.unknownSeries(seriesId);
|
|
1220
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.spec.categories.length) return this.badIndex(index);
|
|
1221
|
+
const parsed = parseChartValue(value);
|
|
1222
|
+
if (parsed === void 0) return this.reject(`"${String(value)}" is not a number; enter a number or leave the cell blank.`, [{ path: `${seriesId}[${index}]`, message: `"${String(value)}" is not a number.` }]);
|
|
1223
|
+
if (series.values[index] === parsed) {
|
|
1224
|
+
if (this.issues.length) {
|
|
1225
|
+
this.issues = [];
|
|
1226
|
+
this.notify(false);
|
|
1227
|
+
}
|
|
1228
|
+
return { ok: true };
|
|
1229
|
+
}
|
|
1230
|
+
const next = this.edited((d) => {
|
|
1231
|
+
d.series.find((s) => s.id === seriesId).values[index] = parsed;
|
|
1232
|
+
});
|
|
1233
|
+
this.commit(_nullishCoalesce(options.label, () => ( `Edit ${series.name} \xB7 ${this.spec.categories[index]}`)), next);
|
|
1234
|
+
return { ok: true };
|
|
1235
|
+
}
|
|
1236
|
+
/** Several values as one undo step (all-or-nothing). */
|
|
1237
|
+
setValues(cells, options = {}) {
|
|
1238
|
+
const issues = [];
|
|
1239
|
+
const parsed = [];
|
|
1240
|
+
for (const cell of cells) {
|
|
1241
|
+
if (!this.getSeries(cell.seriesId)) issues.push({ path: cell.seriesId, message: this.unknownSeries(cell.seriesId).message });
|
|
1242
|
+
else if (!Number.isInteger(cell.index) || cell.index < 0 || cell.index >= this.spec.categories.length) issues.push({ path: `${cell.seriesId}[${cell.index}]`, message: this.badIndex(cell.index).message });
|
|
1243
|
+
else {
|
|
1244
|
+
const value = parseChartValue(cell.value);
|
|
1245
|
+
if (value === void 0) issues.push({ path: `${cell.seriesId}[${cell.index}]`, message: `"${String(cell.value)}" is not a number.` });
|
|
1246
|
+
else parsed.push({ seriesId: cell.seriesId, index: cell.index, value });
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1250
|
+
const next = this.edited((d) => {
|
|
1251
|
+
for (const cell of parsed) d.series.find((s) => s.id === cell.seriesId).values[cell.index] = cell.value;
|
|
1252
|
+
});
|
|
1253
|
+
if (JSON.stringify(next.series) !== JSON.stringify(this.spec.series)) this.commit(_nullishCoalesce(options.label, () => ( "Edit values")), next);
|
|
1254
|
+
return { ok: true };
|
|
1255
|
+
}
|
|
1256
|
+
/** Adds a series (values padded to the categories) and selects it. Returns its id. */
|
|
1257
|
+
addSeries(input = {}, options = {}) {
|
|
1258
|
+
const id = input.id && !this.getSeries(input.id) ? input.id : uniqueChartSeriesId(this.spec.series);
|
|
1259
|
+
const name = _nullishCoalesce(input.name, () => ( `Series ${this.spec.series.length + 1}`));
|
|
1260
|
+
const at = Math.max(0, Math.min(this.spec.series.length, _nullishCoalesce(options.at, () => ( this.spec.series.length))));
|
|
1261
|
+
const series = [...this.spec.series];
|
|
1262
|
+
series.splice(at, 0, { ...input, id, name, values: _nullishCoalesce(input.values, () => ( [])) });
|
|
1263
|
+
const { spec, issues } = normalizeChartSpec({ ...this.spec, series });
|
|
1264
|
+
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1265
|
+
this.commit(`Add series "${name}"`, spec, { kind: "series", seriesId: id });
|
|
1266
|
+
return { ok: true, id };
|
|
1267
|
+
}
|
|
1268
|
+
removeSeries(id) {
|
|
1269
|
+
const series = this.getSeries(id);
|
|
1270
|
+
if (!series) return this.unknownSeries(id);
|
|
1271
|
+
this.commit(
|
|
1272
|
+
`Delete series "${series.name}"`,
|
|
1273
|
+
this.edited((d) => {
|
|
1274
|
+
d.series = d.series.filter((s) => s.id !== id);
|
|
1275
|
+
})
|
|
1276
|
+
);
|
|
1277
|
+
return { ok: true };
|
|
1278
|
+
}
|
|
1279
|
+
updateSeries(id, patch) {
|
|
1280
|
+
const series = this.getSeries(id);
|
|
1281
|
+
if (!series) return this.unknownSeries(id);
|
|
1282
|
+
const next = cloneChartSeries(series);
|
|
1283
|
+
if (patch.name !== void 0) next.name = String(patch.name);
|
|
1284
|
+
if (patch.color === null || patch.color === "") delete next.color;
|
|
1285
|
+
else if (patch.color !== void 0) next.color = patch.color;
|
|
1286
|
+
if (patch.type === null) delete next.type;
|
|
1287
|
+
else if (patch.type !== void 0) next.type = patch.type;
|
|
1288
|
+
if (patch.hidden !== void 0) {
|
|
1289
|
+
if (patch.hidden) next.hidden = true;
|
|
1290
|
+
else delete next.hidden;
|
|
1291
|
+
}
|
|
1292
|
+
const series2 = this.spec.series.map((s) => s.id === id ? next : s);
|
|
1293
|
+
const { spec, issues } = normalizeChartSpec({ ...this.spec, series: series2 });
|
|
1294
|
+
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1295
|
+
if (JSON.stringify(spec.series) === JSON.stringify(this.spec.series)) return { ok: true };
|
|
1296
|
+
const label = patch.hidden !== void 0 && Object.keys(patch).length === 1 ? `${patch.hidden ? "Hide" : "Show"} "${series.name}"` : patch.name !== void 0 && Object.keys(patch).length === 1 ? "Rename series" : `Change series "${series.name}"`;
|
|
1297
|
+
this.commit(label, spec);
|
|
1298
|
+
return { ok: true };
|
|
1299
|
+
}
|
|
1300
|
+
toggleSeries(id) {
|
|
1301
|
+
const series = this.getSeries(id);
|
|
1302
|
+
if (!series) return this.unknownSeries(id);
|
|
1303
|
+
return this.updateSeries(id, { hidden: !series.hidden });
|
|
1304
|
+
}
|
|
1305
|
+
/** Moves a series to position `to` (drawing and legend order). */
|
|
1306
|
+
moveSeries(id, to) {
|
|
1307
|
+
const from = this.spec.series.findIndex((s) => s.id === id);
|
|
1308
|
+
if (from < 0) return this.unknownSeries(id);
|
|
1309
|
+
const target = Math.max(0, Math.min(this.spec.series.length - 1, to));
|
|
1310
|
+
if (target === from) return { ok: true };
|
|
1311
|
+
this.commit(
|
|
1312
|
+
"Reorder series",
|
|
1313
|
+
this.edited((d) => {
|
|
1314
|
+
const [s] = d.series.splice(from, 1);
|
|
1315
|
+
d.series.splice(target, 0, s);
|
|
1316
|
+
})
|
|
1317
|
+
);
|
|
1318
|
+
return { ok: true };
|
|
1319
|
+
}
|
|
1320
|
+
/** Inserts a category (with gaps in every series) at `at` (default: the end). Returns its index. */
|
|
1321
|
+
addCategory(label, options = {}) {
|
|
1322
|
+
const at = Math.max(0, Math.min(this.spec.categories.length, _nullishCoalesce(options.at, () => ( this.spec.categories.length))));
|
|
1323
|
+
const name = _nullishCoalesce(label, () => ( `${this.spec.categories.length + 1}`));
|
|
1324
|
+
this.commit(
|
|
1325
|
+
`Add category "${name}"`,
|
|
1326
|
+
this.edited((d) => {
|
|
1327
|
+
d.categories.splice(at, 0, name);
|
|
1328
|
+
for (const s of d.series) s.values.splice(at, 0, null);
|
|
1329
|
+
}),
|
|
1330
|
+
{ kind: "category", index: at }
|
|
1331
|
+
);
|
|
1332
|
+
return { ok: true, index: at };
|
|
1333
|
+
}
|
|
1334
|
+
removeCategory(index) {
|
|
1335
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.spec.categories.length) return this.badIndex(index);
|
|
1336
|
+
this.commit(
|
|
1337
|
+
`Delete category "${this.spec.categories[index]}"`,
|
|
1338
|
+
this.edited((d) => {
|
|
1339
|
+
d.categories.splice(index, 1);
|
|
1340
|
+
for (const s of d.series) s.values.splice(index, 1);
|
|
1341
|
+
})
|
|
1342
|
+
);
|
|
1343
|
+
return { ok: true };
|
|
1344
|
+
}
|
|
1345
|
+
renameCategory(index, label) {
|
|
1346
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.spec.categories.length) return this.badIndex(index);
|
|
1347
|
+
if (this.spec.categories[index] === label) return { ok: true };
|
|
1348
|
+
this.commit(
|
|
1349
|
+
"Rename category",
|
|
1350
|
+
this.edited((d) => {
|
|
1351
|
+
d.categories[index] = String(label);
|
|
1352
|
+
})
|
|
1353
|
+
);
|
|
1354
|
+
return { ok: true };
|
|
1355
|
+
}
|
|
1356
|
+
moveCategory(from, to) {
|
|
1357
|
+
if (!Number.isInteger(from) || from < 0 || from >= this.spec.categories.length) return this.badIndex(from);
|
|
1358
|
+
const target = Math.max(0, Math.min(this.spec.categories.length - 1, to));
|
|
1359
|
+
if (target === from) return { ok: true };
|
|
1360
|
+
const move = (list) => {
|
|
1361
|
+
const [item] = list.splice(from, 1);
|
|
1362
|
+
list.splice(target, 0, item);
|
|
1363
|
+
};
|
|
1364
|
+
this.commit(
|
|
1365
|
+
"Reorder categories",
|
|
1366
|
+
this.edited((d) => {
|
|
1367
|
+
move(d.categories);
|
|
1368
|
+
for (const s of d.series) move(s.values);
|
|
1369
|
+
})
|
|
1370
|
+
);
|
|
1371
|
+
return { ok: true };
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Pastes a block of cells (TSV / CSV text or rows) into the data grid with its top-left at
|
|
1375
|
+
* `row` (category index) and `column` (series index; `-1` is the category column). The
|
|
1376
|
+
* grid grows to fit. Non-numeric value cells become gaps and are reported in `issues`.
|
|
1377
|
+
*/
|
|
1378
|
+
pasteCells(row, column, cells) {
|
|
1379
|
+
const rows = typeof cells === "string" ? cells.includes(" ") ? _chunkTNNJXF46cjs.parseTsv.call(void 0, cells) : _chunkTNNJXF46cjs.parseCsv.call(void 0, cells) : cells.map((r) => [...r]);
|
|
1380
|
+
if (!rows.length) return { ok: true };
|
|
1381
|
+
const issues = [];
|
|
1382
|
+
const next = this.edited((d) => {
|
|
1383
|
+
const width = Math.max(...rows.map((r) => r.length));
|
|
1384
|
+
const lastSeries = column + width - 1;
|
|
1385
|
+
while (d.series.length <= lastSeries) {
|
|
1386
|
+
const id = uniqueChartSeriesId(d.series);
|
|
1387
|
+
d.series.push({ id, name: `Series ${d.series.length + 1}`, values: d.categories.map(() => null) });
|
|
1388
|
+
}
|
|
1389
|
+
const lastRow = row + rows.length - 1;
|
|
1390
|
+
while (d.categories.length <= lastRow) {
|
|
1391
|
+
d.categories.push(`${d.categories.length + 1}`);
|
|
1392
|
+
for (const s of d.series) s.values.push(null);
|
|
1393
|
+
}
|
|
1394
|
+
rows.forEach((cellsInRow, r) => {
|
|
1395
|
+
cellsInRow.forEach((cell, c) => {
|
|
1396
|
+
const col = column + c;
|
|
1397
|
+
const index = row + r;
|
|
1398
|
+
if (col < 0) d.categories[index] = cell === null || cell === void 0 ? "" : String(cell).trim();
|
|
1399
|
+
else {
|
|
1400
|
+
const parsed = parseChartValue(cell);
|
|
1401
|
+
if (parsed === void 0) issues.push({ path: `${d.series[col].id}[${index}]`, message: `"${String(cell)}" is not a number; it was left blank.` });
|
|
1402
|
+
d.series[col].values[index] = _nullishCoalesce(parsed, () => ( null));
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
});
|
|
1406
|
+
});
|
|
1407
|
+
this.commit("Paste cells", next);
|
|
1408
|
+
if (issues.length) {
|
|
1409
|
+
this.issues = issues;
|
|
1410
|
+
this.notify(false);
|
|
1411
|
+
}
|
|
1412
|
+
return issues.length ? { ok: true, issues } : { ok: true };
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Replaces the data with a grid of cells (see `chartDataFromTable`): one undo step. Series
|
|
1416
|
+
* colours and types are kept by name.
|
|
1417
|
+
*/
|
|
1418
|
+
importTable(rows, options = {}) {
|
|
1419
|
+
const data = chartDataFromTable(rows, options);
|
|
1420
|
+
if (!data.categories.length || !data.series.length) return this.reject("There is no data to import: expected a header row and at least one row of values.");
|
|
1421
|
+
const byName = new Map(this.spec.series.map((s) => [s.name, s]));
|
|
1422
|
+
const series = data.series.map((s) => {
|
|
1423
|
+
const old = byName.get(s.name);
|
|
1424
|
+
return { ...s, ..._optionalChain([old, 'optionalAccess', _21 => _21.color]) ? { color: old.color } : {}, ..._optionalChain([old, 'optionalAccess', _22 => _22.type]) ? { type: old.type } : {}, ...old ? { id: old.id } : {} };
|
|
1425
|
+
});
|
|
1426
|
+
const { spec } = normalizeChartSpec({ ...this.spec, categories: data.categories, series });
|
|
1427
|
+
this.commit("Import data", spec);
|
|
1428
|
+
if (data.issues.length) {
|
|
1429
|
+
this.issues = data.issues;
|
|
1430
|
+
this.notify(false);
|
|
1431
|
+
return { ok: true, issues: data.issues };
|
|
1432
|
+
}
|
|
1433
|
+
return { ok: true };
|
|
1434
|
+
}
|
|
1435
|
+
/** Imports CSV (or TSV, detected by tabs) text; see `importTable`. */
|
|
1436
|
+
importCsv(text2, options = {}) {
|
|
1437
|
+
const delimiter = _nullishCoalesce(options.delimiter, () => ( (text2.includes(" ") ? " " : ",")));
|
|
1438
|
+
return this.importTable(_chunkTNNJXF46cjs.parseCsv.call(void 0, text2, { delimiter }), options);
|
|
1439
|
+
}
|
|
1440
|
+
// -------------------------------------------------------------------------
|
|
1441
|
+
// Selection and gestures
|
|
1442
|
+
// -------------------------------------------------------------------------
|
|
1443
|
+
/** Selects a chart element (view state; not an undo step). */
|
|
1444
|
+
select(selection) {
|
|
1445
|
+
const next = this.validSelection(selection, this.spec);
|
|
1446
|
+
if (sameChartSelection(next, this.selection) || next === null && this.selection === null) return;
|
|
1447
|
+
this.selection = next;
|
|
1448
|
+
this.notify(false);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Deletes what is selected: a series or category is removed, a point becomes a gap, a
|
|
1452
|
+
* title is cleared, the legend or an axis is hidden.
|
|
1453
|
+
*/
|
|
1454
|
+
deleteSelection() {
|
|
1455
|
+
const sel = this.selection;
|
|
1456
|
+
if (!sel) return fail("Nothing is selected.");
|
|
1457
|
+
switch (sel.kind) {
|
|
1458
|
+
case "series":
|
|
1459
|
+
return this.removeSeries(sel.seriesId);
|
|
1460
|
+
case "category":
|
|
1461
|
+
return this.removeCategory(sel.index);
|
|
1462
|
+
case "point":
|
|
1463
|
+
return this.setValue(sel.seriesId, sel.index, null, { label: "Clear value" });
|
|
1464
|
+
case "title":
|
|
1465
|
+
return this.setTitle("");
|
|
1466
|
+
case "subtitle":
|
|
1467
|
+
return this.setSubtitle("");
|
|
1468
|
+
case "legend":
|
|
1469
|
+
return this.setLegend({ show: false });
|
|
1470
|
+
case "axis":
|
|
1471
|
+
return this.setAxis(sel.axis, { show: false });
|
|
1472
|
+
default:
|
|
1473
|
+
return fail("The chart itself cannot be deleted.");
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/** Adds `delta` to a value (a gap counts as 0). */
|
|
1477
|
+
nudgeValue(seriesId, index, delta) {
|
|
1478
|
+
const series = this.getSeries(seriesId);
|
|
1479
|
+
if (!series) return this.unknownSeries(seriesId);
|
|
1480
|
+
const current = _nullishCoalesce(series.values[index], () => ( 0));
|
|
1481
|
+
return this.setValue(seriesId, index, Number((current + delta).toPrecision(12)), { label: `Edit ${series.name} \xB7 ${_nullishCoalesce(this.spec.categories[index], () => ( index))}` });
|
|
1482
|
+
}
|
|
1483
|
+
/** Starts a continuous edit (a drag): every change until `endGesture()` is one undo step. */
|
|
1484
|
+
beginGesture(key) {
|
|
1485
|
+
if (this.gestureKey === key) return;
|
|
1486
|
+
this.history.endGesture();
|
|
1487
|
+
this.gestureKey = key;
|
|
1488
|
+
this.gestureCount++;
|
|
1489
|
+
this.notify(false);
|
|
1490
|
+
}
|
|
1491
|
+
endGesture() {
|
|
1492
|
+
if (this.gestureKey === null) return;
|
|
1493
|
+
this.gestureKey = null;
|
|
1494
|
+
this.history.endGesture();
|
|
1495
|
+
this.notify(false);
|
|
1496
|
+
}
|
|
1497
|
+
// -------------------------------------------------------------------------
|
|
1498
|
+
// Persistence and history
|
|
1499
|
+
// -------------------------------------------------------------------------
|
|
1500
|
+
/** The spec, for saving (a copy). */
|
|
1501
|
+
toJSON() {
|
|
1502
|
+
return cloneChartSpec(this.spec);
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Replaces the chart without an undo step (e.g. when opening a document). Problems are
|
|
1506
|
+
* fixed with defaults and reported in `state.issues` (and returned).
|
|
1507
|
+
*/
|
|
1508
|
+
load(input, options = {}) {
|
|
1509
|
+
const { spec, issues } = normalizeChartSpec(input);
|
|
1510
|
+
this.spec = spec;
|
|
1511
|
+
this.issues = issues;
|
|
1512
|
+
this.selection = this.validSelection(this.selection, spec);
|
|
1513
|
+
if (options.clearHistory) {
|
|
1514
|
+
this.applying = true;
|
|
1515
|
+
try {
|
|
1516
|
+
this.history.clear();
|
|
1517
|
+
} finally {
|
|
1518
|
+
this.applying = false;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
this.notify(true);
|
|
1522
|
+
return issues;
|
|
1523
|
+
}
|
|
1524
|
+
undo() {
|
|
1525
|
+
this.endGesture();
|
|
1526
|
+
return this.history.undo();
|
|
1527
|
+
}
|
|
1528
|
+
redo() {
|
|
1529
|
+
this.endGesture();
|
|
1530
|
+
return this.history.redo();
|
|
1531
|
+
}
|
|
1532
|
+
/** Stops listening to the history (reconnected by the next `subscribe`). */
|
|
1533
|
+
destroy() {
|
|
1534
|
+
_optionalChain([this, 'access', _23 => _23.unsubscribeHistory, 'optionalCall', _24 => _24()]);
|
|
1535
|
+
this.unsubscribeHistory = void 0;
|
|
1536
|
+
this.listeners.clear();
|
|
1537
|
+
}
|
|
1538
|
+
}, _class);
|
|
1539
|
+
function createChartController(options = {}) {
|
|
1540
|
+
return new ChartController(options);
|
|
1541
|
+
}
|
|
1542
|
+
function updateLabel(patch) {
|
|
1543
|
+
const keys = Object.keys(patch);
|
|
1544
|
+
if (keys.length !== 1) return "Change chart";
|
|
1545
|
+
const labels = {
|
|
1546
|
+
type: "Change chart type",
|
|
1547
|
+
title: "Edit title",
|
|
1548
|
+
subtitle: "Edit subtitle",
|
|
1549
|
+
categories: "Edit categories",
|
|
1550
|
+
series: "Edit series",
|
|
1551
|
+
xAxis: "Change category axis",
|
|
1552
|
+
yAxis: "Change value axis",
|
|
1553
|
+
legend: "Change legend",
|
|
1554
|
+
stacking: "Change stacking",
|
|
1555
|
+
orientation: "Change orientation",
|
|
1556
|
+
curve: "Change line style",
|
|
1557
|
+
points: "Toggle points",
|
|
1558
|
+
dataLabels: "Toggle data labels",
|
|
1559
|
+
palette: "Change colours",
|
|
1560
|
+
innerRadius: "Change donut hole"
|
|
1561
|
+
};
|
|
1562
|
+
return _nullishCoalesce(labels[keys[0]], () => ( "Change chart"));
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// src/chart/commands.ts
|
|
1566
|
+
var fail2 = (message) => {
|
|
1567
|
+
throw new Error(message);
|
|
1568
|
+
};
|
|
1569
|
+
var check = (result) => {
|
|
1570
|
+
if (!result.ok) fail2(_nullishCoalesce(result.message, () => ( "The change was rejected.")));
|
|
1571
|
+
};
|
|
1572
|
+
var VALUE = { type: ["number", "string", "null"], description: 'A number, numeric text ("1,200", "12%") or null for a gap.' };
|
|
1573
|
+
var FORMAT = {
|
|
1574
|
+
type: "object",
|
|
1575
|
+
properties: {
|
|
1576
|
+
style: { type: "string", enum: [...CHART_NUMBER_STYLES] },
|
|
1577
|
+
decimals: { type: "integer", minimum: 0, maximum: 10 },
|
|
1578
|
+
currency: { type: "string", description: 'ISO code for style "currency", e.g. "EUR".' },
|
|
1579
|
+
prefix: { type: "string" },
|
|
1580
|
+
suffix: { type: "string" }
|
|
1581
|
+
},
|
|
1582
|
+
additionalProperties: false
|
|
1583
|
+
};
|
|
1584
|
+
var AXIS = {
|
|
1585
|
+
type: "object",
|
|
1586
|
+
properties: {
|
|
1587
|
+
show: { type: "boolean" },
|
|
1588
|
+
title: { type: "string" },
|
|
1589
|
+
min: { type: ["number", "null"], description: "Fixed lower bound, or null for automatic." },
|
|
1590
|
+
max: { type: ["number", "null"], description: "Fixed upper bound, or null for automatic." },
|
|
1591
|
+
gridlines: { type: "boolean" },
|
|
1592
|
+
format: FORMAT
|
|
1593
|
+
},
|
|
1594
|
+
additionalProperties: false
|
|
1595
|
+
};
|
|
1596
|
+
var SERIES = {
|
|
1597
|
+
type: "object",
|
|
1598
|
+
properties: {
|
|
1599
|
+
id: { type: "string" },
|
|
1600
|
+
name: { type: "string" },
|
|
1601
|
+
values: { type: "array", items: VALUE, description: "One value per category." },
|
|
1602
|
+
color: { type: "string", description: "CSS colour." },
|
|
1603
|
+
type: { type: "string", enum: [...CHART_SERIES_TYPES], description: "Draw this series differently (combo charts)." },
|
|
1604
|
+
hidden: { type: "boolean" }
|
|
1605
|
+
},
|
|
1606
|
+
required: ["name", "values"],
|
|
1607
|
+
additionalProperties: false
|
|
1608
|
+
};
|
|
1609
|
+
function createChartCommands(controller, options = {}) {
|
|
1610
|
+
const prefix = _nullishCoalesce(options.prefix, () => ( "chart."));
|
|
1611
|
+
const base = { group: _nullishCoalesce(options.group, () => ( "Chart")), ...options.scope ? { scope: options.scope } : {} };
|
|
1612
|
+
const seriesIds = () => controller.getSpec().series.map((s) => s.id);
|
|
1613
|
+
const resolveSeries = (ref) => {
|
|
1614
|
+
const spec = controller.getSpec();
|
|
1615
|
+
const series = _nullishCoalesce(spec.series.find((s) => s.id === ref), () => ( spec.series.find((s) => s.name === ref)));
|
|
1616
|
+
if (!series) fail2(`There is no series "${ref}"; expected one of ${seriesIds().map((id) => `"${id}"`).join(", ") || "(none yet)"}.`);
|
|
1617
|
+
return series;
|
|
1618
|
+
};
|
|
1619
|
+
const resolveCategory = (ref) => {
|
|
1620
|
+
const categories = controller.getSpec().categories;
|
|
1621
|
+
const index = typeof ref === "number" ? ref : categories.indexOf(ref);
|
|
1622
|
+
if (index < 0 || index >= categories.length || !Number.isInteger(index)) fail2(`There is no category ${JSON.stringify(ref)}; expected an index from 0 to ${categories.length - 1} or one of the labels.`);
|
|
1623
|
+
return index;
|
|
1624
|
+
};
|
|
1625
|
+
return [
|
|
1626
|
+
{
|
|
1627
|
+
...base,
|
|
1628
|
+
id: `${prefix}describe`,
|
|
1629
|
+
label: "Describe Chart",
|
|
1630
|
+
readOnly: true,
|
|
1631
|
+
keywords: ["chart", "data", "spec", "get"],
|
|
1632
|
+
description: "Returns the chart spec (type, titles, axes, legend, options) and its data as a table with series names in the header row.",
|
|
1633
|
+
run: () => {
|
|
1634
|
+
const spec = controller.getSpec();
|
|
1635
|
+
return { spec: controller.toJSON(), table: chartDataToTable(spec), issues: controller.getState().issues };
|
|
1636
|
+
}
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
...base,
|
|
1640
|
+
id: `${prefix}update`,
|
|
1641
|
+
label: "Change Chart\u2026",
|
|
1642
|
+
keywords: ["chart", "title", "axis", "legend", "stack", "style", "format"],
|
|
1643
|
+
description: "Changes chart presentation as one undo step: type, titles, axes (x = categories, y = values), legend, stacking, orientation, curve, points, data labels, palette or donut hole. Invalid values change nothing and return why.",
|
|
1644
|
+
params: {
|
|
1645
|
+
type: "object",
|
|
1646
|
+
properties: {
|
|
1647
|
+
type: { type: "string", enum: [...CHART_TYPES] },
|
|
1648
|
+
title: { type: "string" },
|
|
1649
|
+
subtitle: { type: "string" },
|
|
1650
|
+
xAxis: AXIS,
|
|
1651
|
+
yAxis: AXIS,
|
|
1652
|
+
legend: { type: "object", properties: { show: { type: "boolean" }, position: { type: "string", enum: [...CHART_LEGEND_POSITIONS] } }, additionalProperties: false },
|
|
1653
|
+
stacking: { type: "string", enum: [...CHART_STACKINGS] },
|
|
1654
|
+
orientation: { type: "string", enum: [...CHART_ORIENTATIONS] },
|
|
1655
|
+
curve: { type: "string", enum: [...CHART_CURVES] },
|
|
1656
|
+
points: { type: "boolean" },
|
|
1657
|
+
dataLabels: { type: "boolean" },
|
|
1658
|
+
palette: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
1659
|
+
innerRadius: { type: "number", minimum: 0, maximum: 0.9 }
|
|
1660
|
+
},
|
|
1661
|
+
additionalProperties: false
|
|
1662
|
+
},
|
|
1663
|
+
run: (_ctx, params) => {
|
|
1664
|
+
check(controller.update(params));
|
|
1665
|
+
return { spec: controller.toJSON() };
|
|
1666
|
+
}
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
...base,
|
|
1670
|
+
id: `${prefix}setData`,
|
|
1671
|
+
label: "Replace Chart Data\u2026",
|
|
1672
|
+
keywords: ["chart", "data", "series", "categories"],
|
|
1673
|
+
description: "Replaces the categories and series as one undo step. Each series has one value per category (padded with gaps).",
|
|
1674
|
+
params: {
|
|
1675
|
+
type: "object",
|
|
1676
|
+
properties: {
|
|
1677
|
+
categories: { type: "array", items: { type: "string" } },
|
|
1678
|
+
series: { type: "array", items: SERIES }
|
|
1679
|
+
},
|
|
1680
|
+
additionalProperties: false
|
|
1681
|
+
},
|
|
1682
|
+
run: (_ctx, params) => {
|
|
1683
|
+
check(controller.setData(params));
|
|
1684
|
+
return { table: chartDataToTable(controller.getSpec()) };
|
|
1685
|
+
}
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
...base,
|
|
1689
|
+
id: `${prefix}setValues`,
|
|
1690
|
+
label: "Set Chart Values\u2026",
|
|
1691
|
+
keywords: ["chart", "value", "cell", "edit"],
|
|
1692
|
+
description: "Sets individual values as one undo step. Series by id or name; category by index or label.",
|
|
1693
|
+
params: {
|
|
1694
|
+
type: "object",
|
|
1695
|
+
properties: {
|
|
1696
|
+
cells: {
|
|
1697
|
+
type: "array",
|
|
1698
|
+
minItems: 1,
|
|
1699
|
+
items: {
|
|
1700
|
+
type: "object",
|
|
1701
|
+
properties: { series: { type: "string" }, category: { type: ["string", "integer"] }, value: VALUE },
|
|
1702
|
+
required: ["series", "category", "value"],
|
|
1703
|
+
additionalProperties: false
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
},
|
|
1707
|
+
required: ["cells"]
|
|
1708
|
+
},
|
|
1709
|
+
run: (_ctx, params) => {
|
|
1710
|
+
check(controller.setValues(params.cells.map((c) => ({ seriesId: resolveSeries(c.series).id, index: resolveCategory(c.category), value: c.value }))));
|
|
1711
|
+
return { table: chartDataToTable(controller.getSpec()) };
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
{
|
|
1715
|
+
...base,
|
|
1716
|
+
id: `${prefix}addSeries`,
|
|
1717
|
+
label: "Add Series",
|
|
1718
|
+
keywords: ["chart", "series", "new"],
|
|
1719
|
+
description: "Adds a series (default: an empty one at the end). Returns its id.",
|
|
1720
|
+
params: { type: "object", properties: { ...SERIES.properties, at: { type: "integer", minimum: 0 } }, additionalProperties: false },
|
|
1721
|
+
run: (_ctx, params = {}) => {
|
|
1722
|
+
const { at, ...input } = _nullishCoalesce(params, () => ( {}));
|
|
1723
|
+
const result = controller.addSeries(input, { at });
|
|
1724
|
+
check(result);
|
|
1725
|
+
return { id: result.id };
|
|
1726
|
+
}
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
...base,
|
|
1730
|
+
id: `${prefix}updateSeries`,
|
|
1731
|
+
label: "Change Series\u2026",
|
|
1732
|
+
keywords: ["chart", "series", "rename", "colour", "color", "hide"],
|
|
1733
|
+
description: "Renames, recolours, hides or changes how a series is drawn (null colour / type resets it).",
|
|
1734
|
+
params: {
|
|
1735
|
+
type: "object",
|
|
1736
|
+
properties: {
|
|
1737
|
+
series: { type: "string", description: "Series id or name." },
|
|
1738
|
+
name: { type: "string" },
|
|
1739
|
+
color: { type: ["string", "null"] },
|
|
1740
|
+
type: { type: ["string", "null"], enum: [...CHART_SERIES_TYPES, null] },
|
|
1741
|
+
hidden: { type: "boolean" }
|
|
1742
|
+
},
|
|
1743
|
+
required: ["series"],
|
|
1744
|
+
additionalProperties: false
|
|
1745
|
+
},
|
|
1746
|
+
run: (_ctx, params) => {
|
|
1747
|
+
const { series, ...patch } = params;
|
|
1748
|
+
const id = resolveSeries(series).id;
|
|
1749
|
+
check(controller.updateSeries(id, patch));
|
|
1750
|
+
return { series: controller.getSeries(id) };
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
...base,
|
|
1755
|
+
id: `${prefix}removeSeries`,
|
|
1756
|
+
label: "Delete Series",
|
|
1757
|
+
keywords: ["chart", "series", "remove"],
|
|
1758
|
+
description: "Deletes a series by id or name.",
|
|
1759
|
+
params: { type: "object", properties: { series: { type: "string" } }, required: ["series"], additionalProperties: false },
|
|
1760
|
+
run: (_ctx, params) => {
|
|
1761
|
+
check(controller.removeSeries(resolveSeries(params.series).id));
|
|
1762
|
+
return { series: seriesIds() };
|
|
1763
|
+
}
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
...base,
|
|
1767
|
+
id: `${prefix}addCategory`,
|
|
1768
|
+
label: "Add Category",
|
|
1769
|
+
keywords: ["chart", "category", "row", "label"],
|
|
1770
|
+
description: "Inserts a category (gaps in every series) at `at` (default: the end).",
|
|
1771
|
+
params: { type: "object", properties: { label: { type: "string" }, at: { type: "integer", minimum: 0 } }, additionalProperties: false },
|
|
1772
|
+
run: (_ctx, params = {}) => controller.addCategory(_optionalChain([params, 'optionalAccess', _25 => _25.label]), { at: _optionalChain([params, 'optionalAccess', _26 => _26.at]) })
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
...base,
|
|
1776
|
+
id: `${prefix}removeCategory`,
|
|
1777
|
+
label: "Delete Category",
|
|
1778
|
+
keywords: ["chart", "category", "row", "remove"],
|
|
1779
|
+
description: "Deletes a category (and its values) by index or label.",
|
|
1780
|
+
params: { type: "object", properties: { category: { type: ["string", "integer"] } }, required: ["category"], additionalProperties: false },
|
|
1781
|
+
run: (_ctx, params) => {
|
|
1782
|
+
check(controller.removeCategory(resolveCategory(params.category)));
|
|
1783
|
+
return { categories: controller.getSpec().categories };
|
|
1784
|
+
}
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
...base,
|
|
1788
|
+
id: `${prefix}importCsv`,
|
|
1789
|
+
label: "Import Chart Data from CSV\u2026",
|
|
1790
|
+
keywords: ["chart", "csv", "tsv", "paste", "import"],
|
|
1791
|
+
description: "Replaces the data with CSV / TSV text: by default the first column holds categories and the first row series names.",
|
|
1792
|
+
params: {
|
|
1793
|
+
type: "object",
|
|
1794
|
+
properties: {
|
|
1795
|
+
csv: { type: "string" },
|
|
1796
|
+
seriesIn: { type: "string", enum: ["columns", "rows"] },
|
|
1797
|
+
header: { type: "boolean" }
|
|
1798
|
+
},
|
|
1799
|
+
required: ["csv"],
|
|
1800
|
+
additionalProperties: false
|
|
1801
|
+
},
|
|
1802
|
+
run: (_ctx, params) => {
|
|
1803
|
+
const result = controller.importCsv(params.csv, { seriesIn: params.seriesIn, header: params.header });
|
|
1804
|
+
check(result);
|
|
1805
|
+
return { table: chartDataToTable(controller.getSpec()), issues: _nullishCoalesce(result.issues, () => ( [])) };
|
|
1806
|
+
}
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
...base,
|
|
1810
|
+
id: `${prefix}exportSvg`,
|
|
1811
|
+
label: "Export Chart as SVG",
|
|
1812
|
+
readOnly: true,
|
|
1813
|
+
keywords: ["chart", "svg", "export", "image"],
|
|
1814
|
+
description: "Returns the chart drawn as a standalone SVG string.",
|
|
1815
|
+
params: { type: "object", properties: { width: { type: "number", minimum: 50 }, height: { type: "number", minimum: 50 } }, additionalProperties: false },
|
|
1816
|
+
run: (_ctx, params = {}) => ({
|
|
1817
|
+
svg: chartToSvg(controller.getSpec(), { width: _nullishCoalesce(_optionalChain([params, 'optionalAccess', _27 => _27.width]), () => ( 640)), height: _nullishCoalesce(_optionalChain([params, 'optionalAccess', _28 => _28.height]), () => ( 400)) })
|
|
1818
|
+
})
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
...base,
|
|
1822
|
+
id: `${prefix}undo`,
|
|
1823
|
+
label: "Undo Chart Edit",
|
|
1824
|
+
keywords: ["chart", "undo"],
|
|
1825
|
+
enabled: () => controller.getState().canUndo || "Nothing to undo",
|
|
1826
|
+
run: () => ({ undone: controller.undo() })
|
|
1827
|
+
},
|
|
1828
|
+
{
|
|
1829
|
+
...base,
|
|
1830
|
+
id: `${prefix}redo`,
|
|
1831
|
+
label: "Redo Chart Edit",
|
|
1832
|
+
keywords: ["chart", "redo"],
|
|
1833
|
+
enabled: () => controller.getState().canRedo || "Nothing to redo",
|
|
1834
|
+
run: () => ({ redone: controller.redo() })
|
|
1835
|
+
}
|
|
1836
|
+
];
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// src/chart/interaction.ts
|
|
1840
|
+
function chartClientToScene(scene, box, clientX, clientY) {
|
|
1841
|
+
const sx = box.width ? scene.width / box.width : 1;
|
|
1842
|
+
const sy = box.height ? scene.height / box.height : 1;
|
|
1843
|
+
return { x: (clientX - box.left) * sx, y: (clientY - box.top) * sy };
|
|
1844
|
+
}
|
|
1845
|
+
function canDragChartPoint(controller, scene, target) {
|
|
1846
|
+
if (_optionalChain([target, 'optionalAccess', _29 => _29.kind]) !== "point" || !scene.valueScale || scene.percent) return false;
|
|
1847
|
+
const spec = controller.getSpec();
|
|
1848
|
+
if (!isCartesianChart(spec.type)) return false;
|
|
1849
|
+
const series = controller.getSeries(target.seriesId);
|
|
1850
|
+
return !!series && !series.hidden && series.values[target.index] !== null;
|
|
1851
|
+
}
|
|
1852
|
+
function startChartPointDrag(controller, scene, target, start, options = {}) {
|
|
1853
|
+
if (!canDragChartPoint(controller, scene, target)) return null;
|
|
1854
|
+
const { seriesId, index } = target;
|
|
1855
|
+
const startValue = _nullishCoalesce(controller.getSeries(seriesId).values[index], () => ( 0));
|
|
1856
|
+
const startAt = chartValueAtPoint(scene, start);
|
|
1857
|
+
if (startAt === void 0) return null;
|
|
1858
|
+
controller.beginGesture(`drag:${seriesId}:${index}`);
|
|
1859
|
+
let ended = false;
|
|
1860
|
+
return {
|
|
1861
|
+
move(point) {
|
|
1862
|
+
if (ended) return;
|
|
1863
|
+
const at = chartValueAtPoint(scene, point);
|
|
1864
|
+
if (at === void 0) return;
|
|
1865
|
+
controller.setValue(seriesId, index, snapChartValue(scene, startValue + (at - startAt), options.step), { label: `Drag ${_nullishCoalesce(_optionalChain([controller, 'access', _30 => _30.getSeries, 'call', _31 => _31(seriesId), 'optionalAccess', _32 => _32.name]), () => ( "value"))}` });
|
|
1866
|
+
},
|
|
1867
|
+
end() {
|
|
1868
|
+
if (ended) return;
|
|
1869
|
+
ended = true;
|
|
1870
|
+
controller.endGesture();
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
function handleChartKey(controller, scene, event) {
|
|
1875
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
1876
|
+
const { selection, spec } = controller.getState();
|
|
1877
|
+
switch (event.key) {
|
|
1878
|
+
case "Escape":
|
|
1879
|
+
if (!selection) return false;
|
|
1880
|
+
controller.select(null);
|
|
1881
|
+
return true;
|
|
1882
|
+
case "Delete":
|
|
1883
|
+
case "Backspace":
|
|
1884
|
+
if (!selection || selection.kind === "chart") return false;
|
|
1885
|
+
controller.deleteSelection();
|
|
1886
|
+
return true;
|
|
1887
|
+
case "ArrowLeft":
|
|
1888
|
+
case "ArrowRight": {
|
|
1889
|
+
if (_optionalChain([selection, 'optionalAccess', _33 => _33.kind]) !== "point" && _optionalChain([selection, 'optionalAccess', _34 => _34.kind]) !== "category") return false;
|
|
1890
|
+
const n = spec.categories.length;
|
|
1891
|
+
const index = Math.max(0, Math.min(n - 1, selection.index + (event.key === "ArrowRight" ? 1 : -1)));
|
|
1892
|
+
controller.select({ ...selection, index });
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
case "ArrowUp":
|
|
1896
|
+
case "ArrowDown": {
|
|
1897
|
+
if (_optionalChain([selection, 'optionalAccess', _35 => _35.kind]) !== "point" || _optionalChain([scene, 'optionalAccess', _36 => _36.percent])) return false;
|
|
1898
|
+
const ticks = _nullishCoalesce(_optionalChain([scene, 'optionalAccess', _37 => _37.valueScale, 'optionalAccess', _38 => _38.ticks]), () => ( []));
|
|
1899
|
+
const step = ticks.length > 1 ? ticks[1] - ticks[0] : 1;
|
|
1900
|
+
const delta = (event.shiftKey ? step : step / 10) * (event.key === "ArrowUp" ? 1 : -1);
|
|
1901
|
+
controller.nudgeValue(selection.seriesId, selection.index, delta);
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1904
|
+
default:
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
function describeChartSelection(controller, selection = controller.getState().selection) {
|
|
1909
|
+
if (!selection) return "Nothing selected";
|
|
1910
|
+
const spec = controller.getSpec();
|
|
1911
|
+
switch (selection.kind) {
|
|
1912
|
+
case "chart":
|
|
1913
|
+
return "Chart";
|
|
1914
|
+
case "title":
|
|
1915
|
+
return "Title";
|
|
1916
|
+
case "subtitle":
|
|
1917
|
+
return "Subtitle";
|
|
1918
|
+
case "legend":
|
|
1919
|
+
return "Legend";
|
|
1920
|
+
case "axis":
|
|
1921
|
+
return selection.axis === "x" ? "Category axis" : "Value axis";
|
|
1922
|
+
case "series":
|
|
1923
|
+
return `Series "${_nullishCoalesce(_optionalChain([controller, 'access', _39 => _39.getSeries, 'call', _40 => _40(selection.seriesId), 'optionalAccess', _41 => _41.name]), () => ( selection.seriesId))}"`;
|
|
1924
|
+
case "category":
|
|
1925
|
+
return `Category "${_nullishCoalesce(spec.categories[selection.index], () => ( selection.index))}"`;
|
|
1926
|
+
case "point": {
|
|
1927
|
+
const series = controller.getSeries(selection.seriesId);
|
|
1928
|
+
const value = _optionalChain([series, 'optionalAccess', _42 => _42.values, 'access', _43 => _43[selection.index]]);
|
|
1929
|
+
return `${_nullishCoalesce(_optionalChain([series, 'optionalAccess', _44 => _44.name]), () => ( selection.seriesId))} \xB7 ${_nullishCoalesce(spec.categories[selection.index], () => ( selection.index))}: ${_nullishCoalesce(value, () => ( "blank"))}`;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
exports.CHART_CURVES = CHART_CURVES; exports.CHART_LEGEND_POSITIONS = CHART_LEGEND_POSITIONS; exports.CHART_NUMBER_STYLES = CHART_NUMBER_STYLES; exports.CHART_ORIENTATIONS = CHART_ORIENTATIONS; exports.CHART_SERIES_TYPES = CHART_SERIES_TYPES; exports.CHART_STACKINGS = CHART_STACKINGS; exports.CHART_TYPES = CHART_TYPES; exports.CHART_TYPE_LABELS = CHART_TYPE_LABELS; exports.CSS_VARIABLE_CHART_THEME = CSS_VARIABLE_CHART_THEME; exports.ChartController = ChartController; exports.DEFAULT_CHART_PALETTE = DEFAULT_CHART_PALETTE; exports.DEFAULT_CHART_THEME = DEFAULT_CHART_THEME; exports.canDragChartPoint = canDragChartPoint; exports.chartClientToScene = chartClientToScene; exports.chartColorInputValue = chartColorInputValue; exports.chartDataFromTable = chartDataFromTable; exports.chartDataToTable = chartDataToTable; exports.chartSceneToSvg = chartSceneToSvg; exports.chartSeriesColor = chartSeriesColor; exports.chartSeriesType = chartSeriesType; exports.chartSliceColor = chartSliceColor; exports.chartTextBaseline = chartTextBaseline; exports.chartTextTransform = chartTextTransform; exports.chartToSvg = chartToSvg; exports.chartValueAtPoint = chartValueAtPoint; exports.cloneChartSeries = cloneChartSeries; exports.cloneChartSpec = cloneChartSpec; exports.createChartCommands = createChartCommands; exports.createChartController = createChartController; exports.createChartSpec = createChartSpec; exports.defaultChartAxis = defaultChartAxis; exports.describeChartSelection = describeChartSelection; exports.estimateChartTextWidth = estimateChartTextWidth; exports.formatChartNumber = formatChartNumber; exports.handleChartKey = handleChartKey; exports.isCartesianChart = isCartesianChart; exports.layoutChart = layoutChart; exports.linearMap = linearMap; exports.niceScale = niceScale; exports.niceStep = niceStep; exports.normalizeChartAxis = normalizeChartAxis; exports.normalizeChartSpec = normalizeChartSpec; exports.normalizeChartValues = normalizeChartValues; exports.parseChartValue = parseChartValue; exports.sameChartSelection = sameChartSelection; exports.snapChartValue = snapChartValue; exports.startChartPointDrag = startChartPointDrag; exports.stepDecimals = stepDecimals; exports.uniqueChartSeriesId = uniqueChartSeriesId;
|
|
1984
|
+
//# sourceMappingURL=index.cjs.map
|