@pixerate/editor 0.46.1 → 0.48.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/THIRD_PARTY_NOTICES.md +20 -0
- package/dist/agent/index.cjs +252 -34
- package/dist/agent/index.cjs.map +1 -1
- package/dist/agent/index.d.cts +19 -7
- package/dist/agent/index.d.ts +19 -7
- package/dist/agent/index.js +228 -10
- package/dist/agent/index.js.map +1 -1
- package/dist/{builtins-CZW-aYKS.d.cts → builtins-1kqwLUnb.d.cts} +2 -2
- package/dist/{builtins-DghNQbT-.d.ts → builtins-BKYlHH1s.d.ts} +2 -2
- package/dist/chart/index.cjs +1596 -1849
- package/dist/chart/index.cjs.map +1 -1
- package/dist/chart/index.d.cts +760 -548
- package/dist/chart/index.d.ts +760 -548
- package/dist/chart/index.js +1499 -1752
- package/dist/chart/index.js.map +1 -1
- package/dist/{chunk-3RPOW4CS.js → chunk-2CJ3LPYQ.js} +12 -10
- package/dist/{chunk-3RPOW4CS.js.map → chunk-2CJ3LPYQ.js.map} +1 -1
- package/dist/{chunk-PQM5W7ZN.js → chunk-4FX5ACVG.js} +29 -2
- package/dist/{chunk-PQM5W7ZN.js.map → chunk-4FX5ACVG.js.map} +1 -1
- package/dist/{chunk-HHQ4XJWT.js → chunk-AMWXSYCI.js} +2 -2
- package/dist/chunk-EKYHKQPG.js +1895 -0
- package/dist/chunk-EKYHKQPG.js.map +1 -0
- package/dist/chunk-HXUL3LS7.js +113 -0
- package/dist/chunk-HXUL3LS7.js.map +1 -0
- package/dist/{chunk-ZRNHGAQT.js → chunk-JQ3BB3E6.js} +43 -1891
- package/dist/chunk-JQ3BB3E6.js.map +1 -0
- package/dist/chunk-LTUM52X6.js +4074 -0
- package/dist/chunk-LTUM52X6.js.map +1 -0
- package/dist/{chunk-J3R2OWOE.cjs → chunk-MUCYWI6W.cjs} +608 -15
- package/dist/chunk-MUCYWI6W.cjs.map +1 -0
- package/dist/chunk-PIMCHMZE.cjs +521 -0
- package/dist/chunk-PIMCHMZE.cjs.map +1 -0
- package/dist/{chunk-TK2XZK6Z.js → chunk-PKBWLEL7.js} +594 -1
- package/dist/chunk-PKBWLEL7.js.map +1 -0
- package/dist/chunk-PRSJJZBS.cjs +4074 -0
- package/dist/chunk-PRSJJZBS.cjs.map +1 -0
- package/dist/{chunk-EFDCLENW.cjs → chunk-RIWPYSKU.cjs} +29 -2
- package/dist/chunk-RIWPYSKU.cjs.map +1 -0
- package/dist/chunk-U6IZKC7B.cjs +113 -0
- package/dist/chunk-U6IZKC7B.cjs.map +1 -0
- package/dist/{chunk-EWCOFSCQ.cjs → chunk-UN33YOXW.cjs} +252 -2100
- package/dist/chunk-UN33YOXW.cjs.map +1 -0
- package/dist/{chunk-Q25ZS4F7.js → chunk-VKPEPWKZ.js} +72 -92
- package/dist/chunk-VKPEPWKZ.js.map +1 -0
- package/dist/{chunk-N4MLNJPO.cjs → chunk-WXEHCVJQ.cjs} +160 -180
- package/dist/chunk-WXEHCVJQ.cjs.map +1 -0
- package/dist/{chunk-K2LVZ4FA.cjs → chunk-XFYUFVAX.cjs} +3 -3
- package/dist/{chunk-K2LVZ4FA.cjs.map → chunk-XFYUFVAX.cjs.map} +1 -1
- package/dist/chunk-XP5IUZUO.cjs +1895 -0
- package/dist/chunk-XP5IUZUO.cjs.map +1 -0
- package/dist/chunk-XUUD43OV.js +521 -0
- package/dist/chunk-XUUD43OV.js.map +1 -0
- package/dist/{chunk-U7VGDSNY.cjs → chunk-YU4WQE5M.cjs} +101 -99
- package/dist/chunk-YU4WQE5M.cjs.map +1 -0
- package/dist/commands/index.cjs +93 -11
- package/dist/commands/index.cjs.map +1 -1
- package/dist/commands/index.d.cts +9 -7
- package/dist/commands/index.d.ts +9 -7
- package/dist/commands/index.js +92 -10
- package/dist/commands/index.js.map +1 -1
- package/dist/controller-ChjCCMLl.d.cts +822 -0
- package/dist/controller-DtbEo3om.d.ts +822 -0
- package/dist/extensions/index.cjs +32 -3
- package/dist/extensions/index.cjs.map +1 -1
- package/dist/extensions/index.d.cts +5 -2
- package/dist/extensions/index.d.ts +5 -2
- package/dist/extensions/index.js +31 -2
- package/dist/generation/index.cjs +2 -2
- package/dist/generation/index.js +1 -1
- package/dist/{index-CfOQf70b.d.cts → index-DoHqT1mq.d.cts} +222 -5
- package/dist/{index-jwJ_onWf.d.ts → index-YLZ0fKqE.d.ts} +222 -5
- package/dist/index.cjs +38 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -5
- package/dist/index.d.ts +13 -5
- package/dist/index.js +52 -24
- package/dist/index.js.map +1 -1
- package/dist/parameters/index.d.cts +1 -1
- package/dist/parameters/index.d.ts +1 -1
- package/dist/{registry-vz6HOiiy.d.cts → registry-CaR7LC6W.d.cts} +2 -0
- package/dist/{registry-BfVD-vVg.d.ts → registry-rVtFJjRM.d.ts} +2 -0
- package/dist/spreadsheet/index.cjs +5 -3
- package/dist/spreadsheet/index.cjs.map +1 -1
- package/dist/spreadsheet/index.js +24 -22
- package/dist/tools/index.cjs +3 -3
- package/dist/tools/index.js +2 -2
- package/dist/vector/index.d.cts +1 -1
- package/dist/vector/index.d.ts +1 -1
- package/package.json +2 -1
- package/dist/chunk-EFDCLENW.cjs.map +0 -1
- package/dist/chunk-EWCOFSCQ.cjs.map +0 -1
- package/dist/chunk-J3R2OWOE.cjs.map +0 -1
- package/dist/chunk-N4MLNJPO.cjs.map +0 -1
- package/dist/chunk-Q25ZS4F7.js.map +0 -1
- package/dist/chunk-TK2XZK6Z.js.map +0 -1
- package/dist/chunk-U7VGDSNY.cjs.map +0 -1
- package/dist/chunk-ZRNHGAQT.js.map +0 -1
- /package/dist/{chunk-HHQ4XJWT.js.map → chunk-AMWXSYCI.js.map} +0 -0
package/dist/chart/index.js
CHANGED
|
@@ -1,1677 +1,257 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
CATEGORY_REF,
|
|
3
|
+
CHART_UPDATE_PROPERTIES,
|
|
4
|
+
HEIGHT,
|
|
5
|
+
SERIES_PROPS,
|
|
6
|
+
SERIES_REF,
|
|
7
|
+
TRENDLINE_PROPS,
|
|
8
|
+
VALUE,
|
|
9
|
+
WIDTH
|
|
10
|
+
} from "../chunk-HXUL3LS7.js";
|
|
11
|
+
import {
|
|
12
|
+
CHART_OOXML_CONTENT_TYPE,
|
|
13
|
+
CHART_OOXML_NS,
|
|
14
|
+
CHART_OOXML_RELATIONSHIP,
|
|
15
|
+
chartFormatToCode,
|
|
16
|
+
chartFromOoxml,
|
|
17
|
+
chartToOoxml
|
|
18
|
+
} from "../chunk-XUUD43OV.js";
|
|
5
19
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
20
|
+
CHART_AXIS_KINDS,
|
|
21
|
+
CHART_COLOR_SCHEMES,
|
|
22
|
+
CHART_CURVES,
|
|
23
|
+
CHART_DATE_STEPS,
|
|
24
|
+
CHART_LABEL_POSITIONS,
|
|
25
|
+
CHART_LAYOUT_PRESETS,
|
|
26
|
+
CHART_LEGEND_POSITIONS,
|
|
27
|
+
CHART_MARKERS,
|
|
28
|
+
CHART_MERMAID_META_PREFIX,
|
|
29
|
+
CHART_NUMBER_STYLES,
|
|
30
|
+
CHART_ORIENTATIONS,
|
|
31
|
+
CHART_PALETTE_SHADES,
|
|
32
|
+
CHART_SERIES_TYPES,
|
|
33
|
+
CHART_STACKINGS,
|
|
34
|
+
CHART_TRENDLINE_TYPES,
|
|
35
|
+
CHART_TYPES,
|
|
36
|
+
CHART_TYPE_LABELS,
|
|
37
|
+
CSS_VARIABLE_CHART_THEME,
|
|
38
|
+
ChartController,
|
|
39
|
+
DEFAULT_CHART_PALETTE,
|
|
40
|
+
DEFAULT_CHART_THEME,
|
|
41
|
+
MAX_CHART_DATA_LABELS,
|
|
42
|
+
MAX_CHART_LINE_POINTS,
|
|
43
|
+
MAX_CHART_MARKERS,
|
|
44
|
+
chartBoxStats,
|
|
45
|
+
chartColorDistance,
|
|
46
|
+
chartColorInputValue,
|
|
47
|
+
chartColorsByCategory,
|
|
48
|
+
chartContrastRatio,
|
|
49
|
+
chartDataFromTable,
|
|
50
|
+
chartDataToTable,
|
|
51
|
+
chartDateLabels,
|
|
52
|
+
chartDateToSerial,
|
|
53
|
+
chartFamily,
|
|
54
|
+
chartFromMermaid,
|
|
55
|
+
chartHistogram,
|
|
56
|
+
chartLuminance,
|
|
57
|
+
chartMermaidFamily,
|
|
58
|
+
chartMovingAverage,
|
|
59
|
+
chartPointColor,
|
|
60
|
+
chartPresetPatch,
|
|
61
|
+
chartQuantile,
|
|
62
|
+
chartRgbToHex,
|
|
63
|
+
chartRgbToOklab,
|
|
64
|
+
chartSceneToDomSpec,
|
|
65
|
+
chartSceneToSvg,
|
|
66
|
+
chartSerialToDate,
|
|
67
|
+
chartSeriesColor,
|
|
68
|
+
chartSeriesType,
|
|
69
|
+
chartSliceColor,
|
|
70
|
+
chartSquarify,
|
|
71
|
+
chartSupportsTrendlines,
|
|
72
|
+
chartTextBaseline,
|
|
73
|
+
chartTextOn,
|
|
74
|
+
chartTextTransform,
|
|
75
|
+
chartToMermaid,
|
|
76
|
+
chartToSvg,
|
|
77
|
+
chartTrendlineName,
|
|
78
|
+
chartValueAtPoint,
|
|
79
|
+
chartXAtPoint,
|
|
80
|
+
checkChartColors,
|
|
81
|
+
cloneChartSeries,
|
|
82
|
+
cloneChartSpec,
|
|
83
|
+
compileChartFormatCode,
|
|
84
|
+
contrastingTextColor,
|
|
85
|
+
createChartController,
|
|
86
|
+
createChartSpec,
|
|
87
|
+
cyclePaletteColor,
|
|
88
|
+
decimateChartPoints,
|
|
89
|
+
defaultChartAxis,
|
|
90
|
+
describeChart,
|
|
91
|
+
estimateChartTextWidth,
|
|
92
|
+
findMermaidCharts,
|
|
93
|
+
fitChartTrendline,
|
|
94
|
+
formatChartDate,
|
|
95
|
+
formatChartNumber,
|
|
96
|
+
formatChartValueWithCode,
|
|
97
|
+
isCartesianChart,
|
|
98
|
+
isXyChart,
|
|
99
|
+
layoutChart,
|
|
100
|
+
layoutSparkline,
|
|
101
|
+
linearMap,
|
|
102
|
+
monochromeChartPalette,
|
|
103
|
+
niceScale,
|
|
104
|
+
niceStep,
|
|
105
|
+
normalizeChartAxis,
|
|
106
|
+
normalizeChartSpec,
|
|
107
|
+
normalizeChartTrendline,
|
|
108
|
+
normalizeChartValues,
|
|
109
|
+
parseChartColor,
|
|
110
|
+
parseChartDate,
|
|
111
|
+
parseChartValue,
|
|
112
|
+
sameChartSelection,
|
|
113
|
+
shadeChartColor,
|
|
114
|
+
simulateChartVision,
|
|
115
|
+
snapChartValue,
|
|
116
|
+
sparklineToSvg,
|
|
117
|
+
stepDecimals,
|
|
118
|
+
summarizeChart,
|
|
119
|
+
summarizeChartSeries,
|
|
120
|
+
trendNumberText,
|
|
121
|
+
uniqueChartSeriesId
|
|
122
|
+
} from "../chunk-LTUM52X6.js";
|
|
123
|
+
import "../chunk-EKYHKQPG.js";
|
|
124
|
+
import {
|
|
125
|
+
parseCsv
|
|
126
|
+
} from "../chunk-664LQYFJ.js";
|
|
127
|
+
import "../chunk-QVZZGT4X.js";
|
|
8
128
|
|
|
9
|
-
// src/chart/
|
|
10
|
-
var
|
|
11
|
-
var
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (series?.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;
|
|
129
|
+
// src/chart/recommend.ts
|
|
130
|
+
var blank = (c) => c === null || c === void 0 || String(c).trim() === "";
|
|
131
|
+
var isText = (c) => blank(c) || parseChartValue(c) === void 0;
|
|
132
|
+
function analyzeChartTable(rows) {
|
|
133
|
+
const grid = rows.filter((r) => r.some((c) => !blank(c)));
|
|
134
|
+
const height = grid.length;
|
|
135
|
+
const width = Math.max(0, ...grid.map((r) => r.length));
|
|
136
|
+
const cell = (r, c) => grid[r]?.[c];
|
|
137
|
+
const headerRow = height > 1 && grid[0].some((c) => !blank(c) && isText(c)) && Array.from({ length: width - 1 }, (_, i) => i + 1).every((c) => isText(cell(0, c)));
|
|
138
|
+
const blankCorner = headerRow && blank(cell(0, 0));
|
|
139
|
+
const labelColumn = width > 1 && (blankCorner || Array.from({ length: height - (headerRow ? 1 : 0) }, (_, i) => i + (headerRow ? 1 : 0)).every((r) => isText(cell(r, 0))));
|
|
140
|
+
const byRows = width > height + 1;
|
|
141
|
+
const dataRows = height - (headerRow ? 1 : 0);
|
|
142
|
+
const dataCols = width - (labelColumn ? 1 : 0);
|
|
143
|
+
let anyNumber = false;
|
|
144
|
+
let nonNegative = true;
|
|
145
|
+
let anyNegative = false;
|
|
146
|
+
let anyPositive = false;
|
|
147
|
+
for (let r = headerRow ? 1 : 0; r < height; r++)
|
|
148
|
+
for (let c = labelColumn ? 1 : 0; c < width; c++) {
|
|
149
|
+
const v = parseChartValue(cell(r, c));
|
|
150
|
+
if (typeof v !== "number") continue;
|
|
151
|
+
anyNumber = true;
|
|
152
|
+
if (v < 0) {
|
|
153
|
+
nonNegative = false;
|
|
154
|
+
anyNegative = true;
|
|
155
|
+
} else if (v > 0) anyPositive = true;
|
|
189
156
|
}
|
|
190
|
-
|
|
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) {
|
|
157
|
+
const labels = byRows ? grid[0]?.slice(labelColumn ? 1 : 0) ?? [] : grid.slice(headerRow ? 1 : 0).map((r) => r[0]);
|
|
158
|
+
const dates = (byRows ? headerRow : labelColumn) && labels.length > 1 && labels.filter((l) => !blank(l) && parseChartDate(String(l)) !== null).length >= labels.length * 0.8;
|
|
271
159
|
return {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
160
|
+
headerRow,
|
|
161
|
+
labelColumn,
|
|
162
|
+
byRows,
|
|
163
|
+
series: Math.max(0, byRows ? dataRows : dataCols),
|
|
164
|
+
points: Math.max(0, byRows ? dataCols : dataRows),
|
|
165
|
+
anyNumber,
|
|
166
|
+
nonNegative,
|
|
167
|
+
mixedSigns: anyNegative && anyPositive,
|
|
168
|
+
dates
|
|
279
169
|
};
|
|
280
170
|
}
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const header = 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(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
|
-
[options.categoryHeader ?? "", ...spec.series.map((s) => s.name)],
|
|
318
|
-
...spec.categories.map((category, i) => [category, ...spec.series.map((s) => 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
|
-
if (fixedMin !== null && !Number.isFinite(fixedMin)) fixedMin = null;
|
|
339
|
-
if (fixedMax !== null && !Number.isFinite(fixedMax)) fixedMax = null;
|
|
340
|
-
let lo = fixedMin ?? min;
|
|
341
|
-
let hi = fixedMax ?? max;
|
|
342
|
-
if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
|
|
343
|
-
fixedMin = fixedMax = null;
|
|
344
|
-
lo = 0;
|
|
345
|
-
hi = 1;
|
|
346
|
-
}
|
|
347
|
-
if (lo > hi) [lo, hi] = [hi, lo];
|
|
348
|
-
if (lo === hi) {
|
|
349
|
-
const pad = lo === 0 ? 1 : Math.abs(lo) * 0.1;
|
|
350
|
-
if (fixedMin === null && lo !== 0) lo -= pad;
|
|
351
|
-
if (fixedMax === null || lo === hi) hi += pad;
|
|
352
|
-
}
|
|
353
|
-
const span = hi - lo;
|
|
354
|
-
const step = Number.isFinite(span) ? niceStep(span, count) : niceStep(hi / 2 - lo / 2, count) * 2;
|
|
355
|
-
const finiteOr = (n2, fallback) => Number.isFinite(n2) ? n2 : fallback;
|
|
356
|
-
const niceMin = fixedMin ?? finiteOr(snap(Math.floor(lo / step + 1e-9) * step, step), lo);
|
|
357
|
-
const niceMax = fixedMax ?? finiteOr(snap(Math.ceil(hi / step - 1e-9) * step, step), hi);
|
|
358
|
-
const firstIndex = Math.ceil(niceMin / step - 1e-9);
|
|
359
|
-
const lastIndex = Math.floor(niceMax / step + 1e-9);
|
|
360
|
-
const n = Math.min(999, Math.max(-1, lastIndex - firstIndex));
|
|
361
|
-
const ticks = [];
|
|
362
|
-
for (let i = 0; i <= n; i++) {
|
|
363
|
-
const t = snap((firstIndex + i) * step, step);
|
|
364
|
-
if (Number.isFinite(t) && t !== ticks[ticks.length - 1]) ticks.push(t);
|
|
365
|
-
}
|
|
366
|
-
return { min: niceMin, max: niceMax, step, ticks };
|
|
367
|
-
}
|
|
368
|
-
function snap(n, step) {
|
|
369
|
-
const c = clean(n);
|
|
370
|
-
return Math.abs(c - n) <= step * 1e-6 ? c : n;
|
|
371
|
-
}
|
|
372
|
-
function stepDecimals(step) {
|
|
373
|
-
if (!(step > 0) || !Number.isFinite(step)) return 0;
|
|
374
|
-
for (let d = 0; d < 6; d++) {
|
|
375
|
-
const scaled = step * Math.pow(10, d);
|
|
376
|
-
if (Math.abs(scaled - Math.round(scaled)) < 1e-9) return d;
|
|
377
|
-
}
|
|
378
|
-
return 6;
|
|
171
|
+
function recommendChartTypes(rows) {
|
|
172
|
+
const shape = analyzeChartTable(rows);
|
|
173
|
+
if (!shape.anyNumber || !shape.series) return [];
|
|
174
|
+
return recommendForShape(shape);
|
|
379
175
|
}
|
|
380
|
-
function
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
body = `${trim(grouped(v, d))}${unit.suffix}`;
|
|
417
|
-
} else body = trim(grouped(value, decimalsFor(value)));
|
|
418
|
-
} else if (style === "currency") {
|
|
419
|
-
const code = (format.currency || "USD").toUpperCase();
|
|
420
|
-
const symbol = CURRENCY_SYMBOLS[code] ?? `${code} `;
|
|
421
|
-
const d = format.decimals ?? (fallbackDecimals !== void 0 ? fallbackDecimals : Number.isInteger(value) ? 0 : 2);
|
|
422
|
-
const s = grouped(Math.abs(value), d);
|
|
423
|
-
body = `${value < 0 ? "-" : ""}${symbol}${s}`;
|
|
424
|
-
} else body = trim(grouped(value, decimalsFor(value)));
|
|
425
|
-
return `${format.prefix ?? ""}${body}${format.suffix ?? ""}`;
|
|
426
|
-
}
|
|
427
|
-
function linearMap(value, domain, range) {
|
|
428
|
-
const [d0, d1] = domain;
|
|
429
|
-
const [r0, r1] = range;
|
|
430
|
-
if (d1 === d0) return (r0 + r1) / 2;
|
|
431
|
-
return r0 + (value - d0) / (d1 - d0) * (r1 - r0);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// src/chart/layout.ts
|
|
435
|
-
var estimateChartTextWidth = (text2, size, weight = 400) => text2.length * size * (weight >= 600 ? 0.6 : 0.56);
|
|
436
|
-
function sameChartSelection(a, b) {
|
|
437
|
-
if (!a || !b || a.kind !== b.kind) return false;
|
|
438
|
-
switch (a.kind) {
|
|
439
|
-
case "axis":
|
|
440
|
-
return a.axis === b.axis;
|
|
441
|
-
case "series":
|
|
442
|
-
return a.seriesId === b.seriesId;
|
|
443
|
-
case "point":
|
|
444
|
-
return a.seriesId === b.seriesId && a.index === b.index;
|
|
445
|
-
case "category":
|
|
446
|
-
return a.index === b.index;
|
|
447
|
-
default:
|
|
448
|
-
return true;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
function contrastingTextColor(fill) {
|
|
452
|
-
let rgb = null;
|
|
453
|
-
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(fill.trim());
|
|
454
|
-
if (hex) {
|
|
455
|
-
const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
|
|
456
|
-
rgb = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
457
|
-
} else {
|
|
458
|
-
const fn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(fill.trim());
|
|
459
|
-
if (fn) rgb = fn.slice(1, 4).map(Number);
|
|
460
|
-
}
|
|
461
|
-
if (!rgb) return "#ffffff";
|
|
462
|
-
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
|
463
|
-
return luminance > 0.5 ? "#0f172a" : "#ffffff";
|
|
464
|
-
}
|
|
465
|
-
var r2 = (n) => Math.round(n * 100) / 100;
|
|
466
|
-
function curvePath(points, curve, map, moveFirst = true) {
|
|
467
|
-
if (!points.length) return "";
|
|
468
|
-
const xy = points.map(map);
|
|
469
|
-
let d = `${moveFirst ? "M" : "L"}${r2(xy[0][0])},${r2(xy[0][1])}`;
|
|
470
|
-
if (points.length === 1) return d;
|
|
471
|
-
if (curve === "step") {
|
|
472
|
-
for (let i = 1; i < points.length; i++) {
|
|
473
|
-
const mid = (points[i - 1].c + points[i].c) / 2;
|
|
474
|
-
const a = map({ c: mid, v: points[i - 1].v });
|
|
475
|
-
const b = map({ c: mid, v: points[i].v });
|
|
476
|
-
d += `L${r2(a[0])},${r2(a[1])}L${r2(b[0])},${r2(b[1])}L${r2(xy[i][0])},${r2(xy[i][1])}`;
|
|
477
|
-
}
|
|
478
|
-
return d;
|
|
479
|
-
}
|
|
480
|
-
if (curve === "smooth" && points.length > 2) {
|
|
481
|
-
const n = points.length;
|
|
482
|
-
const dc = points.slice(1).map((p, i) => p.c - points[i].c);
|
|
483
|
-
const slope = points.slice(1).map((p, i) => dc[i] === 0 ? 0 : (p.v - points[i].v) / dc[i]);
|
|
484
|
-
const tangent = points.map((_, i) => {
|
|
485
|
-
if (i === 0) return slope[0];
|
|
486
|
-
if (i === n - 1) return slope[n - 2];
|
|
487
|
-
const a = slope[i - 1];
|
|
488
|
-
const b = slope[i];
|
|
489
|
-
if (a * b <= 0) return 0;
|
|
490
|
-
const wa = 2 * dc[i] + dc[i - 1];
|
|
491
|
-
const wb = dc[i] + 2 * dc[i - 1];
|
|
492
|
-
return (wa + wb) / (wa / a + wb / b);
|
|
493
|
-
});
|
|
494
|
-
for (let i = 1; i < n; i++) {
|
|
495
|
-
const h = dc[i - 1] / 3;
|
|
496
|
-
const p0 = points[i - 1];
|
|
497
|
-
const p1 = points[i];
|
|
498
|
-
const a = map({ c: p0.c + h, v: p0.v + tangent[i - 1] * h });
|
|
499
|
-
const b = map({ c: p1.c - h, v: p1.v - tangent[i] * h });
|
|
500
|
-
d += `C${r2(a[0])},${r2(a[1])} ${r2(b[0])},${r2(b[1])} ${r2(xy[i][0])},${r2(xy[i][1])}`;
|
|
501
|
-
}
|
|
502
|
-
return d;
|
|
503
|
-
}
|
|
504
|
-
for (let i = 1; i < xy.length; i++) d += `L${r2(xy[i][0])},${r2(xy[i][1])}`;
|
|
505
|
-
return d;
|
|
506
|
-
}
|
|
507
|
-
function arcPath(cx, cy, outer, inner, start, end) {
|
|
508
|
-
const full = end - start >= Math.PI * 2 - 1e-9;
|
|
509
|
-
if (full) end = start + Math.PI * 2 - 1e-4;
|
|
510
|
-
const p = (r, a) => `${r2(cx + r * Math.sin(a))},${r2(cy - r * Math.cos(a))}`;
|
|
511
|
-
const large = end - start > Math.PI ? 1 : 0;
|
|
512
|
-
let d = `M${p(outer, start)}A${r2(outer)},${r2(outer)} 0 ${large} 1 ${p(outer, end)}`;
|
|
513
|
-
if (inner > 0) d += `L${p(inner, end)}A${r2(inner)},${r2(inner)} 0 ${large} 0 ${p(inner, start)}Z`;
|
|
514
|
-
else d += `L${r2(cx)},${r2(cy)}Z`;
|
|
515
|
-
return d;
|
|
516
|
-
}
|
|
517
|
-
function layoutChart(spec, options) {
|
|
518
|
-
const width = Math.max(1, options.width);
|
|
519
|
-
const height = Math.max(1, options.height);
|
|
520
|
-
const theme = { ...DEFAULT_CHART_THEME, ...options.theme };
|
|
521
|
-
const measure = options.measureText ?? estimateChartTextWidth;
|
|
522
|
-
const selection = options.selection ?? null;
|
|
523
|
-
const fs = theme.fontSize;
|
|
524
|
-
const marks = [];
|
|
525
|
-
const isSelected = (target) => sameChartSelection(selection, target);
|
|
526
|
-
const seriesSelected = (id) => selection?.kind === "series" && selection.seriesId === id;
|
|
527
|
-
const pad = 12;
|
|
528
|
-
marks.push({ kind: "rect", key: "background", role: "background", x: 0, y: 0, width, height, fill: theme.background, target: { kind: "chart" } });
|
|
529
|
-
let top = pad;
|
|
530
|
-
const outline = (key, box) => marks.push({ kind: "rect", key, role: "selection", ...box, fill: "none", stroke: theme.selection, strokeWidth: 1, radius: 3 });
|
|
531
|
-
const titleMark = (kind, label, size, weight, fill) => {
|
|
532
|
-
const target = { kind };
|
|
533
|
-
const selected = isSelected(target);
|
|
534
|
-
marks.push({ kind: "text", key: kind, role: kind, x: width / 2, y: top, text: label, anchor: "middle", baseline: "top", size, weight, fill, target, selected });
|
|
535
|
-
if (selected) {
|
|
536
|
-
const w = measure(label, size, weight);
|
|
537
|
-
outline(`${kind}-selection`, { x: width / 2 - w / 2 - 4, y: top - 3, width: w + 8, height: size * 1.25 + 6 });
|
|
538
|
-
}
|
|
539
|
-
top += size * 1.35;
|
|
540
|
-
};
|
|
541
|
-
if (spec.title) titleMark("title", spec.title, theme.titleSize, 600, theme.text);
|
|
542
|
-
if (spec.subtitle) titleMark("subtitle", spec.subtitle, fs + 1, 400, theme.mutedText);
|
|
543
|
-
if (spec.title || spec.subtitle) top += 6;
|
|
544
|
-
const cartesian = isCartesianChart(spec.type);
|
|
545
|
-
const visible = spec.series.filter((s) => !s.hidden);
|
|
546
|
-
const legendItems = cartesian ? spec.series.map((s, i) => ({
|
|
547
|
-
label: s.name,
|
|
548
|
-
color: chartSeriesColor(spec, i),
|
|
549
|
-
target: { kind: "series", seriesId: s.id },
|
|
550
|
-
faded: !!s.hidden,
|
|
551
|
-
selected: seriesSelected(s.id),
|
|
552
|
-
shape: chartSeriesType(spec, s) === "line" ? "line" : "square"
|
|
553
|
-
})) : spec.categories.map((c, i) => ({
|
|
554
|
-
label: c,
|
|
555
|
-
color: chartSliceColor(spec, i),
|
|
556
|
-
target: { kind: "category", index: i },
|
|
557
|
-
faded: false,
|
|
558
|
-
selected: isSelected({ kind: "category", index: i }),
|
|
559
|
-
shape: "square"
|
|
560
|
-
}));
|
|
561
|
-
let area = { x: pad, y: top, width: width - pad * 2, height: height - top - pad };
|
|
562
|
-
const showLegend = spec.legend.show && legendItems.length > 0 && (!cartesian || spec.series.length > 1 || spec.series.some((s) => s.hidden));
|
|
563
|
-
if (showLegend) area = layoutLegend(legendItems, spec.legend.position, area, theme, measure, marks, isSelected({ kind: "legend" }));
|
|
564
|
-
const empty = (message) => {
|
|
565
|
-
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 });
|
|
566
|
-
return { width, height, plot: { x: area.x, y: area.y, width: 0, height: 0 }, marks, percent: false, theme };
|
|
567
|
-
};
|
|
568
|
-
const hasData = visible.some((s) => s.values.some((v) => v !== null));
|
|
569
|
-
if (!spec.categories.length || !visible.length || !hasData) return empty(options.emptyMessage ?? "No data");
|
|
570
|
-
if (!cartesian) return layoutPie(spec, visible[0], area, theme, marks, selection, width, height);
|
|
571
|
-
const horizontal = spec.orientation === "horizontal";
|
|
572
|
-
const n = spec.categories.length;
|
|
573
|
-
const types = new Map(visible.map((s) => [s.id, chartSeriesType(spec, s)]));
|
|
574
|
-
const stacked = spec.stacking !== "none";
|
|
575
|
-
const percent = spec.stacking === "percent";
|
|
576
|
-
const stacks = /* @__PURE__ */ new Map();
|
|
577
|
-
if (stacked) {
|
|
578
|
-
for (const kind of ["bar", "area"]) {
|
|
579
|
-
const group = visible.filter((s) => types.get(s.id) === kind);
|
|
580
|
-
if (!group.length) continue;
|
|
581
|
-
const totals = Array.from({ length: n }, (_, i) => group.reduce((t, s) => t + Math.abs(s.values[i] ?? 0), 0));
|
|
582
|
-
const pos = Array(n).fill(0);
|
|
583
|
-
const neg = Array(n).fill(0);
|
|
584
|
-
for (const s of group) {
|
|
585
|
-
const segments = s.values.map((raw, i) => {
|
|
586
|
-
if (raw === null && kind === "bar") return null;
|
|
587
|
-
let v = raw ?? 0;
|
|
588
|
-
if (percent) v = totals[i] ? v / totals[i] : 0;
|
|
589
|
-
const base = v >= 0 ? pos[i] : neg[i];
|
|
590
|
-
const seg = [base, base + v];
|
|
591
|
-
if (v >= 0) pos[i] += v;
|
|
592
|
-
else neg[i] += v;
|
|
593
|
-
return seg;
|
|
594
|
-
});
|
|
595
|
-
stacks.set(s.id, { segments });
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
let lo = Infinity;
|
|
600
|
-
let hi = -Infinity;
|
|
601
|
-
const see = (v) => {
|
|
602
|
-
lo = Math.min(lo, v);
|
|
603
|
-
hi = Math.max(hi, v);
|
|
604
|
-
};
|
|
605
|
-
for (const s of visible) {
|
|
606
|
-
const stack = stacks.get(s.id);
|
|
607
|
-
if (stack) stack.segments.forEach((seg) => seg && (see(seg[0]), see(seg[1])));
|
|
608
|
-
else s.values.forEach((v) => v !== null && see(v));
|
|
609
|
-
}
|
|
610
|
-
if (!Number.isFinite(lo)) {
|
|
611
|
-
lo = 0;
|
|
612
|
-
hi = 1;
|
|
613
|
-
}
|
|
614
|
-
if ([...types.values()].some((t) => t === "bar" || t === "area")) {
|
|
615
|
-
lo = Math.min(lo, 0);
|
|
616
|
-
hi = Math.max(hi, 0);
|
|
617
|
-
}
|
|
618
|
-
const valueFormat = percent && !spec.yAxis.format.style ? { ...spec.yAxis.format, style: "percent" } : spec.yAxis.format;
|
|
619
|
-
const xAxis = spec.xAxis;
|
|
620
|
-
const yAxis = spec.yAxis;
|
|
621
|
-
const titleBand = fs * 1.3 + 6;
|
|
622
|
-
const frozen = options.valueDomain;
|
|
623
|
-
const fixedMin = frozen && Number.isFinite(frozen.min) ? frozen.min : yAxis.min;
|
|
624
|
-
const fixedMax = frozen && Number.isFinite(frozen.max) ? frozen.max : yAxis.max;
|
|
625
|
-
const valueLen0 = horizontal ? area.width : area.height;
|
|
626
|
-
let scale = niceScale(lo, hi, Math.max(2, Math.min(10, Math.round(valueLen0 / 56))), fixedMin, fixedMax);
|
|
627
|
-
const tickText = (t) => formatChartNumber(t, valueFormat, valueFormat.decimals ?? stepDecimals(scale.step));
|
|
628
|
-
const valueLabelWidth = Math.max(0, ...scale.ticks.map((t) => measure(tickText(t), fs)));
|
|
629
|
-
const catLabelWidth = Math.max(0, ...spec.categories.map((c) => measure(c, fs)));
|
|
630
|
-
let left;
|
|
631
|
-
let bottom;
|
|
632
|
-
let rotate = 0;
|
|
633
|
-
if (horizontal) {
|
|
634
|
-
left = (xAxis.show ? Math.min(catLabelWidth, area.width * 0.35) + 8 : 2) + (xAxis.title ? titleBand : 0);
|
|
635
|
-
bottom = (yAxis.show ? fs * 1.6 : 2) + (yAxis.title ? titleBand : 0);
|
|
636
|
-
} else {
|
|
637
|
-
left = (yAxis.show ? valueLabelWidth + 8 : 2) + (yAxis.title ? titleBand : 0);
|
|
638
|
-
const band2 = (area.width - left - 8) / n;
|
|
639
|
-
if (xAxis.show && catLabelWidth > band2 * 0.92) rotate = -40;
|
|
640
|
-
const catHeight = rotate ? Math.min(catLabelWidth * Math.sin(Math.PI / 4.5) + fs, area.height * 0.4) : fs * 1.6;
|
|
641
|
-
bottom = (xAxis.show ? catHeight : 2) + (xAxis.title ? titleBand : 0);
|
|
642
|
-
}
|
|
643
|
-
const rightPad = horizontal ? Math.max(8, valueLabelWidth / 2) : 8;
|
|
644
|
-
const plot = {
|
|
645
|
-
x: area.x + left,
|
|
646
|
-
y: area.y + (horizontal ? 4 : fs / 2 + 2),
|
|
647
|
-
width: Math.max(1, area.width - left - rightPad),
|
|
648
|
-
height: Math.max(1, area.height - bottom - (horizontal ? 4 : fs / 2 + 2))
|
|
649
|
-
};
|
|
650
|
-
scale = niceScale(lo, hi, Math.max(2, Math.min(10, Math.round((horizontal ? plot.width : plot.height) / 56))), fixedMin, fixedMax);
|
|
651
|
-
const domain = [scale.min, scale.max];
|
|
652
|
-
const valueRange = horizontal ? [plot.x, plot.x + plot.width] : [plot.y + plot.height, plot.y];
|
|
653
|
-
const valuePx = (v) => linearMap(Math.min(scale.max, Math.max(scale.min, v)), domain, valueRange);
|
|
654
|
-
const catStart = horizontal ? plot.y : plot.x;
|
|
655
|
-
const catLen = horizontal ? plot.height : plot.width;
|
|
656
|
-
const band = catLen / n;
|
|
657
|
-
const catCenter = (i) => catStart + band * (i + 0.5);
|
|
658
|
-
const map = (p) => horizontal ? [p.v, p.c] : [p.c, p.v];
|
|
659
|
-
const zero = valuePx(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
660
|
-
const axisSelected = (axis) => isSelected({ kind: "axis", axis });
|
|
661
|
-
if (yAxis.gridlines) {
|
|
662
|
-
scale.ticks.forEach((t, i) => {
|
|
663
|
-
const p = valuePx(t);
|
|
664
|
-
const [x1, y1] = map({ c: catStart, v: p });
|
|
665
|
-
const [x2, y2] = map({ c: catStart + catLen, v: p });
|
|
666
|
-
marks.push({ kind: "line", key: `grid-y-${i}`, role: "grid", x1, y1, x2, y2, stroke: theme.grid, strokeWidth: 1 });
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
if (xAxis.gridlines) {
|
|
670
|
-
for (let i = 1; i < n; i++) {
|
|
671
|
-
const c = catStart + band * i;
|
|
672
|
-
const [x1, y1] = map({ c, v: valueRange[0] });
|
|
673
|
-
const [x2, y2] = map({ c, v: valueRange[1] });
|
|
674
|
-
marks.push({ kind: "line", key: `grid-x-${i}`, role: "grid", x1, y1, x2, y2, stroke: theme.grid, strokeWidth: 1 });
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
const yTarget = { kind: "axis", axis: "y" };
|
|
678
|
-
const xTarget = { kind: "axis", axis: "x" };
|
|
679
|
-
if (yAxis.show) {
|
|
680
|
-
scale.ticks.forEach((t, i) => {
|
|
681
|
-
const p = valuePx(t);
|
|
682
|
-
const label = tickText(t);
|
|
683
|
-
if (horizontal)
|
|
684
|
-
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 });
|
|
685
|
-
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 });
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
if (xAxis.show) {
|
|
689
|
-
const [x1, y1] = map({ c: catStart, v: zero });
|
|
690
|
-
const [x2, y2] = map({ c: catStart + catLen, v: zero });
|
|
691
|
-
marks.push({ kind: "line", key: "axis-x", role: "axis", x1, y1, x2, y2, stroke: theme.axis, strokeWidth: 1, target: xTarget });
|
|
692
|
-
const every = horizontal ? Math.max(1, Math.ceil(fs * 1.3 / band)) : rotate ? Math.max(1, Math.ceil(fs * 1.4 / band)) : 1;
|
|
693
|
-
spec.categories.forEach((c, i) => {
|
|
694
|
-
if (i % every) return;
|
|
695
|
-
const target = { kind: "category", index: i };
|
|
696
|
-
const selected = isSelected(target);
|
|
697
|
-
const fill = selected ? theme.selection : theme.mutedText;
|
|
698
|
-
if (horizontal) {
|
|
699
|
-
const maxW = left - (xAxis.title ? titleBand : 0) - 8;
|
|
700
|
-
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 });
|
|
701
|
-
} else if (rotate)
|
|
702
|
-
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 });
|
|
703
|
-
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 });
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
const bottomTitle = horizontal ? yAxis.title : xAxis.title;
|
|
707
|
-
const leftTitle = horizontal ? xAxis.title : yAxis.title;
|
|
708
|
-
if (bottomTitle)
|
|
709
|
-
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 });
|
|
710
|
-
if (leftTitle)
|
|
711
|
-
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 });
|
|
712
|
-
for (const axis of ["x", "y"]) {
|
|
713
|
-
if (!axisSelected(axis)) continue;
|
|
714
|
-
const onLeft = axis === "y" !== horizontal;
|
|
715
|
-
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 };
|
|
716
|
-
outline(`axis-${axis}-selection`, box);
|
|
717
|
-
}
|
|
718
|
-
const bars = visible.filter((s) => types.get(s.id) === "bar");
|
|
719
|
-
const slots = stacked ? 1 : bars.length;
|
|
720
|
-
const groupWidth = band * (bars.length ? 0.72 : 0);
|
|
721
|
-
const barWidth = slots ? groupWidth / slots : 0;
|
|
722
|
-
const labelMarks = [];
|
|
723
|
-
const decimals = valueFormat.decimals ?? void 0;
|
|
724
|
-
const dataLabel = (v) => formatChartNumber(v, valueFormat, decimals);
|
|
725
|
-
const drawOrder = [...visible].sort((a, b) => order(types.get(a.id)) - order(types.get(b.id)));
|
|
726
|
-
for (const s of drawOrder) {
|
|
727
|
-
const si = spec.series.indexOf(s);
|
|
728
|
-
const color = chartSeriesColor(spec, si);
|
|
729
|
-
const type = types.get(s.id);
|
|
730
|
-
const stack = stacks.get(s.id);
|
|
731
|
-
const selSeries = seriesSelected(s.id);
|
|
732
|
-
const seriesTarget = { kind: "series", seriesId: s.id };
|
|
733
|
-
if (type === "bar") {
|
|
734
|
-
const slot = stacked ? 0 : bars.indexOf(s);
|
|
735
|
-
s.values.forEach((raw, i) => {
|
|
736
|
-
const seg = stack ? stack.segments[i] : raw === null ? null : [0, raw];
|
|
737
|
-
if (!seg || raw === null) return;
|
|
738
|
-
const a = valuePx(seg[0]);
|
|
739
|
-
const b = valuePx(seg[1]);
|
|
740
|
-
const c0 = catCenter(i) - groupWidth / 2 + slot * barWidth + barWidth * 0.06;
|
|
741
|
-
const cw = barWidth * 0.88;
|
|
742
|
-
const target = { kind: "point", seriesId: s.id, index: i };
|
|
743
|
-
const selected = isSelected(target);
|
|
744
|
-
const [x, y] = map({ c: c0, v: Math.min(a, b) });
|
|
745
|
-
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) };
|
|
746
|
-
marks.push({
|
|
747
|
-
kind: "rect",
|
|
748
|
-
key: `bar-${s.id}-${i}`,
|
|
749
|
-
role: "bar",
|
|
750
|
-
...rect,
|
|
751
|
-
fill: color,
|
|
752
|
-
radius: Math.min(2, cw / 4),
|
|
753
|
-
target,
|
|
754
|
-
selected: selected || selSeries,
|
|
755
|
-
...selSeries ? { stroke: theme.selection, strokeWidth: 1.5 } : {}
|
|
756
|
-
});
|
|
757
|
-
if (selected) outline(`bar-${s.id}-${i}-selection`, { x: rect.x - 2, y: rect.y - 2, width: rect.width + 4, height: rect.height + 4 });
|
|
758
|
-
if (spec.dataLabels) {
|
|
759
|
-
const v = percent ? seg[1] - seg[0] : raw;
|
|
760
|
-
const text2 = dataLabel(v);
|
|
761
|
-
if (stacked && Math.abs(b - a) < (horizontal ? measure(text2, fs - 1) + 4 : fs + 1)) return;
|
|
762
|
-
const end = valuePx(seg[1]);
|
|
763
|
-
const outward = (seg[1] >= seg[0] ? 1 : -1) * (horizontal ? 1 : -1);
|
|
764
|
-
const [lx, ly] = map({ c: c0 + cw / 2, v: stacked ? (a + b) / 2 : end + outward * 4 });
|
|
765
|
-
labelMarks.push({
|
|
766
|
-
kind: "text",
|
|
767
|
-
key: `label-${s.id}-${i}`,
|
|
768
|
-
role: "data-label",
|
|
769
|
-
x: lx,
|
|
770
|
-
y: ly,
|
|
771
|
-
text: text2,
|
|
772
|
-
anchor: horizontal && !stacked ? outward > 0 ? "start" : "end" : "middle",
|
|
773
|
-
baseline: horizontal || stacked ? "middle" : outward < 0 ? "bottom" : "top",
|
|
774
|
-
size: fs - 1,
|
|
775
|
-
fill: stacked ? contrastingTextColor(color) : theme.text,
|
|
776
|
-
target
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
});
|
|
780
|
-
continue;
|
|
781
|
-
}
|
|
782
|
-
const pointValue = (i) => stack ? stack.segments[i]?.[1] ?? null : s.values[i];
|
|
783
|
-
const runs = [];
|
|
784
|
-
let run = [];
|
|
785
|
-
for (let i = 0; i < n; i++) {
|
|
786
|
-
const v = pointValue(i);
|
|
787
|
-
if (v === null) {
|
|
788
|
-
if (run.length) runs.push(run);
|
|
789
|
-
run = [];
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
run.push({ c: catCenter(i), v: valuePx(v) });
|
|
793
|
-
}
|
|
794
|
-
if (run.length) runs.push(run);
|
|
795
|
-
if (type === "area") {
|
|
796
|
-
const parts = runs.map((r) => {
|
|
797
|
-
const baseline = stack ? r.map((p) => ({ c: p.c, v: valuePx(stack.segments[Math.round((p.c - catStart) / band - 0.5)]?.[0] ?? 0) })).reverse() : [
|
|
798
|
-
{ c: r[r.length - 1].c, v: zero },
|
|
799
|
-
{ c: r[0].c, v: zero }
|
|
800
|
-
];
|
|
801
|
-
return `${curvePath(r, spec.curve, map)}${stack ? curvePath(baseline, spec.curve, map, false) : baseline.map((p) => `L${map(p).map(r2).join(",")}`).join("")}Z`;
|
|
802
|
-
});
|
|
803
|
-
marks.push({ kind: "path", key: `area-${s.id}`, role: "area", d: parts.join(""), fill: color, opacity: 0.22, target: seriesTarget, selected: selSeries });
|
|
804
|
-
}
|
|
805
|
-
if (type === "line" || type === "area") {
|
|
806
|
-
marks.push({
|
|
807
|
-
kind: "path",
|
|
808
|
-
key: `line-${s.id}`,
|
|
809
|
-
role: "line",
|
|
810
|
-
d: runs.map((r) => curvePath(r, spec.curve, map)).join(""),
|
|
811
|
-
fill: "none",
|
|
812
|
-
stroke: color,
|
|
813
|
-
strokeWidth: selSeries ? 3.5 : 2,
|
|
814
|
-
target: seriesTarget,
|
|
815
|
-
selected: selSeries
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
const showDots = type === "scatter" || spec.points;
|
|
819
|
-
for (let i = 0; i < n; i++) {
|
|
820
|
-
const v = pointValue(i);
|
|
821
|
-
if (v === null || s.values[i] === null) continue;
|
|
822
|
-
const [cx, cy] = map({ c: catCenter(i), v: valuePx(v) });
|
|
823
|
-
const target = { kind: "point", seriesId: s.id, index: i };
|
|
824
|
-
const selected = isSelected(target);
|
|
825
|
-
const r = type === "scatter" ? 4.5 : 3.5;
|
|
826
|
-
marks.push({
|
|
827
|
-
kind: "circle",
|
|
828
|
-
key: `point-${s.id}-${i}`,
|
|
829
|
-
role: "point",
|
|
830
|
-
cx,
|
|
831
|
-
cy,
|
|
832
|
-
r: showDots ? r : 6,
|
|
833
|
-
fill: showDots ? type === "scatter" ? color : theme.surface : "transparent",
|
|
834
|
-
...showDots ? { stroke: color, strokeWidth: 2 } : {},
|
|
835
|
-
opacity: type === "scatter" ? 0.85 : void 0,
|
|
836
|
-
target,
|
|
837
|
-
selected: selected || selSeries
|
|
838
|
-
});
|
|
839
|
-
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 });
|
|
840
|
-
if (spec.dataLabels) {
|
|
841
|
-
const raw = s.values[i];
|
|
842
|
-
const shown = percent && stack ? stack.segments[i][1] - stack.segments[i][0] : raw;
|
|
843
|
-
labelMarks.push({
|
|
844
|
-
kind: "text",
|
|
845
|
-
key: `label-${s.id}-${i}`,
|
|
846
|
-
role: "data-label",
|
|
847
|
-
x: horizontal ? cx + 8 : cx,
|
|
848
|
-
y: horizontal ? cy : cy - 8,
|
|
849
|
-
text: dataLabel(shown),
|
|
850
|
-
anchor: horizontal ? "start" : "middle",
|
|
851
|
-
baseline: horizontal ? "middle" : "bottom",
|
|
852
|
-
size: fs - 1,
|
|
853
|
-
fill: theme.text,
|
|
854
|
-
target
|
|
855
|
-
});
|
|
176
|
+
function recommendForShape(shape) {
|
|
177
|
+
const out = [];
|
|
178
|
+
const add = (type, label, reason, stacking) => out.push({ type, label, reason, ...stacking ? { stacking } : {} });
|
|
179
|
+
if (shape.series === 2 && !shape.labelColumn && !shape.byRows) {
|
|
180
|
+
add("scatter", "Scatter", "Two numeric columns without labels: the first is x, the second y.");
|
|
181
|
+
add("line", "Line", "Shows the second column in order.");
|
|
182
|
+
add("bar", "Column", "Compares the values side by side.");
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
if (shape.series === 1 && !shape.labelColumn && shape.points > 20) {
|
|
186
|
+
add("histogram", "Histogram", "Many unlabelled values: shows how they are distributed.");
|
|
187
|
+
add("box", "Box plot", "Summarises the spread: quartiles, median and outliers.");
|
|
188
|
+
add("line", "Line", "Shows the values in order.");
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
if (shape.dates) {
|
|
192
|
+
add("line", "Line", "The categories are dates: a line shows change over time.");
|
|
193
|
+
add("area", "Area", "Shows change over time with volume.");
|
|
194
|
+
add("bar", "Column", "Compares each period.");
|
|
195
|
+
if (shape.series > 1) add("area", "Stacked area", "Shows how the parts add up over time.", "stacked");
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
if (shape.series === 1) {
|
|
199
|
+
if (shape.points > 12) {
|
|
200
|
+
add("line", "Line", "One long series: a line shows its shape.");
|
|
201
|
+
add("bar", "Column", "Compares every value.");
|
|
202
|
+
add("area", "Area", "Emphasises the volume under the series.");
|
|
203
|
+
} else {
|
|
204
|
+
add("bar", "Column", "One short series: columns compare the values.");
|
|
205
|
+
if (shape.mixedSigns) add("waterfall", "Waterfall", "Positive and negative values: shows how they add up to a total.");
|
|
206
|
+
add("bar", "Bar", "Horizontal bars fit long category names.");
|
|
207
|
+
if (shape.nonNegative && shape.points >= 2) {
|
|
208
|
+
add("pie", "Pie", "Shares of a whole (all values are positive).");
|
|
209
|
+
add("donut", "Donut", "Shares of a whole with the total in the middle.");
|
|
210
|
+
if (shape.points >= 5) add("treemap", "Treemap", "Compares many shares by area.");
|
|
211
|
+
add("funnel", "Funnel", "Shows stages that narrow down.");
|
|
856
212
|
}
|
|
213
|
+
add("line", "Line", "Shows the values in order.");
|
|
857
214
|
}
|
|
215
|
+
return out;
|
|
858
216
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
function truncate(text2, maxWidth, size, measure) {
|
|
865
|
-
if (measure(text2, size) <= maxWidth) return text2;
|
|
866
|
-
let out = text2;
|
|
867
|
-
while (out.length > 1 && measure(`${out}\u2026`, size) > maxWidth) out = out.slice(0, -1);
|
|
868
|
-
return `${out}\u2026`;
|
|
869
|
-
}
|
|
870
|
-
function layoutLegend(items, position, area, theme, measure, marks, legendSelected) {
|
|
871
|
-
const fs = theme.fontSize;
|
|
872
|
-
const swatch = 10;
|
|
873
|
-
const rowH = fs * 1.7;
|
|
874
|
-
const itemW = (item) => swatch + 6 + measure(item.label, fs) + 14;
|
|
875
|
-
const placed = [];
|
|
876
|
-
let box;
|
|
877
|
-
let rest;
|
|
878
|
-
if (position === "left" || position === "right") {
|
|
879
|
-
const w = Math.min(area.width * 0.35, Math.max(...items.map(itemW)));
|
|
880
|
-
const h = Math.min(items.length * rowH, area.height);
|
|
881
|
-
const x = position === "left" ? area.x : area.x + area.width - w;
|
|
882
|
-
const y = area.y + (area.height - h) / 2;
|
|
883
|
-
items.forEach((item, i) => {
|
|
884
|
-
if ((i + 1) * rowH <= area.height + 0.5) placed.push({ item, x, y: y + i * rowH + rowH / 2 });
|
|
885
|
-
});
|
|
886
|
-
box = { x, y, width: w, height: h };
|
|
887
|
-
rest = position === "left" ? { ...area, x: area.x + w + 8, width: area.width - w - 8 } : { ...area, width: area.width - w - 8 };
|
|
217
|
+
if (shape.points > 12) {
|
|
218
|
+
add("line", "Line", "Several long series: lines keep them readable.");
|
|
219
|
+
add("bar", "Column", "Compares the series at each category.");
|
|
220
|
+
add("area", "Area", "Emphasises volume.");
|
|
221
|
+
add("bar", "Stacked column", "Shows how the series add up.", "stacked");
|
|
888
222
|
} else {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
rows.push([]);
|
|
895
|
-
used = 0;
|
|
896
|
-
}
|
|
897
|
-
rows[rows.length - 1].push(item);
|
|
898
|
-
used += w;
|
|
899
|
-
}
|
|
900
|
-
const shown = rows.slice(0, 3);
|
|
901
|
-
const h = shown.length * rowH;
|
|
902
|
-
const y0 = position === "top" ? area.y : area.y + area.height - h;
|
|
903
|
-
shown.forEach((row, r) => {
|
|
904
|
-
const total = row.reduce((t, item) => t + itemW(item), 0) - 14;
|
|
905
|
-
let x = area.x + (area.width - total) / 2;
|
|
906
|
-
for (const item of row) {
|
|
907
|
-
placed.push({ item, x, y: y0 + r * rowH + rowH / 2 });
|
|
908
|
-
x += itemW(item);
|
|
909
|
-
}
|
|
910
|
-
});
|
|
911
|
-
box = { x: area.x, y: y0, width: area.width, height: h };
|
|
912
|
-
rest = position === "top" ? { ...area, y: area.y + h + 6, height: area.height - h - 6 } : { ...area, height: area.height - h - 6 };
|
|
913
|
-
}
|
|
914
|
-
placed.forEach(({ item, x, y }, i) => {
|
|
915
|
-
const opacity = item.faded ? 0.4 : void 0;
|
|
916
|
-
if (item.shape === "line")
|
|
917
|
-
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 });
|
|
918
|
-
else
|
|
919
|
-
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 });
|
|
920
|
-
marks.push({
|
|
921
|
-
kind: "text",
|
|
922
|
-
key: `legend-label-${i}`,
|
|
923
|
-
role: "legend-label",
|
|
924
|
-
x: x + swatch + 6,
|
|
925
|
-
y,
|
|
926
|
-
text: item.label,
|
|
927
|
-
anchor: "start",
|
|
928
|
-
baseline: "middle",
|
|
929
|
-
size: fs,
|
|
930
|
-
fill: item.selected ? theme.selection : theme.text,
|
|
931
|
-
weight: item.selected ? 600 : void 0,
|
|
932
|
-
opacity,
|
|
933
|
-
target: item.target,
|
|
934
|
-
selected: item.selected
|
|
935
|
-
});
|
|
936
|
-
});
|
|
937
|
-
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 });
|
|
938
|
-
return rest;
|
|
939
|
-
}
|
|
940
|
-
function layoutPie(spec, series, area, theme, marks, selection, width, height) {
|
|
941
|
-
const fs = theme.fontSize;
|
|
942
|
-
const values = spec.categories.map((_, i) => Math.max(0, series.values[i] ?? 0));
|
|
943
|
-
const total = values.reduce((a, b) => a + b, 0);
|
|
944
|
-
const labelRoom = spec.dataLabels ? fs * 2.2 : 4;
|
|
945
|
-
const outer = Math.max(4, Math.min(area.width, area.height) / 2 - labelRoom);
|
|
946
|
-
const inner = spec.type === "donut" ? outer * spec.innerRadius : 0;
|
|
947
|
-
const cx = area.x + area.width / 2;
|
|
948
|
-
const cy = area.y + area.height / 2;
|
|
949
|
-
const plot = { x: cx - outer, y: cy - outer, width: outer * 2, height: outer * 2 };
|
|
950
|
-
if (total <= 0) {
|
|
951
|
-
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 });
|
|
952
|
-
return { width, height, plot, marks, percent: false, theme };
|
|
953
|
-
}
|
|
954
|
-
let angle = 0;
|
|
955
|
-
const format = spec.yAxis.format;
|
|
956
|
-
values.forEach((v, i) => {
|
|
957
|
-
if (v <= 0) return;
|
|
958
|
-
const sweep = v / total * Math.PI * 2;
|
|
959
|
-
const start = angle;
|
|
960
|
-
const end = angle + sweep;
|
|
961
|
-
angle = end;
|
|
962
|
-
const target = { kind: "point", seriesId: series.id, index: i };
|
|
963
|
-
const selected = sameChartSelection(selection, target) || sameChartSelection(selection, { kind: "category", index: i });
|
|
964
|
-
const seriesSel = selection?.kind === "series" && selection.seriesId === series.id;
|
|
965
|
-
const mid = (start + end) / 2;
|
|
966
|
-
const off = selected ? 6 : 0;
|
|
967
|
-
const ox = cx + off * Math.sin(mid);
|
|
968
|
-
const oy = cy - off * Math.cos(mid);
|
|
969
|
-
marks.push({
|
|
970
|
-
kind: "path",
|
|
971
|
-
key: `slice-${i}`,
|
|
972
|
-
role: "slice",
|
|
973
|
-
d: arcPath(ox, oy, outer, inner, start, end),
|
|
974
|
-
fill: chartSliceColor(spec, i),
|
|
975
|
-
stroke: selected || seriesSel ? theme.selection : theme.surface,
|
|
976
|
-
strokeWidth: selected || seriesSel ? 2 : 1,
|
|
977
|
-
target,
|
|
978
|
-
selected: selected || seriesSel
|
|
979
|
-
});
|
|
980
|
-
if (spec.dataLabels && sweep > 0.12) {
|
|
981
|
-
const share = v / total;
|
|
982
|
-
const text2 = format.style ? formatChartNumber(v, format) : `${Math.round(share * 100)}%`;
|
|
983
|
-
const lr = outer + fs * 1.1;
|
|
984
|
-
const lx = ox + lr * Math.sin(mid);
|
|
985
|
-
const ly = oy - lr * Math.cos(mid);
|
|
986
|
-
const anchor = Math.abs(Math.sin(mid)) < 0.2 ? "middle" : Math.sin(mid) > 0 ? "start" : "end";
|
|
987
|
-
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 });
|
|
988
|
-
}
|
|
989
|
-
});
|
|
990
|
-
if (spec.type === "donut" && inner > fs * 2) {
|
|
991
|
-
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 } });
|
|
992
|
-
}
|
|
993
|
-
return { width, height, plot, marks, percent: false, theme };
|
|
994
|
-
}
|
|
995
|
-
function chartValueAtPoint(scene, point) {
|
|
996
|
-
const scale = scene.valueScale;
|
|
997
|
-
if (!scale || scene.percent) return void 0;
|
|
998
|
-
const px = scale.axis === "y" ? point.y : point.x;
|
|
999
|
-
return linearMap(px, scale.range, scale.domain);
|
|
1000
|
-
}
|
|
1001
|
-
function snapChartValue(scene, value, step) {
|
|
1002
|
-
const ticks = scene.valueScale?.ticks ?? [];
|
|
1003
|
-
const unit = step ?? (ticks.length > 1 ? (ticks[1] - ticks[0]) / 10 : 1);
|
|
1004
|
-
if (!(unit > 0)) return value;
|
|
1005
|
-
const snapped = Math.round(value / unit) * unit;
|
|
1006
|
-
return Number(snapped.toPrecision(12));
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
// src/chart/svg.ts
|
|
1010
|
-
function chartTextBaseline(baseline) {
|
|
1011
|
-
return baseline === "top" ? "hanging" : baseline === "middle" ? "central" : "text-after-edge";
|
|
1012
|
-
}
|
|
1013
|
-
function chartTextTransform(mark) {
|
|
1014
|
-
return mark.rotate ? `rotate(${mark.rotate} ${mark.x} ${mark.y})` : void 0;
|
|
1015
|
-
}
|
|
1016
|
-
var escape = (text2) => text2.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1017
|
-
function attrs(values) {
|
|
1018
|
-
return Object.entries(values).filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => ` ${k}="${escape(String(v))}"`).join("");
|
|
1019
|
-
}
|
|
1020
|
-
function markToSvg(mark, fontFamily) {
|
|
1021
|
-
const common = { opacity: mark.opacity, "data-role": mark.role };
|
|
1022
|
-
switch (mark.kind) {
|
|
1023
|
-
case "rect":
|
|
1024
|
-
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 })}/>`;
|
|
1025
|
-
case "path":
|
|
1026
|
-
return `<path${attrs({ d: mark.d, fill: mark.fill ?? "none", stroke: mark.stroke, "stroke-width": mark.strokeWidth, "stroke-dasharray": mark.dash, "stroke-linejoin": "round", "stroke-linecap": "round", ...common })}/>`;
|
|
1027
|
-
case "circle":
|
|
1028
|
-
return `<circle${attrs({ cx: mark.cx, cy: mark.cy, r: mark.r, fill: mark.fill, stroke: mark.stroke, "stroke-width": mark.strokeWidth, ...common })}/>`;
|
|
1029
|
-
case "line":
|
|
1030
|
-
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 })}/>`;
|
|
1031
|
-
case "text":
|
|
1032
|
-
return `<text${attrs({
|
|
1033
|
-
x: mark.x,
|
|
1034
|
-
y: mark.y,
|
|
1035
|
-
"text-anchor": mark.anchor,
|
|
1036
|
-
"dominant-baseline": chartTextBaseline(mark.baseline),
|
|
1037
|
-
"font-size": mark.size,
|
|
1038
|
-
"font-weight": mark.weight,
|
|
1039
|
-
"font-family": fontFamily,
|
|
1040
|
-
fill: mark.fill,
|
|
1041
|
-
transform: chartTextTransform(mark),
|
|
1042
|
-
...common
|
|
1043
|
-
})}>${escape(mark.text)}</text>`;
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
function chartSceneToSvg(scene, options = {}) {
|
|
1047
|
-
const hide = options.hideSelection ?? true;
|
|
1048
|
-
const font = scene.theme.fontFamily;
|
|
1049
|
-
const body = scene.marks.filter((m) => !(hide && m.role === "selection")).map((m) => markToSvg(m, font)).join("");
|
|
1050
|
-
const label = options.ariaLabel;
|
|
1051
|
-
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>`;
|
|
1052
|
-
}
|
|
1053
|
-
function chartToSvg(spec, options) {
|
|
1054
|
-
const scene = layoutChart(spec, { ...options, selection: null });
|
|
1055
|
-
return chartSceneToSvg(scene, { ...options, ariaLabel: options.ariaLabel ?? (spec.title || void 0) });
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// src/chart/controller.ts
|
|
1059
|
-
function sameChartJson(a, b) {
|
|
1060
|
-
if (a === b) return true;
|
|
1061
|
-
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return Number.isNaN(a) && Number.isNaN(b);
|
|
1062
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
1063
|
-
if (Array.isArray(a)) {
|
|
1064
|
-
const bb = b;
|
|
1065
|
-
return a.length === bb.length && a.every((v, i) => sameChartJson(v, bb[i]));
|
|
1066
|
-
}
|
|
1067
|
-
const ao = a;
|
|
1068
|
-
const bo = b;
|
|
1069
|
-
const keys = Object.keys(ao).filter((k) => ao[k] !== void 0);
|
|
1070
|
-
if (keys.length !== Object.keys(bo).filter((k) => bo[k] !== void 0).length) return false;
|
|
1071
|
-
return keys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && sameChartJson(ao[k], bo[k]));
|
|
1072
|
-
}
|
|
1073
|
-
var fail = (message, issues) => ({ ok: false, message, ...issues ? { issues } : {} });
|
|
1074
|
-
var ChartController = class {
|
|
1075
|
-
history;
|
|
1076
|
-
spec;
|
|
1077
|
-
selection = null;
|
|
1078
|
-
issues = [];
|
|
1079
|
-
gestureKey = null;
|
|
1080
|
-
gestureCount = 0;
|
|
1081
|
-
gestureStart = null;
|
|
1082
|
-
revision = 0;
|
|
1083
|
-
applying = false;
|
|
1084
|
-
onChange;
|
|
1085
|
-
listeners = /* @__PURE__ */ new Set();
|
|
1086
|
-
unsubscribeHistory;
|
|
1087
|
-
state;
|
|
1088
|
-
constructor(options = {}) {
|
|
1089
|
-
this.history = options.history ?? new HistoryManager();
|
|
1090
|
-
this.onChange = options.onChange;
|
|
1091
|
-
const { spec, issues } = normalizeChartSpec(options.spec ?? {});
|
|
1092
|
-
this.spec = spec;
|
|
1093
|
-
this.issues = issues;
|
|
1094
|
-
this.state = this.computeState();
|
|
1095
|
-
this.connect();
|
|
1096
|
-
}
|
|
1097
|
-
connect() {
|
|
1098
|
-
this.unsubscribeHistory?.();
|
|
1099
|
-
this.unsubscribeHistory = this.history.subscribe((h) => {
|
|
1100
|
-
const s = this.state;
|
|
1101
|
-
if (this.applying || s.canUndo === h.canUndo && s.canRedo === h.canRedo && s.undoLabel === h.undoName && s.redoLabel === h.redoName) return;
|
|
1102
|
-
this.notify(false);
|
|
1103
|
-
});
|
|
1104
|
-
}
|
|
1105
|
-
// -------------------------------------------------------------------------
|
|
1106
|
-
// State
|
|
1107
|
-
// -------------------------------------------------------------------------
|
|
1108
|
-
/** The current snapshot (stable until the next change). */
|
|
1109
|
-
getState = () => this.state;
|
|
1110
|
-
/** Calls `listener` after every change. Returns an unsubscribe function. */
|
|
1111
|
-
subscribe = (listener) => {
|
|
1112
|
-
if (!this.unsubscribeHistory) this.connect();
|
|
1113
|
-
this.listeners.add(listener);
|
|
1114
|
-
return () => {
|
|
1115
|
-
this.listeners.delete(listener);
|
|
1116
|
-
};
|
|
1117
|
-
};
|
|
1118
|
-
/** The current spec (do not mutate; edit through the controller). */
|
|
1119
|
-
getSpec() {
|
|
1120
|
-
return this.spec;
|
|
1121
|
-
}
|
|
1122
|
-
getSeries(id) {
|
|
1123
|
-
return this.spec.series.find((s) => s.id === id);
|
|
1124
|
-
}
|
|
1125
|
-
computeState() {
|
|
1126
|
-
const h = this.history.getState();
|
|
1127
|
-
const sel = this.selection;
|
|
1128
|
-
const seriesId = sel?.kind === "series" || sel?.kind === "point" ? sel.seriesId : null;
|
|
1129
|
-
return {
|
|
1130
|
-
spec: this.spec,
|
|
1131
|
-
selection: sel,
|
|
1132
|
-
selectedSeries: seriesId ? this.spec.series.find((s) => s.id === seriesId) ?? null : null,
|
|
1133
|
-
selectedIndex: sel?.kind === "point" || sel?.kind === "category" ? sel.index : null,
|
|
1134
|
-
issues: this.issues,
|
|
1135
|
-
gesture: this.gestureKey,
|
|
1136
|
-
canUndo: h.canUndo,
|
|
1137
|
-
canRedo: h.canRedo,
|
|
1138
|
-
undoLabel: h.undoName,
|
|
1139
|
-
redoLabel: h.redoName,
|
|
1140
|
-
revision: this.revision
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
notify(specChanged) {
|
|
1144
|
-
this.revision++;
|
|
1145
|
-
this.state = this.computeState();
|
|
1146
|
-
if (specChanged) this.onChange?.(this.spec);
|
|
1147
|
-
const state = this.state;
|
|
1148
|
-
this.listeners.forEach((listener) => {
|
|
1149
|
-
try {
|
|
1150
|
-
listener(state);
|
|
1151
|
-
} catch (error) {
|
|
1152
|
-
console.error("[ChartController] Error in state listener:", error);
|
|
1153
|
-
}
|
|
1154
|
-
});
|
|
1155
|
-
}
|
|
1156
|
-
// -------------------------------------------------------------------------
|
|
1157
|
-
// Changes: every edit records a before / after snapshot of the spec
|
|
1158
|
-
// -------------------------------------------------------------------------
|
|
1159
|
-
/** Drops a selection that no longer points at anything. */
|
|
1160
|
-
validSelection(sel, spec) {
|
|
1161
|
-
if (!sel) return null;
|
|
1162
|
-
switch (sel.kind) {
|
|
1163
|
-
case "series":
|
|
1164
|
-
return spec.series.some((s) => s.id === sel.seriesId) ? sel : null;
|
|
1165
|
-
case "point":
|
|
1166
|
-
return spec.series.some((s) => s.id === sel.seriesId) && sel.index < spec.categories.length ? sel : null;
|
|
1167
|
-
case "category":
|
|
1168
|
-
return sel.index < spec.categories.length ? sel : null;
|
|
1169
|
-
case "title":
|
|
1170
|
-
return spec.title ? sel : null;
|
|
1171
|
-
case "subtitle":
|
|
1172
|
-
return spec.subtitle ? sel : null;
|
|
1173
|
-
default:
|
|
1174
|
-
return sel;
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
applySpec(spec) {
|
|
1178
|
-
this.applying = true;
|
|
1179
|
-
try {
|
|
1180
|
-
this.spec = spec;
|
|
1181
|
-
this.selection = this.validSelection(this.selection, spec);
|
|
1182
|
-
} finally {
|
|
1183
|
-
this.applying = false;
|
|
1184
|
-
}
|
|
1185
|
-
this.notify(true);
|
|
1186
|
-
}
|
|
1187
|
-
commit(label, next, select) {
|
|
1188
|
-
const before = this.spec;
|
|
1189
|
-
const hadIssues = this.issues.length > 0;
|
|
1190
|
-
this.issues = [];
|
|
1191
|
-
if (sameChartJson(before, next)) {
|
|
1192
|
-
const reselect = select !== void 0 && !sameChartSelection(this.selection, select) && !(select === null && this.selection === null);
|
|
1193
|
-
if (reselect) this.selection = select;
|
|
1194
|
-
if (reselect || hadIssues) this.notify(false);
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
const command = {
|
|
1198
|
-
name: label,
|
|
1199
|
-
tag: "chart",
|
|
1200
|
-
execute: () => this.applySpec(next),
|
|
1201
|
-
undo: () => this.applySpec(before),
|
|
1202
|
-
redo: () => this.applySpec(next)
|
|
1203
|
-
};
|
|
1204
|
-
if (select !== void 0) this.selection = select;
|
|
1205
|
-
if (this.gestureKey !== null) this.history.executeMerged(command, { key: `chart.gesture:${this.gestureKey}:${this.gestureCount}` });
|
|
1206
|
-
else this.history.execute(command);
|
|
1207
|
-
}
|
|
1208
|
-
reject(message, issues = []) {
|
|
1209
|
-
this.issues = issues.length ? issues : [{ path: "", message }];
|
|
1210
|
-
this.notify(false);
|
|
1211
|
-
return fail(message, issues.length ? issues : void 0);
|
|
1212
|
-
}
|
|
1213
|
-
/** A copy of the spec with `edit` applied (series and categories copied as needed). */
|
|
1214
|
-
edited(edit) {
|
|
1215
|
-
const draft = cloneChartSpec(this.spec);
|
|
1216
|
-
edit(draft);
|
|
1217
|
-
return draft;
|
|
1218
|
-
}
|
|
1219
|
-
unknownSeries(id) {
|
|
1220
|
-
const ids = this.spec.series.map((s) => `"${s.id}"`);
|
|
1221
|
-
return fail(`There is no series "${id}"; ${ids.length ? `expected one of ${ids.join(", ")}` : "the chart has no series yet"}.`);
|
|
1222
|
-
}
|
|
1223
|
-
badIndex(index) {
|
|
1224
|
-
const n = this.spec.categories.length;
|
|
1225
|
-
return fail(n ? `Category index ${index} is out of range; expected 0 to ${n - 1}.` : "The chart has no categories yet.");
|
|
1226
|
-
}
|
|
1227
|
-
// -------------------------------------------------------------------------
|
|
1228
|
-
// Presentation
|
|
1229
|
-
// -------------------------------------------------------------------------
|
|
1230
|
-
/**
|
|
1231
|
-
* Changes any presentation or data fields as one undo step. All-or-nothing: if anything
|
|
1232
|
-
* is invalid, nothing changes and every problem is returned (and shown in `state.issues`).
|
|
1233
|
-
*/
|
|
1234
|
-
update(patch, options = {}) {
|
|
1235
|
-
const s = this.spec;
|
|
1236
|
-
const merged = {
|
|
1237
|
-
...s,
|
|
1238
|
-
...patch,
|
|
1239
|
-
xAxis: patch.xAxis ? { ...s.xAxis, ...patch.xAxis } : s.xAxis,
|
|
1240
|
-
yAxis: patch.yAxis ? { ...s.yAxis, ...patch.yAxis } : s.yAxis,
|
|
1241
|
-
legend: patch.legend ? { ...s.legend, ...patch.legend } : s.legend,
|
|
1242
|
-
version: 1
|
|
1243
|
-
};
|
|
1244
|
-
const { spec, issues } = normalizeChartSpec(merged);
|
|
1245
|
-
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1246
|
-
if (JSON.stringify(spec) === JSON.stringify(s)) return { ok: true };
|
|
1247
|
-
this.commit(options.label ?? updateLabel(patch), spec);
|
|
1248
|
-
return { ok: true };
|
|
1249
|
-
}
|
|
1250
|
-
setType(type) {
|
|
1251
|
-
return this.update({ type }, { label: `Change chart type` });
|
|
1252
|
-
}
|
|
1253
|
-
setTitle(title) {
|
|
1254
|
-
return this.update({ title }, { label: "Edit title" });
|
|
1255
|
-
}
|
|
1256
|
-
setSubtitle(subtitle) {
|
|
1257
|
-
return this.update({ subtitle }, { label: "Edit subtitle" });
|
|
1258
|
-
}
|
|
1259
|
-
/** Changes the category (`x`) or value (`y`) axis. */
|
|
1260
|
-
setAxis(axis, patch) {
|
|
1261
|
-
return this.update(axis === "x" ? { xAxis: patch } : { yAxis: patch }, { label: `Change ${axis === "x" ? "category" : "value"} axis` });
|
|
1262
|
-
}
|
|
1263
|
-
setLegend(patch) {
|
|
1264
|
-
return this.update({ legend: patch }, { label: "Change legend" });
|
|
1265
|
-
}
|
|
1266
|
-
// -------------------------------------------------------------------------
|
|
1267
|
-
// Data
|
|
1268
|
-
// -------------------------------------------------------------------------
|
|
1269
|
-
/**
|
|
1270
|
-
* Replaces categories and / or series as one undo step. Values are validated and padded
|
|
1271
|
-
* to the categories; all-or-nothing like `update`.
|
|
1272
|
-
*/
|
|
1273
|
-
setData(data, options = {}) {
|
|
1274
|
-
return this.update({ ...data.categories ? { categories: data.categories } : {}, ...data.series ? { series: data.series } : {} }, { label: options.label ?? "Edit chart data" });
|
|
1275
|
-
}
|
|
1276
|
-
/**
|
|
1277
|
-
* Sets one value (numbers, numeric text such as `1,200` or `12%`, or blank for a gap).
|
|
1278
|
-
* One undo step, or part of the current gesture.
|
|
1279
|
-
*/
|
|
1280
|
-
setValue(seriesId, index, value, options = {}) {
|
|
1281
|
-
const series = this.getSeries(seriesId);
|
|
1282
|
-
if (!series) return this.unknownSeries(seriesId);
|
|
1283
|
-
if (!Number.isInteger(index) || index < 0 || index >= this.spec.categories.length) return this.badIndex(index);
|
|
1284
|
-
const parsed = parseChartValue(value);
|
|
1285
|
-
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.` }]);
|
|
1286
|
-
if (series.values[index] === parsed) {
|
|
1287
|
-
if (this.issues.length) {
|
|
1288
|
-
this.issues = [];
|
|
1289
|
-
this.notify(false);
|
|
1290
|
-
}
|
|
1291
|
-
return { ok: true };
|
|
1292
|
-
}
|
|
1293
|
-
const next = this.edited((d) => {
|
|
1294
|
-
d.series.find((s) => s.id === seriesId).values[index] = parsed;
|
|
1295
|
-
});
|
|
1296
|
-
this.commit(options.label ?? `Edit ${series.name} \xB7 ${this.spec.categories[index]}`, next);
|
|
1297
|
-
return { ok: true };
|
|
1298
|
-
}
|
|
1299
|
-
/** Several values as one undo step (all-or-nothing). */
|
|
1300
|
-
setValues(cells, options = {}) {
|
|
1301
|
-
const issues = [];
|
|
1302
|
-
const parsed = [];
|
|
1303
|
-
for (const cell of cells) {
|
|
1304
|
-
if (!this.getSeries(cell.seriesId)) issues.push({ path: cell.seriesId, message: this.unknownSeries(cell.seriesId).message });
|
|
1305
|
-
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 });
|
|
1306
|
-
else {
|
|
1307
|
-
const value = parseChartValue(cell.value);
|
|
1308
|
-
if (value === void 0) issues.push({ path: `${cell.seriesId}[${cell.index}]`, message: `"${String(cell.value)}" is not a number.` });
|
|
1309
|
-
else parsed.push({ seriesId: cell.seriesId, index: cell.index, value });
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1313
|
-
const next = this.edited((d) => {
|
|
1314
|
-
for (const cell of parsed) d.series.find((s) => s.id === cell.seriesId).values[cell.index] = cell.value;
|
|
1315
|
-
});
|
|
1316
|
-
if (JSON.stringify(next.series) !== JSON.stringify(this.spec.series)) this.commit(options.label ?? "Edit values", next);
|
|
1317
|
-
return { ok: true };
|
|
1318
|
-
}
|
|
1319
|
-
/** Adds a series (values padded to the categories) and selects it. Returns its id. */
|
|
1320
|
-
addSeries(input = {}, options = {}) {
|
|
1321
|
-
const id = input.id && !this.getSeries(input.id) ? input.id : uniqueChartSeriesId(this.spec.series);
|
|
1322
|
-
const name = input.name ?? `Series ${this.spec.series.length + 1}`;
|
|
1323
|
-
const at = Math.max(0, Math.min(this.spec.series.length, options.at ?? this.spec.series.length));
|
|
1324
|
-
const series = [...this.spec.series];
|
|
1325
|
-
series.splice(at, 0, { ...input, id, name, values: input.values ?? [] });
|
|
1326
|
-
const { spec, issues } = normalizeChartSpec({ ...this.spec, series });
|
|
1327
|
-
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1328
|
-
this.commit(`Add series "${name}"`, spec, { kind: "series", seriesId: id });
|
|
1329
|
-
return { ok: true, id };
|
|
1330
|
-
}
|
|
1331
|
-
removeSeries(id) {
|
|
1332
|
-
const series = this.getSeries(id);
|
|
1333
|
-
if (!series) return this.unknownSeries(id);
|
|
1334
|
-
this.commit(
|
|
1335
|
-
`Delete series "${series.name}"`,
|
|
1336
|
-
this.edited((d) => {
|
|
1337
|
-
d.series = d.series.filter((s) => s.id !== id);
|
|
1338
|
-
})
|
|
1339
|
-
);
|
|
1340
|
-
return { ok: true };
|
|
1341
|
-
}
|
|
1342
|
-
updateSeries(id, patch) {
|
|
1343
|
-
const series = this.getSeries(id);
|
|
1344
|
-
if (!series) return this.unknownSeries(id);
|
|
1345
|
-
const next = cloneChartSeries(series);
|
|
1346
|
-
if (patch.name !== void 0) next.name = String(patch.name);
|
|
1347
|
-
if (patch.color === null || patch.color === "") delete next.color;
|
|
1348
|
-
else if (patch.color !== void 0) next.color = patch.color;
|
|
1349
|
-
if (patch.type === null) delete next.type;
|
|
1350
|
-
else if (patch.type !== void 0) next.type = patch.type;
|
|
1351
|
-
if (patch.hidden !== void 0) {
|
|
1352
|
-
if (patch.hidden) next.hidden = true;
|
|
1353
|
-
else delete next.hidden;
|
|
1354
|
-
}
|
|
1355
|
-
const series2 = this.spec.series.map((s) => s.id === id ? next : s);
|
|
1356
|
-
const { spec, issues } = normalizeChartSpec({ ...this.spec, series: series2 });
|
|
1357
|
-
if (issues.length) return this.reject(issues.map((i) => i.message).join(" "), issues);
|
|
1358
|
-
if (JSON.stringify(spec.series) === JSON.stringify(this.spec.series)) return { ok: true };
|
|
1359
|
-
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}"`;
|
|
1360
|
-
this.commit(label, spec);
|
|
1361
|
-
return { ok: true };
|
|
1362
|
-
}
|
|
1363
|
-
toggleSeries(id) {
|
|
1364
|
-
const series = this.getSeries(id);
|
|
1365
|
-
if (!series) return this.unknownSeries(id);
|
|
1366
|
-
return this.updateSeries(id, { hidden: !series.hidden });
|
|
1367
|
-
}
|
|
1368
|
-
/** Moves a series to position `to` (drawing and legend order). */
|
|
1369
|
-
moveSeries(id, to) {
|
|
1370
|
-
const from = this.spec.series.findIndex((s) => s.id === id);
|
|
1371
|
-
if (from < 0) return this.unknownSeries(id);
|
|
1372
|
-
const target = Math.max(0, Math.min(this.spec.series.length - 1, to));
|
|
1373
|
-
if (target === from) return { ok: true };
|
|
1374
|
-
this.commit(
|
|
1375
|
-
"Reorder series",
|
|
1376
|
-
this.edited((d) => {
|
|
1377
|
-
const [s] = d.series.splice(from, 1);
|
|
1378
|
-
d.series.splice(target, 0, s);
|
|
1379
|
-
})
|
|
1380
|
-
);
|
|
1381
|
-
return { ok: true };
|
|
1382
|
-
}
|
|
1383
|
-
/** Inserts a category (with gaps in every series) at `at` (default: the end). Returns its index. */
|
|
1384
|
-
addCategory(label, options = {}) {
|
|
1385
|
-
const at = Math.max(0, Math.min(this.spec.categories.length, options.at ?? this.spec.categories.length));
|
|
1386
|
-
const name = label ?? `${this.spec.categories.length + 1}`;
|
|
1387
|
-
this.commit(
|
|
1388
|
-
`Add category "${name}"`,
|
|
1389
|
-
this.edited((d) => {
|
|
1390
|
-
d.categories.splice(at, 0, name);
|
|
1391
|
-
for (const s of d.series) s.values.splice(at, 0, null);
|
|
1392
|
-
}),
|
|
1393
|
-
{ kind: "category", index: at }
|
|
1394
|
-
);
|
|
1395
|
-
return { ok: true, index: at };
|
|
1396
|
-
}
|
|
1397
|
-
removeCategory(index) {
|
|
1398
|
-
if (!Number.isInteger(index) || index < 0 || index >= this.spec.categories.length) return this.badIndex(index);
|
|
1399
|
-
this.commit(
|
|
1400
|
-
`Delete category "${this.spec.categories[index]}"`,
|
|
1401
|
-
this.edited((d) => {
|
|
1402
|
-
d.categories.splice(index, 1);
|
|
1403
|
-
for (const s of d.series) s.values.splice(index, 1);
|
|
1404
|
-
})
|
|
1405
|
-
);
|
|
1406
|
-
return { ok: true };
|
|
1407
|
-
}
|
|
1408
|
-
renameCategory(index, label) {
|
|
1409
|
-
if (!Number.isInteger(index) || index < 0 || index >= this.spec.categories.length) return this.badIndex(index);
|
|
1410
|
-
if (this.spec.categories[index] === label) return { ok: true };
|
|
1411
|
-
this.commit(
|
|
1412
|
-
"Rename category",
|
|
1413
|
-
this.edited((d) => {
|
|
1414
|
-
d.categories[index] = String(label);
|
|
1415
|
-
})
|
|
1416
|
-
);
|
|
1417
|
-
return { ok: true };
|
|
1418
|
-
}
|
|
1419
|
-
moveCategory(from, to) {
|
|
1420
|
-
if (!Number.isInteger(from) || from < 0 || from >= this.spec.categories.length) return this.badIndex(from);
|
|
1421
|
-
const target = Math.max(0, Math.min(this.spec.categories.length - 1, to));
|
|
1422
|
-
if (target === from) return { ok: true };
|
|
1423
|
-
const move = (list) => {
|
|
1424
|
-
const [item] = list.splice(from, 1);
|
|
1425
|
-
list.splice(target, 0, item);
|
|
1426
|
-
};
|
|
1427
|
-
this.commit(
|
|
1428
|
-
"Reorder categories",
|
|
1429
|
-
this.edited((d) => {
|
|
1430
|
-
move(d.categories);
|
|
1431
|
-
for (const s of d.series) move(s.values);
|
|
1432
|
-
})
|
|
1433
|
-
);
|
|
1434
|
-
return { ok: true };
|
|
223
|
+
add("bar", "Column", "Several series: clustered columns compare them per category.");
|
|
224
|
+
add("bar", "Bar", "Horizontal bars fit long category names.");
|
|
225
|
+
add("bar", "Stacked column", "Shows how the series add up.", "stacked");
|
|
226
|
+
add("line", "Line", "Shows the series in order.");
|
|
227
|
+
if (shape.points >= 3 && shape.series <= 6) add("radar", "Radar", "Compares several measures across the series.");
|
|
1435
228
|
}
|
|
1436
|
-
|
|
1437
|
-
* Pastes a block of cells (TSV / CSV text or rows) into the data grid with its top-left at
|
|
1438
|
-
* `row` (category index) and `column` (series index; `-1` is the category column). The
|
|
1439
|
-
* grid grows to fit. Non-numeric value cells become gaps and are reported in `issues`.
|
|
1440
|
-
*/
|
|
1441
|
-
pasteCells(row, column, cells) {
|
|
1442
|
-
const rows = typeof cells === "string" ? cells.includes(" ") ? parseTsv(cells) : parseCsv(cells) : cells.map((r) => [...r]);
|
|
1443
|
-
if (!rows.length) return { ok: true };
|
|
1444
|
-
const issues = [];
|
|
1445
|
-
const next = this.edited((d) => {
|
|
1446
|
-
const width = Math.max(...rows.map((r) => r.length));
|
|
1447
|
-
const lastSeries = column + width - 1;
|
|
1448
|
-
while (d.series.length <= lastSeries) {
|
|
1449
|
-
const id = uniqueChartSeriesId(d.series);
|
|
1450
|
-
d.series.push({ id, name: `Series ${d.series.length + 1}`, values: d.categories.map(() => null) });
|
|
1451
|
-
}
|
|
1452
|
-
const lastRow = row + rows.length - 1;
|
|
1453
|
-
while (d.categories.length <= lastRow) {
|
|
1454
|
-
d.categories.push(`${d.categories.length + 1}`);
|
|
1455
|
-
for (const s of d.series) s.values.push(null);
|
|
1456
|
-
}
|
|
1457
|
-
rows.forEach((cellsInRow, r) => {
|
|
1458
|
-
cellsInRow.forEach((cell, c) => {
|
|
1459
|
-
const col = column + c;
|
|
1460
|
-
const index = row + r;
|
|
1461
|
-
if (col < 0) d.categories[index] = cell === null || cell === void 0 ? "" : String(cell).trim();
|
|
1462
|
-
else {
|
|
1463
|
-
const parsed = parseChartValue(cell);
|
|
1464
|
-
if (parsed === void 0) issues.push({ path: `${d.series[col].id}[${index}]`, message: `"${String(cell)}" is not a number; it was left blank.` });
|
|
1465
|
-
d.series[col].values[index] = parsed ?? null;
|
|
1466
|
-
}
|
|
1467
|
-
});
|
|
1468
|
-
});
|
|
1469
|
-
});
|
|
1470
|
-
this.commit("Paste cells", next);
|
|
1471
|
-
if (issues.length) {
|
|
1472
|
-
this.issues = issues;
|
|
1473
|
-
this.notify(false);
|
|
1474
|
-
}
|
|
1475
|
-
return issues.length ? { ok: true, issues } : { ok: true };
|
|
1476
|
-
}
|
|
1477
|
-
/**
|
|
1478
|
-
* Replaces the data with a grid of cells (see `chartDataFromTable`): one undo step. Series
|
|
1479
|
-
* colours and types are kept by name.
|
|
1480
|
-
*/
|
|
1481
|
-
importTable(rows, options = {}) {
|
|
1482
|
-
const data = chartDataFromTable(rows, options);
|
|
1483
|
-
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.");
|
|
1484
|
-
const byName = new Map(this.spec.series.map((s) => [s.name, s]));
|
|
1485
|
-
const series = data.series.map((s) => {
|
|
1486
|
-
const old = byName.get(s.name);
|
|
1487
|
-
return { ...s, ...old?.color ? { color: old.color } : {}, ...old?.type ? { type: old.type } : {}, ...old ? { id: old.id } : {} };
|
|
1488
|
-
});
|
|
1489
|
-
const { spec } = normalizeChartSpec({ ...this.spec, categories: data.categories, series });
|
|
1490
|
-
this.commit("Import data", spec);
|
|
1491
|
-
if (data.issues.length) {
|
|
1492
|
-
this.issues = data.issues;
|
|
1493
|
-
this.notify(false);
|
|
1494
|
-
return { ok: true, issues: data.issues };
|
|
1495
|
-
}
|
|
1496
|
-
return { ok: true };
|
|
1497
|
-
}
|
|
1498
|
-
/** Imports CSV (or TSV, detected by tabs) text; see `importTable`. */
|
|
1499
|
-
importCsv(text2, options = {}) {
|
|
1500
|
-
const delimiter = options.delimiter ?? (text2.includes(" ") ? " " : ",");
|
|
1501
|
-
return this.importTable(parseCsv(text2, { delimiter }), options);
|
|
1502
|
-
}
|
|
1503
|
-
// -------------------------------------------------------------------------
|
|
1504
|
-
// Selection and gestures
|
|
1505
|
-
// -------------------------------------------------------------------------
|
|
1506
|
-
/** Selects a chart element (view state; not an undo step). */
|
|
1507
|
-
select(selection) {
|
|
1508
|
-
const next = this.validSelection(selection, this.spec);
|
|
1509
|
-
if (sameChartSelection(next, this.selection) || next === null && this.selection === null) return;
|
|
1510
|
-
this.selection = next;
|
|
1511
|
-
this.notify(false);
|
|
1512
|
-
}
|
|
1513
|
-
/**
|
|
1514
|
-
* Deletes what is selected: a series or category is removed, a point becomes a gap, a
|
|
1515
|
-
* title is cleared, the legend or an axis is hidden.
|
|
1516
|
-
*/
|
|
1517
|
-
deleteSelection() {
|
|
1518
|
-
const sel = this.selection;
|
|
1519
|
-
if (!sel) return fail("Nothing is selected.");
|
|
1520
|
-
switch (sel.kind) {
|
|
1521
|
-
case "series":
|
|
1522
|
-
return this.removeSeries(sel.seriesId);
|
|
1523
|
-
case "category":
|
|
1524
|
-
return this.removeCategory(sel.index);
|
|
1525
|
-
case "point":
|
|
1526
|
-
return this.setValue(sel.seriesId, sel.index, null, { label: "Clear value" });
|
|
1527
|
-
case "title":
|
|
1528
|
-
return this.setTitle("");
|
|
1529
|
-
case "subtitle":
|
|
1530
|
-
return this.setSubtitle("");
|
|
1531
|
-
case "legend":
|
|
1532
|
-
return this.setLegend({ show: false });
|
|
1533
|
-
case "axis":
|
|
1534
|
-
return this.setAxis(sel.axis, { show: false });
|
|
1535
|
-
default:
|
|
1536
|
-
return fail("The chart itself cannot be deleted.");
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
/** Adds `delta` to a value (a gap counts as 0). */
|
|
1540
|
-
nudgeValue(seriesId, index, delta) {
|
|
1541
|
-
const series = this.getSeries(seriesId);
|
|
1542
|
-
if (!series) return this.unknownSeries(seriesId);
|
|
1543
|
-
const current = series.values[index] ?? 0;
|
|
1544
|
-
return this.setValue(seriesId, index, Number((current + delta).toPrecision(12)), { label: `Edit ${series.name} \xB7 ${this.spec.categories[index] ?? index}` });
|
|
1545
|
-
}
|
|
1546
|
-
/** Starts a continuous edit (a drag): every change until `endGesture()` is one undo step. */
|
|
1547
|
-
beginGesture(key) {
|
|
1548
|
-
if (this.gestureKey === key) return;
|
|
1549
|
-
this.history.endGesture();
|
|
1550
|
-
this.gestureKey = key;
|
|
1551
|
-
this.gestureCount++;
|
|
1552
|
-
this.gestureStart = { spec: this.spec, mark: this.history.mark() };
|
|
1553
|
-
this.notify(false);
|
|
1554
|
-
}
|
|
1555
|
-
endGesture() {
|
|
1556
|
-
if (this.gestureKey === null) return;
|
|
1557
|
-
this.gestureKey = null;
|
|
1558
|
-
this.history.endGesture();
|
|
1559
|
-
const start = this.gestureStart;
|
|
1560
|
-
this.gestureStart = null;
|
|
1561
|
-
if (start && sameChartJson(start.spec, this.spec)) this.history.revertTo(start.mark);
|
|
1562
|
-
this.notify(false);
|
|
1563
|
-
}
|
|
1564
|
-
// -------------------------------------------------------------------------
|
|
1565
|
-
// Persistence and history
|
|
1566
|
-
// -------------------------------------------------------------------------
|
|
1567
|
-
/** The spec, for saving (a copy). */
|
|
1568
|
-
toJSON() {
|
|
1569
|
-
return cloneChartSpec(this.spec);
|
|
1570
|
-
}
|
|
1571
|
-
/**
|
|
1572
|
-
* Replaces the chart without an undo step (e.g. when opening a document). Problems are
|
|
1573
|
-
* fixed with defaults and reported in `state.issues` (and returned).
|
|
1574
|
-
*/
|
|
1575
|
-
load(input, options = {}) {
|
|
1576
|
-
const { spec, issues } = normalizeChartSpec(input);
|
|
1577
|
-
this.spec = spec;
|
|
1578
|
-
this.issues = issues;
|
|
1579
|
-
this.selection = this.validSelection(this.selection, spec);
|
|
1580
|
-
if (options.clearHistory) {
|
|
1581
|
-
this.applying = true;
|
|
1582
|
-
try {
|
|
1583
|
-
this.history.clear();
|
|
1584
|
-
} finally {
|
|
1585
|
-
this.applying = false;
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
this.notify(true);
|
|
1589
|
-
return issues;
|
|
1590
|
-
}
|
|
1591
|
-
undo() {
|
|
1592
|
-
this.endGesture();
|
|
1593
|
-
return this.history.undo();
|
|
1594
|
-
}
|
|
1595
|
-
redo() {
|
|
1596
|
-
this.endGesture();
|
|
1597
|
-
return this.history.redo();
|
|
1598
|
-
}
|
|
1599
|
-
/** Stops listening to the history (reconnected by the next `subscribe`). */
|
|
1600
|
-
destroy() {
|
|
1601
|
-
this.unsubscribeHistory?.();
|
|
1602
|
-
this.unsubscribeHistory = void 0;
|
|
1603
|
-
this.listeners.clear();
|
|
1604
|
-
}
|
|
1605
|
-
};
|
|
1606
|
-
function createChartController(options = {}) {
|
|
1607
|
-
return new ChartController(options);
|
|
229
|
+
return out;
|
|
1608
230
|
}
|
|
1609
|
-
function
|
|
1610
|
-
const
|
|
1611
|
-
|
|
1612
|
-
const
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
dataLabels: "Toggle data labels",
|
|
1626
|
-
palette: "Change colours",
|
|
1627
|
-
innerRadius: "Change donut hole"
|
|
1628
|
-
};
|
|
1629
|
-
return labels[keys[0]] ?? "Change chart";
|
|
231
|
+
function chartFromTable(rows, options = {}) {
|
|
232
|
+
const shape = analyzeChartTable(rows);
|
|
233
|
+
const rec = (options.type ?? "auto") === "auto" ? recommendForShape(shape)[0] : void 0;
|
|
234
|
+
const type = rec?.type ?? (options.type === "auto" || !options.type ? "bar" : options.type);
|
|
235
|
+
const stacking = rec?.stacking ?? "none";
|
|
236
|
+
const grid = rows.filter((r) => r.some((c) => !blank(c))).map((r) => [...r]);
|
|
237
|
+
const width = Math.max(0, ...grid.map((r) => r.length));
|
|
238
|
+
let table = grid;
|
|
239
|
+
if (!shape.labelColumn) table = table.map((r, i) => [shape.headerRow && i === 0 ? "" : String(i + (shape.headerRow ? 0 : 1)), ...r]);
|
|
240
|
+
if (!shape.headerRow) table = [["", ...Array.from({ length: width }, (_, i) => `Series ${i + 1}`)], ...table];
|
|
241
|
+
const xFromFirst = type === "scatter" && !shape.labelColumn && !shape.byRows && shape.series > 1;
|
|
242
|
+
if (xFromFirst) {
|
|
243
|
+
table = table.map((r) => [r[1], ...r.slice(2)]);
|
|
244
|
+
}
|
|
245
|
+
const data = chartDataFromTable(table, { seriesIn: shape.byRows ? "rows" : "columns", header: true, xFromFirstColumn: xFromFirst });
|
|
246
|
+
return { type, stacking, categories: data.categories, series: data.series, issues: data.issues, shape };
|
|
1630
247
|
}
|
|
1631
248
|
|
|
1632
249
|
// src/chart/commands.ts
|
|
1633
|
-
var
|
|
1634
|
-
throw new Error(message);
|
|
1635
|
-
};
|
|
1636
|
-
var check = (result) => {
|
|
1637
|
-
if (!result.ok) fail2(result.message ?? "The change was rejected.");
|
|
1638
|
-
};
|
|
1639
|
-
var VALUE = { type: ["number", "string", "null"], description: 'A number, numeric text ("1,200", "12%") or null for a gap.' };
|
|
1640
|
-
var FORMAT = {
|
|
1641
|
-
type: "object",
|
|
1642
|
-
properties: {
|
|
1643
|
-
style: { type: "string", enum: [...CHART_NUMBER_STYLES] },
|
|
1644
|
-
decimals: { type: "integer", minimum: 0, maximum: 10 },
|
|
1645
|
-
currency: { type: "string", description: 'ISO code for style "currency", e.g. "EUR".' },
|
|
1646
|
-
prefix: { type: "string" },
|
|
1647
|
-
suffix: { type: "string" }
|
|
1648
|
-
},
|
|
1649
|
-
additionalProperties: false
|
|
1650
|
-
};
|
|
1651
|
-
var AXIS = {
|
|
1652
|
-
type: "object",
|
|
1653
|
-
properties: {
|
|
1654
|
-
show: { type: "boolean" },
|
|
1655
|
-
title: { type: "string" },
|
|
1656
|
-
min: { type: ["number", "null"], description: "Fixed lower bound, or null for automatic." },
|
|
1657
|
-
max: { type: ["number", "null"], description: "Fixed upper bound, or null for automatic." },
|
|
1658
|
-
gridlines: { type: "boolean" },
|
|
1659
|
-
format: FORMAT
|
|
1660
|
-
},
|
|
1661
|
-
additionalProperties: false
|
|
250
|
+
var fail = (message) => {
|
|
251
|
+
throw new Error(message);
|
|
1662
252
|
};
|
|
1663
|
-
var
|
|
1664
|
-
|
|
1665
|
-
properties: {
|
|
1666
|
-
id: { type: "string" },
|
|
1667
|
-
name: { type: "string" },
|
|
1668
|
-
values: { type: "array", items: VALUE, description: "One value per category." },
|
|
1669
|
-
color: { type: "string", description: "CSS colour." },
|
|
1670
|
-
type: { type: "string", enum: [...CHART_SERIES_TYPES], description: "Draw this series differently (combo charts)." },
|
|
1671
|
-
hidden: { type: "boolean" }
|
|
1672
|
-
},
|
|
1673
|
-
required: ["name", "values"],
|
|
1674
|
-
additionalProperties: false
|
|
253
|
+
var check = (result) => {
|
|
254
|
+
if (!result.ok) fail(result.message ?? "The change was rejected.");
|
|
1675
255
|
};
|
|
1676
256
|
function createChartCommands(controller, options = {}) {
|
|
1677
257
|
const prefix = options.prefix ?? "chart.";
|
|
@@ -1680,53 +260,37 @@ function createChartCommands(controller, options = {}) {
|
|
|
1680
260
|
const resolveSeries = (ref) => {
|
|
1681
261
|
const spec = controller.getSpec();
|
|
1682
262
|
const series = spec.series.find((s) => s.id === ref) ?? spec.series.find((s) => s.name === ref);
|
|
1683
|
-
if (!series)
|
|
263
|
+
if (!series) fail(`There is no series "${ref}"; expected one of ${seriesIds().map((id) => `"${id}"`).join(", ") || "(none yet)"}.`);
|
|
1684
264
|
return series;
|
|
1685
265
|
};
|
|
1686
266
|
const resolveCategory = (ref) => {
|
|
1687
267
|
const categories = controller.getSpec().categories;
|
|
1688
268
|
const index = typeof ref === "number" ? ref : categories.indexOf(ref);
|
|
1689
|
-
if (index < 0 || index >= categories.length || !Number.isInteger(index))
|
|
269
|
+
if (index < 0 || index >= categories.length || !Number.isInteger(index)) fail(`There is no category ${JSON.stringify(ref)}; expected an index from 0 to ${categories.length - 1} or one of the labels.`);
|
|
1690
270
|
return index;
|
|
1691
271
|
};
|
|
272
|
+
const object = (properties, required) => ({ type: "object", properties, ...required ? { required } : {}, additionalProperties: false });
|
|
1692
273
|
return [
|
|
1693
274
|
{
|
|
1694
275
|
...base,
|
|
1695
276
|
id: `${prefix}describe`,
|
|
1696
277
|
label: "Describe Chart",
|
|
1697
278
|
readOnly: true,
|
|
1698
|
-
keywords: ["chart", "data", "spec", "get"],
|
|
1699
|
-
description: "Returns the chart spec
|
|
1700
|
-
|
|
279
|
+
keywords: ["chart", "data", "spec", "get", "summary"],
|
|
280
|
+
description: "Returns the chart spec, its data as a table (series names in the header row) and a summary: per-series min / max / mean / trend, axes as drawn, layout warnings and a colour check.",
|
|
281
|
+
params: object({ width: WIDTH, height: HEIGHT }),
|
|
282
|
+
run: (_ctx, params = {}) => {
|
|
1701
283
|
const spec = controller.getSpec();
|
|
1702
|
-
return { spec: controller.toJSON(), table: chartDataToTable(spec), issues: controller.getState().issues };
|
|
284
|
+
return { spec: controller.toJSON(), table: chartDataToTable(spec), summary: summarizeChart(controller.toJSON(), { width: params?.width ?? 640, height: params?.height ?? 400 }), issues: controller.getState().issues };
|
|
1703
285
|
}
|
|
1704
286
|
},
|
|
1705
287
|
{
|
|
1706
288
|
...base,
|
|
1707
289
|
id: `${prefix}update`,
|
|
1708
290
|
label: "Change Chart\u2026",
|
|
1709
|
-
keywords: ["chart", "title", "axis", "legend", "stack", "style", "format"],
|
|
1710
|
-
description: "Changes chart presentation as one undo step: type, titles, axes (
|
|
1711
|
-
params:
|
|
1712
|
-
type: "object",
|
|
1713
|
-
properties: {
|
|
1714
|
-
type: { type: "string", enum: [...CHART_TYPES] },
|
|
1715
|
-
title: { type: "string" },
|
|
1716
|
-
subtitle: { type: "string" },
|
|
1717
|
-
xAxis: AXIS,
|
|
1718
|
-
yAxis: AXIS,
|
|
1719
|
-
legend: { type: "object", properties: { show: { type: "boolean" }, position: { type: "string", enum: [...CHART_LEGEND_POSITIONS] } }, additionalProperties: false },
|
|
1720
|
-
stacking: { type: "string", enum: [...CHART_STACKINGS] },
|
|
1721
|
-
orientation: { type: "string", enum: [...CHART_ORIENTATIONS] },
|
|
1722
|
-
curve: { type: "string", enum: [...CHART_CURVES] },
|
|
1723
|
-
points: { type: "boolean" },
|
|
1724
|
-
dataLabels: { type: "boolean" },
|
|
1725
|
-
palette: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
1726
|
-
innerRadius: { type: "number", minimum: 0, maximum: 0.9 }
|
|
1727
|
-
},
|
|
1728
|
-
additionalProperties: false
|
|
1729
|
-
},
|
|
291
|
+
keywords: ["chart", "title", "axis", "legend", "stack", "style", "format", "type"],
|
|
292
|
+
description: "Changes chart presentation as one undo step: type, titles, axes (xAxis = categories, yAxis = values, yAxis2 = secondary values), legend, stacking, orientation, curve, points, data labels, palette, donut hole, bar spacing, histogram bins or waterfall total. Invalid values change nothing and return why.",
|
|
293
|
+
params: object(CHART_UPDATE_PROPERTIES),
|
|
1730
294
|
run: (_ctx, params) => {
|
|
1731
295
|
check(controller.update(params));
|
|
1732
296
|
return { spec: controller.toJSON() };
|
|
@@ -1738,14 +302,10 @@ function createChartCommands(controller, options = {}) {
|
|
|
1738
302
|
label: "Replace Chart Data\u2026",
|
|
1739
303
|
keywords: ["chart", "data", "series", "categories"],
|
|
1740
304
|
description: "Replaces the categories and series as one undo step. Each series has one value per category (padded with gaps).",
|
|
1741
|
-
params: {
|
|
1742
|
-
type: "
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
series: { type: "array", items: SERIES }
|
|
1746
|
-
},
|
|
1747
|
-
additionalProperties: false
|
|
1748
|
-
},
|
|
305
|
+
params: object({
|
|
306
|
+
categories: { type: "array", items: { type: "string" }, description: "Category labels in order." },
|
|
307
|
+
series: { type: "array", description: "The new series.", items: object({ id: { type: "string", description: "Stable id (optional)." }, ...SERIES_PROPS }, ["name", "values"]) }
|
|
308
|
+
}),
|
|
1749
309
|
run: (_ctx, params) => {
|
|
1750
310
|
check(controller.setData(params));
|
|
1751
311
|
return { table: chartDataToTable(controller.getSpec()) };
|
|
@@ -1757,22 +317,7 @@ function createChartCommands(controller, options = {}) {
|
|
|
1757
317
|
label: "Set Chart Values\u2026",
|
|
1758
318
|
keywords: ["chart", "value", "cell", "edit"],
|
|
1759
319
|
description: "Sets individual values as one undo step. Series by id or name; category by index or label.",
|
|
1760
|
-
params: {
|
|
1761
|
-
type: "object",
|
|
1762
|
-
properties: {
|
|
1763
|
-
cells: {
|
|
1764
|
-
type: "array",
|
|
1765
|
-
minItems: 1,
|
|
1766
|
-
items: {
|
|
1767
|
-
type: "object",
|
|
1768
|
-
properties: { series: { type: "string" }, category: { type: ["string", "integer"] }, value: VALUE },
|
|
1769
|
-
required: ["series", "category", "value"],
|
|
1770
|
-
additionalProperties: false
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
},
|
|
1774
|
-
required: ["cells"]
|
|
1775
|
-
},
|
|
320
|
+
params: object({ cells: { type: "array", minItems: 1, description: "Values to set.", items: object({ series: SERIES_REF, category: CATEGORY_REF, value: VALUE }, ["series", "category", "value"]) } }, ["cells"]),
|
|
1776
321
|
run: (_ctx, params) => {
|
|
1777
322
|
check(controller.setValues(params.cells.map((c) => ({ seriesId: resolveSeries(c.series).id, index: resolveCategory(c.category), value: c.value }))));
|
|
1778
323
|
return { table: chartDataToTable(controller.getSpec()) };
|
|
@@ -1784,10 +329,11 @@ function createChartCommands(controller, options = {}) {
|
|
|
1784
329
|
label: "Add Series",
|
|
1785
330
|
keywords: ["chart", "series", "new"],
|
|
1786
331
|
description: "Adds a series (default: an empty one at the end). Returns its id.",
|
|
1787
|
-
params: { type: "
|
|
332
|
+
params: object({ ...SERIES_PROPS, id: { type: "string", description: "Id to use (default series-N)." }, at: { type: "integer", minimum: 0, description: "Position (default: last)." } }),
|
|
1788
333
|
run: (_ctx, params = {}) => {
|
|
1789
334
|
const { at, ...input } = params ?? {};
|
|
1790
|
-
const
|
|
335
|
+
const clean = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== null));
|
|
336
|
+
const result = controller.addSeries(clean, { at });
|
|
1791
337
|
check(result);
|
|
1792
338
|
return { id: result.id };
|
|
1793
339
|
}
|
|
@@ -1796,20 +342,9 @@ function createChartCommands(controller, options = {}) {
|
|
|
1796
342
|
...base,
|
|
1797
343
|
id: `${prefix}updateSeries`,
|
|
1798
344
|
label: "Change Series\u2026",
|
|
1799
|
-
keywords: ["chart", "series", "rename", "colour", "color", "hide"],
|
|
1800
|
-
description: "Renames, recolours, hides or changes how a series is drawn (null
|
|
1801
|
-
params: {
|
|
1802
|
-
type: "object",
|
|
1803
|
-
properties: {
|
|
1804
|
-
series: { type: "string", description: "Series id or name." },
|
|
1805
|
-
name: { type: "string" },
|
|
1806
|
-
color: { type: ["string", "null"] },
|
|
1807
|
-
type: { type: ["string", "null"], enum: [...CHART_SERIES_TYPES, null] },
|
|
1808
|
-
hidden: { type: "boolean" }
|
|
1809
|
-
},
|
|
1810
|
-
required: ["series"],
|
|
1811
|
-
additionalProperties: false
|
|
1812
|
-
},
|
|
345
|
+
keywords: ["chart", "series", "rename", "colour", "color", "hide", "axis", "marker"],
|
|
346
|
+
description: "Renames, recolours, hides, moves to the secondary axis or changes how a series is drawn (null resets a field).",
|
|
347
|
+
params: object({ series: SERIES_REF, ...Object.fromEntries(Object.entries(SERIES_PROPS).filter(([k]) => k !== "values")) }, ["series"]),
|
|
1813
348
|
run: (_ctx, params) => {
|
|
1814
349
|
const { series, ...patch } = params;
|
|
1815
350
|
const id = resolveSeries(series).id;
|
|
@@ -1823,7 +358,7 @@ function createChartCommands(controller, options = {}) {
|
|
|
1823
358
|
label: "Delete Series",
|
|
1824
359
|
keywords: ["chart", "series", "remove"],
|
|
1825
360
|
description: "Deletes a series by id or name.",
|
|
1826
|
-
params:
|
|
361
|
+
params: object({ series: SERIES_REF }, ["series"]),
|
|
1827
362
|
run: (_ctx, params) => {
|
|
1828
363
|
check(controller.removeSeries(resolveSeries(params.series).id));
|
|
1829
364
|
return { series: seriesIds() };
|
|
@@ -1835,7 +370,7 @@ function createChartCommands(controller, options = {}) {
|
|
|
1835
370
|
label: "Add Category",
|
|
1836
371
|
keywords: ["chart", "category", "row", "label"],
|
|
1837
372
|
description: "Inserts a category (gaps in every series) at `at` (default: the end).",
|
|
1838
|
-
params: { type: "
|
|
373
|
+
params: object({ label: { type: "string", description: "Category label (default: its number)." }, at: { type: "integer", minimum: 0, description: "Index to insert at (default: the end)." } }),
|
|
1839
374
|
run: (_ctx, params = {}) => controller.addCategory(params?.label, { at: params?.at })
|
|
1840
375
|
},
|
|
1841
376
|
{
|
|
@@ -1844,46 +379,156 @@ function createChartCommands(controller, options = {}) {
|
|
|
1844
379
|
label: "Delete Category",
|
|
1845
380
|
keywords: ["chart", "category", "row", "remove"],
|
|
1846
381
|
description: "Deletes a category (and its values) by index or label.",
|
|
1847
|
-
params:
|
|
382
|
+
params: object({ category: CATEGORY_REF }, ["category"]),
|
|
1848
383
|
run: (_ctx, params) => {
|
|
1849
384
|
check(controller.removeCategory(resolveCategory(params.category)));
|
|
1850
385
|
return { categories: controller.getSpec().categories };
|
|
1851
386
|
}
|
|
1852
387
|
},
|
|
388
|
+
{
|
|
389
|
+
...base,
|
|
390
|
+
id: `${prefix}addTrendline`,
|
|
391
|
+
label: "Add Trendline\u2026",
|
|
392
|
+
keywords: ["chart", "trend", "regression", "fit", "forecast", "moving average"],
|
|
393
|
+
description: "Fits a trendline to a series (linear, exponential, logarithmic, power, polynomial or moving average). Returns its index and the fit.",
|
|
394
|
+
params: object({ series: SERIES_REF, ...TRENDLINE_PROPS }, ["series", "type"]),
|
|
395
|
+
run: (_ctx, params) => {
|
|
396
|
+
const { series, ...line } = params;
|
|
397
|
+
const result = controller.addTrendline(resolveSeries(series).id, line);
|
|
398
|
+
check(result);
|
|
399
|
+
const ok = result;
|
|
400
|
+
return { index: ok.index, ...ok.fit ? { coefficients: ok.fit.coefficients, r2: ok.fit.r2, equation: ok.fit.equation } : {} };
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
...base,
|
|
405
|
+
id: `${prefix}removeTrendline`,
|
|
406
|
+
label: "Delete Trendline",
|
|
407
|
+
keywords: ["chart", "trend", "remove"],
|
|
408
|
+
description: "Deletes a trendline from a series.",
|
|
409
|
+
params: object({ series: SERIES_REF, index: { type: "integer", minimum: 0, description: "Trendline index (0 = first)." } }, ["series", "index"]),
|
|
410
|
+
run: (_ctx, params) => {
|
|
411
|
+
check(controller.removeTrendline(resolveSeries(params.series).id, params.index));
|
|
412
|
+
return { trendlines: controller.getSeries(resolveSeries(params.series).id)?.trendlines ?? [] };
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
...base,
|
|
417
|
+
id: `${prefix}applyPreset`,
|
|
418
|
+
label: "Apply Chart Style\u2026",
|
|
419
|
+
keywords: ["chart", "quick layout", "style", "colours", "colors", "monochrome", "theme"],
|
|
420
|
+
description: "Applies a quick layout and / or colour scheme as one undo step.",
|
|
421
|
+
params: object({
|
|
422
|
+
layout: { type: "string", enum: CHART_LAYOUT_PRESETS.map((p) => p.id), description: "Quick layout id." },
|
|
423
|
+
colors: { type: "string", enum: CHART_COLOR_SCHEMES.map((p) => p.id), description: "Colour scheme id." }
|
|
424
|
+
}),
|
|
425
|
+
run: (_ctx, params = {}) => {
|
|
426
|
+
check(controller.applyPreset(params ?? {}));
|
|
427
|
+
return { spec: controller.toJSON() };
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
...base,
|
|
432
|
+
id: `${prefix}transpose`,
|
|
433
|
+
label: "Switch Rows and Columns",
|
|
434
|
+
keywords: ["chart", "transpose", "swap", "rows", "columns", "series"],
|
|
435
|
+
description: "Swaps series and categories as one undo step.",
|
|
436
|
+
run: () => {
|
|
437
|
+
check(controller.transpose());
|
|
438
|
+
return { table: chartDataToTable(controller.getSpec()) };
|
|
439
|
+
}
|
|
440
|
+
},
|
|
1853
441
|
{
|
|
1854
442
|
...base,
|
|
1855
443
|
id: `${prefix}importCsv`,
|
|
1856
444
|
label: "Import Chart Data from CSV\u2026",
|
|
1857
445
|
keywords: ["chart", "csv", "tsv", "paste", "import"],
|
|
1858
446
|
description: "Replaces the data with CSV / TSV text: by default the first column holds categories and the first row series names.",
|
|
1859
|
-
params:
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
header: { type: "boolean" }
|
|
447
|
+
params: object(
|
|
448
|
+
{
|
|
449
|
+
csv: { type: "string", description: "CSV or TSV text." },
|
|
450
|
+
seriesIn: { type: "string", enum: ["columns", "rows"], description: "Series run down columns (default) or along rows." },
|
|
451
|
+
header: { type: ["boolean", "string"], enum: [true, false, "auto"], description: "The first row holds series names (default true)." }
|
|
1865
452
|
},
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
},
|
|
453
|
+
["csv"]
|
|
454
|
+
),
|
|
1869
455
|
run: (_ctx, params) => {
|
|
1870
456
|
const result = controller.importCsv(params.csv, { seriesIn: params.seriesIn, header: params.header });
|
|
1871
457
|
check(result);
|
|
1872
458
|
return { table: chartDataToTable(controller.getSpec()), issues: result.issues ?? [] };
|
|
1873
459
|
}
|
|
1874
460
|
},
|
|
461
|
+
{
|
|
462
|
+
...base,
|
|
463
|
+
id: `${prefix}importMermaid`,
|
|
464
|
+
label: "Import Mermaid Chart\u2026",
|
|
465
|
+
keywords: ["chart", "mermaid", "markdown", "xychart", "pie", "radar", "import"],
|
|
466
|
+
description: "Replaces the chart with Mermaid xychart-beta, pie or radar-beta text (one undo step). Unreadable lines are listed with line numbers.",
|
|
467
|
+
params: object({ mermaid: { type: "string", description: "Mermaid text." } }, ["mermaid"]),
|
|
468
|
+
run: (_ctx, params) => {
|
|
469
|
+
const read = chartFromMermaid(params.mermaid);
|
|
470
|
+
if (!read.family) fail(read.issues.map((i) => i.message).join(" ") || "Expected Mermaid starting with xychart-beta, pie or radar-beta.");
|
|
471
|
+
check(controller.update({ ...read.spec }, { label: "Import Mermaid chart" }));
|
|
472
|
+
return { spec: controller.toJSON(), issues: read.issues };
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
...base,
|
|
477
|
+
id: `${prefix}exportMermaid`,
|
|
478
|
+
label: "Copy Chart as Mermaid",
|
|
479
|
+
readOnly: true,
|
|
480
|
+
keywords: ["chart", "mermaid", "markdown", "export"],
|
|
481
|
+
description: "Returns the chart as Mermaid text (with a round-trip comment unless meta is false).",
|
|
482
|
+
params: object({ meta: { type: "boolean", description: "Include the %% @pixerate round-trip comment (default true)." } }),
|
|
483
|
+
run: (_ctx, params = {}) => ({ mermaid: chartToMermaid(controller.toJSON(), { meta: params?.meta }) })
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
...base,
|
|
487
|
+
id: `${prefix}exportOoxml`,
|
|
488
|
+
label: "Export Chart as Office XML",
|
|
489
|
+
readOnly: true,
|
|
490
|
+
keywords: ["chart", "office", "word", "powerpoint", "docx", "pptx", "xml", "export"],
|
|
491
|
+
description: "Returns the chart as a DrawingML chart part (c:chartSpace) and what could not be written.",
|
|
492
|
+
run: () => chartToOoxml(controller.toJSON())
|
|
493
|
+
},
|
|
1875
494
|
{
|
|
1876
495
|
...base,
|
|
1877
496
|
id: `${prefix}exportSvg`,
|
|
1878
497
|
label: "Export Chart as SVG",
|
|
1879
498
|
readOnly: true,
|
|
1880
499
|
keywords: ["chart", "svg", "export", "image"],
|
|
1881
|
-
description: "Returns the chart drawn as a standalone SVG string.",
|
|
1882
|
-
params:
|
|
500
|
+
description: "Returns the chart drawn as a standalone, accessible SVG string (with <title> and <desc>).",
|
|
501
|
+
params: object({ width: WIDTH, height: HEIGHT }),
|
|
502
|
+
run: (_ctx, params = {}) => ({
|
|
503
|
+
svg: chartToSvg(controller.toJSON(), { width: params?.width ?? 640, height: params?.height ?? 400 })
|
|
504
|
+
})
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
...base,
|
|
508
|
+
id: `${prefix}recommend`,
|
|
509
|
+
label: "Recommend Chart Types",
|
|
510
|
+
readOnly: true,
|
|
511
|
+
keywords: ["chart", "recommend", "suggest", "type"],
|
|
512
|
+
description: "Suggests chart types for the current data (or CSV), best first, each with a reason.",
|
|
513
|
+
params: object({ csv: { type: "string", description: "CSV text to analyse instead of the current data." } }),
|
|
1883
514
|
run: (_ctx, params = {}) => ({
|
|
1884
|
-
|
|
515
|
+
recommendations: recommendChartTypes(params?.csv !== void 0 ? parseCsv(params.csv) : chartDataToTable(controller.getSpec()))
|
|
1885
516
|
})
|
|
1886
517
|
},
|
|
518
|
+
{
|
|
519
|
+
...base,
|
|
520
|
+
id: `${prefix}checkColors`,
|
|
521
|
+
label: "Check Chart Colours",
|
|
522
|
+
readOnly: true,
|
|
523
|
+
keywords: ["chart", "colour", "color", "accessibility", "colour-blind", "contrast"],
|
|
524
|
+
description: "Checks that the series colours can be told apart (including with colour-blindness) and stand out from the background.",
|
|
525
|
+
run: () => {
|
|
526
|
+
const s = controller.getSpec();
|
|
527
|
+
const visible = s.series.filter((x) => !x.hidden);
|
|
528
|
+
const colors = chartColorsByCategory(s.type) ? s.categories.map((_, i) => visible[0]?.pointColors?.[i] || chartSliceColor(s, i)) : visible.map((x) => chartSeriesColor(s, s.series.indexOf(x)));
|
|
529
|
+
return { colors, ...checkChartColors(colors) };
|
|
530
|
+
}
|
|
531
|
+
},
|
|
1887
532
|
{
|
|
1888
533
|
...base,
|
|
1889
534
|
id: `${prefix}undo`,
|
|
@@ -1912,50 +557,122 @@ function chartClientToScene(scene, box, clientX, clientY) {
|
|
|
1912
557
|
function canDragChartPoint(controller, scene, target) {
|
|
1913
558
|
if (target?.kind !== "point" || !scene.valueScale || scene.percent) return false;
|
|
1914
559
|
const spec = controller.getSpec();
|
|
1915
|
-
|
|
560
|
+
const family = chartFamily(spec);
|
|
561
|
+
if (family !== "cartesian" && family !== "xy" && family !== "waterfall") return false;
|
|
1916
562
|
const series = controller.getSeries(target.seriesId);
|
|
1917
563
|
return !!series && !series.hidden && series.values[target.index] !== null;
|
|
1918
564
|
}
|
|
565
|
+
function snapChartDragValue(scene, value, options = {}) {
|
|
566
|
+
const scale = options.scale ?? scene.valueScale;
|
|
567
|
+
const tolerance = options.tolerancePx ?? 6;
|
|
568
|
+
if (scale) {
|
|
569
|
+
const px = (v) => linearMap(v, scale.domain, scale.range);
|
|
570
|
+
const at = px(value);
|
|
571
|
+
let best = null;
|
|
572
|
+
let bestDist = tolerance;
|
|
573
|
+
for (const c of [...options.candidates ?? [], ...scale.ticks]) {
|
|
574
|
+
if (!Number.isFinite(c)) continue;
|
|
575
|
+
const d = Math.abs(px(c) - at);
|
|
576
|
+
if (d <= bestDist) {
|
|
577
|
+
best = c;
|
|
578
|
+
bestDist = d;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (best !== null) return best;
|
|
582
|
+
}
|
|
583
|
+
return snapChartValue(scene, value, options.step, scale?.ticks);
|
|
584
|
+
}
|
|
1919
585
|
function startChartPointDrag(controller, scene, target, start, options = {}) {
|
|
1920
586
|
if (!canDragChartPoint(controller, scene, target)) return null;
|
|
1921
587
|
const { seriesId, index } = target;
|
|
1922
|
-
const
|
|
1923
|
-
const
|
|
588
|
+
const spec = controller.getSpec();
|
|
589
|
+
const series = controller.getSeries(seriesId);
|
|
590
|
+
const secondary = series.axis === "secondary" && !!scene.valueScale2;
|
|
591
|
+
const scale = secondary ? scene.valueScale2 : scene.valueScale;
|
|
592
|
+
const xy = chartFamily(spec) === "xy" && !!scene.xScale;
|
|
593
|
+
const startValue = series.values[index] ?? 0;
|
|
594
|
+
const startX = series.x?.[index] ?? null;
|
|
595
|
+
const startAt = chartValueAtPoint(scene, start, secondary);
|
|
596
|
+
const startAtX = xy ? chartXAtPoint(scene, start) : void 0;
|
|
1924
597
|
if (startAt === void 0) return null;
|
|
598
|
+
const candidates = [0, startValue];
|
|
599
|
+
if (!xy) {
|
|
600
|
+
for (const s of spec.series) if (s.id !== seriesId && !s.hidden && s.axis === "secondary" === secondary) candidates.push(s.values[index] ?? NaN);
|
|
601
|
+
candidates.push(series.values[index - 1] ?? NaN, series.values[index + 1] ?? NaN);
|
|
602
|
+
}
|
|
1925
603
|
controller.beginGesture(`drag:${seriesId}:${index}`);
|
|
1926
604
|
let ended = false;
|
|
1927
605
|
const [min, max] = scene.valueScale.domain;
|
|
606
|
+
const label = `Drag ${series.name}`;
|
|
1928
607
|
return {
|
|
1929
608
|
valueDomain: { min, max },
|
|
1930
|
-
|
|
609
|
+
...scene.valueScale2 ? { valueDomain2: { min: scene.valueScale2.domain[0], max: scene.valueScale2.domain[1] } } : {},
|
|
610
|
+
...xy ? { xDomain: { min: scene.xScale.domain[0], max: scene.xScale.domain[1] } } : {},
|
|
611
|
+
target: { seriesId, index },
|
|
612
|
+
move(point, moveOptions = {}) {
|
|
1931
613
|
if (ended) return;
|
|
1932
|
-
const at = chartValueAtPoint(scene, point);
|
|
614
|
+
const at = chartValueAtPoint(scene, point, secondary);
|
|
1933
615
|
if (at === void 0) return;
|
|
1934
|
-
|
|
616
|
+
const factor = moveOptions.fine ? 0.1 : 1;
|
|
617
|
+
let dy = (at - startAt) * factor;
|
|
618
|
+
let dx = 0;
|
|
619
|
+
if (xy && startX !== null && startAtX !== void 0) {
|
|
620
|
+
const atX = chartXAtPoint(scene, point);
|
|
621
|
+
if (atX !== void 0) dx = (atX - startAtX) * factor;
|
|
622
|
+
if (moveOptions.lockAxis) {
|
|
623
|
+
if (Math.abs(point.x - start.x) >= Math.abs(point.y - start.y)) dy = 0;
|
|
624
|
+
else dx = 0;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const snap = (v, s, c) => {
|
|
628
|
+
if (moveOptions.noSnap) return Number(v.toPrecision(12));
|
|
629
|
+
if (moveOptions.fine) {
|
|
630
|
+
const unit = options.step ? options.step / 10 : s.ticks.length > 1 ? (s.ticks[1] - s.ticks[0]) / 100 : void 0;
|
|
631
|
+
return snapChartValue(scene, v, unit, s.ticks);
|
|
632
|
+
}
|
|
633
|
+
return snapChartDragValue(scene, v, { candidates: c, tolerancePx: options.tolerancePx, step: options.step, scale: s });
|
|
634
|
+
};
|
|
635
|
+
const y = snap(startValue + dy, scale, candidates);
|
|
636
|
+
if (xy && startX !== null) {
|
|
637
|
+
const x = dx !== 0 ? snap(startX + dx, scene.xScale, [0, startX]) : startX;
|
|
638
|
+
const xs = [...series.x ?? []];
|
|
639
|
+
xs[index] = x;
|
|
640
|
+
controller.setValue(seriesId, index, y, { label });
|
|
641
|
+
if (x !== controller.getSeries(seriesId)?.x?.[index]) controller.updateSeries(seriesId, { x: xs });
|
|
642
|
+
} else controller.setValue(seriesId, index, y, { label });
|
|
1935
643
|
},
|
|
1936
644
|
end() {
|
|
1937
645
|
if (ended) return;
|
|
1938
646
|
ended = true;
|
|
1939
647
|
controller.endGesture();
|
|
648
|
+
},
|
|
649
|
+
cancel() {
|
|
650
|
+
if (ended) return;
|
|
651
|
+
ended = true;
|
|
652
|
+
controller.cancelGesture();
|
|
1940
653
|
}
|
|
1941
654
|
};
|
|
1942
655
|
}
|
|
1943
656
|
function handleChartKey(controller, scene, event) {
|
|
1944
|
-
if (event.metaKey || event.ctrlKey
|
|
1945
|
-
const { selection, spec } = controller.getState();
|
|
657
|
+
if (event.metaKey || event.ctrlKey) return false;
|
|
658
|
+
const { selection, spec, gesture } = controller.getState();
|
|
1946
659
|
switch (event.key) {
|
|
1947
660
|
case "Escape":
|
|
661
|
+
if (gesture) {
|
|
662
|
+
controller.cancelGesture();
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
1948
665
|
if (!selection) return false;
|
|
1949
666
|
controller.select(null);
|
|
1950
667
|
return true;
|
|
1951
668
|
case "Delete":
|
|
1952
669
|
case "Backspace":
|
|
1953
|
-
if (!selection || selection.kind === "chart") return false;
|
|
670
|
+
if (event.altKey || !selection || selection.kind === "chart") return false;
|
|
1954
671
|
controller.deleteSelection();
|
|
1955
672
|
return true;
|
|
1956
673
|
case "ArrowLeft":
|
|
1957
674
|
case "ArrowRight": {
|
|
1958
|
-
if (selection?.kind !== "point" && selection?.kind !== "category") return false;
|
|
675
|
+
if (event.altKey || selection?.kind !== "point" && selection?.kind !== "category") return false;
|
|
1959
676
|
const n = spec.categories.length;
|
|
1960
677
|
const index = Math.max(0, Math.min(n - 1, selection.index + (event.key === "ArrowRight" ? 1 : -1)));
|
|
1961
678
|
controller.select({ ...selection, index });
|
|
@@ -1964,10 +681,11 @@ function handleChartKey(controller, scene, event) {
|
|
|
1964
681
|
case "ArrowUp":
|
|
1965
682
|
case "ArrowDown": {
|
|
1966
683
|
if (selection?.kind !== "point" || scene?.percent) return false;
|
|
1967
|
-
const
|
|
684
|
+
const secondary = controller.getSeries(selection.seriesId)?.axis === "secondary";
|
|
685
|
+
const ticks = (secondary ? scene?.valueScale2 : null)?.ticks ?? scene?.valueScale?.ticks ?? [];
|
|
1968
686
|
const step = ticks.length > 1 ? ticks[1] - ticks[0] : 1;
|
|
1969
|
-
const
|
|
1970
|
-
controller.nudgeValue(selection.seriesId, selection.index,
|
|
687
|
+
const amount = event.shiftKey ? step : event.altKey ? step / 100 : step / 10;
|
|
688
|
+
controller.nudgeValue(selection.seriesId, selection.index, amount * (event.key === "ArrowUp" ? 1 : -1));
|
|
1971
689
|
return true;
|
|
1972
690
|
}
|
|
1973
691
|
default:
|
|
@@ -1987,68 +705,1097 @@ function describeChartSelection(controller, selection = controller.getState().se
|
|
|
1987
705
|
case "legend":
|
|
1988
706
|
return "Legend";
|
|
1989
707
|
case "axis":
|
|
1990
|
-
return selection.axis === "x" ? "Category axis" : "Value axis";
|
|
708
|
+
return selection.axis === "x" ? chartFamily(spec) === "xy" ? "X axis" : "Category axis" : selection.axis === "y" ? "Value axis" : "Secondary value axis";
|
|
1991
709
|
case "series":
|
|
1992
710
|
return `Series "${controller.getSeries(selection.seriesId)?.name ?? selection.seriesId}"`;
|
|
1993
711
|
case "category":
|
|
1994
712
|
return `Category "${spec.categories[selection.index] ?? selection.index}"`;
|
|
713
|
+
case "trendline": {
|
|
714
|
+
const series = controller.getSeries(selection.seriesId);
|
|
715
|
+
const line = series?.trendlines?.[selection.index];
|
|
716
|
+
return `${line ? line.type.replace("-", " ") : "Trendline"} trendline on "${series?.name ?? selection.seriesId}"`;
|
|
717
|
+
}
|
|
1995
718
|
case "point": {
|
|
1996
719
|
const series = controller.getSeries(selection.seriesId);
|
|
1997
720
|
const value = series?.values[selection.index];
|
|
721
|
+
const x = series?.x?.[selection.index];
|
|
722
|
+
if (x !== void 0 && x !== null && chartFamily(spec) === "xy") return `${series?.name ?? selection.seriesId}: (${x}, ${value ?? "blank"})`;
|
|
1998
723
|
return `${series?.name ?? selection.seriesId} \xB7 ${spec.categories[selection.index] ?? selection.index}: ${value ?? "blank"}`;
|
|
1999
724
|
}
|
|
2000
725
|
}
|
|
2001
726
|
}
|
|
727
|
+
function chartHitTest(scene, point, measure = (t, s, w = 400) => t.length * s * (w >= 600 ? 0.6 : 0.56)) {
|
|
728
|
+
for (let i = scene.marks.length - 1; i >= 0; i--) {
|
|
729
|
+
const m = scene.marks[i];
|
|
730
|
+
if (!m.target || m.role === "selection" || m.role === "background") continue;
|
|
731
|
+
let hit = false;
|
|
732
|
+
switch (m.kind) {
|
|
733
|
+
case "rect":
|
|
734
|
+
hit = point.x >= m.x && point.x <= m.x + m.width && point.y >= m.y && point.y <= m.y + m.height;
|
|
735
|
+
break;
|
|
736
|
+
case "circle":
|
|
737
|
+
hit = Math.hypot(point.x - m.cx, point.y - m.cy) <= Math.max(m.r, 4) + 2;
|
|
738
|
+
break;
|
|
739
|
+
case "line": {
|
|
740
|
+
const dx = m.x2 - m.x1;
|
|
741
|
+
const dy = m.y2 - m.y1;
|
|
742
|
+
const len2 = dx * dx + dy * dy;
|
|
743
|
+
const t = len2 ? Math.max(0, Math.min(1, ((point.x - m.x1) * dx + (point.y - m.y1) * dy) / len2)) : 0;
|
|
744
|
+
hit = Math.hypot(point.x - (m.x1 + t * dx), point.y - (m.y1 + t * dy)) <= 4;
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case "text": {
|
|
748
|
+
if (m.rotate) break;
|
|
749
|
+
const w = measure(m.text, m.size, m.weight);
|
|
750
|
+
const x0 = m.anchor === "start" ? m.x : m.anchor === "end" ? m.x - w : m.x - w / 2;
|
|
751
|
+
const y0 = m.baseline === "top" ? m.y : m.baseline === "bottom" ? m.y - m.size : m.y - m.size / 2;
|
|
752
|
+
hit = point.x >= x0 - 2 && point.x <= x0 + w + 2 && point.y >= y0 - 2 && point.y <= y0 + m.size + 2;
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
default:
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
if (hit) return m.target;
|
|
759
|
+
}
|
|
760
|
+
return point.x >= 0 && point.y >= 0 && point.x <= scene.width && point.y <= scene.height ? { kind: "chart" } : null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/chart/tools.ts
|
|
764
|
+
var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
765
|
+
var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
766
|
+
var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
767
|
+
var DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
|
|
768
|
+
var fail2 = (message) => {
|
|
769
|
+
throw new Error(message);
|
|
770
|
+
};
|
|
771
|
+
function resolve(result) {
|
|
772
|
+
if (!result.ok) fail2(result.message ?? "The change was rejected.");
|
|
773
|
+
return result;
|
|
774
|
+
}
|
|
775
|
+
function createChartToolProvider(controller, options = {}) {
|
|
776
|
+
const size = options.size ?? { width: 640, height: 400 };
|
|
777
|
+
const readOnlyReason = () => {
|
|
778
|
+
const r = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
|
|
779
|
+
if (!r) return null;
|
|
780
|
+
return typeof r === "string" ? r : "This chart is read-only.";
|
|
781
|
+
};
|
|
782
|
+
const spec = () => controller.getSpec();
|
|
783
|
+
const seriesById = (ref) => {
|
|
784
|
+
const s = spec().series.find((x) => x.id === ref) ?? spec().series.find((x) => x.name === ref);
|
|
785
|
+
if (!s) fail2(`There is no series "${ref}"; expected one of ${spec().series.map((x) => `"${x.id}" (${x.name})`).join(", ") || "(none yet; add one with add_chart_series)"}.`);
|
|
786
|
+
return s;
|
|
787
|
+
};
|
|
788
|
+
const categoryIndex = (ref) => {
|
|
789
|
+
const categories = spec().categories;
|
|
790
|
+
const index = typeof ref === "number" ? ref : categories.indexOf(ref);
|
|
791
|
+
if (!Number.isInteger(index) || index < 0 || index >= categories.length)
|
|
792
|
+
fail2(`There is no category ${JSON.stringify(ref)}; expected an index from 0 to ${categories.length - 1} or one of ${categories.slice(0, 20).map((c) => JSON.stringify(c)).join(", ")}.`);
|
|
793
|
+
return index;
|
|
794
|
+
};
|
|
795
|
+
const table = () => chartDataToTable(spec());
|
|
796
|
+
const Parser = () => options.DOMParser ?? globalThis.DOMParser;
|
|
797
|
+
const getChart = {
|
|
798
|
+
name: "get_chart",
|
|
799
|
+
title: "Read the chart",
|
|
800
|
+
description: "Returns the chart spec (type, titles, axes, legend, series with ids and settings), its data as a table (header row of series names, then one row per category), the current selection, and a summary at the given size: per-series min / max / mean / trend, the axes as drawn, layout warnings (hidden labels, thinned lines), a colour check and a plain-language description. Use the series ids and category indexes it returns with the write tools.",
|
|
801
|
+
inputSchema: { type: "object", properties: { width: WIDTH, height: HEIGHT }, additionalProperties: false },
|
|
802
|
+
annotations: { title: "Read the chart", ...READ },
|
|
803
|
+
handler: ({ width = size.width, height = size.height }) => {
|
|
804
|
+
const reason = readOnlyReason();
|
|
805
|
+
return {
|
|
806
|
+
spec: controller.toJSON(),
|
|
807
|
+
table: table(),
|
|
808
|
+
selection: controller.getState().selection,
|
|
809
|
+
summary: summarizeChart(controller.toJSON(), { width, height }),
|
|
810
|
+
issues: controller.getState().issues,
|
|
811
|
+
...reason ? { readOnly: reason } : {}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
const renderChart = {
|
|
816
|
+
name: "render_chart",
|
|
817
|
+
title: "Render the chart",
|
|
818
|
+
description: 'Draws the chart without changing it, to check it visually: SVG text by default, or a PNG image (format "png") when the host can rasterize. Look for overlapping or hidden labels; get_chart lists layout warnings without a render.',
|
|
819
|
+
inputSchema: {
|
|
820
|
+
type: "object",
|
|
821
|
+
properties: { width: WIDTH, height: HEIGHT, format: { type: "string", enum: ["svg", "png"], description: "svg (default) or png (only when the host supports it)." } },
|
|
822
|
+
additionalProperties: false
|
|
823
|
+
},
|
|
824
|
+
annotations: { title: "Render the chart", ...READ },
|
|
825
|
+
handler: async ({ width = size.width, height = size.height, format = "svg" }) => {
|
|
826
|
+
const svg = chartToSvg(controller.toJSON(), { width, height });
|
|
827
|
+
if (format === "png") {
|
|
828
|
+
if (!options.rasterize) fail2('This host cannot rasterize charts; call render_chart with format "svg".');
|
|
829
|
+
const image = await options.rasterize(svg, { width, height });
|
|
830
|
+
return { content: [{ type: "image", data: image.data, mimeType: image.mimeType }], structuredContent: { width, height, mimeType: image.mimeType } };
|
|
831
|
+
}
|
|
832
|
+
return { svg, width, height };
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
const checkColors = {
|
|
836
|
+
name: "check_chart_colors",
|
|
837
|
+
title: "Check chart colours",
|
|
838
|
+
description: "Checks that colours can be told apart with normal vision and with protanopia, deuteranopia and tritanopia (OKLab distance after simulation), and that they stand out from the background. Checks the chart's own series (or slice) colours unless colours are given. Returns the closest pair per vision, contrast ratios and problems as sentences.",
|
|
839
|
+
inputSchema: {
|
|
840
|
+
type: "object",
|
|
841
|
+
properties: {
|
|
842
|
+
colors: { type: "array", items: { type: "string" }, minItems: 1, description: "Colours to check instead of the chart's (hex or rgb())." },
|
|
843
|
+
surface: { type: "string", description: "Background colour (default white)." }
|
|
844
|
+
},
|
|
845
|
+
additionalProperties: false
|
|
846
|
+
},
|
|
847
|
+
annotations: { title: "Check chart colours", ...READ },
|
|
848
|
+
handler: ({ colors, surface }) => {
|
|
849
|
+
const s = spec();
|
|
850
|
+
const visible = s.series.filter((x) => !x.hidden);
|
|
851
|
+
const own = chartColorsByCategory(s.type) ? s.categories.map((_, i) => visible[0]?.pointColors?.[i] || chartSliceColor(s, i)) : visible.map((x) => chartSeriesColor(s, s.series.indexOf(x)));
|
|
852
|
+
return { colors: colors ?? own, ...checkChartColors(colors ?? own, { surface }) };
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
const recommend = {
|
|
856
|
+
name: "recommend_chart",
|
|
857
|
+
title: "Recommend chart types",
|
|
858
|
+
description: "Suggests chart types for data, best first, each with a reason: from CSV text when given, else from the chart's current data. Uses the shape of the data (series count, points, dates, negative values).",
|
|
859
|
+
inputSchema: { type: "object", properties: { csv: { type: "string", description: "CSV or TSV text to analyse instead of the current data (first row may be series names, first column categories)." } }, additionalProperties: false },
|
|
860
|
+
annotations: { title: "Recommend chart types", ...READ },
|
|
861
|
+
handler: ({ csv }) => {
|
|
862
|
+
const rows = csv !== void 0 ? parseCsv(csv, { delimiter: csv.includes(" ") ? " " : "," }) : table();
|
|
863
|
+
const recommendations = recommendChartTypes(rows);
|
|
864
|
+
if (!recommendations.length) fail2("There are no numbers to chart: give CSV with at least one numeric column.");
|
|
865
|
+
return { recommendations };
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
const getTrendline = {
|
|
869
|
+
name: "get_chart_trendline",
|
|
870
|
+
title: "Read a trendline fit",
|
|
871
|
+
description: "Returns a trendline's fitted coefficients (in x for category charts, x = 1, 2, 3, \u2026), R\xB2, equation and the number of points, or the moving-average points.",
|
|
872
|
+
inputSchema: {
|
|
873
|
+
type: "object",
|
|
874
|
+
properties: { series: SERIES_REF, index: { type: "integer", minimum: 0, description: "Trendline index on the series (0 = first)." } },
|
|
875
|
+
required: ["series", "index"],
|
|
876
|
+
additionalProperties: false
|
|
877
|
+
},
|
|
878
|
+
annotations: { title: "Read a trendline fit", ...READ },
|
|
879
|
+
handler: ({ series, index }) => {
|
|
880
|
+
const s = seriesById(series);
|
|
881
|
+
const line = s.trendlines?.[index];
|
|
882
|
+
if (!line) fail2(`"${s.name}" has no trendline ${index}; it has ${s.trendlines?.length ?? 0}.`);
|
|
883
|
+
if (line.type === "moving-average") return { trendline: line, points: controller.trendlineMovingAverage(s.id, index) };
|
|
884
|
+
const fit = controller.trendlineFit(s.id, index);
|
|
885
|
+
if (!fit) fail2(`The ${line.type} trendline on "${s.name}" cannot be fitted: it needs more points (and positive values for log, exponential and power fits).`);
|
|
886
|
+
return { trendline: line, coefficients: fit.coefficients, r2: fit.r2, equation: fit.equation, points: fit.points };
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
const listPresets = {
|
|
890
|
+
name: "list_chart_presets",
|
|
891
|
+
title: "List chart presets",
|
|
892
|
+
description: "Lists the quick layouts and colour schemes apply_chart_preset accepts, with what each changes.",
|
|
893
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
894
|
+
annotations: { title: "List chart presets", ...READ },
|
|
895
|
+
handler: () => ({
|
|
896
|
+
layouts: CHART_LAYOUT_PRESETS.map(({ id, label, description }) => ({ id, label, description })),
|
|
897
|
+
colorSchemes: CHART_COLOR_SCHEMES.map(({ id, label, description, palette }) => ({ id, label, description, palette }))
|
|
898
|
+
})
|
|
899
|
+
};
|
|
900
|
+
const exportMermaid = {
|
|
901
|
+
name: "export_chart_mermaid",
|
|
902
|
+
title: "Export chart as Mermaid",
|
|
903
|
+
description: 'Returns the chart as Mermaid text (xychart-beta, pie or radar-beta) for Markdown or chat. With meta (default true) one "%% @pixerate {\u2026}" comment keeps what Mermaid cannot express, so import_chart_mermaid gets the chart back exactly.',
|
|
904
|
+
inputSchema: { type: "object", properties: { meta: { type: "boolean", description: "Include the round-trip comment (default true)." } }, additionalProperties: false },
|
|
905
|
+
annotations: { title: "Export chart as Mermaid", ...READ },
|
|
906
|
+
handler: ({ meta = true }) => ({ mermaid: chartToMermaid(controller.toJSON(), { meta }) })
|
|
907
|
+
};
|
|
908
|
+
const exportOoxml = {
|
|
909
|
+
name: "export_chart_ooxml",
|
|
910
|
+
title: "Export chart as Office XML",
|
|
911
|
+
description: "Returns the chart as a DrawingML chart part (c:chartSpace) for Word or PowerPoint files, with what could not be written as sentences.",
|
|
912
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
913
|
+
annotations: { title: "Export chart as Office XML", ...READ },
|
|
914
|
+
handler: () => chartToOoxml(controller.toJSON())
|
|
915
|
+
};
|
|
916
|
+
const updateChart = {
|
|
917
|
+
name: "update_chart",
|
|
918
|
+
title: "Change chart presentation",
|
|
919
|
+
description: "Changes how the chart looks, as one undo step: type, titles, axes (xAxis = categories or numeric x, yAxis = values, yAxis2 = secondary values), legend, stacking, orientation, curve, points, data labels and their position, palette, donut hole, bar spacing, histogram bins, waterfall total. All-or-nothing: invalid values change nothing and come back as sentences. Returns the new spec.",
|
|
920
|
+
inputSchema: {
|
|
921
|
+
type: "object",
|
|
922
|
+
properties: CHART_UPDATE_PROPERTIES,
|
|
923
|
+
additionalProperties: false
|
|
924
|
+
},
|
|
925
|
+
annotations: { title: "Change chart presentation", ...WRITE_IDEMPOTENT },
|
|
926
|
+
handler: (args) => {
|
|
927
|
+
if (!Object.keys(args).length) fail2('Nothing to change: pass at least one field, e.g. {"type": "line"}.');
|
|
928
|
+
resolve(controller.update(args));
|
|
929
|
+
return { spec: controller.toJSON() };
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
const setData = {
|
|
933
|
+
name: "set_chart_data",
|
|
934
|
+
title: "Replace chart data",
|
|
935
|
+
description: "Replaces the categories and / or the series as one undo step. Each series has one value per category (padded with gaps). Returns the data table and the series ids.",
|
|
936
|
+
inputSchema: {
|
|
937
|
+
type: "object",
|
|
938
|
+
properties: {
|
|
939
|
+
categories: { type: "array", items: { type: "string" }, description: "Category labels in order." },
|
|
940
|
+
series: {
|
|
941
|
+
type: "array",
|
|
942
|
+
description: "The new series (replaces all).",
|
|
943
|
+
items: { type: "object", properties: { id: { type: "string", description: "Stable id (optional)." }, ...SERIES_PROPS }, required: ["name", "values"], additionalProperties: false }
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
additionalProperties: false
|
|
947
|
+
},
|
|
948
|
+
annotations: { title: "Replace chart data", ...DESTRUCTIVE },
|
|
949
|
+
handler: (args) => {
|
|
950
|
+
if (!args.categories && !args.series) fail2("Pass categories, series or both.");
|
|
951
|
+
resolve(controller.setData(args));
|
|
952
|
+
return { table: table(), series: spec().series.map((s) => ({ id: s.id, name: s.name })) };
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
const setValues = {
|
|
956
|
+
name: "set_chart_values",
|
|
957
|
+
title: "Set chart values",
|
|
958
|
+
description: "Sets individual values as one undo step (all-or-nothing). Series by id or name, category by index or label. Returns the values as stored.",
|
|
959
|
+
inputSchema: {
|
|
960
|
+
type: "object",
|
|
961
|
+
properties: {
|
|
962
|
+
cells: {
|
|
963
|
+
type: "array",
|
|
964
|
+
minItems: 1,
|
|
965
|
+
description: "Values to set.",
|
|
966
|
+
items: { type: "object", properties: { series: SERIES_REF, category: CATEGORY_REF, value: VALUE }, required: ["series", "category", "value"], additionalProperties: false }
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
required: ["cells"],
|
|
970
|
+
additionalProperties: false
|
|
971
|
+
},
|
|
972
|
+
annotations: { title: "Set chart values", ...WRITE_IDEMPOTENT },
|
|
973
|
+
handler: async ({ cells }) => {
|
|
974
|
+
const resolved = cells.map((c) => ({ seriesId: seriesById(c.series).id, index: categoryIndex(c.category), value: c.value }));
|
|
975
|
+
const parsed = resolved.map((c) => parseChartValue(c.value));
|
|
976
|
+
const bad = resolved.find((_, i) => parsed[i] === void 0);
|
|
977
|
+
if (bad) fail2(`"${String(bad.value)}" is not a number; pass a number, numeric text or null for a gap. Nothing was changed.`);
|
|
978
|
+
if (options.director && parsed.every((v) => typeof v === "number")) {
|
|
979
|
+
const results = await options.director.perform(options.actor ?? { id: "agent", name: "Agent" }, resolved.map((c, i) => ({ kind: "setValue", seriesId: c.seriesId, index: c.index, value: parsed[i] })));
|
|
980
|
+
const failed = results.find((r) => !r.ok);
|
|
981
|
+
if (failed && !failed.ok) fail2(failed.message);
|
|
982
|
+
} else resolve(controller.setValues(resolved));
|
|
983
|
+
return { cells: resolved.map((c) => ({ series: c.seriesId, category: spec().categories[c.index], index: c.index, value: controller.getSeries(c.seriesId).values[c.index] })) };
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
const addSeries = {
|
|
987
|
+
name: "add_chart_series",
|
|
988
|
+
title: "Add a series",
|
|
989
|
+
description: "Adds a series (values padded to the categories) and selects it. Returns its id and stored values.",
|
|
990
|
+
inputSchema: {
|
|
991
|
+
type: "object",
|
|
992
|
+
properties: { ...SERIES_PROPS, id: { type: "string", description: "Id to use (default: series-N)." }, at: { type: "integer", minimum: 0, description: "Position (default: last)." } },
|
|
993
|
+
required: ["name"],
|
|
994
|
+
additionalProperties: false
|
|
995
|
+
},
|
|
996
|
+
annotations: { title: "Add a series", ...WRITE },
|
|
997
|
+
handler: ({ at, ...input }) => {
|
|
998
|
+
const clean = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== null));
|
|
999
|
+
const result = resolve(controller.addSeries(clean, { at }));
|
|
1000
|
+
const s = controller.getSeries(result.id);
|
|
1001
|
+
return { id: s.id, name: s.name, values: s.values };
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
const updateSeries = {
|
|
1005
|
+
name: "update_chart_series",
|
|
1006
|
+
title: "Change a series",
|
|
1007
|
+
description: "Renames, recolours, hides, moves to the secondary axis or changes how a series is drawn (marker, curve, labels, format, x values, bubble sizes, point colours). null resets a field. Values change with set_chart_values. Returns the series.",
|
|
1008
|
+
inputSchema: {
|
|
1009
|
+
type: "object",
|
|
1010
|
+
properties: { series: SERIES_REF, ...Object.fromEntries(Object.entries(SERIES_PROPS).filter(([k]) => k !== "values")) },
|
|
1011
|
+
required: ["series"],
|
|
1012
|
+
additionalProperties: false
|
|
1013
|
+
},
|
|
1014
|
+
annotations: { title: "Change a series", ...WRITE_IDEMPOTENT },
|
|
1015
|
+
handler: ({ series, ...patch }) => {
|
|
1016
|
+
const id = seriesById(series).id;
|
|
1017
|
+
if (!Object.keys(patch).length) fail2('Nothing to change: pass a field such as {"color": "#e8710a"} or {"axis": "secondary"}.');
|
|
1018
|
+
resolve(controller.updateSeries(id, patch));
|
|
1019
|
+
return { series: controller.getSeries(id) };
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
const removeSeries = {
|
|
1023
|
+
name: "remove_chart_series",
|
|
1024
|
+
title: "Delete a series",
|
|
1025
|
+
description: "Deletes a series and its values. Returns the remaining series ids.",
|
|
1026
|
+
inputSchema: { type: "object", properties: { series: SERIES_REF }, required: ["series"], additionalProperties: false },
|
|
1027
|
+
annotations: { title: "Delete a series", ...DESTRUCTIVE },
|
|
1028
|
+
handler: ({ series }) => {
|
|
1029
|
+
resolve(controller.removeSeries(seriesById(series).id));
|
|
1030
|
+
return { series: spec().series.map((s) => ({ id: s.id, name: s.name })) };
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
const addCategory = {
|
|
1034
|
+
name: "add_chart_category",
|
|
1035
|
+
title: "Add a category",
|
|
1036
|
+
description: "Inserts a category (a gap in every series) and returns its index.",
|
|
1037
|
+
inputSchema: {
|
|
1038
|
+
type: "object",
|
|
1039
|
+
properties: { label: { type: "string", description: "Category label (default: its number)." }, at: { type: "integer", minimum: 0, description: "Index to insert at (default: the end)." } },
|
|
1040
|
+
additionalProperties: false
|
|
1041
|
+
},
|
|
1042
|
+
annotations: { title: "Add a category", ...WRITE },
|
|
1043
|
+
handler: ({ label, at }) => {
|
|
1044
|
+
const result = controller.addCategory(label, { at });
|
|
1045
|
+
return { index: result.index, categories: spec().categories };
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const removeCategory = {
|
|
1049
|
+
name: "remove_chart_category",
|
|
1050
|
+
title: "Delete a category",
|
|
1051
|
+
description: "Deletes a category and its value in every series. Returns the remaining categories.",
|
|
1052
|
+
inputSchema: { type: "object", properties: { category: CATEGORY_REF }, required: ["category"], additionalProperties: false },
|
|
1053
|
+
annotations: { title: "Delete a category", ...DESTRUCTIVE },
|
|
1054
|
+
handler: ({ category }) => {
|
|
1055
|
+
resolve(controller.removeCategory(categoryIndex(category)));
|
|
1056
|
+
return { categories: spec().categories };
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
const importCsv = {
|
|
1060
|
+
name: "import_chart_csv",
|
|
1061
|
+
title: "Import chart data from CSV",
|
|
1062
|
+
description: 'Replaces the data with CSV or TSV text as one undo step. By default the first row holds series names and the first column categories (detected when "auto"). chartType "auto" also picks the recommended chart type (two unlabelled numeric columns become a scatter of x against y). Returns the data table, the chart type and cells that were not numbers.',
|
|
1063
|
+
inputSchema: {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
properties: {
|
|
1066
|
+
csv: { type: "string", description: "CSV or TSV text (tabs are detected)." },
|
|
1067
|
+
seriesIn: { type: "string", enum: ["columns", "rows"], description: 'Series run down columns (default) or along rows. Ignored with chartType "auto".' },
|
|
1068
|
+
header: { type: ["boolean", "string"], enum: [true, false, "auto"], description: 'The first row holds series names: true (default), false or "auto". Ignored with chartType "auto".' },
|
|
1069
|
+
chartType: { type: "string", enum: ["keep", "auto"], description: '"keep" (default) keeps the chart type; "auto" reads the table shape and picks a type.' }
|
|
1070
|
+
},
|
|
1071
|
+
required: ["csv"],
|
|
1072
|
+
additionalProperties: false
|
|
1073
|
+
},
|
|
1074
|
+
annotations: { title: "Import chart data from CSV", ...DESTRUCTIVE },
|
|
1075
|
+
handler: ({ csv, seriesIn, header, chartType = "keep" }) => {
|
|
1076
|
+
if (chartType === "auto") {
|
|
1077
|
+
const rows = parseCsv(csv, { delimiter: csv.includes(" ") ? " " : "," });
|
|
1078
|
+
const built = chartFromTable(rows);
|
|
1079
|
+
if (!built.series.length || !built.categories.length) fail2("There is no data to import: expected at least one row of numbers.");
|
|
1080
|
+
resolve(controller.update({ type: built.type, stacking: built.stacking, categories: built.categories, series: built.series }, { label: "Import data" }));
|
|
1081
|
+
return { type: built.type, table: table(), issues: built.issues };
|
|
1082
|
+
}
|
|
1083
|
+
const result = resolve(controller.importCsv(csv, { seriesIn, header }));
|
|
1084
|
+
return { type: spec().type, table: table(), issues: result.issues ?? [] };
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
const importMermaid = {
|
|
1088
|
+
name: "import_chart_mermaid",
|
|
1089
|
+
title: "Import a Mermaid chart",
|
|
1090
|
+
description: 'Replaces the chart with Mermaid xychart-beta, pie or radar-beta text as one undo step (a "%% @pixerate" comment from export_chart_mermaid restores everything else). Lines that cannot be read are skipped and listed with their line numbers. Returns the spec.',
|
|
1091
|
+
inputSchema: { type: "object", properties: { mermaid: { type: "string", description: "Mermaid text, optionally inside a ```mermaid fence." } }, required: ["mermaid"], additionalProperties: false },
|
|
1092
|
+
annotations: { title: "Import a Mermaid chart", ...DESTRUCTIVE },
|
|
1093
|
+
handler: ({ mermaid }) => {
|
|
1094
|
+
const fenced = /```(?:mermaid)?\s*\n([\s\S]*?)```/.exec(mermaid);
|
|
1095
|
+
const read = chartFromMermaid(fenced ? fenced[1] : mermaid);
|
|
1096
|
+
if (!read.family) fail2(read.issues.map((i) => i.message).join(" ") || "Expected Mermaid starting with xychart-beta, pie or radar-beta.");
|
|
1097
|
+
resolve(controller.update({ ...read.spec }, { label: "Import Mermaid chart" }));
|
|
1098
|
+
return { spec: controller.toJSON(), issues: read.issues };
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
const importOoxml = {
|
|
1102
|
+
name: "import_chart_ooxml",
|
|
1103
|
+
title: "Import an Office chart",
|
|
1104
|
+
description: "Replaces the chart with an Office chart part (DrawingML c:chartSpace, or chartex for waterfall, funnel, treemap, histogram and box charts) as one undo step. Returns the spec, problems, and the features the editor cannot hold.",
|
|
1105
|
+
inputSchema: { type: "object", properties: { xml: { type: "string", description: "The chart part XML, e.g. word/charts/chart1.xml." } }, required: ["xml"], additionalProperties: false },
|
|
1106
|
+
annotations: { title: "Import an Office chart", ...DESTRUCTIVE },
|
|
1107
|
+
handler: ({ xml }) => {
|
|
1108
|
+
const read = chartFromOoxml(xml, { DOMParser: Parser() });
|
|
1109
|
+
if (read.issues.some((i) => i.path === "" || i.path === "plotArea")) fail2(read.issues.map((i) => i.message).join(" "));
|
|
1110
|
+
resolve(controller.update({ ...read.spec }, { label: "Import Office chart" }));
|
|
1111
|
+
return { spec: controller.toJSON(), issues: read.issues.map((i) => i.message), unsupported: read.unsupported, lossless: read.lossless };
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
const addTrendline = {
|
|
1115
|
+
name: "add_chart_trendline",
|
|
1116
|
+
title: "Add a trendline",
|
|
1117
|
+
description: "Fits a trendline to a series and draws it (unstacked bar, line, area, scatter and bubble charts). Returns its index and the fit: coefficients, R\xB2 and equation.",
|
|
1118
|
+
inputSchema: { type: "object", properties: { series: SERIES_REF, ...TRENDLINE_PROPS }, required: ["series", "type"], additionalProperties: false },
|
|
1119
|
+
annotations: { title: "Add a trendline", ...WRITE },
|
|
1120
|
+
handler: ({ series, ...line }) => {
|
|
1121
|
+
const s = seriesById(series);
|
|
1122
|
+
const result = resolve(controller.addTrendline(s.id, line));
|
|
1123
|
+
return { series: s.id, index: result.index, ...result.fit ? { coefficients: result.fit.coefficients, r2: result.fit.r2, equation: result.fit.equation } : {} };
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
const removeTrendline = {
|
|
1127
|
+
name: "remove_chart_trendline",
|
|
1128
|
+
title: "Delete a trendline",
|
|
1129
|
+
description: "Deletes a trendline from a series. Returns the series' remaining trendlines.",
|
|
1130
|
+
inputSchema: {
|
|
1131
|
+
type: "object",
|
|
1132
|
+
properties: { series: SERIES_REF, index: { type: "integer", minimum: 0, description: "Trendline index on the series (0 = first)." } },
|
|
1133
|
+
required: ["series", "index"],
|
|
1134
|
+
additionalProperties: false
|
|
1135
|
+
},
|
|
1136
|
+
annotations: { title: "Delete a trendline", ...DESTRUCTIVE },
|
|
1137
|
+
handler: ({ series, index }) => {
|
|
1138
|
+
const s = seriesById(series);
|
|
1139
|
+
resolve(controller.removeTrendline(s.id, index));
|
|
1140
|
+
return { series: s.id, trendlines: controller.getSeries(s.id)?.trendlines ?? [] };
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
const applyPreset = {
|
|
1144
|
+
name: "apply_chart_preset",
|
|
1145
|
+
title: "Apply a chart style",
|
|
1146
|
+
description: "Applies a quick layout (legend, labels and grid lines together) and / or a colour scheme as one undo step. See list_chart_presets for the names. Returns the new spec.",
|
|
1147
|
+
inputSchema: {
|
|
1148
|
+
type: "object",
|
|
1149
|
+
properties: {
|
|
1150
|
+
layout: { type: "string", enum: CHART_LAYOUT_PRESETS.map((p) => p.id), description: "Quick layout id." },
|
|
1151
|
+
colors: { type: "string", enum: CHART_COLOR_SCHEMES.map((p) => p.id), description: "Colour scheme id." }
|
|
1152
|
+
},
|
|
1153
|
+
additionalProperties: false
|
|
1154
|
+
},
|
|
1155
|
+
annotations: { title: "Apply a chart style", ...WRITE_IDEMPOTENT },
|
|
1156
|
+
handler: ({ layout, colors }) => {
|
|
1157
|
+
resolve(controller.applyPreset({ layout, colors }));
|
|
1158
|
+
return { spec: controller.toJSON() };
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
const transpose = {
|
|
1162
|
+
name: "transpose_chart",
|
|
1163
|
+
title: "Switch rows and columns",
|
|
1164
|
+
description: "Swaps series and categories (each category becomes a series) as one undo step. Per-series colours and settings are dropped. Returns the data table.",
|
|
1165
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1166
|
+
annotations: { title: "Switch rows and columns", ...WRITE },
|
|
1167
|
+
handler: () => {
|
|
1168
|
+
resolve(controller.transpose());
|
|
1169
|
+
return { table: table(), series: spec().series.map((s) => ({ id: s.id, name: s.name })) };
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
const select = {
|
|
1173
|
+
name: "select_chart_element",
|
|
1174
|
+
title: "Select a chart element",
|
|
1175
|
+
description: 'Selects part of the chart (as a click would), for press_chart_key and for the user to see. kind "none" clears the selection. Returns the selection.',
|
|
1176
|
+
inputSchema: {
|
|
1177
|
+
type: "object",
|
|
1178
|
+
properties: {
|
|
1179
|
+
kind: { type: "string", enum: ["none", "chart", "title", "subtitle", "legend", "axis", "series", "point", "category", "trendline"], description: "What to select." },
|
|
1180
|
+
series: { ...SERIES_REF, description: "Series id or name (series, point, trendline)." },
|
|
1181
|
+
category: { ...CATEGORY_REF, description: "Category index or label (point, category)." },
|
|
1182
|
+
axis: { type: "string", enum: ["x", "y", "y2"], description: "Axis (axis)." },
|
|
1183
|
+
index: { type: "integer", minimum: 0, description: "Trendline index (trendline)." }
|
|
1184
|
+
},
|
|
1185
|
+
required: ["kind"],
|
|
1186
|
+
additionalProperties: false
|
|
1187
|
+
},
|
|
1188
|
+
annotations: { title: "Select a chart element", ...WRITE_IDEMPOTENT },
|
|
1189
|
+
handler: async ({ kind, series, category, axis, index }) => {
|
|
1190
|
+
let sel;
|
|
1191
|
+
const need = (v, what) => v === void 0 && fail2(`kind "${kind}" needs ${what}.`);
|
|
1192
|
+
switch (kind) {
|
|
1193
|
+
case "none":
|
|
1194
|
+
sel = null;
|
|
1195
|
+
break;
|
|
1196
|
+
case "axis":
|
|
1197
|
+
need(axis, "axis");
|
|
1198
|
+
sel = { kind, axis };
|
|
1199
|
+
break;
|
|
1200
|
+
case "series":
|
|
1201
|
+
need(series, "series");
|
|
1202
|
+
sel = { kind, seriesId: seriesById(series).id };
|
|
1203
|
+
break;
|
|
1204
|
+
case "point":
|
|
1205
|
+
need(series, "series");
|
|
1206
|
+
need(category, "category");
|
|
1207
|
+
sel = { kind, seriesId: seriesById(series).id, index: categoryIndex(category) };
|
|
1208
|
+
break;
|
|
1209
|
+
case "category":
|
|
1210
|
+
need(category, "category");
|
|
1211
|
+
sel = { kind, index: categoryIndex(category) };
|
|
1212
|
+
break;
|
|
1213
|
+
case "trendline":
|
|
1214
|
+
need(series, "series");
|
|
1215
|
+
need(index, "index");
|
|
1216
|
+
sel = { kind, seriesId: seriesById(series).id, index };
|
|
1217
|
+
break;
|
|
1218
|
+
default:
|
|
1219
|
+
sel = { kind };
|
|
1220
|
+
}
|
|
1221
|
+
if (options.director) await options.director.perform(options.actor ?? { id: "agent", name: "Agent" }, [{ kind: "select", selection: sel }]);
|
|
1222
|
+
else controller.select(sel);
|
|
1223
|
+
const now = controller.getState().selection;
|
|
1224
|
+
if (sel && !now) fail2(`Nothing to select there (e.g. the chart has no ${kind} of that kind).`);
|
|
1225
|
+
return { selection: now };
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
const drag = {
|
|
1229
|
+
name: "drag_chart_point",
|
|
1230
|
+
title: "Drag a chart point",
|
|
1231
|
+
description: "Drags on the chart as a pointer would, through the same code as the mouse: from a bar or point to a new position, with the same snapping (to other values, zero and ticks), fine adjustment (fine: Alt) and axis lock on scatter charts (lockAxis: Shift). Positions are 0\u20131 fractions of the chart's width and height at the given size (use render_chart to see where marks are). One undo step. Returns what was dragged and the value before and after.",
|
|
1232
|
+
inputSchema: {
|
|
1233
|
+
type: "object",
|
|
1234
|
+
properties: {
|
|
1235
|
+
from: { type: "object", description: "Start, as fractions of the chart (0,0 top-left).", properties: { x: { type: "number", minimum: 0, maximum: 1, description: "Fraction of the width." }, y: { type: "number", minimum: 0, maximum: 1, description: "Fraction of the height." } }, required: ["x", "y"], additionalProperties: false },
|
|
1236
|
+
to: { type: "object", description: "End, as fractions of the chart.", properties: { x: { type: "number", minimum: 0, maximum: 1, description: "Fraction of the width." }, y: { type: "number", minimum: 0, maximum: 1, description: "Fraction of the height." } }, required: ["x", "y"], additionalProperties: false },
|
|
1237
|
+
fine: { type: "boolean", description: "Move a tenth as far (Alt)." },
|
|
1238
|
+
lockAxis: { type: "boolean", description: "Scatter charts: change only the axis moved along most (Shift)." },
|
|
1239
|
+
width: WIDTH,
|
|
1240
|
+
height: HEIGHT
|
|
1241
|
+
},
|
|
1242
|
+
required: ["from", "to"],
|
|
1243
|
+
additionalProperties: false
|
|
1244
|
+
},
|
|
1245
|
+
annotations: { title: "Drag a chart point", ...WRITE },
|
|
1246
|
+
handler: ({ from, to, fine, lockAxis, width = size.width, height = size.height }) => {
|
|
1247
|
+
const scene = layoutChart(controller.toJSON(), { width, height });
|
|
1248
|
+
const start = { x: from.x * width, y: from.y * height };
|
|
1249
|
+
const target = chartHitTest(scene, start);
|
|
1250
|
+
if (target?.kind !== "point") fail2(`There is no bar or point at (${from.x}, ${from.y}); ${target ? `that is the ${target.kind}` : "that is outside the chart"}. Use render_chart to find marks.`);
|
|
1251
|
+
const t = target;
|
|
1252
|
+
const before = controller.getSeries(t.seriesId)?.values[t.index] ?? null;
|
|
1253
|
+
const session = startChartPointDrag(controller, scene, t, start);
|
|
1254
|
+
if (!session) fail2("That point cannot be dragged here (percent-stacked charts, pies and gaps cannot be dragged).");
|
|
1255
|
+
session.move({ x: to.x * width, y: to.y * height }, { fine, lockAxis });
|
|
1256
|
+
session.end();
|
|
1257
|
+
const s = controller.getSeries(t.seriesId);
|
|
1258
|
+
return { series: s.id, category: spec().categories[t.index], index: t.index, before, after: s.values[t.index], ...s.x ? { x: s.x[t.index] } : {} };
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
const key = {
|
|
1262
|
+
name: "press_chart_key",
|
|
1263
|
+
title: "Press a key on the chart",
|
|
1264
|
+
description: "Sends a key to the chart as the keyboard would, acting on the selection (select_chart_element first): \u2190 \u2192 move between categories, \u2191 \u2193 change the selected value by a tenth of a tick (shift: a whole tick, alt: a hundredth), Delete removes the selection, Escape clears it. Returns whether the key did anything and the new selection.",
|
|
1265
|
+
inputSchema: {
|
|
1266
|
+
type: "object",
|
|
1267
|
+
properties: {
|
|
1268
|
+
key: { type: "string", enum: ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Delete", "Backspace", "Escape"], description: "Key name." },
|
|
1269
|
+
shift: { type: "boolean", description: "Hold Shift." },
|
|
1270
|
+
alt: { type: "boolean", description: "Hold Alt / Option." }
|
|
1271
|
+
},
|
|
1272
|
+
required: ["key"],
|
|
1273
|
+
additionalProperties: false
|
|
1274
|
+
},
|
|
1275
|
+
annotations: { title: "Press a key on the chart", ...DESTRUCTIVE },
|
|
1276
|
+
handler: ({ key: k, shift, alt }) => {
|
|
1277
|
+
const scene = layoutChart(controller.toJSON(), size);
|
|
1278
|
+
const used = handleChartKey(controller, scene, { key: k, shiftKey: shift, altKey: alt });
|
|
1279
|
+
const sel = controller.getState().selection;
|
|
1280
|
+
const value = sel?.kind === "point" ? controller.getSeries(sel.seriesId)?.values[sel.index] ?? null : void 0;
|
|
1281
|
+
return { used, selection: sel, ...value !== void 0 ? { value } : {} };
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
const undo = {
|
|
1285
|
+
name: "undo_chart",
|
|
1286
|
+
title: "Undo a chart edit",
|
|
1287
|
+
description: "Undoes the last chart edit. Returns whether something was undone and what the next undo would be.",
|
|
1288
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1289
|
+
annotations: { title: "Undo a chart edit", ...WRITE },
|
|
1290
|
+
handler: () => {
|
|
1291
|
+
const label = controller.getState().undoLabel;
|
|
1292
|
+
const undone = controller.undo();
|
|
1293
|
+
return { undone, ...undone ? { label } : {}, next: controller.getState().undoLabel || null };
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
const redo = {
|
|
1297
|
+
name: "redo_chart",
|
|
1298
|
+
title: "Redo a chart edit",
|
|
1299
|
+
description: "Redoes the last undone chart edit. Returns whether something was redone.",
|
|
1300
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1301
|
+
annotations: { title: "Redo a chart edit", ...WRITE },
|
|
1302
|
+
handler: () => {
|
|
1303
|
+
const label = controller.getState().redoLabel;
|
|
1304
|
+
const redone = controller.redo();
|
|
1305
|
+
return { redone, ...redone ? { label } : {} };
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
const reads = [getChart, renderChart, checkColors, recommend, getTrendline, listPresets, exportMermaid, exportOoxml];
|
|
1309
|
+
const writes = [updateChart, setData, setValues, addSeries, updateSeries, removeSeries, addCategory, removeCategory, importCsv, importMermaid, addTrendline, removeTrendline, applyPreset, transpose, select, drag, key, undo, redo];
|
|
1310
|
+
return {
|
|
1311
|
+
id: options.id ?? "chart",
|
|
1312
|
+
namespace: options.namespace,
|
|
1313
|
+
tools: () => {
|
|
1314
|
+
if (readOnlyReason()) return reads;
|
|
1315
|
+
return [...reads, ...writes.slice(0, 10), ...Parser() ? [importOoxml] : [], ...writes.slice(10)];
|
|
1316
|
+
},
|
|
1317
|
+
subscribe: (notify) => {
|
|
1318
|
+
let last = readOnlyReason();
|
|
1319
|
+
return controller.subscribe(() => {
|
|
1320
|
+
const now = readOnlyReason();
|
|
1321
|
+
if (now !== last) {
|
|
1322
|
+
last = now;
|
|
1323
|
+
notify();
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/chart/stream.ts
|
|
1331
|
+
function parsePartialChartJson(text) {
|
|
1332
|
+
const src = text.trim();
|
|
1333
|
+
if (!src) return void 0;
|
|
1334
|
+
try {
|
|
1335
|
+
return JSON.parse(src);
|
|
1336
|
+
} catch {
|
|
1337
|
+
}
|
|
1338
|
+
const stack = [];
|
|
1339
|
+
let inString = false;
|
|
1340
|
+
let escaped = false;
|
|
1341
|
+
let out = "";
|
|
1342
|
+
for (const ch of src) {
|
|
1343
|
+
out += ch;
|
|
1344
|
+
if (inString) {
|
|
1345
|
+
if (escaped) escaped = false;
|
|
1346
|
+
else if (ch === "\\") escaped = true;
|
|
1347
|
+
else if (ch === '"') inString = false;
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (ch === '"') inString = true;
|
|
1351
|
+
else if (ch === "{" || ch === "[") stack.push(ch === "{" ? "}" : "]");
|
|
1352
|
+
else if (ch === "}" || ch === "]") stack.pop();
|
|
1353
|
+
}
|
|
1354
|
+
if (inString) out += '"';
|
|
1355
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1356
|
+
const trimmed = out.replace(/\s+$/, "");
|
|
1357
|
+
const candidate = trimmed + [...stack].reverse().join("");
|
|
1358
|
+
try {
|
|
1359
|
+
return JSON.parse(candidate);
|
|
1360
|
+
} catch {
|
|
1361
|
+
const next = trimmed.replace(/,\s*$/, "").replace(/(?:,|\{)\s*"[^"]*"\s*:?\s*$/, (m) => m.trim().startsWith("{") ? "{" : "").replace(/:\s*$/, ": null").replace(/(?<=[\[,:]\s*)(-|tr?u?|fa?l?s?|nu?l?)$/, "").replace(/(?<=[\[,:]\s*)(-?\d+\.)$/, (m) => m.slice(0, -1));
|
|
1362
|
+
if (next === trimmed) break;
|
|
1363
|
+
out = next;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return void 0;
|
|
1367
|
+
}
|
|
1368
|
+
var ChartStream = class {
|
|
1369
|
+
constructor(format = "auto") {
|
|
1370
|
+
this.format = format;
|
|
1371
|
+
}
|
|
1372
|
+
format;
|
|
1373
|
+
lo = Infinity;
|
|
1374
|
+
hi = -Infinity;
|
|
1375
|
+
last = null;
|
|
1376
|
+
/** Reads the source so far. `complete` when nothing more will arrive. */
|
|
1377
|
+
update(source, complete = false) {
|
|
1378
|
+
const text = source.replace(/^\s*```(?:mermaid|json)?\s*\n?/, "").replace(/```\s*$/, "");
|
|
1379
|
+
const format = this.format === "auto" ? /^\s*[[{]/.test(text) ? "json" : "mermaid" : this.format;
|
|
1380
|
+
let spec = null;
|
|
1381
|
+
let issues = [];
|
|
1382
|
+
if (format === "json") {
|
|
1383
|
+
const value = complete ? safeParse(text) : parsePartialChartJson(text);
|
|
1384
|
+
if (value !== void 0) {
|
|
1385
|
+
const read = normalizeChartSpec(value);
|
|
1386
|
+
spec = read.spec;
|
|
1387
|
+
issues = complete ? read.issues : [];
|
|
1388
|
+
}
|
|
1389
|
+
} else {
|
|
1390
|
+
const read = chartFromMermaid(text, { partial: !complete });
|
|
1391
|
+
if (read.family) {
|
|
1392
|
+
spec = read.spec;
|
|
1393
|
+
issues = complete ? read.issues : [];
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (spec && !spec.series.some((s) => s.values.some((v) => v !== null))) spec = complete ? spec : this.last;
|
|
1397
|
+
if (spec) this.last = spec;
|
|
1398
|
+
if (spec) {
|
|
1399
|
+
for (const s of spec.series) for (const v of s.values) if (v !== null && Number.isFinite(v)) this.lo = Math.min(this.lo, v), this.hi = Math.max(this.hi, v);
|
|
1400
|
+
}
|
|
1401
|
+
const valueDomain = !complete && Number.isFinite(this.lo) && this.hi > this.lo ? { min: Math.min(0, this.lo), max: this.hi } : null;
|
|
1402
|
+
return { spec, issues, valueDomain };
|
|
1403
|
+
}
|
|
1404
|
+
/** Forgets the axis bounds (a new source). */
|
|
1405
|
+
reset() {
|
|
1406
|
+
this.lo = Infinity;
|
|
1407
|
+
this.hi = -Infinity;
|
|
1408
|
+
this.last = null;
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
function safeParse(text) {
|
|
1412
|
+
try {
|
|
1413
|
+
return JSON.parse(text);
|
|
1414
|
+
} catch {
|
|
1415
|
+
return parsePartialChartJson(text);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
var mix = (a, b, t) => a + (b - a) * t;
|
|
1419
|
+
function blendPath(a, b, t) {
|
|
1420
|
+
const na = a.match(/-?\d*\.?\d+(?:e-?\d+)?/g);
|
|
1421
|
+
const nb = b.match(/-?\d*\.?\d+(?:e-?\d+)?/g);
|
|
1422
|
+
if (!na || !nb || na.length !== nb.length || a.replace(/-?\d*\.?\d+(?:e-?\d+)?/g, "#") !== b.replace(/-?\d*\.?\d+(?:e-?\d+)?/g, "#")) return t < 1 ? a : b;
|
|
1423
|
+
let i = 0;
|
|
1424
|
+
return b.replace(/-?\d*\.?\d+(?:e-?\d+)?/g, () => {
|
|
1425
|
+
const v = mix(Number(na[i]), Number(nb[i]), t);
|
|
1426
|
+
i++;
|
|
1427
|
+
return String(Math.round(v * 100) / 100);
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
function blendChartScenes(from, to, t) {
|
|
1431
|
+
if (!from || t >= 1) return to;
|
|
1432
|
+
const k = Math.max(0, Math.min(1, t));
|
|
1433
|
+
const before = new Map(from.marks.map((m) => [m.key, m]));
|
|
1434
|
+
const marks = to.marks.map((m) => {
|
|
1435
|
+
const a = before.get(m.key);
|
|
1436
|
+
if (!a || a.kind !== m.kind) return { ...m, opacity: (m.opacity ?? 1) * k };
|
|
1437
|
+
switch (m.kind) {
|
|
1438
|
+
case "rect": {
|
|
1439
|
+
const p = a;
|
|
1440
|
+
return { ...m, x: mix(p.x, m.x, k), y: mix(p.y, m.y, k), width: mix(p.width, m.width, k), height: mix(p.height, m.height, k) };
|
|
1441
|
+
}
|
|
1442
|
+
case "circle": {
|
|
1443
|
+
const p = a;
|
|
1444
|
+
return { ...m, cx: mix(p.cx, m.cx, k), cy: mix(p.cy, m.cy, k), r: mix(p.r, m.r, k) };
|
|
1445
|
+
}
|
|
1446
|
+
case "line": {
|
|
1447
|
+
const p = a;
|
|
1448
|
+
return { ...m, x1: mix(p.x1, m.x1, k), y1: mix(p.y1, m.y1, k), x2: mix(p.x2, m.x2, k), y2: mix(p.y2, m.y2, k) };
|
|
1449
|
+
}
|
|
1450
|
+
case "text": {
|
|
1451
|
+
const p = a;
|
|
1452
|
+
return { ...m, x: mix(p.x, m.x, k), y: mix(p.y, m.y, k) };
|
|
1453
|
+
}
|
|
1454
|
+
case "path":
|
|
1455
|
+
return { ...m, d: blendPath(a.d, m.d, k) };
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
return { ...to, marks };
|
|
1459
|
+
}
|
|
1460
|
+
var easeChartTween = (t) => 1 - Math.pow(1 - Math.max(0, Math.min(1, t)), 3);
|
|
1461
|
+
|
|
1462
|
+
// src/chart/presence.ts
|
|
1463
|
+
var ChartPresence = class {
|
|
1464
|
+
state = { actors: [] };
|
|
1465
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1466
|
+
getState = () => this.state;
|
|
1467
|
+
subscribe = (listener) => {
|
|
1468
|
+
this.listeners.add(listener);
|
|
1469
|
+
return () => {
|
|
1470
|
+
this.listeners.delete(listener);
|
|
1471
|
+
};
|
|
1472
|
+
};
|
|
1473
|
+
/** Adds or changes an actor. */
|
|
1474
|
+
set(actor) {
|
|
1475
|
+
const current = this.state.actors.find((a) => a.id === actor.id);
|
|
1476
|
+
const next = { name: actor.id, color: CHART_PRESENCE_COLORS[this.state.actors.length % CHART_PRESENCE_COLORS.length], x: 0, y: 0, pressed: false, ...current, ...actor };
|
|
1477
|
+
this.state = { actors: current ? this.state.actors.map((a) => a.id === actor.id ? next : a) : [...this.state.actors, next] };
|
|
1478
|
+
this.listeners.forEach((l) => l(this.state));
|
|
1479
|
+
}
|
|
1480
|
+
remove(id) {
|
|
1481
|
+
if (!this.state.actors.some((a) => a.id === id)) return;
|
|
1482
|
+
this.state = { actors: this.state.actors.filter((a) => a.id !== id) };
|
|
1483
|
+
this.listeners.forEach((l) => l(this.state));
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
var CHART_PRESENCE_COLORS = ["#7c3aed", "#db2777", "#0891b2", "#ea580c", "#16a34a"];
|
|
1487
|
+
var realChartClock = {
|
|
1488
|
+
now: () => Date.now(),
|
|
1489
|
+
wait: (ms) => new Promise((resolve2) => setTimeout(resolve2, Math.max(0, ms)))
|
|
1490
|
+
};
|
|
1491
|
+
function createInstantChartClock(start = 0) {
|
|
1492
|
+
let t = start;
|
|
1493
|
+
return {
|
|
1494
|
+
now: () => t,
|
|
1495
|
+
wait: async (ms) => {
|
|
1496
|
+
t += Math.max(0, ms);
|
|
1497
|
+
},
|
|
1498
|
+
elapsed: () => t - start
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
function chartPointAnchor(scene, seriesId, index) {
|
|
1502
|
+
const mark = scene.marks.find((m) => (m.role === "bar" || m.role === "point") && m.target?.kind === "point" && m.target.seriesId === seriesId && m.target.index === index);
|
|
1503
|
+
if (!mark) return null;
|
|
1504
|
+
if (mark.kind === "rect") return { x: mark.x + mark.width / 2, y: mark.y };
|
|
1505
|
+
if (mark.kind === "circle") return { x: mark.cx, y: mark.cy };
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
var anchorFor = (scene, role) => {
|
|
1509
|
+
const m = scene.marks.find((x) => x.role === role);
|
|
1510
|
+
if (!m) return null;
|
|
1511
|
+
if (m.kind === "text") return { x: m.x, y: m.y };
|
|
1512
|
+
if (m.kind === "rect") return { x: m.x + m.width / 2, y: m.y + m.height / 2 };
|
|
1513
|
+
if (m.kind === "line") return { x: (m.x1 + m.x2) / 2, y: (m.y1 + m.y2) / 2 };
|
|
1514
|
+
return null;
|
|
1515
|
+
};
|
|
1516
|
+
function planChartCommand(spec, scene, command, speed = 1) {
|
|
1517
|
+
const ms = (n) => Math.round(n / Math.max(0.1, speed));
|
|
1518
|
+
const steps = [];
|
|
1519
|
+
const note = "note" in command && command.note ? command.note : void 0;
|
|
1520
|
+
if (note) steps.push({ type: "say", text: note, ms: ms(500) });
|
|
1521
|
+
switch (command.kind) {
|
|
1522
|
+
case "say":
|
|
1523
|
+
steps.push({ type: "say", text: command.text, ms: ms(900) });
|
|
1524
|
+
return steps;
|
|
1525
|
+
case "setValue": {
|
|
1526
|
+
const at = chartPointAnchor(scene, command.seriesId, command.index);
|
|
1527
|
+
const series = spec.series.find((s) => s.id === command.seriesId);
|
|
1528
|
+
const from = series?.values[command.index] ?? 0;
|
|
1529
|
+
const scale = series?.axis === "secondary" ? scene.valueScale2 ?? scene.valueScale : scene.valueScale;
|
|
1530
|
+
if (at && scale) {
|
|
1531
|
+
const px = linearMap(command.value, scale.domain, scale.range);
|
|
1532
|
+
const to = scale.axis === "y" ? { x: at.x, y: px } : { x: px, y: at.y };
|
|
1533
|
+
steps.push({ type: "move", to: at, ms: ms(380) }, { type: "press" }, { type: "drag", to, ms: ms(520), from, value: command.value }, { type: "release" });
|
|
1534
|
+
} else steps.push({ type: "drag", to: { x: scene.plot.x + scene.plot.width / 2, y: scene.plot.y + scene.plot.height / 2 }, ms: ms(200), from, value: command.value });
|
|
1535
|
+
steps.push({ type: "commit" });
|
|
1536
|
+
return steps;
|
|
1537
|
+
}
|
|
1538
|
+
case "select": {
|
|
1539
|
+
const sel = command.selection;
|
|
1540
|
+
let to = null;
|
|
1541
|
+
if (sel?.kind === "point") to = chartPointAnchor(scene, sel.seriesId, sel.index);
|
|
1542
|
+
else if (sel) {
|
|
1543
|
+
const m = scene.marks.find((x) => x.target && JSON.stringify(x.target) === JSON.stringify(sel));
|
|
1544
|
+
if (m?.kind === "text") to = { x: m.x, y: m.y };
|
|
1545
|
+
else if (m?.kind === "rect") to = { x: m.x + m.width / 2, y: m.y + m.height / 2 };
|
|
1546
|
+
else if (m?.kind === "circle") to = { x: m.cx, y: m.cy };
|
|
1547
|
+
}
|
|
1548
|
+
if (to) steps.push({ type: "move", to, ms: ms(380) }, { type: "press" }, { type: "release" });
|
|
1549
|
+
steps.push({ type: "commit" });
|
|
1550
|
+
return steps;
|
|
1551
|
+
}
|
|
1552
|
+
case "update": {
|
|
1553
|
+
const keys = Object.keys(command.patch);
|
|
1554
|
+
const role = keys.includes("title") ? "title" : keys.includes("legend") ? "legend-label" : keys.some((k) => k === "xAxis") ? "axis" : keys.some((k) => k === "yAxis" || k === "yAxis2") ? "tick-label" : null;
|
|
1555
|
+
const to = role && anchorFor(scene, role) || { x: scene.plot.x + scene.plot.width / 2, y: scene.plot.y + 12 };
|
|
1556
|
+
steps.push({ type: "move", to, ms: ms(380) }, { type: "press" }, { type: "release" }, { type: "commit" });
|
|
1557
|
+
return steps;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
var ChartDirector = class {
|
|
1562
|
+
constructor(controller, presence, options) {
|
|
1563
|
+
this.controller = controller;
|
|
1564
|
+
this.presence = presence;
|
|
1565
|
+
this.options = options;
|
|
1566
|
+
this.clock = options.clock ?? realChartClock;
|
|
1567
|
+
}
|
|
1568
|
+
controller;
|
|
1569
|
+
presence;
|
|
1570
|
+
options;
|
|
1571
|
+
queues = /* @__PURE__ */ new Map();
|
|
1572
|
+
clock;
|
|
1573
|
+
calls = 0;
|
|
1574
|
+
/** Acts out `commands` in order for `actor`. Resolves with one result per command. */
|
|
1575
|
+
perform(actor, commands) {
|
|
1576
|
+
const prev = this.queues.get(actor.id) ?? Promise.resolve();
|
|
1577
|
+
const run = prev.then(async () => {
|
|
1578
|
+
const results = [];
|
|
1579
|
+
this.presence.set({ id: actor.id, ...actor.name ? { name: actor.name } : {}, ...actor.color ? { color: actor.color } : {} });
|
|
1580
|
+
this.controller.beginGesture(`agent:${actor.id}:${++this.calls}`);
|
|
1581
|
+
try {
|
|
1582
|
+
for (const command of commands) results.push(await this.performOne(actor.id, command));
|
|
1583
|
+
} finally {
|
|
1584
|
+
this.controller.endGesture();
|
|
1585
|
+
this.presence.set({ id: actor.id, message: void 0, pressed: false });
|
|
1586
|
+
}
|
|
1587
|
+
const linger = this.options.linger ?? 1500;
|
|
1588
|
+
if (linger > 0) {
|
|
1589
|
+
await this.clock.wait(linger);
|
|
1590
|
+
if (this.queues.get(actor.id) === run) this.presence.remove(actor.id);
|
|
1591
|
+
}
|
|
1592
|
+
return results;
|
|
1593
|
+
});
|
|
1594
|
+
this.queues.set(actor.id, run.catch(() => void 0));
|
|
1595
|
+
return run;
|
|
1596
|
+
}
|
|
1597
|
+
stale(command) {
|
|
1598
|
+
const spec = this.controller.getSpec();
|
|
1599
|
+
if (command.kind === "setValue") {
|
|
1600
|
+
const s = spec.series.find((x) => x.id === command.seriesId);
|
|
1601
|
+
if (!s) return `The series "${command.seriesId}" no longer exists.`;
|
|
1602
|
+
if (command.index < 0 || command.index >= spec.categories.length) return `Category ${command.index} no longer exists.`;
|
|
1603
|
+
if (!Number.isFinite(command.value)) return "The value must be a finite number.";
|
|
1604
|
+
}
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
async move(id, from, to, ms, each) {
|
|
1608
|
+
const frames = Math.max(1, Math.round(ms / 16));
|
|
1609
|
+
for (let f = 1; f <= frames; f++) {
|
|
1610
|
+
const t = f / frames;
|
|
1611
|
+
const e = 1 - Math.pow(1 - t, 3);
|
|
1612
|
+
this.presence.set({ id, x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e });
|
|
1613
|
+
each?.(e);
|
|
1614
|
+
await this.clock.wait(ms / frames);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
async performOne(id, command) {
|
|
1618
|
+
const stale = this.stale(command);
|
|
1619
|
+
if (stale) return { ok: false, command, message: stale };
|
|
1620
|
+
const steps = planChartCommand(this.controller.getSpec(), this.options.scene(), command, this.options.speed);
|
|
1621
|
+
const where = () => {
|
|
1622
|
+
const a = this.presence.getState().actors.find((x) => x.id === id);
|
|
1623
|
+
return { x: a.x, y: a.y };
|
|
1624
|
+
};
|
|
1625
|
+
{
|
|
1626
|
+
for (const step of steps) {
|
|
1627
|
+
switch (step.type) {
|
|
1628
|
+
case "say":
|
|
1629
|
+
this.presence.set({ id, message: step.text });
|
|
1630
|
+
await this.clock.wait(step.ms);
|
|
1631
|
+
break;
|
|
1632
|
+
case "move":
|
|
1633
|
+
await this.move(id, where(), step.to, step.ms);
|
|
1634
|
+
break;
|
|
1635
|
+
case "press":
|
|
1636
|
+
this.presence.set({ id, pressed: true });
|
|
1637
|
+
break;
|
|
1638
|
+
case "release":
|
|
1639
|
+
this.presence.set({ id, pressed: false });
|
|
1640
|
+
break;
|
|
1641
|
+
case "drag": {
|
|
1642
|
+
if (command.kind !== "setValue") break;
|
|
1643
|
+
const stale2 = this.stale(command);
|
|
1644
|
+
if (stale2) return { ok: false, command, message: stale2 };
|
|
1645
|
+
const label = `${this.presence.getState().actors.find((a) => a.id === id)?.name ?? "Agent"}: edit value`;
|
|
1646
|
+
await this.move(id, where(), step.to, step.ms, (e) => {
|
|
1647
|
+
const v = step.from + (step.value - step.from) * e;
|
|
1648
|
+
this.controller.setValue(command.seriesId, command.index, Number(v.toPrecision(12)), { label });
|
|
1649
|
+
});
|
|
1650
|
+
this.controller.setValue(command.seriesId, command.index, step.value, { label });
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
case "commit": {
|
|
1654
|
+
if (command.kind === "select") this.controller.select(command.selection);
|
|
1655
|
+
else if (command.kind === "update") {
|
|
1656
|
+
const result = this.controller.update(command.patch, command.label ? { label: command.label } : {});
|
|
1657
|
+
if (!result.ok) return { ok: false, command, message: result.message };
|
|
1658
|
+
}
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return { ok: true, command };
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
2002
1667
|
export {
|
|
1668
|
+
CHART_AXIS_KINDS,
|
|
1669
|
+
CHART_COLOR_SCHEMES,
|
|
2003
1670
|
CHART_CURVES,
|
|
1671
|
+
CHART_DATE_STEPS,
|
|
1672
|
+
CHART_LABEL_POSITIONS,
|
|
1673
|
+
CHART_LAYOUT_PRESETS,
|
|
2004
1674
|
CHART_LEGEND_POSITIONS,
|
|
1675
|
+
CHART_MARKERS,
|
|
1676
|
+
CHART_MERMAID_META_PREFIX,
|
|
2005
1677
|
CHART_NUMBER_STYLES,
|
|
1678
|
+
CHART_OOXML_CONTENT_TYPE,
|
|
1679
|
+
CHART_OOXML_NS,
|
|
1680
|
+
CHART_OOXML_RELATIONSHIP,
|
|
2006
1681
|
CHART_ORIENTATIONS,
|
|
1682
|
+
CHART_PALETTE_SHADES,
|
|
1683
|
+
CHART_PRESENCE_COLORS,
|
|
2007
1684
|
CHART_SERIES_TYPES,
|
|
2008
1685
|
CHART_STACKINGS,
|
|
1686
|
+
CHART_TRENDLINE_TYPES,
|
|
2009
1687
|
CHART_TYPES,
|
|
2010
1688
|
CHART_TYPE_LABELS,
|
|
2011
1689
|
CSS_VARIABLE_CHART_THEME,
|
|
2012
1690
|
ChartController,
|
|
1691
|
+
ChartDirector,
|
|
1692
|
+
ChartPresence,
|
|
1693
|
+
ChartStream,
|
|
2013
1694
|
DEFAULT_CHART_PALETTE,
|
|
2014
1695
|
DEFAULT_CHART_THEME,
|
|
1696
|
+
MAX_CHART_DATA_LABELS,
|
|
1697
|
+
MAX_CHART_LINE_POINTS,
|
|
1698
|
+
MAX_CHART_MARKERS,
|
|
1699
|
+
analyzeChartTable,
|
|
1700
|
+
blendChartScenes,
|
|
2015
1701
|
canDragChartPoint,
|
|
1702
|
+
chartBoxStats,
|
|
2016
1703
|
chartClientToScene,
|
|
1704
|
+
chartColorDistance,
|
|
2017
1705
|
chartColorInputValue,
|
|
1706
|
+
chartColorsByCategory,
|
|
1707
|
+
chartContrastRatio,
|
|
2018
1708
|
chartDataFromTable,
|
|
2019
1709
|
chartDataToTable,
|
|
1710
|
+
chartDateLabels,
|
|
1711
|
+
chartDateToSerial,
|
|
1712
|
+
chartFamily,
|
|
1713
|
+
chartFormatToCode,
|
|
1714
|
+
chartFromMermaid,
|
|
1715
|
+
chartFromOoxml,
|
|
1716
|
+
chartFromTable,
|
|
1717
|
+
chartHistogram,
|
|
1718
|
+
chartHitTest,
|
|
1719
|
+
chartLuminance,
|
|
1720
|
+
chartMermaidFamily,
|
|
1721
|
+
chartMovingAverage,
|
|
1722
|
+
chartPointAnchor,
|
|
1723
|
+
chartPointColor,
|
|
1724
|
+
chartPresetPatch,
|
|
1725
|
+
chartQuantile,
|
|
1726
|
+
chartRgbToHex,
|
|
1727
|
+
chartRgbToOklab,
|
|
1728
|
+
chartSceneToDomSpec,
|
|
2020
1729
|
chartSceneToSvg,
|
|
1730
|
+
chartSerialToDate,
|
|
2021
1731
|
chartSeriesColor,
|
|
2022
1732
|
chartSeriesType,
|
|
2023
1733
|
chartSliceColor,
|
|
1734
|
+
chartSquarify,
|
|
1735
|
+
chartSupportsTrendlines,
|
|
2024
1736
|
chartTextBaseline,
|
|
1737
|
+
chartTextOn,
|
|
2025
1738
|
chartTextTransform,
|
|
1739
|
+
chartToMermaid,
|
|
1740
|
+
chartToOoxml,
|
|
2026
1741
|
chartToSvg,
|
|
1742
|
+
chartTrendlineName,
|
|
2027
1743
|
chartValueAtPoint,
|
|
1744
|
+
chartXAtPoint,
|
|
1745
|
+
checkChartColors,
|
|
2028
1746
|
cloneChartSeries,
|
|
2029
1747
|
cloneChartSpec,
|
|
1748
|
+
compileChartFormatCode,
|
|
2030
1749
|
contrastingTextColor,
|
|
2031
1750
|
createChartCommands,
|
|
2032
1751
|
createChartController,
|
|
2033
1752
|
createChartSpec,
|
|
1753
|
+
createChartToolProvider,
|
|
1754
|
+
createInstantChartClock,
|
|
1755
|
+
cyclePaletteColor,
|
|
1756
|
+
decimateChartPoints,
|
|
2034
1757
|
defaultChartAxis,
|
|
1758
|
+
describeChart,
|
|
2035
1759
|
describeChartSelection,
|
|
1760
|
+
easeChartTween,
|
|
2036
1761
|
estimateChartTextWidth,
|
|
1762
|
+
findMermaidCharts,
|
|
1763
|
+
fitChartTrendline,
|
|
1764
|
+
formatChartDate,
|
|
2037
1765
|
formatChartNumber,
|
|
1766
|
+
formatChartValueWithCode,
|
|
2038
1767
|
handleChartKey,
|
|
2039
1768
|
isCartesianChart,
|
|
1769
|
+
isXyChart,
|
|
2040
1770
|
layoutChart,
|
|
1771
|
+
layoutSparkline,
|
|
2041
1772
|
linearMap,
|
|
1773
|
+
monochromeChartPalette,
|
|
2042
1774
|
niceScale,
|
|
2043
1775
|
niceStep,
|
|
2044
1776
|
normalizeChartAxis,
|
|
2045
1777
|
normalizeChartSpec,
|
|
1778
|
+
normalizeChartTrendline,
|
|
2046
1779
|
normalizeChartValues,
|
|
1780
|
+
parseChartColor,
|
|
1781
|
+
parseChartDate,
|
|
2047
1782
|
parseChartValue,
|
|
1783
|
+
parsePartialChartJson,
|
|
1784
|
+
planChartCommand,
|
|
1785
|
+
realChartClock,
|
|
1786
|
+
recommendChartTypes,
|
|
1787
|
+
recommendForShape,
|
|
2048
1788
|
sameChartSelection,
|
|
1789
|
+
shadeChartColor,
|
|
1790
|
+
simulateChartVision,
|
|
1791
|
+
snapChartDragValue,
|
|
2049
1792
|
snapChartValue,
|
|
1793
|
+
sparklineToSvg,
|
|
2050
1794
|
startChartPointDrag,
|
|
2051
1795
|
stepDecimals,
|
|
1796
|
+
summarizeChart,
|
|
1797
|
+
summarizeChartSeries,
|
|
1798
|
+
trendNumberText,
|
|
2052
1799
|
uniqueChartSeriesId
|
|
2053
1800
|
};
|
|
2054
1801
|
//# sourceMappingURL=index.js.map
|