@k2b/cloud 0.31.0 → 0.33.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.
Files changed (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
@@ -1,6 +1,32 @@
1
- import { type ChartDatum, charts, computeDomain, i18n, niceStep } from "@k2b/stdlib";
2
- import type { ChartRenderOptions } from "@k2b/ui";
3
- import type { CloudAiChartInput } from "./default-tool-contracts";
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 === "histogram") return [{ values: input.data }];
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
- // Ticks are round numbers already; fifteen significant digits show them exactly (0.005, 1.25M) without float noise.
140
- const tick = new Intl.NumberFormat(locale, { notation: "compact", maximumSignificantDigits: 15 });
141
- // An x axis often counts years or steps, which compact notation would turn into 2.02K.
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
- // The table, tooltips and Copy data show the value as given.
144
- const value = new Intl.NumberFormat(locale, { maximumSignificantDigits: 15 });
145
- const points = seriesOf(input).flatMap((series) => series.data);
146
- const written = points.flatMap((point) => (typeof point.x === "string" ? [point.x] : []));
147
- if (written.length === 0)
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)) * NARROW));
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(WIDTH - left - 16, 1) / NARROW;
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
- /** Options for `@k2b/ui` Chart and `prepareChartSnapshot`; the chat block draws the title itself. */
188
- export function cloudAiChartRenderOptions(input: CloudAiChartInput, locale: string): ChartRenderOptions {
189
- const format = formats(input, locale);
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
- const values = input.data.map((item) => item.value);
193
- const left = leftPadding(input.yAxis?.scale === "log" ? values : [...values, 0], input.yAxis, format.tick);
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(input.yAxis, format.tick),
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 lastTick = Math.max(
214
- ...tickCandidates(
215
- points.map((point) => point.x),
216
- input.xAxis,
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(input.xAxis, format.x.tick),
222
- yAxis: axis(input.yAxis, format.tick),
223
- legend: input.legend ?? input.series.length > 1,
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) * NARROW)),
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 { kind: input.kind, data: input.data, legend: input.legend ?? !input.showLabels, showLabels: input.showLabels };
239
- case "histogram":
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
- xAxis: { format: format.tick },
245
- yAxis: { format: format.tick },
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: { left: leftPadding([0, input.data.length], undefined, format.tick) },
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(/&amp;/g, "&");
271
475
 
272
476
  /** Draws the options with inspection metadata; throws what the renderer would throw in the chat. */
273
- const draw = (options: ChartRenderOptions): string => {
274
- const { kind, ...rest } = options;
275
- return (charts[kind] as (value: object) => string)({ ...rest, width: WIDTH, height: HEIGHT, inspect: true });
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]!)) as ChartDatum);
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">) => `${datum.seriesIndex ?? 0}:${datum.index}`;
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 = input.kind === "gauge" && input.unit ? ` ${input.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 = input.kind === "bar" || input.kind === "pie" || input.kind === "donut" ? input.data[datum.index]?.label : datum.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("x", input.kind === "sparkline" ? "#" : header, value, input.kind === "sparkline" ? String(value + 1) : format.x.value(value));
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(field.key, t[field.key], value, `${format.value(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
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
- if (new Set(seriesOf(input).flatMap((series) => series.data.map((point) => typeof point.x))).size > 1)
356
- return "Use either numbers or dates for x in all series, not both.";
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
- for (const { values, axis, zero } of valueAxes(input)) {
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
- } else if (!axis?.domain && !steppable(zero ? [...values, 0] : values, axis?.ticks))
364
- return "The values differ too little for their size to label an axis; subtract a common offset first.";
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
  }