@k2b/cloud 0.31.0 → 0.32.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/package.json +6 -4
- package/src/_internal/define-app.ts +3 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +7 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +5 -2
package/src/ai/chart-block.ts
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
// Named renderers, not the `charts` namespace, so bundles leave out the map and state timeline renderers.
|
|
2
|
+
import {
|
|
3
|
+
autoBin,
|
|
4
|
+
bar,
|
|
5
|
+
barGauge,
|
|
6
|
+
boxplot,
|
|
7
|
+
type ChartDatum,
|
|
8
|
+
type charts,
|
|
9
|
+
computeDomain,
|
|
10
|
+
donut,
|
|
11
|
+
extendDomainToNice,
|
|
12
|
+
gauge,
|
|
13
|
+
heatmap,
|
|
14
|
+
histogram,
|
|
15
|
+
i18n,
|
|
16
|
+
line,
|
|
17
|
+
niceLogTicks,
|
|
18
|
+
niceStep,
|
|
19
|
+
pie,
|
|
20
|
+
scatter,
|
|
21
|
+
sparkline,
|
|
22
|
+
stat,
|
|
23
|
+
} from "@k2b/stdlib";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import type { CloudAiChartInput, CloudAiFormat, CloudAiNumberFormat } from "./default-tool-contracts";
|
|
26
|
+
|
|
27
|
+
type RenderOptions = {
|
|
28
|
+
[K in CloudAiChartInput["kind"]]: { kind: K } & Omit<Parameters<(typeof charts)[K]>[0], "width" | "height" | "inspect">;
|
|
29
|
+
}[CloudAiChartInput["kind"]];
|
|
4
30
|
|
|
5
31
|
/**
|
|
6
32
|
* The chart chat block: the plain-data `cloud.chart()` options of the `chart` tool become the options of the shared
|
|
@@ -22,6 +48,12 @@ const labels = /* @__PURE__ */ i18n.define({
|
|
|
22
48
|
count: "Count",
|
|
23
49
|
min: "Minimum",
|
|
24
50
|
max: "Maximum",
|
|
51
|
+
q1: "Q1",
|
|
52
|
+
q2: "Median",
|
|
53
|
+
q3: "Q3",
|
|
54
|
+
whiskerLow: "Whisker low",
|
|
55
|
+
whiskerHigh: "Whisker high",
|
|
56
|
+
delta: "Change",
|
|
25
57
|
},
|
|
26
58
|
de: {
|
|
27
59
|
category: "Kategorie",
|
|
@@ -33,6 +65,12 @@ const labels = /* @__PURE__ */ i18n.define({
|
|
|
33
65
|
count: "Anzahl",
|
|
34
66
|
min: "Minimum",
|
|
35
67
|
max: "Maximum",
|
|
68
|
+
q1: "Q1",
|
|
69
|
+
q2: "Median",
|
|
70
|
+
q3: "Q3",
|
|
71
|
+
whiskerLow: "Unterer Whisker",
|
|
72
|
+
whiskerHigh: "Oberer Whisker",
|
|
73
|
+
delta: "Änderung",
|
|
36
74
|
},
|
|
37
75
|
},
|
|
38
76
|
});
|
|
@@ -52,6 +90,10 @@ const CHAR = 6.7;
|
|
|
52
90
|
const NARROW = 480 / 296;
|
|
53
91
|
/** The renderer's tick count when an axis sets none. */
|
|
54
92
|
const TICKS = 5;
|
|
93
|
+
/** Heatmap labels are 11px high; 13 units per row leave readable space at the 18rem frame height. */
|
|
94
|
+
const HEATMAP_ROW_HEIGHT = 13;
|
|
95
|
+
/** The mapping sets only left padding, so the renderer supplies 16 top and 32 bottom units. */
|
|
96
|
+
const HEATMAP_MAX_ROWS = Math.floor((HEIGHT - 16 - 32) / HEATMAP_ROW_HEIGHT);
|
|
55
97
|
/** Below this radius, about 30px on a 320px phone, a pie no longer reads as one. */
|
|
56
98
|
const MIN_PIE_RADIUS = 48;
|
|
57
99
|
|
|
@@ -76,31 +118,59 @@ const dateTime = (value: number | string) => (typeof value === "number" ? value
|
|
|
76
118
|
|
|
77
119
|
const seriesOf = (input: CloudAiChartInput) => (input.kind === "line" || input.kind === "scatter" ? input.series : []);
|
|
78
120
|
|
|
79
|
-
type ChartAxisInput = { label?: string; domain?: number[]; ticks?: number; scale?: "linear" | "log" };
|
|
121
|
+
type ChartAxisInput = { label?: string; domain?: number[]; ticks?: number; scale?: "linear" | "log"; format?: CloudAiFormat };
|
|
80
122
|
|
|
81
123
|
const bounds = (domain: number[] | undefined): [number, number] | undefined => {
|
|
82
124
|
const [min, max] = domain ?? [];
|
|
83
125
|
return min === undefined || max === undefined ? undefined : [min, max];
|
|
84
126
|
};
|
|
85
127
|
|
|
86
|
-
const axis = ({ domain, ...value }: ChartAxisInput = {}, format: (value: number) => string) => ({
|
|
128
|
+
const axis = ({ domain, format: _format, ...value }: ChartAxisInput = {}, format: (value: number) => string) => ({
|
|
87
129
|
...value,
|
|
88
130
|
...(domain ? { domain: bounds(domain) } : {}),
|
|
89
131
|
format,
|
|
90
132
|
});
|
|
91
133
|
|
|
134
|
+
const referencesOf = (input: CloudAiChartInput, name: "x" | "y") =>
|
|
135
|
+
"references" in input ? (input.references ?? []).filter((ref) => (ref.axis ?? "y") === name).map((ref) => dateTime(ref.value)) : [];
|
|
136
|
+
|
|
92
137
|
/** The value axes of a chart with what they show: the data checks and the label room read them alike. */
|
|
93
|
-
const valueAxes = (input: CloudAiChartInput): { values: number[]; axis?: ChartAxisInput; zero?: boolean }[] => {
|
|
94
|
-
if (input.kind === "bar") return [{ values: input.data.map((item) => item.value), axis: input.yAxis, zero: true }];
|
|
95
|
-
if (input.kind === "
|
|
138
|
+
const valueAxes = (input: CloudAiChartInput): { values: number[]; axis?: ChartAxisInput; zero?: boolean; name: "x" | "y" }[] => {
|
|
139
|
+
if (input.kind === "bar") return [{ values: input.data.map((item) => item.value), axis: input.yAxis, zero: true, name: "y" }];
|
|
140
|
+
if (input.kind === "boxplot") return [{ values: input.data.flatMap((item) => item.values), axis: input.yAxis, name: "y" }];
|
|
141
|
+
if (input.kind === "histogram") {
|
|
142
|
+
if (!input.references?.length) return [{ values: input.data, name: "x" }];
|
|
143
|
+
const { edges, counts } = autoBin(input.data, input.bins);
|
|
144
|
+
return [
|
|
145
|
+
{ values: edges, name: "x" },
|
|
146
|
+
{ values: counts, zero: true, name: "y" },
|
|
147
|
+
];
|
|
148
|
+
}
|
|
96
149
|
if (input.kind !== "line" && input.kind !== "scatter") return [];
|
|
97
150
|
const points = input.series.flatMap((series) => series.data);
|
|
98
151
|
return [
|
|
99
|
-
{ values: points.map((point) => dateTime(point.x)), axis: input.xAxis },
|
|
100
|
-
{ values: points.map((point) => point.y), axis: input.yAxis },
|
|
152
|
+
{ values: points.map((point) => dateTime(point.x)), axis: input.xAxis, name: "x" },
|
|
153
|
+
{ values: points.map((point) => point.y), axis: input.yAxis, name: "y" },
|
|
101
154
|
];
|
|
102
155
|
};
|
|
103
156
|
|
|
157
|
+
/** Only references add a fixed domain; charts without them keep the renderer's existing defaults. */
|
|
158
|
+
const referencedAxis = (input: CloudAiChartInput, name: "x" | "y"): ChartAxisInput | undefined => {
|
|
159
|
+
const entry = valueAxes(input).find((entry) => entry.name === name);
|
|
160
|
+
if (!entry) return undefined;
|
|
161
|
+
const refs = referencesOf(input, name);
|
|
162
|
+
if (!refs.length || entry.axis?.domain) return entry.axis;
|
|
163
|
+
const values = [...entry.values, ...refs, ...(entry.zero && entry.axis?.scale !== "log" ? [0] : [])].filter(Number.isFinite);
|
|
164
|
+
if (entry.axis?.scale === "log") {
|
|
165
|
+
const positive = values.filter((value) => value > 0);
|
|
166
|
+
return { ...entry.axis, domain: positive.length ? niceLogTicks(Math.min(...positive), Math.max(...positive)).domain : [1, 10] };
|
|
167
|
+
}
|
|
168
|
+
const [min, max] = computeDomain(values);
|
|
169
|
+
return { ...entry.axis, domain: extendDomainToNice(min, max, niceStep(max - min, entry.axis?.ticks ?? TICKS)).domain };
|
|
170
|
+
};
|
|
171
|
+
const renderReferences = (input: CloudAiChartInput) =>
|
|
172
|
+
"references" in input ? input.references?.map((ref) => ({ ...ref, value: dateTime(ref.value) })) : undefined;
|
|
173
|
+
|
|
104
174
|
/**
|
|
105
175
|
* Whether the renderer can step from the lowest to the highest tick. Values that differ by less than the float
|
|
106
176
|
* precision of their size, such as 1e17 and 1e17 + 16, would make it count forever.
|
|
@@ -118,7 +188,7 @@ const steppable = (values: number[], ticks = TICKS) => {
|
|
|
118
188
|
const tickCandidates = (values: number[], value?: ChartAxisInput): number[] => {
|
|
119
189
|
const domain = bounds(value?.domain);
|
|
120
190
|
if (value?.scale === "log") {
|
|
121
|
-
const positive = values.filter((entry) => entry > 0);
|
|
191
|
+
const positive = values.filter((entry) => Number.isFinite(entry) && entry > 0);
|
|
122
192
|
const [min, max] = domain ?? (positive.length ? [Math.min(...positive), Math.max(...positive)] : [1, 10]);
|
|
123
193
|
return [min, max, 10 ** Math.floor(Math.log10(min)), 10 ** Math.ceil(Math.log10(max))];
|
|
124
194
|
}
|
|
@@ -135,23 +205,47 @@ type Formats = {
|
|
|
135
205
|
x: { tick: (value: number) => string; value: (value: number) => string };
|
|
136
206
|
};
|
|
137
207
|
|
|
208
|
+
/** Value precision and tick precision share the same style and units; ticks must remain distinct. */
|
|
209
|
+
export const cloudAiNumberFormatters = (format: CloudAiNumberFormat | undefined, locale: string) => {
|
|
210
|
+
const common: Intl.NumberFormatOptions = {
|
|
211
|
+
style: format?.style === "number" ? "decimal" : format?.style,
|
|
212
|
+
currency: format?.currency,
|
|
213
|
+
};
|
|
214
|
+
const value = new Intl.NumberFormat(
|
|
215
|
+
locale,
|
|
216
|
+
format
|
|
217
|
+
? {
|
|
218
|
+
...common,
|
|
219
|
+
...(format.decimals === undefined ? {} : { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
|
|
220
|
+
...(format.compact ? { notation: "compact" } : {}),
|
|
221
|
+
}
|
|
222
|
+
: { maximumSignificantDigits: 15 },
|
|
223
|
+
);
|
|
224
|
+
let tick: Intl.NumberFormat | undefined;
|
|
225
|
+
const tickValue = (v: number) => {
|
|
226
|
+
tick ??= new Intl.NumberFormat(locale, {
|
|
227
|
+
...common,
|
|
228
|
+
notation: !format || format.compact ? "compact" : "standard",
|
|
229
|
+
maximumSignificantDigits: 15,
|
|
230
|
+
});
|
|
231
|
+
return tick.format(v);
|
|
232
|
+
};
|
|
233
|
+
const unit = format?.unit ? ` ${format.unit}` : "";
|
|
234
|
+
return { value: (v: number) => `${value.format(v)}${unit}`, tick: (v: number) => `${tickValue(v)}${unit}` };
|
|
235
|
+
};
|
|
236
|
+
const numberFormat = (format: CloudAiFormat | undefined) => (format && !("date" in format) ? format : undefined);
|
|
138
237
|
const formats = (input: CloudAiChartInput, locale: string): Formats => {
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
238
|
+
const yFormat = "yAxis" in input ? numberFormat(input.yAxis?.format) : "format" in input ? input.format : undefined;
|
|
239
|
+
const y = cloudAiNumberFormatters(yFormat, locale);
|
|
240
|
+
const xFormat = "xAxis" in input ? input.xAxis?.format : undefined;
|
|
241
|
+
const x = cloudAiNumberFormatters(numberFormat(xFormat), locale);
|
|
242
|
+
// An unformatted x axis often counts years or steps, which compact notation would turn into 2.02K.
|
|
142
243
|
const xTick = new Intl.NumberFormat(locale, { maximumSignificantDigits: 15, useGrouping: "min2" });
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
tick: (v) => tick.format(v),
|
|
150
|
-
value: (v) => value.format(v),
|
|
151
|
-
x: { tick: (v) => xTick.format(v), value: (v) => value.format(v) },
|
|
152
|
-
};
|
|
153
|
-
const times = written.some((date) => date.length > 10);
|
|
154
|
-
const seconds = written.some((date) => date.length > 16);
|
|
244
|
+
const written = seriesOf(input).flatMap((series) => series.data.flatMap((point) => (typeof point.x === "string" ? [point.x] : [])));
|
|
245
|
+
if (!written.length) return { ...y, x: { tick: xFormat ? x.tick : (v) => xTick.format(v), value: x.value } };
|
|
246
|
+
const dateFormat = xFormat && "date" in xFormat ? xFormat.date : undefined;
|
|
247
|
+
const times = dateFormat ? dateFormat !== "day" : written.some((date) => date.length > 10);
|
|
248
|
+
const seconds = dateFormat ? dateFormat === "second" : written.some((date) => date.length > 16);
|
|
155
249
|
const clock: Intl.DateTimeFormatOptions = times ? { hour: "2-digit", minute: "2-digit", ...(seconds ? { second: "2-digit" } : {}) } : {};
|
|
156
250
|
const dateTick = new Intl.DateTimeFormat(locale, { timeZone: "UTC", day: "numeric", month: "short", ...clock });
|
|
157
251
|
const dateValue = new Intl.DateTimeFormat(locale, {
|
|
@@ -159,45 +253,106 @@ const formats = (input: CloudAiChartInput, locale: string): Formats => {
|
|
|
159
253
|
dateStyle: "medium",
|
|
160
254
|
...(times ? { timeStyle: seconds ? "medium" : "short" } : {}),
|
|
161
255
|
});
|
|
162
|
-
return {
|
|
163
|
-
tick: (v) => tick.format(v),
|
|
164
|
-
value: (v) => value.format(v),
|
|
165
|
-
x: { tick: (v) => dateTick.format(v), value: (v) => dateValue.format(v) },
|
|
166
|
-
};
|
|
256
|
+
return { ...y, x: { tick: (v) => dateTick.format(v), value: (v) => dateValue.format(v) } };
|
|
167
257
|
};
|
|
168
258
|
|
|
259
|
+
/** The table's x formatter also titles the shared cursor. */
|
|
260
|
+
export const cloudAiChartXValueFormat = (input: CloudAiChartInput, locale: string): ((x: number) => string) =>
|
|
261
|
+
formats(input, locale).x.value;
|
|
262
|
+
|
|
169
263
|
/** Room for the longest y tick label, so values like 1.2M never run past the left edge on a phone. */
|
|
170
|
-
const leftPadding = (values: number[], value: ChartAxisInput | undefined, tick: (value: number) => string) => {
|
|
264
|
+
const leftPadding = (values: number[], value: ChartAxisInput | undefined, tick: (value: number) => string, factor = NARROW) => {
|
|
171
265
|
// A tick between the ends can carry one more digit than they do.
|
|
172
266
|
const longest = Math.max(...tickCandidates(values, value).map((entry) => tick(entry).length)) + 1;
|
|
173
|
-
return Math.max(40, Math.ceil((longest * CHAR + 8 + (value?.label ? 16 : 0)) *
|
|
267
|
+
return Math.max(40, Math.ceil((longest * CHAR + 8 + (value?.label ? 16 : 0)) * factor));
|
|
174
268
|
};
|
|
175
269
|
|
|
176
270
|
/**
|
|
177
271
|
* Category labels never shorten: when they would overlap in the narrowest column, every n-th label shows in full and
|
|
178
272
|
* the others stay empty, as in `cloud.chart()`. Table and tooltips keep every label.
|
|
179
273
|
*/
|
|
180
|
-
const thinned = <T extends { label: string }>(data: T[], left: number): T[] => {
|
|
274
|
+
const thinned = <T extends { label: string }>(data: T[], left: number, width = WIDTH, factor = NARROW): T[] => {
|
|
181
275
|
const longest = Math.max(1, ...data.map((item) => item.label.length));
|
|
182
|
-
const plot = Math.max(
|
|
276
|
+
const plot = Math.max(width - left - 16, 1) / factor;
|
|
183
277
|
const step = Math.max(1, Math.ceil(((longest + 1) * CHAR * data.length) / plot));
|
|
184
278
|
return step === 1 ? data : data.map((item, index) => (index % step === 0 ? item : { ...item, label: "" }));
|
|
185
279
|
};
|
|
186
280
|
|
|
187
|
-
/**
|
|
188
|
-
export function
|
|
189
|
-
|
|
281
|
+
/** Entries of the renderer's legend, also usable when the chat lays it out outside the SVG. */
|
|
282
|
+
export function cloudAiChartLegend(
|
|
283
|
+
input: CloudAiChartInput,
|
|
284
|
+
locale: string,
|
|
285
|
+
): { label: string; color: number; style?: "solid" | "dashed" | "dotted"; reference?: boolean }[] {
|
|
286
|
+
if (input.kind === "bar")
|
|
287
|
+
return input.colorByBar && input.legend ? input.data.map((entry, index) => ({ label: entry.label, color: index % 8 })) : [];
|
|
288
|
+
if (input.kind !== "line" && input.kind !== "scatter") return [];
|
|
289
|
+
if (!(input.legend ?? input.series.length > 1)) return [];
|
|
290
|
+
const t = labels.resolve([locale]).t;
|
|
291
|
+
return input.series.map((entry, index) => ({
|
|
292
|
+
label: entry.label ?? `${t.series} ${index + 1}`,
|
|
293
|
+
color: index % 8,
|
|
294
|
+
...(input.kind === "line" ? { style: input.series[index]?.style ?? (entry.role === "reference" ? "dashed" : "solid") } : {}),
|
|
295
|
+
...(entry.role === "reference" ? { reference: true } : {}),
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether a scatter point belongs to a comparison series, for hollow snapshot marks. A line's comparison series is
|
|
301
|
+
* dashed instead: hollow marks would show every one of its points.
|
|
302
|
+
*/
|
|
303
|
+
export const cloudAiChartReferenceMark = (input: CloudAiChartInput, datum: ChartDatum): boolean =>
|
|
304
|
+
input.kind === "scatter" && input.series[datum.seriesIndex ?? 0]?.role === "reference";
|
|
305
|
+
|
|
306
|
+
/** Options for the shared renderer; the chat block draws the title itself. */
|
|
307
|
+
export function cloudAiChartRenderOptions(
|
|
308
|
+
input: CloudAiChartInput,
|
|
309
|
+
locale: string,
|
|
310
|
+
options: {
|
|
311
|
+
width?: number;
|
|
312
|
+
legend?: "svg" | "outside";
|
|
313
|
+
formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string };
|
|
314
|
+
} = {},
|
|
315
|
+
): RenderOptions {
|
|
316
|
+
const defaults = formats(input, locale);
|
|
317
|
+
// Function formats must be measured with the same text the renderer will print.
|
|
318
|
+
const format = {
|
|
319
|
+
...defaults,
|
|
320
|
+
tick: options.formats?.y ?? defaults.tick,
|
|
321
|
+
value: options.formats?.value ?? defaults.value,
|
|
322
|
+
x: { ...defaults.x, tick: options.formats?.x ?? defaults.x.tick },
|
|
323
|
+
};
|
|
324
|
+
const factor = options.width === undefined ? NARROW : 1;
|
|
325
|
+
const width = options.width ?? WIDTH;
|
|
326
|
+
const references = renderReferences(input);
|
|
190
327
|
switch (input.kind) {
|
|
191
|
-
case "bar":
|
|
192
|
-
|
|
193
|
-
const
|
|
328
|
+
case "bar":
|
|
329
|
+
case "boxplot": {
|
|
330
|
+
const values = input.kind === "bar" ? input.data.map((item) => item.value) : input.data.flatMap((item) => item.values);
|
|
331
|
+
const yAxis = referencedAxis(input, "y");
|
|
332
|
+
// The renderer prints bar values with the axis format, so printed values take the value precision for both.
|
|
333
|
+
const yFormat = options.formats?.y ?? (input.kind === "bar" && input.showValues ? format.value : format.tick);
|
|
334
|
+
const left = leftPadding(
|
|
335
|
+
[...values, ...referencesOf(input, "y"), ...(input.kind === "bar" && input.yAxis?.scale !== "log" ? [0] : [])],
|
|
336
|
+
yAxis,
|
|
337
|
+
yFormat,
|
|
338
|
+
factor,
|
|
339
|
+
);
|
|
340
|
+
if (input.kind === "boxplot")
|
|
341
|
+
return {
|
|
342
|
+
kind: "boxplot",
|
|
343
|
+
groups: thinned(input.data, left, width, factor),
|
|
344
|
+
yAxis: axis(yAxis, format.tick),
|
|
345
|
+
references,
|
|
346
|
+
padding: { left },
|
|
347
|
+
};
|
|
194
348
|
return {
|
|
195
349
|
kind: "bar",
|
|
196
|
-
data: input.colorByBar && input.legend ? input.data : thinned(input.data, left),
|
|
197
|
-
yAxis: axis(
|
|
350
|
+
data: input.colorByBar && input.legend && options.legend !== "outside" ? input.data : thinned(input.data, left, width, factor),
|
|
351
|
+
yAxis: axis(yAxis, yFormat),
|
|
198
352
|
colorByBar: input.colorByBar,
|
|
199
353
|
showValues: input.showValues,
|
|
200
|
-
legend: input.legend,
|
|
354
|
+
legend: options.legend === "outside" ? false : input.legend,
|
|
355
|
+
references,
|
|
201
356
|
padding: { left },
|
|
202
357
|
};
|
|
203
358
|
}
|
|
@@ -205,47 +360,58 @@ export function cloudAiChartRenderOptions(input: CloudAiChartInput, locale: stri
|
|
|
205
360
|
case "scatter": {
|
|
206
361
|
const t = labels.resolve([locale]).t;
|
|
207
362
|
const series = input.series.map((entry, index) => ({
|
|
208
|
-
// The renderer names unnamed series in English; the legend speaks the reader's language like the table.
|
|
209
363
|
label: entry.label ?? `${t.series} ${index + 1}`,
|
|
210
364
|
data: entry.data.map((point) => ({ x: dateTime(point.x), y: point.y })),
|
|
365
|
+
...(input.kind === "line" ? { lineStyle: input.series[index]?.style ?? (entry.role === "reference" ? "dashed" : undefined) } : {}),
|
|
211
366
|
}));
|
|
212
367
|
const points = series.flatMap((entry) => entry.data);
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
),
|
|
218
|
-
);
|
|
368
|
+
const xAxis = referencedAxis(input, "x");
|
|
369
|
+
const yAxis = referencedAxis(input, "y");
|
|
370
|
+
const candidates = tickCandidates([...points.map((point) => point.x), ...referencesOf(input, "x")], xAxis).filter(Number.isFinite);
|
|
371
|
+
const lastTick = candidates.length ? Math.max(...candidates) : 0;
|
|
219
372
|
const common = {
|
|
220
373
|
series,
|
|
221
|
-
xAxis: axis(
|
|
222
|
-
yAxis: axis(
|
|
223
|
-
|
|
374
|
+
xAxis: axis(xAxis, format.x.tick),
|
|
375
|
+
yAxis: axis(yAxis, format.tick),
|
|
376
|
+
references,
|
|
377
|
+
legend: options.legend === "outside" ? false : (input.legend ?? input.series.length > 1),
|
|
224
378
|
padding: {
|
|
225
|
-
left: leftPadding(
|
|
226
|
-
points.map((point) => point.y),
|
|
227
|
-
input.yAxis,
|
|
228
|
-
format.tick,
|
|
229
|
-
),
|
|
379
|
+
left: leftPadding([...points.map((point) => point.y), ...referencesOf(input, "y")], yAxis, format.tick, factor),
|
|
230
380
|
// Labels center on their tick: room for half of the last one right of the plot.
|
|
231
|
-
right: Math.max(16, Math.ceil(((format.x.tick(lastTick).length * CHAR) / 2 + 4) *
|
|
381
|
+
right: Math.max(16, Math.ceil(((format.x.tick(lastTick).length * CHAR) / 2 + 4) * factor)),
|
|
232
382
|
},
|
|
233
383
|
};
|
|
234
384
|
return input.kind === "line" ? { kind: "line", ...common, area: input.area, smooth: input.smooth } : { kind: "scatter", ...common };
|
|
235
385
|
}
|
|
236
386
|
case "pie":
|
|
237
387
|
case "donut":
|
|
238
|
-
return {
|
|
239
|
-
|
|
388
|
+
return {
|
|
389
|
+
kind: input.kind,
|
|
390
|
+
data: input.data,
|
|
391
|
+
legend: input.legend ?? !input.showLabels,
|
|
392
|
+
showLabels: input.showLabels,
|
|
393
|
+
...(options.formats?.value ? { format: options.formats.value } : {}),
|
|
394
|
+
};
|
|
395
|
+
case "histogram": {
|
|
396
|
+
const xAxis = referencedAxis(input, "x");
|
|
397
|
+
const xFormat = options.formats?.x ?? defaults.tick;
|
|
398
|
+
const xLabels = options.formats?.x
|
|
399
|
+
? tickCandidates([...input.data, ...referencesOf(input, "x")], xAxis).map((value) => xFormat(value).length)
|
|
400
|
+
: undefined;
|
|
240
401
|
return {
|
|
241
402
|
kind: "histogram",
|
|
242
403
|
data: input.data,
|
|
243
404
|
bins: input.bins,
|
|
244
|
-
|
|
245
|
-
|
|
405
|
+
references,
|
|
406
|
+
xAxis: axis(xAxis, xFormat),
|
|
407
|
+
yAxis: axis(referencedAxis(input, "y"), format.tick),
|
|
246
408
|
// No bin counts more than every observation.
|
|
247
|
-
padding: {
|
|
409
|
+
padding: {
|
|
410
|
+
left: leftPadding([0, input.data.length, ...referencesOf(input, "y")], referencedAxis(input, "y"), format.tick, factor),
|
|
411
|
+
...(xLabels ? { right: Math.max(16, Math.ceil(((Math.max(...xLabels) * CHAR) / 2 + 4) * factor)) } : {}),
|
|
412
|
+
},
|
|
248
413
|
};
|
|
414
|
+
}
|
|
249
415
|
case "gauge":
|
|
250
416
|
return {
|
|
251
417
|
kind: "gauge",
|
|
@@ -255,6 +421,44 @@ export function cloudAiChartRenderOptions(input: CloudAiChartInput, locale: stri
|
|
|
255
421
|
label: input.label,
|
|
256
422
|
unit: input.unit,
|
|
257
423
|
format: format.value,
|
|
424
|
+
thresholds: input.thresholds,
|
|
425
|
+
};
|
|
426
|
+
case "barGauge":
|
|
427
|
+
return {
|
|
428
|
+
kind: "barGauge",
|
|
429
|
+
data: input.data,
|
|
430
|
+
min: input.min,
|
|
431
|
+
max: input.max,
|
|
432
|
+
unit: input.unit,
|
|
433
|
+
format: format.value,
|
|
434
|
+
thresholds: input.thresholds,
|
|
435
|
+
};
|
|
436
|
+
case "heatmap":
|
|
437
|
+
return {
|
|
438
|
+
kind: "heatmap",
|
|
439
|
+
data: input.data,
|
|
440
|
+
min: input.min,
|
|
441
|
+
max: input.max,
|
|
442
|
+
format: format.value,
|
|
443
|
+
showValues: input.showValues,
|
|
444
|
+
padding: { left: Math.max(40, Math.ceil((Math.max(0, ...input.data.map((item) => item.y.length)) * CHAR + 8) * factor)) },
|
|
445
|
+
};
|
|
446
|
+
case "stat":
|
|
447
|
+
return {
|
|
448
|
+
kind: "stat",
|
|
449
|
+
label: "",
|
|
450
|
+
value: input.value,
|
|
451
|
+
unit: input.unit,
|
|
452
|
+
format: format.value,
|
|
453
|
+
delta: Number.isFinite(input.delta) ? input.delta : undefined,
|
|
454
|
+
deltaFormat: format.value,
|
|
455
|
+
trend:
|
|
456
|
+
!input.better || !input.delta || !Number.isFinite(input.delta)
|
|
457
|
+
? "neutral"
|
|
458
|
+
: input.delta > 0 === (input.better === "up")
|
|
459
|
+
? "up"
|
|
460
|
+
: "down",
|
|
461
|
+
sparkline: input.sparkline,
|
|
258
462
|
};
|
|
259
463
|
case "sparkline":
|
|
260
464
|
return { kind: "sparkline", data: input.data, area: input.area };
|
|
@@ -270,19 +474,54 @@ const decode = (value: string) =>
|
|
|
270
474
|
.replace(/&/g, "&");
|
|
271
475
|
|
|
272
476
|
/** Draws the options with inspection metadata; throws what the renderer would throw in the chat. */
|
|
273
|
-
const draw = (options:
|
|
274
|
-
const {
|
|
275
|
-
|
|
477
|
+
const draw = (options: RenderOptions): string => {
|
|
478
|
+
const common = { width: WIDTH, height: HEIGHT, inspect: true };
|
|
479
|
+
switch (options.kind) {
|
|
480
|
+
case "bar":
|
|
481
|
+
return bar({ ...options, ...common });
|
|
482
|
+
case "line":
|
|
483
|
+
return line({ ...options, ...common });
|
|
484
|
+
case "scatter":
|
|
485
|
+
return scatter({ ...options, ...common });
|
|
486
|
+
case "pie":
|
|
487
|
+
return pie({ ...options, ...common });
|
|
488
|
+
case "donut":
|
|
489
|
+
return donut({ ...options, ...common });
|
|
490
|
+
case "histogram":
|
|
491
|
+
return histogram({ ...options, ...common });
|
|
492
|
+
case "boxplot":
|
|
493
|
+
return boxplot({ ...options, ...common });
|
|
494
|
+
case "gauge":
|
|
495
|
+
return gauge({ ...options, ...common });
|
|
496
|
+
case "barGauge":
|
|
497
|
+
return barGauge({ ...options, ...common });
|
|
498
|
+
case "stat":
|
|
499
|
+
return stat({ ...options, ...common });
|
|
500
|
+
case "heatmap":
|
|
501
|
+
return heatmap({ ...options, ...common });
|
|
502
|
+
case "sparkline":
|
|
503
|
+
return sparkline({ ...options, ...common });
|
|
504
|
+
}
|
|
276
505
|
};
|
|
277
|
-
|
|
506
|
+
const datumSchema = () =>
|
|
507
|
+
z.object({
|
|
508
|
+
index: z.number(),
|
|
509
|
+
seriesIndex: z.number().optional(),
|
|
510
|
+
role: z.enum(["point", "item", "bin", "box", "outlier", "value", "cell", "interval"]),
|
|
511
|
+
label: z.string().optional(),
|
|
512
|
+
anchor: z.tuple([z.number(), z.number()]),
|
|
513
|
+
values: z.array(z.object({ key: z.string(), value: z.union([z.string(), z.number()]), formatted: z.string().optional() })),
|
|
514
|
+
});
|
|
515
|
+
const DatumSchema = /* @__PURE__ */ datumSchema();
|
|
278
516
|
const marks = (svg: string): ChartDatum[] =>
|
|
279
|
-
[...svg.matchAll(/data-chart-datum="([^"]*)"/g)].map((match) => JSON.parse(decode(match[1]!))
|
|
517
|
+
[...svg.matchAll(/data-chart-datum="([^"]*)"/g)].map((match) => DatumSchema.parse(JSON.parse(decode(match[1]!))));
|
|
280
518
|
|
|
281
519
|
/** The outer radius of a drawn pie or donut: the first arc of its first slice. */
|
|
282
520
|
const pieRadius = (svg: string) => Number(/class="stdlib-chart-slice[^"]*" d="[^"A]*A ([\d.]+)/.exec(svg)?.[1] ?? 0);
|
|
283
521
|
|
|
284
522
|
/** One mark, as `prepareChartSnapshot` keys it: series and position in the input. */
|
|
285
|
-
export const cloudAiChartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex">
|
|
523
|
+
export const cloudAiChartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex"> & Partial<Pick<ChartDatum, "role">>) =>
|
|
524
|
+
`${datum.role === "outlier" ? "outlier:" : datum.role === "value" ? "value:" : ""}${datum.seriesIndex ?? 0}:${datum.index}`;
|
|
286
525
|
|
|
287
526
|
export type CloudAiChartColumn = { id: string; label: string; numeric: boolean };
|
|
288
527
|
export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
|
|
@@ -296,8 +535,9 @@ export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
|
|
|
296
535
|
const t = labels.resolve([locale]).t;
|
|
297
536
|
const format = formats(input, locale);
|
|
298
537
|
const series = seriesOf(input);
|
|
538
|
+
const countFormat = cloudAiNumberFormatters(undefined, locale).value;
|
|
299
539
|
const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
|
|
300
|
-
const unit =
|
|
540
|
+
const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
|
|
301
541
|
const columns = new Map<string, CloudAiChartColumn>();
|
|
302
542
|
const rows: CloudAiChartRow[] = [];
|
|
303
543
|
const named = series.some((entry) => entry.label);
|
|
@@ -316,25 +556,45 @@ export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
|
|
|
316
556
|
}
|
|
317
557
|
} else {
|
|
318
558
|
// Bars may hide some axis labels; the table names every category.
|
|
319
|
-
const label =
|
|
559
|
+
const label =
|
|
560
|
+
input.kind === "bar" || input.kind === "pie" || input.kind === "donut" || input.kind === "boxplot" || input.kind === "barGauge"
|
|
561
|
+
? input.data[datum.role === "outlier" ? (datum.seriesIndex ?? 0) : datum.index]?.label
|
|
562
|
+
: datum.label;
|
|
320
563
|
if (label) cell("label", t.category, label, label);
|
|
321
564
|
}
|
|
322
565
|
for (const field of datum.values) {
|
|
323
566
|
const value = field.value;
|
|
567
|
+
if (typeof value === "string" && input.kind === "heatmap" && (field.key === "x" || field.key === "y")) {
|
|
568
|
+
cell(field.key, field.key.toUpperCase(), value, value);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
324
571
|
if (typeof value !== "number" || field.key === "total") continue;
|
|
325
572
|
if (field.key === "x") {
|
|
326
573
|
const header = (input.kind === "line" || input.kind === "scatter" ? input.xAxis?.label : undefined) ?? "X";
|
|
327
|
-
cell(
|
|
574
|
+
cell(
|
|
575
|
+
"x",
|
|
576
|
+
input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
|
|
577
|
+
value,
|
|
578
|
+
input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : format.x.value(value),
|
|
579
|
+
);
|
|
328
580
|
} else if (field.key === "y") {
|
|
329
581
|
const header =
|
|
330
582
|
(input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
|
|
331
|
-
(input.kind === "sparkline" ? t.value : "Y");
|
|
583
|
+
(input.kind === "sparkline" || input.kind === "stat" ? t.value : "Y");
|
|
332
584
|
cell("y", header, value, format.value(value));
|
|
333
585
|
} else if (field.key === "value")
|
|
334
586
|
cell("value", (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value, value, `${format.value(value)}${unit}`);
|
|
587
|
+
else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${format.value(value)}`);
|
|
588
|
+
else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
|
|
589
|
+
cell(field.key, t[field.key], value, format.value(value));
|
|
335
590
|
else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
|
|
336
591
|
else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max")
|
|
337
|
-
cell(
|
|
592
|
+
cell(
|
|
593
|
+
field.key,
|
|
594
|
+
t[field.key],
|
|
595
|
+
value,
|
|
596
|
+
`${field.key === "count" ? countFormat(value) : format.value(value)}${field.key === "min" || field.key === "max" ? unit : ""}`,
|
|
597
|
+
);
|
|
338
598
|
}
|
|
339
599
|
const row = { key: cloudAiChartMarkKey(datum), cells, values };
|
|
340
600
|
rows.push(row);
|
|
@@ -350,18 +610,108 @@ export function cloudAiChartTable(input: CloudAiChartInput, locale: string): Clo
|
|
|
350
610
|
return rows.table();
|
|
351
611
|
}
|
|
352
612
|
|
|
613
|
+
/** Input values count against the budget, including observations summarized into boxes or bins. */
|
|
614
|
+
export const cloudAiChartMarks = (input: CloudAiChartInput): number => {
|
|
615
|
+
switch (input.kind) {
|
|
616
|
+
case "line":
|
|
617
|
+
case "scatter":
|
|
618
|
+
return input.series.reduce((sum, series) => sum + series.data.length, 0);
|
|
619
|
+
case "boxplot":
|
|
620
|
+
return input.data.reduce((sum, box) => sum + box.values.length, 0);
|
|
621
|
+
case "gauge":
|
|
622
|
+
return 1;
|
|
623
|
+
case "stat":
|
|
624
|
+
return 1 + (input.sparkline?.length ?? 0);
|
|
625
|
+
default:
|
|
626
|
+
return input.data.length;
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/** Rejects only precision ranges that would make the renderer count ticks forever. */
|
|
631
|
+
export function chartSteppingIssue(input: CloudAiChartInput): string | undefined {
|
|
632
|
+
// Check raw observations before autoBin computes histogram edges.
|
|
633
|
+
if (input.kind === "histogram" && !steppable(input.data))
|
|
634
|
+
return "The values differ too little for their size to label an axis; subtract a common offset first.";
|
|
635
|
+
for (const { values: data, axis, zero, name } of valueAxes(input)) {
|
|
636
|
+
const values = [...data, ...referencesOf(input, name)];
|
|
637
|
+
if (axis?.scale !== "log" && !axis?.domain && !steppable(zero ? [...values, 0] : values, axis?.ticks))
|
|
638
|
+
return "The values differ too little for their size to label an axis; subtract a common offset first.";
|
|
639
|
+
}
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Both dimensions must fit before suggesting a heatmap orientation change. */
|
|
644
|
+
const heatmapLabelFit = (columns: string[], rows: string[]) => {
|
|
645
|
+
const left = Math.max(40, Math.ceil((Math.max(0, ...rows.map((label) => label.length)) * CHAR + 8) * NARROW));
|
|
646
|
+
const characters = Math.floor((WIDTH - left - 16) / NARROW / columns.length / CHAR) - 1;
|
|
647
|
+
return { characters, columns: columns.every((label) => label.length <= characters), rows: rows.length <= HEATMAP_MAX_ROWS };
|
|
648
|
+
};
|
|
649
|
+
|
|
353
650
|
/** Why chart options would not draw, found without drawing them; undefined when nothing is wrong. */
|
|
354
651
|
export function chartDataIssue(input: CloudAiChartInput): string | undefined {
|
|
355
|
-
|
|
356
|
-
|
|
652
|
+
const points = seriesOf(input).flatMap((series) => series.data);
|
|
653
|
+
if (new Set(points.map((point) => typeof point.x)).size > 1) return "Use either numbers or dates for x in all series, not both.";
|
|
357
654
|
if ((input.kind === "pie" || input.kind === "donut") && !input.data.some((slice) => slice.value > 0))
|
|
358
655
|
return "Pie and donut slices must add up to more than zero.";
|
|
359
|
-
|
|
656
|
+
if ("references" in input)
|
|
657
|
+
for (const ref of input.references ?? []) {
|
|
658
|
+
const name = ref.axis ?? "y";
|
|
659
|
+
if (name === "x" && input.kind !== "line" && input.kind !== "scatter" && input.kind !== "histogram")
|
|
660
|
+
return "Use y references for bar and boxplot charts.";
|
|
661
|
+
const type = name === "x" && points.length ? typeof points[0]!.x : "number";
|
|
662
|
+
if (typeof ref.value !== type) return `Use ${type === "string" ? "dates" : "numbers"} for ${name} references, like the axis values.`;
|
|
663
|
+
}
|
|
664
|
+
const stepping = chartSteppingIssue(input);
|
|
665
|
+
if (stepping) return stepping;
|
|
666
|
+
for (const { values: data, axis, name } of valueAxes(input)) {
|
|
667
|
+
const values = [...data, ...referencesOf(input, name)];
|
|
668
|
+
const dates = name === "x" && typeof points[0]?.x === "string";
|
|
669
|
+
const dateFormat = axis?.format && "date" in axis.format;
|
|
670
|
+
if (axis?.format && dateFormat !== dates) return `Use a ${dates ? "date" : "number"} format for the ${name} axis.`;
|
|
671
|
+
const domain = bounds(axis?.domain);
|
|
672
|
+
if (domain && referencesOf(input, name).some((value) => value < domain[0] || value > domain[1]))
|
|
673
|
+
return `Extend the ${name} domain to include every reference.`;
|
|
360
674
|
if (axis?.scale === "log") {
|
|
361
675
|
if (values.some((value) => value <= 0))
|
|
362
676
|
return "A log axis shows only values above zero; use a linear axis or leave out the values at or below zero.";
|
|
363
|
-
}
|
|
364
|
-
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (input.kind === "heatmap") {
|
|
680
|
+
if (new Set(input.data.map((cell) => JSON.stringify([cell.x, cell.y]))).size !== input.data.length)
|
|
681
|
+
return "Use each heatmap x/y pair once.";
|
|
682
|
+
if (input.min !== undefined && input.max !== undefined && input.min >= input.max) return "Set heatmap min below max.";
|
|
683
|
+
}
|
|
684
|
+
// These renderers key or place labels by their text, so labels cannot be thinned like bars.
|
|
685
|
+
if (input.kind === "heatmap") {
|
|
686
|
+
const columns = [...new Set(input.data.map((item) => item.x))];
|
|
687
|
+
const rows = [...new Set(input.data.map((item) => item.y))];
|
|
688
|
+
const fit = heatmapLabelFit(columns, rows);
|
|
689
|
+
const swapped = heatmapLabelFit(rows, columns);
|
|
690
|
+
const swap = swapped.columns && swapped.rows;
|
|
691
|
+
if (!fit.columns)
|
|
692
|
+
return fit.characters < 1
|
|
693
|
+
? `${columns.length} columns do not fit a phone-wide chart; ${swap ? "swap x and y or use fewer columns." : "use fewer columns or shorter labels."}`
|
|
694
|
+
: `Use column labels of at most ${fit.characters} characters for ${columns.length} columns, ${swap ? "or swap x and y." : "or use fewer columns."}`;
|
|
695
|
+
if (!fit.rows) return `Use at most ${HEATMAP_MAX_ROWS} heatmap rows; ${swap ? "swap x and y or use fewer rows." : "use fewer rows."}`;
|
|
696
|
+
}
|
|
697
|
+
// A bar gauge label sits in a 140-unit column that starts 12 units in.
|
|
698
|
+
const gaugeLabel = Math.floor((140 - 12) / NARROW / CHAR) - 1;
|
|
699
|
+
if (input.kind === "barGauge" && input.data.some((item) => item.label.length > gaugeLabel))
|
|
700
|
+
return `Use bar gauge labels of at most ${gaugeLabel} characters, or a bar chart for longer names.`;
|
|
701
|
+
if (input.kind === "gauge" || input.kind === "barGauge") {
|
|
702
|
+
const ranges =
|
|
703
|
+
input.kind === "gauge"
|
|
704
|
+
? [{ min: input.min ?? 0, max: input.max ?? 100 }]
|
|
705
|
+
: input.data.map((item) => ({ min: item.min ?? input.min ?? 0, max: item.max ?? input.max ?? 100 }));
|
|
706
|
+
// Existing gauges clamp their value and normalize the range; only new band boundaries impose these rules.
|
|
707
|
+
if (input.thresholds) {
|
|
708
|
+
if (input.thresholds.some((threshold, index) => index > 0 && threshold.value <= input.thresholds![index - 1]!.value))
|
|
709
|
+
return "Set thresholds in strictly ascending order.";
|
|
710
|
+
if (
|
|
711
|
+
ranges.some(({ min, max }) => min >= max || input.thresholds!.some((threshold) => threshold.value < min || threshold.value > max))
|
|
712
|
+
)
|
|
713
|
+
return "Keep thresholds within every min..max range.";
|
|
714
|
+
}
|
|
365
715
|
}
|
|
366
716
|
return undefined;
|
|
367
717
|
}
|