@k2b/cloud 0.46.0 → 0.47.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 (68) hide show
  1. package/package.json +8 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/define-app.ts +38 -0
  5. package/src/_internal/heartbeat.ts +19 -0
  6. package/src/_internal/postgres-application-name.ts +11 -0
  7. package/src/_internal/process-sync.ts +9 -0
  8. package/src/_internal/readiness.ts +6 -1
  9. package/src/_internal/resource-principals.ts +4 -34
  10. package/src/_internal/workload-credential.ts +153 -0
  11. package/src/_internal/workload-protocol.ts +36 -0
  12. package/src/ai/application-tasks.ts +5 -2
  13. package/src/ai/chart-block.ts +36 -711
  14. package/src/ai/default-tool-contracts.ts +21 -259
  15. package/src/api/admin-app-keys.ts +66 -0
  16. package/src/api/admin-core-settings.ts +2 -0
  17. package/src/api/admin-identity.ts +27 -1
  18. package/src/api/admin-settings-env.ts +120 -0
  19. package/src/api/capabilities.ts +9 -3
  20. package/src/api/index.ts +3 -0
  21. package/src/api/settings-ops.ts +35 -0
  22. package/src/app-key.ts +125 -0
  23. package/src/browser/CloudResourceSearch.tsx +48 -41
  24. package/src/capabilities/discussion-providers.ts +3 -2
  25. package/src/capabilities/resource-readers.ts +13 -2
  26. package/src/capabilities/response.ts +4 -1
  27. package/src/capabilities/server-invoke.ts +46 -28
  28. package/src/capabilities/server.ts +1 -1
  29. package/src/capabilities/types.ts +1 -1
  30. package/src/cli/admin/app-credentials.ts +32 -7
  31. package/src/cli/admin/app-keys.ts +226 -0
  32. package/src/cli/admin/index.ts +7 -1
  33. package/src/cli/admin/settings.ts +62 -0
  34. package/src/config/define-env.ts +63 -8
  35. package/src/config/env.ts +27 -21
  36. package/src/config/index.ts +12 -1
  37. package/src/config/workflow-ai.ts +28 -0
  38. package/src/contracts/settings-env.ts +21 -0
  39. package/src/contracts/settings-types.ts +6 -0
  40. package/src/index.ts +9 -0
  41. package/src/runtime/clients.ts +72 -0
  42. package/src/runtime/connections.ts +37 -0
  43. package/src/runtime/resolve-connections.ts +91 -0
  44. package/src/server/client-ip.ts +1 -1
  45. package/src/services/app-keys.ts +298 -0
  46. package/src/services/gateway.ts +6 -0
  47. package/src/services/identity/index.ts +3 -0
  48. package/src/services/identity/resource-readers.ts +80 -0
  49. package/src/services/identity/runtime-config.ts +14 -6
  50. package/src/services/identity/workload-runtime.ts +253 -0
  51. package/src/services/index.ts +4 -1
  52. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  53. package/src/services/service-account-credentials.ts +63 -21
  54. package/src/services/settings/api.ts +3 -3
  55. package/src/services/settings/binding.ts +45 -0
  56. package/src/services/settings/core-settings.ts +99 -1
  57. package/src/services/settings/defaults.ts +16 -2
  58. package/src/services/settings/env-status.ts +69 -0
  59. package/src/services/settings/index.ts +69 -26
  60. package/src/services/settings/store.ts +73 -8
  61. package/src/services/settings/validation.ts +40 -0
  62. package/src/shared/index.ts +11 -10
  63. package/src/shared/markdown/extensions/tables.ts +10 -1
  64. package/src/start.ts +36 -0
  65. package/src/workflows/ai/index.ts +1 -0
  66. package/src/workflows/ai/runtime.ts +16 -8
  67. package/src/workflows/ai/settings.ts +5 -0
  68. package/src/shared/markdown/formula.ts +0 -1283
