@k2b/editor 0.0.0 → 0.1.1

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