@k2b/editor 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/editor",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Configurable Markdown editor for SolidJS: editor, toolbar, bubble menu, and slash menu on one shared state.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -70,7 +70,7 @@
70
70
  "@codemirror/state": "6.7.1",
71
71
  "@codemirror/view": "6.43.7",
72
72
  "@k2b/stdlib": "0.30.0",
73
- "@k2b/ui": "0.50.0",
73
+ "@k2b/ui": "0.52.0",
74
74
  "@lezer/common": "1.5.3",
75
75
  "@lezer/highlight": "1.2.4",
76
76
  "@lezer/markdown": "1.7.2",
@@ -1,12 +1,10 @@
1
- import { chartLocaleSchema } from "./chart-locale";
2
-
3
- export { chartAcceptsLocale, chartLocaleSchema } from "./chart-locale";
4
-
1
+ import type { ChartInput, ChartLimits } from "@k2b/ui/charts";
5
2
  import { CALENDAR_MAX_DAYS, calendarDay } from "@k2b/ui/charts/core";
6
3
  import { z } from "zod";
4
+ import { chartLocaleSchema } from "./chart-locale";
7
5
 
8
- /** Optional caller policy. Rendering itself has no AI input budget. */
9
- export type ChartLimits = { maxSeries: number; maxValues: number; maxMarks: number };
6
+ export type { ChartDateFormat, ChartDescription, ChartFormat, ChartInput, ChartLimits, ChartNumberFormat } from "@k2b/ui/charts";
7
+ export { chartAcceptsLocale, chartLocaleSchema } from "./chart-locale";
10
8
 
11
9
  // Every chart schema is built by a function whose call is marked pure, so a bundle that imports neither chart contract
12
10
  // leaves them out. App frames include the app contract through `cloud.chart()`.
@@ -31,12 +29,29 @@ export const chartNumberFormatSchema = () =>
31
29
  const dateFormatSchema = () => z.strictObject({ date: z.enum(["day", "minute", "second"]) });
32
30
  export const chartFormatSchema = () => z.union([chartNumberFormatSchema(), dateFormatSchema()]);
33
31
 
34
- export type ChartNumberFormat = z.output<ReturnType<typeof chartNumberFormatSchema>>;
35
- export type ChartDateFormat = z.output<ReturnType<typeof dateFormatSchema>>;
36
- export type ChartFormat = ChartNumberFormat | ChartDateFormat;
37
-
38
32
  export const chartKindSchema = () =>
39
- z.enum(["area", "bar", "line", "scatter", "pie", "donut", "histogram", "gauge", "sparkline", "boxplot", "heatmap", "barGauge", "stat"]);
33
+ z.enum([
34
+ "waterfall",
35
+ "area",
36
+ "bar",
37
+ "hbar",
38
+ "funnel",
39
+ "line",
40
+ "scatter",
41
+ "pie",
42
+ "donut",
43
+ "histogram",
44
+ "gauge",
45
+ "sparkline",
46
+ "boxplot",
47
+ "heatmap",
48
+ "barGauge",
49
+ "stat",
50
+ "treemap",
51
+ "pareto",
52
+ "bubble",
53
+ "radar",
54
+ ]);
40
55
 