@@ -1,715 +1,40 @@
1
- // Named renderers, not the `charts` namespace, so bundles leave out the map and state timeline renderers.
2
- import {
3
- autoBin,
4
- barGauge,
5
- boxplot,
6
- type ChartDatum,
7
- type charts,
8
- computeDomain,
9
- donut,
10
- extendDomainToNice,
11
- gauge,
12
- heatmap,
13
- histogram,
14
- i18n,
15
- line,
16
- niceLogTicks,
17
- niceStep,
18
- pie,
19
- scatter,
20
- sparkline,
21
- stat,
22
- } from "@k2b/stdlib";
23
- import { type BarChartOptions, barChartLayout, renderBarChartSvg } from "@k2b/ui/charts";
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 } & (K extends "bar"
29
- ? BarChartOptions
30
- : Omit<Parameters<(typeof charts)[K]>[0], "width" | "height" | "inspect">);
31
- }[CloudAiChartInput["kind"]];
32
-
33
- /**
34
- * The chart chat block: the plain-data `cloud.chart()` options of the `chart` tool become the options of the shared
35
- * renderer (`@k2b/ui` Chart and `stdlib.charts`) and a data table with the same rows the chart marks. Pure, so the
36
- * schema, the browser, and the CLI read a chart the same way.
37
- */
38
-
39
- // Marked pure so a bundle that does not read charts, such as a Studio app frame, can leave the labels out.
40
- const labels = /* @__PURE__ */ i18n.define({
41
- baseLocale: "en",
42
- messages: {
43
- en: {
44
- category: "Category",
45
- series: "Series",
46
- value: "Value",
47
- share: "Share",
48
- from: "From",
49
- to: "To",
50
- count: "Count",
51
- min: "Minimum",
52
- max: "Maximum",
53
- q1: "Q1",
54
- q2: "Median",
55
- q3: "Q3",
56
- whiskerLow: "Whisker low",
57
- whiskerHigh: "Whisker high",
58
- delta: "Change",
59
- },
60
- de: {
61
- category: "Kategorie",
62
- series: "Datenreihe",
63
- value: "Wert",
64
- share: "Anteil",
65
- from: "Von",
66
- to: "Bis",
67
- count: "Anzahl",
68
- min: "Minimum",
69
- max: "Maximum",
70
- q1: "Q1",
71
- q2: "Median",
72
- q3: "Q3",
73
- whiskerLow: "Unterer Whisker",
74
- whiskerHigh: "Oberer Whisker",
75
- delta: "Änderung",
76
- },
77
- },
78
- });
79
-
80
- export const checkCloudAiChartLabels = () => labels.check();
81
-
82
- /** Logical drawing size of the shared renderer; CSS fits it to the block. */
83
- const WIDTH = 480;
84
- const HEIGHT = 280;
85
- /** Tick labels are 11px IBM Plex Mono, about 6.7px per character. */
86
- const CHAR = 6.7;
87
- /**
88
- * Text keeps its pixel size while the drawing stretches to the chat column. The drawing is never measured or redrawn,
89
- * so label room is reserved for the narrowest column, about 296px of 480 units on a 320px phone; wide columns show it
90
- * as a little more space left of the axis.
91
- */
92
- const NARROW = 480 / 296;
93
- /** The renderer's tick count when an axis sets none. */
94
- const TICKS = 5;
95
- /** Heatmap labels are 11px high; 13 units per row leave readable space at the 18rem frame height. */
96
- const HEATMAP_ROW_HEIGHT = 13;
97
- /** The mapping sets only left padding, so the renderer supplies 16 top and 32 bottom units. */
98
- const HEATMAP_MAX_ROWS = Math.floor((HEIGHT - 16 - 32) / HEATMAP_ROW_HEIGHT);
99
- /** Below this radius, about 30px on a 320px phone, a pie no longer reads as one. */
100
- const MIN_PIE_RADIUS = 48;
101
-
102
- const DATE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/;
103
-
104
- /**
105
- * A chart date as UTC milliseconds, so calendar days and wall-clock times stay as written on every device. Impossible
106
- * dates such as February 30 are undefined: engines would move them into March or refuse them, each in its own way.
107
- */
108
- export const chartDateTime = (value: string): number | undefined => {
109
- const match = DATE.exec(value);
110
- if (!match) return undefined;
111
- const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
112
- const date = new Date(0);
113
- date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
114
- date.setUTCHours(Number(hour), Number(minute), Number(second));
115
- // Parts out of range roll over, so a date that does not read back as written does not exist.
116
- return date.toISOString().startsWith(`${year}-${month}-${day}T${hour}:${minute}:${second}`) ? date.getTime() : undefined;
117
- };
118
-
119
- const dateTime = (value: number | string) => (typeof value === "number" ? value : (chartDateTime(value) ?? Number.NaN));
120
-
121
- const seriesOf = (input: CloudAiChartInput) => (input.kind === "line" || input.kind === "scatter" ? input.series : []);
122
-
123
- type ChartAxisInput = { label?: string; domain?: number[]; ticks?: number; scale?: "linear" | "log"; format?: CloudAiFormat };
124
-
125
- const bounds = (domain: number[] | undefined): [number, number] | undefined => {
126
- const [min, max] = domain ?? [];
127
- return min === undefined || max === undefined ? undefined : [min, max];
128
- };
129
-
130
- const axis = ({ domain, format: _format, ...value }: ChartAxisInput = {}, format: (value: number) => string) => ({
131
- ...value,
132
- ...(domain ? { domain: bounds(domain) } : {}),
133
- format,
134
- });
135
-
136
- const referencesOf = (input: CloudAiChartInput, name: "x" | "y") =>
137
- "references" in input ? (input.references ?? []).filter((ref) => (ref.axis ?? "y") === name).map((ref) => dateTime(ref.value)) : [];
138
-
139
- /** The value axes of a chart with what they show: the data checks and the label room read them alike. */
140
- const valueAxes = (input: CloudAiChartInput): { values: number[]; axis?: ChartAxisInput; zero?: boolean; name: "x" | "y" }[] => {
141
- if (input.kind === "bar") return [{ values: input.data.map((item) => item.value), axis: input.yAxis, zero: true, name: "y" }];
142
- if (input.kind === "boxplot") return [{ values: input.data.flatMap((item) => item.values), axis: input.yAxis, name: "y" }];
143
- if (input.kind === "histogram") {
144
- if (!input.references?.length) return [{ values: input.data, name: "x" }];
145
- const { edges, counts } = autoBin(input.data, input.bins);
146
- return [
147
- { values: edges, name: "x" },
148
- { values: counts, zero: true, name: "y" },
149
- ];
150
- }
151
- if (input.kind !== "line" && input.kind !== "scatter") return [];
152
- const points = input.series.flatMap((series) => series.data);
153
- return [
154
- { values: points.map((point) => dateTime(point.x)), axis: input.xAxis, name: "x" },
155
- { values: points.map((point) => point.y), axis: input.yAxis, name: "y" },
156
- ];
157
- };
158
-
159
- /** Only references add a fixed domain; charts without them keep the renderer's existing defaults. */
160
- const referencedAxis = (input: CloudAiChartInput, name: "x" | "y"): ChartAxisInput | undefined => {
161
- const entry = valueAxes(input).find((entry) => entry.name === name);
162
- if (!entry) return undefined;
163
- const refs = referencesOf(input, name);
164
- if (!refs.length || entry.axis?.domain) return entry.axis;
165
- const values = [...entry.values, ...refs, ...(entry.zero && entry.axis?.scale !== "log" ? [0] : [])].filter(Number.isFinite);
166
- if (entry.axis?.scale === "log") {
167
- const positive = values.filter((value) => value > 0);
168
- return { ...entry.axis, domain: positive.length ? niceLogTicks(Math.min(...positive), Math.max(...positive)).domain : [1, 10] };
169
- }
170
- const [min, max] = computeDomain(values);
171
- return { ...entry.axis, domain: extendDomainToNice(min, max, niceStep(max - min, entry.axis?.ticks ?? TICKS)).domain };
172
- };
173
- const renderReferences = (input: CloudAiChartInput) =>
174
- "references" in input ? input.references?.map((ref) => ({ ...ref, value: dateTime(ref.value) })) : undefined;
175
-
176
- /**
177
- * Whether the renderer can step from the lowest to the highest tick. Values that differ by less than the float
178
- * precision of their size, such as 1e17 and 1e17 + 16, would make it count forever.
179
- */
180
- const steppable = (values: number[], ticks = TICKS) => {
181
- const [min, max] = computeDomain(values);
182
- const step = niceStep(max - min, ticks);
183
- return [Math.floor(min / step) * step, Math.ceil(max / step) * step].every((value) => value + step !== value && value - step !== value);
184
- };
185
-
186
- /**
187
- * The values whose labels are the widest on a value axis: its ends and the ticks next to them. The renderer puts ticks
188
- * on multiples of a nice step, or on powers of ten on a log axis, and adds the bounds of a fixed domain.
189
- */
190
- const tickCandidates = (values: number[], value?: ChartAxisInput): number[] => {
191
- const domain = bounds(value?.domain);
192
- if (value?.scale === "log") {
193
- const positive = values.filter((entry) => Number.isFinite(entry) && entry > 0);
194
- const [min, max] = domain ?? (positive.length ? [Math.min(...positive), Math.max(...positive)] : [1, 10]);
195
- return [min, max, 10 ** Math.floor(Math.log10(min)), 10 ** Math.ceil(Math.log10(max))];
196
- }
197
- const [min, max] = domain ?? computeDomain(values);
198
- const step = niceStep(max - min, value?.ticks ?? TICKS);
199
- const low = Math.floor(min / step) * step;
200
- const high = Math.ceil(max / step) * step;
201
- return [...(domain ?? [low, high]), low + step, high - step];
202
- };
203
-
204
- type Formats = {
205
- tick: (value: number) => string;
206
- value: (value: number) => string;
207
- x: { tick: (value: number) => string; value: (value: number) => string };
208
- };
209
-
210
- /**
211
- * Value precision and tick precision share the same style and units; ticks must remain distinct. A currency without
212
- * `decimals` or compact notation writes cents only when one of the formatted `values` has them: 418,000 € rather than
213
- * 418,000.00 €.
214
- */
215
- export const cloudAiNumberFormatters = (format: CloudAiNumberFormat | undefined, locale: string, values?: readonly number[]) => {
216
- const common: Intl.NumberFormatOptions = {
217
- style: format?.style === "number" ? "decimal" : format?.style,
218
- currency: format?.currency,
219
- };
220
- // Compact notation keeps its fraction: 1.2K and 1.4K must not both read 1K.
221
- const decimals =
222
- format?.decimals ??
223
- (format?.style === "currency" && !format.compact && values?.length && values.every(Number.isInteger) ? 0 : undefined);
224
- const value = new Intl.NumberFormat(
225
- locale,
226
- format
227
- ? {
228
- ...common,
229
- ...(decimals === undefined ? {} : { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),
230
- ...(format.compact ? { notation: "compact" } : {}),
231
- }
232
- : { maximumSignificantDigits: 15 },
233
- );
234
- let tick: Intl.NumberFormat | undefined;
235
- const tickValue = (v: number) => {
236
- tick ??= new Intl.NumberFormat(locale, {
237
- ...common,
238
- notation: !format || format.compact ? "compact" : "standard",
239
- maximumSignificantDigits: 15,
240
- });
241
- return tick.format(v);
242
- };
243
- const unit = format?.unit ? ` ${format.unit}` : "";
244
- return { value: (v: number) => `${value.format(v)}${unit}`, tick: (v: number) => `${tickValue(v)}${unit}` };
245
- };
246
- const numberFormat = (format: CloudAiFormat | undefined) => (format && !("date" in format) ? format : undefined);
247
- /** The values a chart writes with its value format, for whole-number currency. */
248
- const formattedValues = (input: CloudAiChartInput): number[] => {
249
- switch (input.kind) {
250
- case "line":
251
- case "scatter":
252
- return [...input.series.flatMap((series) => series.data.map((point) => point.y)), ...referencesOf(input, "y")];
253
- case "bar":
254
- return [...input.data.map((item) => item.value), ...referencesOf(input, "y")];
255
- case "pie":
256
- case "donut":
257
- case "heatmap":
258
- return input.data.map((item) => item.value);
259
- case "barGauge":
260
- return [...input.data.flatMap((item) => [item.value, item.min, item.max]), input.min, input.max].filter(
261
- (value) => value !== undefined,
262
- );
263
- case "stat":
264
- return [input.value, ...(input.delta === undefined ? [] : [input.delta]), ...(input.sparkline ?? [])];
265
- case "gauge":
266
- return [input.value, input.min, input.max].filter((value) => value !== undefined);
267
- case "sparkline":
268
- return input.data;
269
- // Box plots write quartiles they compute, and histograms bin edges: those keep their cents.
270
- default:
271
- return [];
272
- }
273
- };
274
- const formats = (input: CloudAiChartInput, locale: string): Formats => {
275
- const yFormat = "yAxis" in input ? numberFormat(input.yAxis?.format) : "format" in input ? input.format : undefined;
276
- const y = cloudAiNumberFormatters(yFormat, locale, formattedValues(input));
277
- const xFormat = "xAxis" in input && input.kind !== "bar" ? input.xAxis?.format : undefined;
278
- const x = cloudAiNumberFormatters(numberFormat(xFormat), locale);
279
- // An unformatted x axis often counts years or steps, which compact notation would turn into 2.02K.
280
- const xTick = new Intl.NumberFormat(locale, { maximumSignificantDigits: 15, useGrouping: "min2" });
281
- const written = seriesOf(input).flatMap((series) => series.data.flatMap((point) => (typeof point.x === "string" ? [point.x] : [])));
282
- if (!written.length) return { ...y, x: { tick: xFormat ? x.tick : (v) => xTick.format(v), value: x.value } };
283
- const dateFormat = xFormat && "date" in xFormat ? xFormat.date : undefined;
284
- const times = dateFormat ? dateFormat !== "day" : written.some((date) => date.length > 10);
285
- const seconds = dateFormat ? dateFormat === "second" : written.some((date) => date.length > 16);
286
- const clock: Intl.DateTimeFormatOptions = times ? { hour: "2-digit", minute: "2-digit", ...(seconds ? { second: "2-digit" } : {}) } : {};
287
- const dateTick = new Intl.DateTimeFormat(locale, { timeZone: "UTC", day: "numeric", month: "short", ...clock });
288
- const dateValue = new Intl.DateTimeFormat(locale, {
289
- timeZone: "UTC",
290
- dateStyle: "medium",
291
- ...(times ? { timeStyle: seconds ? "medium" : "short" } : {}),
292
- });
293
- return { ...y, x: { tick: (v) => dateTick.format(v), value: (v) => dateValue.format(v) } };
294
- };
295
-
296
- /** The table's x formatter also titles the shared cursor. */
297
- export const cloudAiChartXValueFormat = (input: CloudAiChartInput, locale: string): ((x: number) => string) =>
298
- formats(input, locale).x.value;
299
-
300
- /** Room for the longest y tick label, so values like 1.2M never run past the left edge on a phone. */
301
- const leftPadding = (values: number[], value: ChartAxisInput | undefined, tick: (value: number) => string, factor = NARROW) => {
302
- // A tick between the ends can carry one more digit than they do.
303
- const longest = Math.max(...tickCandidates(values, value).map((entry) => tick(entry).length)) + 1;
304
- return Math.max(40, Math.ceil((longest * CHAR + 8 + (value?.label ? 16 : 0)) * factor));
305
- };
306
-
307
- /**
308
- * Box plot labels never shorten: when they would overlap in the narrowest column, every n-th label shows in full and
309
- * the others stay empty, as in `cloud.chart()`. Table and tooltips keep every label.
310
- */
311
- const thinned = <T extends { label: string }>(data: T[], left: number, width = WIDTH, factor = NARROW): T[] => {
312
- const longest = Math.max(1, ...data.map((item) => item.label.length));
313
- const plot = Math.max(width - left - 16, 1) / factor;
314
- const step = Math.max(1, Math.ceil(((longest + 1) * CHAR * data.length) / plot));
315
- return step === 1 ? data : data.map((item, index) => (index % step === 0 ? item : { ...item, label: "" }));
316
- };
317
-
318
- /** Entries of the renderer's legend, also usable when the chat lays it out outside the SVG. */
319
- export function cloudAiChartLegend(
320
- input: CloudAiChartInput,
321
- locale: string,
322
- ): { label: string; color: number; style?: "solid" | "dashed" | "dotted"; reference?: boolean }[] {
323
- if (input.kind === "bar")
324
- return input.colorByBar && input.legend ? input.data.map((entry, index) => ({ label: entry.label, color: index % 8 })) : [];
325
- if (input.kind !== "line" && input.kind !== "scatter") return [];
326
- if (!(input.legend ?? input.series.length > 1)) return [];
327
- const t = labels.resolve([locale]).t;
328
- return input.series.map((entry, index) => ({
329
- label: entry.label ?? `${t.series} ${index + 1}`,
330
- color: index % 8,
331
- ...(input.kind === "line" ? { style: input.series[index]?.style ?? (entry.role === "reference" ? "dashed" : "solid") } : {}),
332
- ...(entry.role === "reference" ? { reference: true } : {}),
333
- }));
334
- }
1
+ // Cloud retains AI policy and its public export names; portable rendering lives in @k2b/editor.
2
+ export {
3
+ type ChartColumn as CloudAiChartColumn,
4
+ type ChartRow as CloudAiChartRow,
5
+ type ChartTable as CloudAiChartTable,
6
+ chartDateTime,
7
+ chartLegend as cloudAiChartLegend,
8
+ chartMarkKey as cloudAiChartMarkKey,
9
+ chartMarks as cloudAiChartMarks,
10
+ chartNumberFormatters as cloudAiNumberFormatters,
11
+ chartReferenceMark as cloudAiChartReferenceMark,
12
+ chartRenderOptions as cloudAiChartRenderOptions,
13
+ chartRows as cloudAiChartRows,
14
+ chartSteppingIssue,
15
+ chartTable as cloudAiChartTable,
16
+ chartTooltip as cloudAiChartTooltip,
17
+ chartXValueFormat as cloudAiChartXValueFormat,
18
+ checkChartLabels as checkCloudAiChartLabels,
19
+ } from "@k2b/editor/charts";
335
20
 
336
- /**
337
- * Whether a scatter point belongs to a comparison series, for hollow snapshot marks. A line's comparison series is
338
- * dashed instead: hollow marks would show every one of its points.
339
- */
340
- export const cloudAiChartReferenceMark = (input: CloudAiChartInput, datum: ChartDatum): boolean =>
341
- input.kind === "scatter" && input.series[datum.seriesIndex ?? 0]?.role === "reference";
342
-
343
- /** Options for the shared renderer; the chat block draws the title itself. */
344
- export function cloudAiChartRenderOptions(
345
- input: CloudAiChartInput,
346
- locale: string,
347
- options: {
348
- width?: number;
349
- legend?: "svg" | "outside";
350
- formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string };
351
- } = {},
352
- ): RenderOptions {
353
- const defaults = formats(input, locale);
354
- // Function formats must be measured with the same text the renderer will print.
355
- const format = {
356
- ...defaults,
357
- tick: options.formats?.y ?? defaults.tick,
358
- value: options.formats?.value ?? defaults.value,
359
- x: { ...defaults.x, tick: options.formats?.x ?? defaults.x.tick },
360
- };
361
- const factor = options.width === undefined ? NARROW : 1;
362
- const width = options.width ?? WIDTH;
363
- const references = renderReferences(input);
364
- switch (input.kind) {
365
- case "bar":
366
- case "boxplot": {
367
- const yAxis = referencedAxis(input, "y");
368
- // The bar renderer places every category label and printed value itself, sized for the narrowest column.
369
- if (input.kind === "bar")
370
- return {
371
- kind: "bar",
372
- data: input.data,
373
- xAxis: input.xAxis,
374
- yAxis: axis(yAxis, options.formats?.y ?? format.tick),
375
- // A host's own function format writes printed values like its ticks and table cells.
376
- valueFormat: options.formats?.value ?? options.formats?.y ?? format.value,
377
- colorByBar: input.colorByBar,
378
- showValues: input.showValues,
379
- legend: options.legend === "outside" ? false : input.legend,
380
- references,
381
- ...(options.width === undefined ? {} : { displayWidth: options.width }),
382
- };
383
- const left = leftPadding([...input.data.flatMap((item) => item.values), ...referencesOf(input, "y")], yAxis, format.tick, factor);
384
- return {
385
- kind: "boxplot",
386
- groups: thinned(input.data, left, width, factor),
387
- yAxis: axis(yAxis, format.tick),
388
- references,
389
- padding: { left },
390
- };
391
- }
392
- case "line":
393
- case "scatter": {
394
- const t = labels.resolve([locale]).t;
395
- const series = input.series.map((entry, index) => ({
396
- label: entry.label ?? `${t.series} ${index + 1}`,
397
- data: entry.data.map((point) => ({ x: dateTime(point.x), y: point.y })),
398
- ...(input.kind === "line" ? { lineStyle: input.series[index]?.style ?? (entry.role === "reference" ? "dashed" : undefined) } : {}),
399
- }));
400
- const points = series.flatMap((entry) => entry.data);
401
- const xAxis = referencedAxis(input, "x");
402
- const yAxis = referencedAxis(input, "y");
403
- const candidates = tickCandidates([...points.map((point) => point.x), ...referencesOf(input, "x")], xAxis).filter(Number.isFinite);
404
- const lastTick = candidates.length ? Math.max(...candidates) : 0;
405
- const common = {
406
- series,
407
- xAxis: axis(xAxis, format.x.tick),
408
- yAxis: axis(yAxis, format.tick),
409
- references,
410
- legend: options.legend === "outside" ? false : (input.legend ?? input.series.length > 1),
411
- padding: {
412
- left: leftPadding([...points.map((point) => point.y), ...referencesOf(input, "y")], yAxis, format.tick, factor),
413
- // Labels center on their tick: room for half of the last one right of the plot.
414
- right: Math.max(16, Math.ceil(((format.x.tick(lastTick).length * CHAR) / 2 + 4) * factor)),
415
- },
416
- };
417
- return input.kind === "line" ? { kind: "line", ...common, area: input.area, smooth: input.smooth } : { kind: "scatter", ...common };
418
- }
419
- case "pie":
420
- case "donut":
421
- return {
422
- kind: input.kind,
423
- data: input.data,
424
- legend: input.legend ?? !input.showLabels,
425
- showLabels: input.showLabels,
426
- ...(options.formats?.value ? { format: options.formats.value } : {}),
427
- };
428
- case "histogram": {
429
- const xAxis = referencedAxis(input, "x");
430
- const xFormat = options.formats?.x ?? defaults.tick;
431
- const xLabels = options.formats?.x
432
- ? tickCandidates([...input.data, ...referencesOf(input, "x")], xAxis).map((value) => xFormat(value).length)
433
- : undefined;
434
- return {
435
- kind: "histogram",
436
- data: input.data,
437
- bins: input.bins,
438
- references,
439
- xAxis: axis(xAxis, xFormat),
440
- yAxis: axis(referencedAxis(input, "y"), format.tick),
441
- // No bin counts more than every observation.
442
- padding: {
443
- left: leftPadding([0, input.data.length, ...referencesOf(input, "y")], referencedAxis(input, "y"), format.tick, factor),
444
- ...(xLabels ? { right: Math.max(16, Math.ceil(((Math.max(...xLabels) * CHAR) / 2 + 4) * factor)) } : {}),
445
- },
446
- };
447
- }
448
- case "gauge":
449
- return {
450
- kind: "gauge",
451
- value: input.value,
452
- min: input.min,
453
- max: input.max,
454
- label: input.label,
455
- unit: input.unit,
456
- format: format.value,
457
- thresholds: input.thresholds,
458
- };
459
- case "barGauge":
460
- return {
461
- kind: "barGauge",
462
- data: input.data,
463
- min: input.min,
464
- max: input.max,
465
- unit: input.unit,
466
- format: format.value,
467
- thresholds: input.thresholds,
468
- };
469
- case "heatmap":
470
- return {
471
- kind: "heatmap",
472
- data: input.data,
473
- min: input.min,
474
- max: input.max,
475
- format: format.value,
476
- showValues: input.showValues,
477
- padding: { left: Math.max(40, Math.ceil((Math.max(0, ...input.data.map((item) => item.y.length)) * CHAR + 8) * factor)) },
478
- };
479
- case "stat":
480
- return {
481
- kind: "stat",
482
- label: "",
483
- value: input.value,
484
- unit: input.unit,
485
- format: format.value,
486
- delta: Number.isFinite(input.delta) ? input.delta : undefined,
487
- deltaFormat: format.value,
488
- trend:
489
- !input.better || !input.delta || !Number.isFinite(input.delta)
490
- ? "neutral"
491
- : input.delta > 0 === (input.better === "up")
492
- ? "up"
493
- : "down",
494
- sparkline: input.sparkline,
495
- };
496
- case "sparkline":
497
- return { kind: "sparkline", data: input.data, area: input.area };
498
- }
499
- }
500
-
501
- const decode = (value: string) =>
502
- value
503
- .replace(/&quot;/g, '"')
504
- .replace(/&apos;|&#39;/g, "'")
505
- .replace(/&lt;/g, "<")
506
- .replace(/&gt;/g, ">")
507
- .replace(/&amp;/g, "&");
508
-
509
- /** Draws the options with inspection metadata; throws what the renderer would throw in the chat. */
510
- const draw = (options: RenderOptions): string => {
511
- const common = { width: WIDTH, height: HEIGHT, inspect: true };
512
- switch (options.kind) {
513
- case "bar":
514
- return renderBarChartSvg({ ...options, ...common, height: barChartLayout(options).height });
515
- case "line":
516
- return line({ ...options, ...common });
517
- case "scatter":
518
- return scatter({ ...options, ...common });
519
- case "pie":
520
- return pie({ ...options, ...common });
521
- case "donut":
522
- return donut({ ...options, ...common });
523
- case "histogram":
524
- return histogram({ ...options, ...common });
525
- case "boxplot":
526
- return boxplot({ ...options, ...common });
527
- case "gauge":
528
- return gauge({ ...options, ...common });
529
- case "barGauge":
530
- return barGauge({ ...options, ...common });
531
- case "stat":
532
- return stat({ ...options, ...common });
533
- case "heatmap":
534
- return heatmap({ ...options, ...common });
535
- case "sparkline":
536
- return sparkline({ ...options, ...common });
537
- }
538
- };
539
- const datumSchema = () =>
540
- z.object({
541
- index: z.number(),
542
- seriesIndex: z.number().optional(),
543
- role: z.enum(["point", "item", "bin", "box", "outlier", "value", "cell", "interval"]),
544
- label: z.string().optional(),
545
- anchor: z.tuple([z.number(), z.number()]),
546
- values: z.array(z.object({ key: z.string(), value: z.union([z.string(), z.number()]), formatted: z.string().optional() })),
547
- });
548
- const DatumSchema = /* @__PURE__ */ datumSchema();
549
- const marks = (svg: string): ChartDatum[] =>
550
- [...svg.matchAll(/data-chart-datum="([^"]*)"/g)].map((match) => DatumSchema.parse(JSON.parse(decode(match[1]!))));
551
-
552
- /** The outer radius of a drawn pie or donut: the first arc of its first slice. */
553
- const pieRadius = (svg: string) => Number(/class="stdlib-chart-slice[^"]*" d="[^"A]*A ([\d.]+)/.exec(svg)?.[1] ?? 0);
554
-
555
- /** One mark, as `prepareChartSnapshot` keys it: series and position in the input. */
556
- export const cloudAiChartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex"> & Partial<Pick<ChartDatum, "role">>) =>
557
- `${datum.role === "outlier" ? "outlier:" : datum.role === "value" ? "value:" : ""}${datum.seriesIndex ?? 0}:${datum.index}`;
558
-
559
- export type CloudAiChartColumn = { id: string; label: string; numeric: boolean };
560
- export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
561
- export type CloudAiChartTable = { columns: CloudAiChartColumn[]; rows: CloudAiChartRow[] };
562
-
563
- /** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
564
- export function cloudAiChartTooltip(
565
- columns: CloudAiChartColumn[],
566
- row: CloudAiChartRow,
567
- ): { title?: string; rows: { label: string; value: string }[] } {
568
- const [first, ...rest] = columns;
569
- const named = first && !first.numeric;
570
- return {
571
- title: named ? row.cells[first.id] : undefined,
572
- rows: (named ? rest : columns)
573
- .filter((column) => column.id in row.cells)
574
- .map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
575
- };
576
- }
577
-
578
- /**
579
- * Builds the data table mark by mark, in the order the renderer emits them, so the chat block fills it from the same
580
- * drawing that shows the chart.
581
- */
582
- export function cloudAiChartRows(
583
- input: CloudAiChartInput,
584
- locale: string,
585
- options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
586
- ) {
587
- const t = labels.resolve([locale]).t;
588
- const format = formats(input, locale);
589
- const valueFormat = input.tooltip?.format
590
- ? cloudAiNumberFormatters(input.tooltip.format, locale, formattedValues(input)).value
591
- : (options.formats?.value ?? (input.kind === "histogram" ? undefined : options.formats?.y) ?? format.value);
592
- const xFormat = options.formats?.x ?? format.x.value;
593
- const series = seriesOf(input);
594
- const countFormat = (input.kind === "histogram" ? options.formats?.y : undefined) ?? cloudAiNumberFormatters(undefined, locale).value;
595
- const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
596
- const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
597
- const columns = new Map<string, CloudAiChartColumn>();
598
- const rows: CloudAiChartRow[] = [];
599
- const named = series.some((entry) => entry.label);
600
- const add = (datum: ChartDatum): CloudAiChartRow => {
601
- const cells: Record<string, string> = {};
602
- const values: Record<string, string | number> = {};
603
- const cell = (id: string, label: string, value: string | number, text: string) => {
604
- if (!columns.has(id)) columns.set(id, { id, label, numeric: typeof value === "number" });
605
- cells[id] = text;
606
- values[id] = value;
607
- };
608
- if (input.kind === "line" || input.kind === "scatter") {
609
- if (named || series.length > 1) {
610
- const name = series[datum.seriesIndex ?? 0]?.label ?? `${t.series} ${(datum.seriesIndex ?? 0) + 1}`;
611
- cell("series", t.series, name, name);
612
- }
613
- } else {
614
- // Bars may hide some axis labels; the table names every category.
615
- const label =
616
- input.kind === "bar" || input.kind === "pie" || input.kind === "donut" || input.kind === "boxplot" || input.kind === "barGauge"
617
- ? input.data[datum.role === "outlier" ? (datum.seriesIndex ?? 0) : datum.index]?.label
618
- : datum.label;
619
- if (label) cell("label", t.category, label, label);
620
- }
621
- for (const field of datum.values) {
622
- const value = field.value;
623
- if (typeof value === "string" && input.kind === "heatmap" && (field.key === "x" || field.key === "y")) {
624
- cell(field.key, field.key.toUpperCase(), value, value);
625
- continue;
626
- }
627
- if (typeof value !== "number" || field.key === "total") continue;
628
- if (field.key === "x") {
629
- const header = (input.kind === "line" || input.kind === "scatter" ? input.xAxis?.label : undefined) ?? "X";
630
- cell(
631
- "x",
632
- input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
633
- value,
634
- input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : xFormat(value),
635
- );
636
- } else if (field.key === "y") {
637
- const header =
638
- input.tooltip?.label ??
639
- (input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
640
- (input.kind === "sparkline" || input.kind === "stat" ? t.value : "Y");
641
- cell("y", header, value, valueFormat(value));
642
- } else if (field.key === "value")
643
- cell(
644
- "value",
645
- input.tooltip?.label ?? (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value,
646
- value,
647
- `${valueFormat(value)}${unit}`,
648
- );
649
- else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${valueFormat(value)}`);
650
- else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
651
- cell(field.key, t[field.key], value, valueFormat(value));
652
- else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
653
- else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max") {
654
- const fieldFormat =
655
- field.key === "count"
656
- ? countFormat
657
- : input.kind === "histogram" && (field.key === "from" || field.key === "to") && options.formats?.x
658
- ? xFormat
659
- : valueFormat;
660
- cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
661
- }
662
- }
663
- const row = { key: cloudAiChartMarkKey(datum), cells, values };
664
- rows.push(row);
665
- return row;
666
- };
667
- return { add, columns: () => [...columns.values()], table: (): CloudAiChartTable => ({ columns: [...columns.values()], rows }) };
668
- }
669
-
670
- /** The data behind every mark of the chart as a table: the accessible alternative and what Copy data copies. */
671
- export function cloudAiChartTable(input: CloudAiChartInput, locale: string): CloudAiChartTable {
672
- const rows = cloudAiChartRows(input, locale);
673
- for (const datum of marks(draw(cloudAiChartRenderOptions(input, locale)))) rows.add(datum);
674
- return rows.table();
675
- }
676
-
677
- /** Input values count against the budget, including observations summarized into boxes or bins. */
678
- export const cloudAiChartMarks = (input: CloudAiChartInput): number => {
679
- switch (input.kind) {
680
- case "line":
681
- case "scatter":
682
- return input.series.reduce((sum, series) => sum + series.data.length, 0);
683
- case "boxplot":
684
- return input.data.reduce((sum, box) => sum + box.values.length, 0);
685
- case "gauge":
686
- return 1;
687
- case "stat":
688
- return 1 + (input.sparkline?.length ?? 0);
689
- default:
690
- return input.data.length;
691
- }
692
- };
693
-
694
- /** Rejects only precision ranges that would make the renderer count ticks forever. */
695
- export function chartSteppingIssue(input: CloudAiChartInput): string | undefined {
696
- // Check raw observations before autoBin computes histogram edges.
697
- if (input.kind === "histogram" && !steppable(input.data))
698
- return "The values differ too little for their size to label an axis; subtract a common offset first.";
699
- for (const { values: data, axis, zero, name } of valueAxes(input)) {
700
- const values = [...data, ...referencesOf(input, name)];
701
- if (axis?.scale !== "log" && !axis?.domain && !steppable(zero ? [...values, 0] : values, axis?.ticks))
702
- return "The values differ too little for their size to label an axis; subtract a common offset first.";
703
- }
704
- return undefined;
705
- }
706
-
707
- /** Both dimensions must fit before suggesting a heatmap orientation change. */
708
- const heatmapLabelFit = (columns: string[], rows: string[]) => {
709
- const left = Math.max(40, Math.ceil((Math.max(0, ...rows.map((label) => label.length)) * CHAR + 8) * NARROW));
710
- const characters = Math.floor((WIDTH - left - 16) / NARROW / columns.length / CHAR) - 1;
711
- return { characters, columns: columns.every((label) => label.length <= characters), rows: rows.length <= HEATMAP_MAX_ROWS };
712
- };
21
+ import {
22
+ chartBounds as bounds,
23
+ CHAR,
24
+ type ChartInput as CloudAiChartInput,
25
+ chartSteppingIssue,
26
+ chartRenderOptions as cloudAiChartRenderOptions,
27
+ drawChart as draw,
28
+ HEATMAP_MAX_ROWS,
29
+ chartHeatmapLabelFit as heatmapLabelFit,
30
+ MIN_PIE_RADIUS,
31
+ chartSvgMarks as marks,
32
+ NARROW,
33
+ chartPieRadius as pieRadius,
34
+ chartReferenceValues as referencesOf,
35
+ chartSeries as seriesOf,
36
+ chartValueAxes as valueAxes,
37
+ } from "@k2b/editor/charts";
713
38
 
714
39
  /** Why chart options would not draw, found without drawing them; undefined when nothing is wrong. */
715
40
  export function chartDataIssue(input: CloudAiChartInput): string | undefined {