41
56
  export const chartParts = (limits?: ChartLimits) => {
42
57
  const ChartLabel = limits ? z.string().trim().min(1).max(80) : z.union([z.string(), z.number().transform(String)]);
@@ -154,6 +169,27 @@ export const chartKinds = (limits?: ChartLimits) => {
154
169
  const ChartKindCommon = { ...ChartCommon, tooltip: ChartTooltip.optional() };
155
170
  const numberFormat = chartNumberFormatSchema();
156
171
  return z.discriminatedUnion("kind", [
172
+ z
173
+ .object({
174
+ kind: z.literal("waterfall"),
175
+ ...ChartKindCommon,
176
+ start: z.object({ label: ChartLabel, value: z.number() }),
177
+ steps: list(z.object({ label: ChartLabel, value: z.number() }), 0, limits?.maxValues),
178
+ end: z.strictObject({ label: ChartLabel }),
179
+ format: numberFormat.optional(),
180
+ })
181
+ .superRefine((spec, ctx) => {
182
+ let balance = spec.start.value;
183
+ let lo = Math.min(0, balance),
184
+ hi = Math.max(0, balance);
185
+ for (const step of spec.steps) {
186
+ balance += step.value;
187
+ lo = Math.min(lo, balance);
188
+ hi = Math.max(hi, balance);
189
+ }
190
+ if (!Number.isFinite(balance) || !Number.isFinite(hi - lo))
191
+ ctx.addIssue({ code: "custom", message: "Waterfall balances must form a finite range." });
192
+ }),
157
193
  z
158
194
  .object({
159
195
  kind: z.literal("area"),
@@ -175,17 +211,79 @@ export const chartKinds = (limits?: ChartLimits) => {
175
211
  if (!Number.isFinite(sum)) ctx.addIssue({ code: "custom", path: ["x", i], message: "Area totals must be finite." });
176
212
  });
177
213
  }),
214
+ z
215
+ .object({
216
+ kind: z.literal("bar"),
217
+ ...ChartKindCommon,
218
+ data: list(ChartCategory, 0, limits?.maxValues).default([]),
219
+ x: list(ChartLabel, 0, limits?.maxValues).optional(),
220
+ series: list(
221
+ z.object({ label: ChartLabel, data: list(ChartNumber.nullable(), 0, limits?.maxValues) }),
222
+ 1,
223
+ limits?.maxSeries,
224
+ ).optional(),
225
+ layout: z.enum(["columns", "rows", "grouped", "stacked"]).optional(),
226
+ normalize: z.boolean().optional(),
227
+ share: z.boolean().optional(),
228
+ limit: z.number().int().positive().optional(),
229
+ tone: z.enum(["in", "out"]).optional(),
230
+ format: numberFormat.optional(),
231
+ compare: z.object({ label: ChartLabel, data: list(ChartNumber.nullable(), 0, limits?.maxValues) }).optional(),
232
+ xAxis: ChartAxis.pick({ label: true }).extend({ ordered: z.boolean().optional() }).strict().optional(),
233
+ yAxis: ChartAxis.optional(),
234
+ colorByBar: z.boolean().optional(),
235
+ showValues: z.boolean().optional(),
236
+ legend: z.boolean().optional(),
237
+ references: ChartReferences.optional(),
238
+ })
239
+ .superRefine((bar, ctx) => {
240
+ const issue = (message: string) => ctx.addIssue({ code: "custom", message });
241
+ if (limits && !bar.series && !bar.data.length)
242
+ ctx.addIssue({ code: "custom", path: ["data"], message: "A bar chart needs data or aligned series." });
243
+ if ((bar.share !== undefined || bar.limit !== undefined || bar.tone !== undefined) && bar.layout !== "rows")
244
+ issue("Share, limit and tone require rows layout.");
245
+ if (bar.layout === "rows" && bar.data.some((d) => Number.isFinite(d.value) && d.value < 0))
246
+ issue("Ranked bar lists require nonnegative values.");
247
+ if (bar.series) {
248
+ if (!bar.x || bar.series.some((s) => s.data.length !== bar.x?.length)) issue("Bar series must have the same length as x.");
249
+ if (bar.data.length) issue("Use data or aligned series, not both.");
250
+ if (bar.layout === "rows" || bar.layout === "columns") issue("Series bars use grouped or stacked layout.");
251
+ } else if (bar.x || bar.compare || bar.layout === "grouped" || bar.layout === "stacked")
252
+ issue("This layout needs aligned x and series.");
253
+ if (bar.layout === "stacked" && bar.yAxis?.scale === "log") issue("Stacked bars need a linear axis.");
254
+ if (bar.normalize && bar.layout !== "stacked") issue("Normalized bars must be stacked.");
255
+ if (bar.layout === "stacked" && bar.series?.some((s) => s.data.some((v) => v !== null && v < 0)))
256
+ issue("Stacked bars require nonnegative values.");
257
+ if (bar.compare && (bar.series?.length !== 1 || bar.compare.data.length !== bar.x?.length || bar.layout === "stacked"))
258
+ issue("Comparison needs one current series and aligned previous values.");
259
+ }),
178
260
  z.object({
179
- kind: z.literal("bar"),
261
+ kind: z.literal("funnel"),
180
262
  ...ChartKindCommon,
181
- data: list(ChartCategory, 1, limits?.maxValues),
182
- xAxis: ChartAxis.pick({ label: true }).strict().optional(),
183
- yAxis: ChartAxis.optional(),
184
- colorByBar: z.boolean().optional(),
185
- showValues: z.boolean().optional(),
186
- legend: z.boolean().optional(),
187
- references: ChartReferences.optional(),
263
+ data: list(z.object({ label: ChartLabel, value: z.number().nonnegative().nullable() }), 1, limits?.maxValues),
264
+ format: numberFormat.optional(),
188
265
  }),
266
+ z
267
+ .object({
268
+ kind: z.literal("hbar"),
269
+ ...ChartKindCommon,
270
+ series: list(ChartLabel, 1, limits?.maxSeries),
271
+ rows: list(
272
+ z.object({
273
+ group: ChartLabel.optional(),
274
+ label: ChartLabel,
275
+ values: list(z.number().nonnegative().nullable(), 0, limits?.maxSeries),
276
+ }),
277
+ 1,
278
+ limits?.maxValues,
279
+ ),
280
+ legend: z.boolean().optional(),
281
+ format: numberFormat.optional(),
282
+ })
283
+ .superRefine((bar, ctx) => {
284
+ if (bar.rows.some((r) => r.values.length !== bar.series.length))
285
+ ctx.addIssue({ code: "custom", message: "Horizontal bar values must match series." });
286
+ }),
189
287
  z.object({
190
288
  kind: z.literal("line"),
191
289
  ...ChartKindCommon,
@@ -319,6 +417,81 @@ export const chartKinds = (limits?: ChartLimits) => {
319
417
  better: z.enum(["up", "down"]).optional(),
320
418
  sparkline: list(ChartNumber, 2, limits?.maxValues).optional(),
321
419
  }),
420
+ z
421
+ .object({
422
+ kind: z.literal("treemap"),
423
+ ...ChartKindCommon,
424
+ groups: list(ChartLabel, 1, limits?.maxSeries),
425
+ data: list(z.object({ label: ChartLabel, group: ChartLabel, value: z.number().nonnegative() }), 1, limits?.maxMarks),
426
+ legend: z.boolean().optional(),
427
+ format: numberFormat.optional(),
428
+ locale: chartLocaleSchema().optional(),
429
+ })
430
+ .superRefine((spec, ctx) => {
431
+ if (!Number.isFinite(spec.data.reduce((sum, d) => sum + d.value, 0)))
432
+ ctx.addIssue({ code: "custom", message: "Treemap total must be finite." });
433
+ if (spec.data.some((d) => !spec.groups.includes(d.group)))
434
+ ctx.addIssue({ code: "custom", message: "Treemap groups must contain every item group." });
435
+ }),
436
+ z
437
+ .object({
438
+ kind: z.literal("pareto"),
439
+ ...ChartKindCommon,
440
+ labels: z.tuple([ChartLabel, ChartLabel]),
441
+ data: list(z.object({ label: ChartLabel, value: z.number().nonnegative(), prev: z.number().nonnegative() }), 1, limits?.maxValues),
442
+ format: numberFormat.optional(),
443
+ locale: chartLocaleSchema().optional(),
444
+ })
445
+ .superRefine((spec, ctx) => {
446
+ if (!Number.isFinite(spec.data.reduce((sum, d) => sum + d.value, 0)))
447
+ ctx.addIssue({ code: "custom", message: "Pareto total must be finite." });
448
+ }),
449
+ z
450
+ .object({
451
+ kind: z.literal("bubble"),
452
+ ...ChartKindCommon,
453
+ groups: list(ChartLabel, 1, limits?.maxSeries),
454
+ data: list(
455
+ z.object({ label: ChartLabel, group: ChartLabel, x: z.number(), y: z.number(), size: z.number().nonnegative().optional() }),
456
+ 1,
457
+ limits?.maxMarks,
458
+ ),
459
+ xLabel: ChartLabel,
460
+ yLabel: ChartLabel,
461
+ sizeLabel: ChartLabel.optional(),
462
+ averages: z.boolean().optional(),
463
+ legend: z.boolean().optional(),
464
+ xFormat: numberFormat.optional(),
465
+ yFormat: numberFormat.optional(),
466
+ format: numberFormat.optional(),
467
+ locale: chartLocaleSchema().optional(),
468
+ })
469
+ .superRefine((spec, ctx) => {
470
+ if (spec.data.some((p) => !spec.groups.includes(p.group)))
471
+ ctx.addIssue({ code: "custom", message: "Bubble groups must contain every point group." });
472
+ for (const key of ["x", "y"] as const)
473
+ if (!Number.isFinite(Math.max(0, ...spec.data.map((p) => p[key])) - Math.min(0, ...spec.data.map((p) => p[key]))))
474
+ ctx.addIssue({ code: "custom", message: "Bubble coordinate ranges must be finite." });
475
+ }),
476
+ z
477
+ .object({
478
+ kind: z.literal("radar"),
479
+ ...ChartKindCommon,
480
+ axes: z.array(ChartLabel).min(3).max(8),
481
+ max: z.number().positive(),
482
+ unit: ChartUnit.optional(),
483
+ series: z
484
+ .array(z.object({ label: ChartLabel, data: z.array(z.number().nonnegative()).min(3).max(8) }))
485
+ .min(1)
486
+ .max(4),
487
+ format: numberFormat.optional(),
488
+ legend: z.boolean().optional(),
489
+ locale: chartLocaleSchema().optional(),
490
+ })
491
+ .superRefine((spec, ctx) => {
492
+ if (spec.series.some((s) => s.data.length !== spec.axes.length || s.data.some((v) => v > spec.max)))
493
+ ctx.addIssue({ code: "custom", message: "Radar series need one value per axis between zero and the common maximum." });
494
+ }),
322
495
  ]);
323
496
  };
324
497
 
@@ -339,5 +512,10 @@ export const chartDateTime = (value: string): number | undefined => {
339
512
  return date.toISOString().startsWith(`${year}-${month}-${day}T${hour}:${minute}:${second}`) ? date.getTime() : undefined;
340
513
  };
341
514
 
342
- /** One chart as the shared renderers read it. */
343
- export type ChartInput = z.output<ReturnType<typeof chartKinds>>;
515
+ // Both directions are checked: a validator change must update the ui-owned description too.
516
+ type Assert<T extends true> = T;
517
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
518
+ type _ChartSchemaMatchesDescription = Assert<Same<z.output<ReturnType<typeof chartKinds>>, ChartInput>>;
519
+ type _ChartNumberFormatSchemaMatchesDescription = Assert<
520
+ Same<z.output<ReturnType<typeof chartNumberFormatSchema>>, import("@k2b/ui/charts").ChartNumberFormat>
521
+ >;
package/src/charts.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type AreaOptions,
5
5
  autoBin,
6
6
  type BarChartOptions,
7
+ type BubbleOptions,
7
8
  barChartLayout,
8
9
  barGauge,
9
10
  boxplot,
@@ -13,19 +14,34 @@ import {
13
14
  computeDomain,
14
15
  donut,
15
16
  extendDomainToNice,
17
+ type FunnelOptions,
16
18
  gauge,
17
19
  type HeatmapChartOptions,
20
+ type HorizontalBarOptions,
21
+ hbarLayout,
18
22
  histogram,
19
23
  line,
20
24
  niceLogTicks,
21
25
  niceStep,
26
+ type ParetoOptions,
22
27
  pie,
28
+ type RadarOptions,
29
+ rankedBarLayout,
23
30
  renderAreaSvg,
24
31
  renderBarChartSvg,
32
+ renderBubbleSvg,
33
+ renderFunnelSvg,
34
+ renderHbarSvg,
25
35
  renderHeatmapSvg,
36
+ renderParetoSvg,
37
+ renderRadarSvg,
38
+ renderTreemapSvg,
39
+ renderWaterfallSvg,
26
40
  scatter,
27
41
  sparkline,
28
42
  stat,
43
+ type TreemapOptions,
44
+ type WaterfallOptions,
29
45
  } from "@k2b/ui/charts/core";
30
46
  import { z } from "zod";
31
47
  import { type ChartFormat, type ChartInput, type ChartNumberFormat, chartDateTime, chartKinds } from "./chart-schema";
@@ -33,15 +49,29 @@ import { type ChartFormat, type ChartInput, type ChartNumberFormat, chartDateTim
33
49
  export * from "./chart-schema";
34
50
 
35
51
  type RenderOptions = {
36
- [K in ChartInput["kind"]]: { kind: K } & (K extends "area"
37
- ? AreaOptions
38
- : K extends "heatmap"
39
- ? HeatmapChartOptions
40
- : K extends "bar"
41
- ? BarChartOptions
42
- : K extends keyof typeof charts
43
- ? Omit<Parameters<(typeof charts)[K]>[0], "width" | "height" | "inspect">
44
- : never);
52
+ [K in ChartInput["kind"]]: { kind: K } & (K extends "funnel"
53
+ ? FunnelOptions
54
+ : K extends "hbar"
55
+ ? HorizontalBarOptions
56
+ : K extends "waterfall"
57
+ ? WaterfallOptions
58
+ : K extends "area"
59
+ ? AreaOptions
60
+ : K extends "treemap"
61
+ ? TreemapOptions
62
+ : K extends "pareto"
63
+ ? ParetoOptions
64
+ : K extends "bubble"
65
+ ? BubbleOptions
66
+ : K extends "radar"
67
+ ? RadarOptions
68
+ : K extends "heatmap"
69
+ ? HeatmapChartOptions
70
+ : K extends "bar"
71
+ ? BarChartOptions & { data: NonNullable<BarChartOptions["data"]> }
72
+ : K extends keyof typeof charts
73
+ ? Omit<Parameters<(typeof charts)[K]>[0], "width" | "height" | "inspect">
74
+ : never);
45
75
  }[ChartInput["kind"]];
46
76
 
47
77
  /**
@@ -56,8 +86,20 @@ const labels = /* @__PURE__ */ i18n.define({
56
86
  messages: {
57
87
  en: {
58
88
  category: "Category",
89
+ size: "Size",
90
+ axis: "Axis",
59
91
  series: "Series",
92
+ group: "Group",
93
+ previous: "Previous",
94
+ change: "Change",
95
+ cumulative: "Cumulative",
96
+ balance: "Balance",
97
+ before: "Balance before",
98
+ inflow: "In",
99
+ outflow: "Out",
100
+ after: "Balance after",
60
101
  value: "Value",
102
+ total: "Total",
61
103
  share: "Share",
62
104
  position: "Position",
63
105
  from: "From",
@@ -71,11 +113,26 @@ const labels = /* @__PURE__ */ i18n.define({
71
113
  whiskerLow: "Whisker low",
72
114
  whiskerHigh: "Whisker high",
73
115
  delta: "Change",
116
+ startShare: "Share of start",
117
+ previousShare: "Share of previous step",
118
+ rank: "Rank",
74
119
  },
75
120
  de: {
76
121
  category: "Kategorie",
122
+ size: "Größe",
123
+ axis: "Achse",
77
124
  series: "Datenreihe",
125
+ group: "Bereich",
126
+ previous: "Vorher",
127
+ change: "Änderung",
128
+ cumulative: "Kumuliert",
129
+ balance: "Stand",
130
+ before: "Stand davor",
131
+ inflow: "Rein",
132
+ outflow: "Raus",
133
+ after: "Stand danach",
78
134
  value: "Wert",
135
+ total: "Gesamt",
79
136
  share: "Anteil",
80
137
  position: "Position",
81
138
  from: "Von",
@@ -89,6 +146,9 @@ const labels = /* @__PURE__ */ i18n.define({
89
146
  whiskerLow: "Unterer Whisker",
90
147
  whiskerHigh: "Oberer Whisker",
91
148
  delta: "Änderung",
149
+ startShare: "Anteil am Start",
150
+ previousShare: "Anteil am vorigen Schritt",
151
+ rank: "Rang",
92
152
  },
93
153
  },
94
154
  });
@@ -137,7 +197,19 @@ export const chartReferenceValues = (input: ChartInput, name: "x" | "y") =>
137
197
 
138
198
  /** The value axes of a chart with what they show: the data checks and the label room read them alike. */
139
199
  export const chartValueAxes = (input: ChartInput): { values: number[]; axis?: ChartAxisInput; zero?: boolean; name: "x" | "y" }[] => {
140
- if (input.kind === "bar") return [{ values: input.data.map((item) => item.value), axis: input.yAxis, zero: true, name: "y" }];
200
+ if (input.kind === "bar")
201
+ return [
202
+ {
203
+ values: [
204
+ ...input.data.map((item) => item.value),
205
+ ...(input.series?.flatMap((s) => s.data.filter((v): v is number => v !== null)) ?? []),
206
+ ...(input.compare?.data.filter((v): v is number => v !== null) ?? []),
207
+ ],
208
+ axis: input.yAxis,
209
+ zero: true,
210
+ name: "y",
211
+ },
212
+ ];
141
213
  if (input.kind === "boxplot") return [{ values: input.data.flatMap((item) => item.values), axis: input.yAxis, name: "y" }];
142
214
  if (input.kind === "histogram") {
143
215
  if (!input.references?.length) return [{ values: input.data, name: "x" }];
@@ -246,13 +318,30 @@ const numberFormat = (format: ChartFormat | undefined) => (format && !("date" in
246
318
  /** The values a chart writes with its value format, for whole-number currency. */
247
319
  const formattedValues = (input: ChartInput): number[] => {
248
320
  switch (input.kind) {
321
+ case "bubble":
322
+ return input.data.flatMap((p) => (p.size === undefined ? [] : [p.size]));
323
+ case "radar":
324
+ return input.series.flatMap((s) => s.data);
325
+ case "waterfall":
326
+ return [input.start.value, ...input.steps.map((step) => step.value)];
249
327
  case "area":
250
328
  return input.series.flatMap((s) => s.data.filter((v): v is number => v !== null));
251
329
  case "line":
252
330
  case "scatter":
253
331
  return [...input.series.flatMap((series) => series.data.map((point) => point.y)), ...chartReferenceValues(input, "y")];
254
332
  case "bar":
255
- return [...input.data.map((item) => item.value), ...chartReferenceValues(input, "y")];
333
+ return [
334
+ ...input.data.map((item) => item.value),
335
+ ...(input.series?.flatMap((s) => s.data.filter((v): v is number => v !== null)) ?? []),
336
+ ...(input.compare?.data.filter((v): v is number => v !== null) ?? []),
337
+ ...chartReferenceValues(input, "y"),
338
+ ];
339
+ case "hbar":
340
+ return input.rows.flatMap((r) => r.values.filter((v): v is number => v !== null));
341
+ case "funnel":
342
+ return input.data.flatMap((d) => (d.value === null ? [] : [d.value]));
343
+ case "treemap":
344
+ case "pareto":
256
345
  case "pie":
257
346
  case "donut":
258
347
  return input.data.map((item) => item.value);
@@ -276,7 +365,7 @@ const formattedValues = (input: ChartInput): number[] => {
276
365
  }
277
366
  };
278
367
  const formats = (input: ChartInput, locale: string): Formats => {
279
- const yFormat = "yAxis" in input ? numberFormat(input.yAxis?.format) : "format" in input ? input.format : undefined;
368
+ const yFormat = ("yAxis" in input ? numberFormat(input.yAxis?.format) : undefined) ?? ("format" in input ? input.format : undefined);
280
369
  const y = chartNumberFormatters(yFormat, locale, formattedValues(input));
281
370
  const xFormat = "xAxis" in input && input.kind !== "bar" ? input.xAxis?.format : undefined;
282
371
  const x = chartNumberFormatters(numberFormat(xFormat), locale);
@@ -323,6 +412,8 @@ export function chartLegend(
323
412
  input: ChartInput,
324
413
  locale: string,
325
414
  ): { label: string; color: number; style?: "solid" | "dashed" | "dotted"; reference?: boolean }[] {
415
+ // These category renderers own their live legends, including prepared snapshots.
416
+ if (input.kind === "hbar" || (input.kind === "bar" && input.series)) return [];
326
417
  if (input.kind === "bar")
327
418
  return input.colorByBar && input.legend ? input.data.map((entry, index) => ({ label: entry.label, color: index % 8 })) : [];
328
419
  if (input.kind === "area") return input.legend === false ? [] : input.series.map((s, i) => ({ label: s.label, color: i % 8 }));
@@ -354,7 +445,7 @@ export function chartRenderOptions(
354
445
  formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string };
355
446
  } = {},
356
447
  ): RenderOptions {
357
- if (input.kind === "heatmap" && input.layout === "calendar") locale = input.locale ?? locale;
448
+ if ("locale" in input && input.locale) locale = input.locale;
358
449
  const defaults = formats(input, locale);
359
450
  // Function formats must be measured with the same text the renderer will print.
360
451
  const format = {
@@ -367,6 +458,74 @@ export function chartRenderOptions(
367
458
  const width = options.width ?? WIDTH;
368
459
  const references = renderReferences(input);
369
460
  switch (input.kind) {
461
+ case "treemap":
462
+ return {
463
+ kind: "treemap",
464
+ legend: input.legend,
465
+ groups: input.groups,
466
+ data: input.data,
467
+ format: format.value,
468
+ locale: input.locale ?? locale,
469
+ displayWidth: options.width,
470
+ };
471
+ case "pareto":
472
+ return {
473
+ kind: "pareto",
474
+ labels: input.labels,
475
+ data: input.data,
476
+ format: format.value,
477
+ locale: input.locale ?? locale,
478
+ displayWidth: options.width,
479
+ };
480
+ case "bubble":
481
+ return {
482
+ kind: "bubble",
483
+ groups: input.groups,
484
+ data: input.data,
485
+ xLabel: input.xLabel,
486
+ yLabel: input.yLabel,
487
+ sizeLabel: input.sizeLabel,
488
+ averages: input.averages,
489
+ legend: input.legend,
490
+ locale,
491
+ displayWidth: options.width,
492
+ xFormat: options.formats?.x ?? (input.xFormat ? chartNumberFormatters(input.xFormat, locale).value : undefined),
493
+ yFormat: options.formats?.y ?? (input.yFormat ? chartNumberFormatters(input.yFormat, locale).value : undefined),
494
+ sizeFormat: format.value,
495
+ };
496
+ case "radar":
497
+ return {
498
+ kind: "radar",
499
+ axes: input.axes,
500
+ max: input.max,
501
+ unit: input.unit,
502
+ series: input.series,
503
+ legend: input.legend,
504
+ locale,
505
+ displayWidth: options.width,
506
+ format: format.value,
507
+ };
508
+ case "funnel":
509
+ return { kind: "funnel", data: input.data, valueFormat: format.value, locale };
510
+ case "hbar":
511
+ return {
512
+ kind: "hbar",
513
+ locale,
514
+ series: input.series,
515
+ rows: input.rows,
516
+ legend: input.legend,
517
+ valueFormat: format.value,
518
+ };
519
+ case "waterfall":
520
+ return {
521
+ kind: "waterfall",
522
+ start: input.start,
523
+ steps: input.steps,
524
+ end: input.end,
525
+ displayWidth: options.width ?? 296,
526
+ locale,
527
+ format: format.value,
528
+ };
370
529
  case "area":
371
530
  return {
372
531
  kind: "area",
@@ -386,14 +545,23 @@ export function chartRenderOptions(
386
545
  if (input.kind === "bar")
387
546
  return {
388
547
  kind: "bar",
548
+ locale,
389
549
  data: input.data,
550
+ x: input.x,
551
+ series: input.series,
552
+ layout: input.layout,
553
+ normalize: input.normalize,
554
+ share: input.share,
555
+ limit: input.limit,
556
+ tone: input.tone,
557
+ compare: input.compare,
390
558
  xAxis: input.xAxis,
391
559
  yAxis: axis(yAxis, options.formats?.y ?? format.tick),
392
560
  // A host's own function format writes printed values like its ticks and table cells.
393
561
  valueFormat: options.formats?.value ?? options.formats?.y ?? format.value,
394
562
  colorByBar: input.colorByBar,
395
563
  showValues: input.showValues,
396
- legend: options.legend === "outside" ? false : input.legend,
564
+ legend: input.series ? input.legend : options.legend === "outside" ? false : input.legend,
397
565
  references,
398
566
  ...(options.width === undefined ? {} : { displayWidth: options.width }),
399
567
  };
@@ -545,6 +713,20 @@ const decode = (value: string) =>
545
713
  export const drawChart = (options: RenderOptions): string => {
546
714
  const common = { width: WIDTH, height: HEIGHT, inspect: true };
547
715
  switch (options.kind) {
716
+ case "treemap":
717
+ return renderTreemapSvg({ ...options, ...common });
718
+ case "pareto":
719
+ return renderParetoSvg({ ...options, ...common });
720
+ case "bubble":
721
+ return renderBubbleSvg({ ...options, ...common });
722
+ case "radar":
723
+ return renderRadarSvg({ ...options, ...common });
724
+ case "funnel":
725
+ return renderFunnelSvg({ ...options, ...common });
726
+ case "hbar":
727
+ return renderHbarSvg({ ...options, ...common, height: hbarLayout(options).height });
728
+ case "waterfall":
729
+ return renderWaterfallSvg({ ...options, ...common });
548
730
  case "area":
549
731
  return renderAreaSvg({ ...options, ...common });
550
732
  case "bar":
@@ -594,7 +776,12 @@ export const chartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex"> &
594
776
  `${datum.role === "outlier" ? "outlier:" : datum.role === "value" ? "value:" : ""}${datum.seriesIndex ?? 0}:${datum.index}`;
595
777
 
596
778
  export type ChartColumn = { id: string; label: string; numeric: boolean };
597
- export type ChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
779
+ export type ChartRow = {
780
+ key: string;
781
+ cells: Record<string, string>;
782
+ values: Record<string, string | number>;
783
+ tooltip?: { title?: string; rows: { label: string; value: string }[] };
784
+ };
598
785
  export type ChartTable = { columns: ChartColumn[]; rows: ChartRow[] };
599
786
 
600
787
  /** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
@@ -603,6 +790,7 @@ export function chartTooltip(
603
790
  row: ChartRow,
604
791
  kind?: ChartInput["kind"],
605
792
  ): { title?: string; rows: { label: string; value: string }[] } {
793
+ if (row.tooltip) return row.tooltip;
606
794
  if (kind === "area")
607
795
  return {
608
796
  title: row.cells.x,
@@ -627,7 +815,7 @@ export function chartRows(
627
815
  locale: string,
628
816
  options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
629
817
  ) {
630
- if (input.kind === "heatmap" && input.layout === "calendar") locale = input.locale ?? locale;
818
+ if ("locale" in input && input.locale) locale = input.locale;
631
819
  const t = labels.resolve([locale]).t;
632
820
  const format = formats(input, locale);
633
821
  const valueFormat = input.tooltip?.format
@@ -644,12 +832,23 @@ export function chartRows(
644
832
  const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
645
833
  const columns = new Map<string, ChartColumn>();
646
834
  const rows: ChartRow[] = [];
835
+ if (input.kind === "waterfall") {
836
+ for (const [id, label, numeric] of [
837
+ ["label", t.category, false],
838
+ ["before", t.before, true],
839
+ ["inflow", t.inflow, true],
840
+ ["outflow", t.outflow, true],
841
+ ["after", t.after, true],
842
+ ] as const)
843
+ columns.set(id, { id, label, numeric });
844
+ }
647
845
  const named = series.some((entry) => entry.label);
648
846
  const add = (datum: ChartDatum): ChartRow => {
847
+ const tooltip = input.kind === "waterfall" ? { title: datum.label, rows: [] as { label: string; value: string }[] } : undefined;
649
848
  const cells: Record<string, string> = {};
650
849
  const values: Record<string, string | number> = {};
651
- const cell = (id: string, label: string, value: string | number, text: string) => {
652
- if (!columns.has(id)) columns.set(id, { id, label, numeric: typeof value === "number" });
850
+ const cell = (id: string, label: string, value: string | number, text: string, numeric = typeof value === "number") => {
851
+ if (!columns.has(id)) columns.set(id, { id, label, numeric });
653
852
  cells[id] = text;
654
853
  values[id] = value;
655
854
  };
@@ -663,13 +862,40 @@ export function chartRows(
663
862
  // Bars may hide some axis labels; the table names every category.
664
863
  const label =
665
864
  input.kind === "bar" || input.kind === "pie" || input.kind === "donut" || input.kind === "boxplot" || input.kind === "barGauge"
666
- ? input.data[datum.role === "outlier" ? (datum.seriesIndex ?? 0) : datum.index]?.label
865
+ ? ((input.kind === "bar" && input.series
866
+ ? undefined
867
+ : input.data[datum.role === "outlier" ? (datum.seriesIndex ?? 0) : datum.index]?.label) ?? datum.label)
667
868
  : datum.label;
668
869
  if (label)
669
- cell("label", input.kind === "heatmap" && input.layout === "calendar" ? calendarWords(locale).day : t.category, label, label);
870
+ cell(
871
+ "label",
872
+ input.kind === "heatmap" && input.layout === "calendar"
873
+ ? calendarWords(locale).day
874
+ : input.kind === "radar"
875
+ ? t.axis
876
+ : t.category,
877
+ label,
878
+ label,
879
+ );
670
880
  }
671
881
  for (const field of datum.values) {
672
882
  const value = field.value;
883
+ if (input.kind === "bubble") {
884
+ const label =
885
+ field.key === "x"
886
+ ? input.xLabel
887
+ : field.key === "y"
888
+ ? input.yLabel
889
+ : field.key === "size"
890
+ ? (input.sizeLabel ?? t.size)
891
+ : t.group;
892
+ cell(field.key, label, value, field.formatted ?? String(value));
893
+ continue;
894
+ }
895
+ if (input.kind === "radar" && field.key.startsWith("series:")) {
896
+ cell(field.key, input.series[Number(field.key.slice(7))]!.label, value, field.formatted ?? String(value));
897
+ continue;
898
+ }
673
899
  if (typeof value === "string" && input.kind === "area" && field.key === "x") {
674
900
  cell("x", t.position, value, field.formatted ?? value);
675
901
  continue;
@@ -678,8 +904,79 @@ export function chartRows(
678
904
  cell(field.key, field.key.toUpperCase(), value, value);
679
905
  continue;
680
906
  }
907
+ if (input.kind === "treemap" && field.key === "group" && typeof value === "string") {
908
+ cell("group", t.group, value, value);
909
+ continue;
910
+ }
911
+ if (
912
+ input.kind === "pareto" &&
913
+ (field.key === "previous" || field.key === "change" || field.key === "cumulative") &&
914
+ typeof value === "number"
915
+ ) {
916
+ cell(field.key, field.key === "previous" ? input.labels[1] : t[field.key], value, field.formatted ?? valueFormat(value));
917
+ continue;
918
+ }
919
+ if (input.kind === "hbar" || (input.kind === "bar" && input.series)) {
920
+ const seriesIndex = field.key.startsWith("series:") ? Number(field.key.slice(7)) : undefined;
921
+ const label =
922
+ seriesIndex === undefined
923
+ ? field.key === "previous" && input.kind === "bar"
924
+ ? (input.compare?.label ?? field.key)
925
+ : field.key === "total"
926
+ ? t.total
927
+ : field.key === "delta"
928
+ ? t.delta
929
+ : field.key
930
+ : input.kind === "hbar"
931
+ ? input.series[seriesIndex]!
932
+ : input.series![seriesIndex]!.label;
933
+ cell(field.key, label, value, field.formatted ?? String(value), true);
934
+ continue;
935
+ }
936
+ if (
937
+ field.key === "startPercent" ||
938
+ field.key === "previousPercent" ||
939
+ field.key === "rank" ||
940
+ ((input.kind === "bar" || input.kind === "funnel") && typeof value === "string")
941
+ ) {
942
+ cell(
943
+ field.key,
944
+ field.key === "startPercent"
945
+ ? t.startShare
946
+ : field.key === "previousPercent"
947
+ ? t.previousShare
948
+ : field.key === "rank"
949
+ ? t.rank
950
+ : field.key === "percent"
951
+ ? t.share
952
+ : t.value,
953
+ value,
954
+ field.formatted ?? String(value),
955
+ true,
956
+ );
957
+ continue;
958
+ }
681
959
  if (typeof value !== "number" || field.key === "total") continue;
682
- if (field.key === "x") {
960
+ if (input.kind === "waterfall" && ["balance", "before", "inflow", "outflow", "change", "after"].includes(field.key)) {
961
+ const label =
962
+ field.key === "balance"
963
+ ? t.balance
964
+ : field.key === "before"
965
+ ? t.before
966
+ : field.key === "inflow"
967
+ ? t.inflow
968
+ : field.key === "outflow"
969
+ ? t.outflow
970
+ : field.key === "after"
971
+ ? t.after
972
+ : t.delta;
973
+ const arrow = field.key === "inflow" ? "↙ " : field.key === "outflow" ? "↗ " : field.key === "change" ? "= " : "";
974
+ const text = `${arrow}${valueFormat(field.key === "inflow" || field.key === "outflow" ? Math.abs(value) : value)}`;
975
+ // Totals occupy the ending balance column; zero changes are neutral inflows.
976
+ const id = field.key === "balance" ? "after" : field.key === "change" ? "inflow" : field.key;
977
+ cell(id, label, value, text);
978
+ if (field.key !== "before") tooltip?.rows.push({ label, value: text });
979
+ } else if (field.key === "x") {
683
980
  const header =
684
981
  (input.kind === "line" || input.kind === "scatter" ? input.xAxis?.label : undefined) ??
685
982
  (input.kind === "area" ? t.position : "X");
@@ -703,11 +1000,13 @@ export function chartRows(
703
1000
  cell(
704
1001
  "value",
705
1002
  input.tooltip?.label ??
706
- (input.kind === "bar"
707
- ? input.yAxis?.label
708
- : input.kind === "heatmap" && input.layout === "calendar"
709
- ? input.label
710
- : undefined) ??
1003
+ (input.kind === "pareto"
1004
+ ? input.labels[0]
1005
+ : input.kind === "bar"
1006
+ ? input.yAxis?.label
1007
+ : input.kind === "heatmap" && input.layout === "calendar"
1008
+ ? input.label
1009
+ : undefined) ??
711
1010
  t.value,
712
1011
  value,
713
1012
  input.kind === "heatmap" && input.layout === "calendar"
@@ -728,11 +1027,41 @@ export function chartRows(
728
1027
  cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
729
1028
  }
730
1029
  }
731
- const row = { key: chartMarkKey(datum), cells, values };
1030
+ const row = { key: chartMarkKey(datum), cells, values, ...(tooltip ? { tooltip } : {}) };
732
1031
  rows.push(row);
733
1032
  return row;
734
1033
  };
735
- return { add, columns: () => [...columns.values()], table: (): ChartTable => ({ columns: [...columns.values()], rows }) };
1034
+ return {
1035
+ add,
1036
+ columns: () => [...columns.values()],
1037
+ table: (): ChartTable => {
1038
+ if (input.kind === "bar" && input.layout === "rows" && input.limit !== undefined) {
1039
+ const source = { ...input, limit: undefined };
1040
+ const all = chartRows(source, locale, options);
1041
+ const layout = rankedBarLayout({ data: source.data, title: source.title, subtitle: source.subtitle, valueFormat, locale });
1042
+ for (const row of layout.rows) {
1043
+ const value = Number.isFinite(row.value) ? row.value : null;
1044
+ all.add({
1045
+ role: "item",
1046
+ index: row.index,
1047
+ label: row.label,
1048
+ anchor: [0, row.y],
1049
+ values: [
1050
+ { key: "value", value: value ?? "–", formatted: value === null ? "–" : valueFormat(value) },
1051
+ {
1052
+ key: "percent",
1053
+ value: row.share === null ? "–" : row.share * 100,
1054
+ formatted: row.share === null ? "–" : percent.format(row.share),
1055
+ },
1056
+ { key: "rank", value: row.rank },
1057
+ ],
1058
+ });
1059
+ }
1060
+ return all.table();
1061
+ }
1062
+ return { columns: [...columns.values()], rows };
1063
+ },
1064
+ };
736
1065
  }
737
1066
 
738
1067
  /** The data behind every mark of the chart as a table: the accessible alternative and what Copy data copies. */
@@ -745,11 +1074,21 @@ export function chartTable(input: ChartInput, locale: string): ChartTable {
745
1074
  /** Input values count against the budget, including observations summarized into boxes or bins. */
746
1075
  export const chartMarks = (input: ChartInput): number => {
747
1076
  switch (input.kind) {
1077
+ case "pareto":
1078
+ return input.data.length * 3;
1079
+ case "radar":
1080
+ return input.axes.length * input.series.length;
1081
+ case "waterfall":
1082
+ return input.steps.length + 2;
748
1083
  case "area":
749
1084
  return input.series.reduce((sum, s) => sum + s.data.length, 0);
750
1085
  case "line":
751
1086
  case "scatter":
752
1087
  return input.series.reduce((sum, series) => sum + series.data.length, 0);
1088
+ case "bar":
1089
+ return input.series ? (input.x?.length ?? 0) * (input.series.length + (input.compare ? 1 : 0)) : input.data.length;
1090
+ case "hbar":
1091
+ return input.rows.length * input.series.length;
753
1092
  case "boxplot":
754
1093
  return input.data.reduce((sum, box) => sum + box.values.length, 0);
755
1094
  case "gauge":
@@ -1,6 +1,7 @@
1
1
  import { highlight, text } from "@k2b/stdlib";
2
2
  import { prepareChartSnapshot } from "@k2b/ui";
3
- import { barChartLayout } from "@k2b/ui/charts";
3
+ import { barChartLayout, hbarLayout } from "@k2b/ui/charts";
4
+ import { paretoLayout } from "@k2b/ui/charts/core";
4
5
  import { Lexer, type Tokens } from "marked";
5
6
  import type { z } from "zod";
6
7
  import {
@@ -457,8 +458,13 @@ export const chartBlockFrame = (body: string[], options: Pick<MarkdownChartOptio
457
458
  */
458
459
  export const notebookChartHeight = (input: ChartInput, locale: string): number | undefined => {
459
460
  const options = chartRenderOptions(input, locale, { legend: "outside" });
461
+ if (options.kind === "treemap") return 280;
462
+ if (options.kind === "pareto") return paretoLayout(options).height;
463
+
464
+ if (options.kind === "bubble" || options.kind === "radar") return 280;
465
+ if (options.kind === "hbar") return hbarLayout(options).height;
460
466
  if (options.kind === "heatmap" && options.layout === "calendar") return 280;
461
- if (options.kind === "area") return 280;
467
+ if (options.kind === "waterfall" || options.kind === "area") return 280;
462
468
  if (options.kind !== "bar") return undefined;
463
469
  const layout = barChartLayout(options);
464
470
  return layout.layout === "rows" ? layout.height : undefined;