@k2b/editor 0.6.5 → 0.8.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.6.5",
3
+ "version": "0.8.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.49.0",
73
+ "@k2b/ui": "0.51.0",
74
74
  "@lezer/common": "1.5.3",
75
75
  "@lezer/highlight": "1.2.4",
76
76
  "@lezer/markdown": "1.7.2",
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+
3
+ /** BCP 47 locale strings must be accepted by every Intl formatter used during rendering. */
4
+ export const chartLocaleSchema = () =>
5
+ z
6
+ .string()
7
+ .min(1)
8
+ .refine((value) => {
9
+ try {
10
+ return Intl.getCanonicalLocales(value).length === 1;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }, "Use a valid BCP 47 locale, such as de-DE.");
15
+
16
+ /** The portable renderers which honour an explicit locale. Keep one policy for single charts and groups. */
17
+ const localeKinds = new Set(["sankey", "treemap", "pareto", "bubble", "radar"]);
18
+ export const chartAcceptsLocale = (chart: { kind?: string; layout?: string }): boolean =>
19
+ chart.kind === "heatmap" ? chart.layout === "calendar" : localeKinds.has(chart.kind ?? "");
@@ -1,7 +1,10 @@
1
+ import type { ChartInput, ChartLimits } from "@k2b/ui/charts";
2
+ import { CALENDAR_MAX_DAYS, calendarDay } from "@k2b/ui/charts/core";
1
3
  import { z } from "zod";
4
+ import { chartLocaleSchema } from "./chart-locale";
2
5
 
3
- /** Optional caller policy. Rendering itself has no AI input budget. */
4
- 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";
5
8
 
6
9
  // Every chart schema is built by a function whose call is marked pure, so a bundle that imports neither chart contract
7
10
  // leaves them out. App frames include the app contract through `cloud.chart()`.
@@ -25,12 +28,25 @@ export const chartNumberFormatSchema = () =>
25
28
  });
26
29
  const dateFormatSchema = () => z.strictObject({ date: z.enum(["day", "minute", "second"]) });
27
30
  export const chartFormatSchema = () => z.union([chartNumberFormatSchema(), dateFormatSchema()]);
28
- export type ChartNumberFormat = z.output<ReturnType<typeof chartNumberFormatSchema>>;
29
- export type ChartDateFormat = z.output<ReturnType<typeof dateFormatSchema>>;
30
- export type ChartFormat = ChartNumberFormat | ChartDateFormat;
31
31
 
32
32
  export const chartKindSchema = () =>
33
- z.enum(["bar", "line", "scatter", "pie", "donut", "histogram", "gauge", "sparkline", "boxplot", "heatmap", "barGauge", "stat"]);
33
+ z.enum([
34
+ "waterfall",
35
+ "area",
36
+ "bar",
37
+ "hbar",
38
+ "line",
39
+ "scatter",
40
+ "pie",
41
+ "donut",
42
+ "histogram",
43
+ "gauge",
44
+ "sparkline",
45
+ "boxplot",
46
+ "heatmap",
47
+ "barGauge",
48
+ "stat",
49
+ ]);
34
50
 
35
51
  export const chartParts = (limits?: ChartLimits) => {
36
52
  const ChartLabel = limits ? z.string().trim().min(1).max(80) : z.union([z.string(), z.number().transform(String)]);
@@ -60,7 +76,13 @@ export const chartParts = (limits?: ChartLimits) => {
60
76
  });
61
77
  // As many lines as series a reader can tell apart.
62
78
  const ChartReferences = list(
63
- z.object({ value: z.union([z.number(), ChartDate]), axis: z.enum(["x", "y"]).optional(), label: ChartLabel.optional() }),
79
+ z.object({
80
+ value: z.union([z.number(), ChartDate]),
81
+ axis: z.enum(["x", "y"]).optional(),
82
+ label: ChartLabel.optional(),
83
+ style: z.enum(["solid", "dashed", "dotted"]).optional(),
84
+ tone: z.enum(["neutral", "info", "success", "warning", "danger"]).optional(),
85
+ }),
64
86
  0,
65
87
  limits?.maxSeries,
66
88
  `Use at most ${limits?.maxSeries} references.`,
@@ -142,17 +164,107 @@ export const chartKinds = (limits?: ChartLimits) => {
142
164
  const ChartKindCommon = { ...ChartCommon, tooltip: ChartTooltip.optional() };
143
165
  const numberFormat = chartNumberFormatSchema();
144
166
  return z.discriminatedUnion("kind", [
145
- z.object({
146
- kind: z.literal("bar"),
147
- ...ChartKindCommon,
148
- data: list(ChartCategory, 1, limits?.maxValues),
149
- xAxis: ChartAxis.pick({ label: true }).strict().optional(),
150
- yAxis: ChartAxis.optional(),
151
- colorByBar: z.boolean().optional(),
152
- showValues: z.boolean().optional(),
153
- legend: z.boolean().optional(),
154
- references: ChartReferences.optional(),
155
- }),
167
+ z
168
+ .object({
169
+ kind: z.literal("waterfall"),
170
+ ...ChartKindCommon,
171
+ start: z.object({ label: ChartLabel, value: z.number() }),
172
+ steps: list(z.object({ label: ChartLabel, value: z.number() }), 0, limits?.maxValues),
173
+ end: z.strictObject({ label: ChartLabel }),
174
+ format: numberFormat.optional(),
175
+ })
176
+ .superRefine((spec, ctx) => {
177
+ let balance = spec.start.value;
178
+ let lo = Math.min(0, balance),
179
+ hi = Math.max(0, balance);
180
+ for (const step of spec.steps) {
181
+ balance += step.value;
182
+ lo = Math.min(lo, balance);
183
+ hi = Math.max(hi, balance);
184
+ }
185
+ if (!Number.isFinite(balance) || !Number.isFinite(hi - lo))
186
+ ctx.addIssue({ code: "custom", message: "Waterfall balances must form a finite range." });
187
+ }),
188
+ z
189
+ .object({
190
+ kind: z.literal("area"),
191
+ ...ChartKindCommon,
192
+ layout: z.literal("stacked"),
193
+ normalize: z.boolean().optional(),
194
+ x: list(z.union([z.number(), ChartLabel]), 1, limits?.maxValues),
195
+ series: list(z.object({ label: ChartLabel, data: list(z.number().min(0).nullable(), 0, limits?.maxValues) }), 1, limits?.maxSeries),
196
+ legend: z.boolean().optional(),
197
+ format: numberFormat.optional(),
198
+ })
199
+ .superRefine((spec, ctx) => {
200
+ spec.series.forEach((s, i) => {
201
+ if (s.data.length !== spec.x.length)
202
+ ctx.addIssue({ code: "custom", path: ["series", i, "data"], message: "Area series must match x length." });
203
+ });
204
+ spec.x.forEach((_, i) => {
205
+ const sum = spec.series.reduce((sum, s) => sum + (s.data[i] ?? 0), 0);
206
+ if (!Number.isFinite(sum)) ctx.addIssue({ code: "custom", path: ["x", i], message: "Area totals must be finite." });
207
+ });
208
+ }),
209
+ z
210
+ .object({
211
+ kind: z.literal("bar"),
212
+ ...ChartKindCommon,
213
+ data: list(ChartCategory, 0, limits?.maxValues).default([]),
214
+ x: list(ChartLabel, 0, limits?.maxValues).optional(),
215
+ series: list(
216
+ z.object({ label: ChartLabel, data: list(ChartNumber.nullable(), 0, limits?.maxValues) }),
217
+ 1,
218
+ limits?.maxSeries,
219
+ ).optional(),
220
+ layout: z.enum(["columns", "rows", "grouped", "stacked"]).optional(),
221
+ normalize: z.boolean().optional(),
222
+ compare: z.object({ label: ChartLabel, data: list(ChartNumber.nullable(), 0, limits?.maxValues) }).optional(),
223
+ xAxis: ChartAxis.pick({ label: true }).extend({ ordered: z.boolean().optional() }).strict().optional(),
224
+ yAxis: ChartAxis.optional(),
225
+ colorByBar: z.boolean().optional(),
226
+ showValues: z.boolean().optional(),
227
+ legend: z.boolean().optional(),
228
+ references: ChartReferences.optional(),
229
+ })
230
+ .superRefine((bar, ctx) => {
231
+ const issue = (message: string) => ctx.addIssue({ code: "custom", message });
232
+ if (limits && !bar.series && !bar.data.length)
233
+ ctx.addIssue({ code: "custom", path: ["data"], message: "A bar chart needs data or aligned series." });
234
+ if (bar.series) {
235
+ if (!bar.x || bar.series.some((s) => s.data.length !== bar.x?.length)) issue("Bar series must have the same length as x.");
236
+ if (bar.data.length) issue("Use data or aligned series, not both.");
237
+ if (bar.layout === "rows" || bar.layout === "columns") issue("Series bars use grouped or stacked layout.");
238
+ } else if (bar.x || bar.compare || bar.layout === "grouped" || bar.layout === "stacked")
239
+ issue("This layout needs aligned x and series.");
240
+ if (bar.layout === "stacked" && bar.yAxis?.scale === "log") issue("Stacked bars need a linear axis.");
241
+ if (bar.normalize && bar.layout !== "stacked") issue("Normalized bars must be stacked.");
242
+ if (bar.layout === "stacked" && bar.series?.some((s) => s.data.some((v) => v !== null && v < 0)))
243
+ issue("Stacked bars require nonnegative values.");
244
+ if (bar.compare && (bar.series?.length !== 1 || bar.compare.data.length !== bar.x?.length || bar.layout === "stacked"))
245
+ issue("Comparison needs one current series and aligned previous values.");
246
+ }),
247
+ z
248
+ .object({
249
+ kind: z.literal("hbar"),
250
+ ...ChartKindCommon,
251
+ series: list(ChartLabel, 1, limits?.maxSeries),
252
+ rows: list(
253
+ z.object({
254
+ group: ChartLabel.optional(),
255
+ label: ChartLabel,
256
+ values: list(z.number().nonnegative().nullable(), 0, limits?.maxSeries),
257
+ }),
258
+ 1,
259
+ limits?.maxValues,
260
+ ),
261
+ legend: z.boolean().optional(),
262
+ format: numberFormat.optional(),
263
+ })
264
+ .superRefine((bar, ctx) => {
265
+ if (bar.rows.some((r) => r.values.length !== bar.series.length))
266
+ ctx.addIssue({ code: "custom", message: "Horizontal bar values must match series." });
267
+ }),
156
268
  z.object({
157
269
  kind: z.literal("line"),
158
270
  ...ChartKindCommon,
@@ -220,15 +332,51 @@ export const chartKinds = (limits?: ChartLimits) => {
220
332
  yAxis: ChartAxis.optional(),
221
333
  references: ChartReferences.optional(),
222
334
  }),
223
- z.object({
224
- kind: z.literal("heatmap"),
225
- ...ChartKindCommon,
226
- data: list(ChartCell, 1, limits?.maxMarks),
227
- format: numberFormat.optional(),
228
- showValues: z.boolean().optional(),
229
- min: z.number().optional(),
230
- max: z.number().optional(),
231
- }),
335
+ z.discriminatedUnion("layout", [
336
+ z
337
+ .object({
338
+ kind: z.literal("heatmap"),
339
+ ...ChartKindCommon,
340
+ layout: z.literal("calendar"),
341
+ data: list(
342
+ z.tuple([
343
+ z.string().refine((day) => {
344
+ try {
345
+ calendarDay(day);
346
+ return true;
347
+ } catch {
348
+ return false;
349
+ }
350
+ }, "Invalid ISO calendar day."),
351
+ ChartNumber.nullable(),
352
+ ]),
353
+ 1,
354
+ limits?.maxMarks,
355
+ ),
356
+ scale: z.enum(["sequential", "diverging"]),
357
+ label: ChartLabel.optional(),
358
+ format: numberFormat.optional(),
359
+ locale: chartLocaleSchema().optional(),
360
+ })
361
+ .superRefine((spec, ctx) => {
362
+ if (ctx.issues.length) return;
363
+ const times = spec.data.map(([day]) => calendarDay(day));
364
+ if (times.length && (Math.max(...times) - Math.min(...times)) / 86400000 >= CALENDAR_MAX_DAYS)
365
+ ctx.addIssue({ code: "custom", message: "Calendar span must not exceed three years." });
366
+ if (new Set(spec.data.map(([day]) => day)).size !== spec.data.length)
367
+ ctx.addIssue({ code: "custom", message: "Calendar dates must be unique." });
368
+ }),
369
+ z.object({
370
+ kind: z.literal("heatmap"),
371
+ ...ChartKindCommon,
372
+ layout: z.undefined().optional(),
373
+ data: list(ChartCell, 1, limits?.maxMarks),
374
+ format: numberFormat.optional(),
375
+ showValues: z.boolean().optional(),
376
+ min: z.number().optional(),
377
+ max: z.number().optional(),
378
+ }),
379
+ ]),
232
380
  z.object({
233
381
  kind: z.literal("barGauge"),
234
382
  ...ChartKindCommon,
@@ -270,5 +418,10 @@ export const chartDateTime = (value: string): number | undefined => {
270
418
  return date.toISOString().startsWith(`${year}-${month}-${day}T${hour}:${minute}:${second}`) ? date.getTime() : undefined;
271
419
  };
272
420
 
273
- /** One chart as the shared renderers read it. */
274
- export type ChartInput = z.output<ReturnType<typeof chartKinds>>;
421
+ // Both directions are checked: a validator change must update the ui-owned description too.
422
+ type Assert<T extends true> = T;
423
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
424
+ type _ChartSchemaMatchesDescription = Assert<Same<z.output<ReturnType<typeof chartKinds>>, ChartInput>>;
425
+ type _ChartNumberFormatSchemaMatchesDescription = Assert<
426
+ Same<z.output<ReturnType<typeof chartNumberFormatSchema>>, import("@k2b/ui/charts").ChartNumberFormat>
427
+ >;
package/src/charts.ts CHANGED
@@ -1,35 +1,56 @@
1
1
  // Named renderers, not the `charts` namespace, so bundles leave out the map and state timeline renderers.
2
+ import { i18n } from "@k2b/stdlib";
2
3
  import {
4
+ type AreaOptions,
3
5
  autoBin,
6
+ type BarChartOptions,
7
+ barChartLayout,
4
8
  barGauge,
5
9
  boxplot,
6
10
  type ChartDatum,
11
+ calendarWords,
7
12
  type charts,
8
13
  computeDomain,
9
14
  donut,
10
15
  extendDomainToNice,
11
16
  gauge,
12
- heatmap,
17
+ type HeatmapChartOptions,
18
+ type HorizontalBarOptions,
19
+ hbarLayout,
13
20
  histogram,
14
- i18n,
15
21
  line,
16
22
  niceLogTicks,
17
23
  niceStep,
18
24
  pie,
25
+ renderAreaSvg,
26
+ renderBarChartSvg,
27
+ renderHbarSvg,
28
+ renderHeatmapSvg,
29
+ renderWaterfallSvg,
19
30
  scatter,
20
31
  sparkline,
21
32
  stat,
22
- } from "@k2b/stdlib";
23
- import { type BarChartOptions, barChartLayout, renderBarChartSvg } from "@k2b/ui/charts";
33
+ type WaterfallOptions,
34
+ } from "@k2b/ui/charts/core";
24
35
  import { z } from "zod";
25
36
  import { type ChartFormat, type ChartInput, type ChartNumberFormat, chartDateTime, chartKinds } from "./chart-schema";
26
37
 
27
38
  export * from "./chart-schema";
28
39
 
29
40
  type RenderOptions = {
30
- [K in ChartInput["kind"]]: { kind: K } & (K extends "bar"
31
- ? BarChartOptions
32
- : Omit<Parameters<(typeof charts)[K]>[0], "width" | "height" | "inspect">);
41
+ [K in ChartInput["kind"]]: { kind: K } & (K extends "hbar"
42
+ ? HorizontalBarOptions
43
+ : K extends "waterfall"
44
+ ? WaterfallOptions
45
+ : K extends "area"
46
+ ? AreaOptions
47
+ : K extends "heatmap"
48
+ ? HeatmapChartOptions
49
+ : K extends "bar"
50
+ ? BarChartOptions & { data: NonNullable<BarChartOptions["data"]> }
51
+ : K extends keyof typeof charts
52
+ ? Omit<Parameters<(typeof charts)[K]>[0], "width" | "height" | "inspect">
53
+ : never);
33
54
  }[ChartInput["kind"]];
34
55
 
35
56
  /**
@@ -45,8 +66,15 @@ const labels = /* @__PURE__ */ i18n.define({
45
66
  en: {
46
67
  category: "Category",
47
68
  series: "Series",
69
+ balance: "Balance",
70
+ before: "Balance before",
71
+ inflow: "In",
72
+ outflow: "Out",
73
+ after: "Balance after",
48
74
  value: "Value",
75
+ total: "Total",
49
76
  share: "Share",
77
+ position: "Position",
50
78
  from: "From",
51
79
  to: "To",
52
80
  count: "Count",
@@ -62,8 +90,15 @@ const labels = /* @__PURE__ */ i18n.define({
62
90
  de: {
63
91
  category: "Kategorie",
64
92
  series: "Datenreihe",
93
+ balance: "Stand",
94
+ before: "Stand davor",
95
+ inflow: "Rein",
96
+ outflow: "Raus",
97
+ after: "Stand danach",
65
98
  value: "Wert",
99
+ total: "Gesamt",
66
100
  share: "Anteil",
101
+ position: "Position",
67
102
  from: "Von",
68
103
  to: "Bis",
69
104
  count: "Anzahl",
@@ -123,7 +158,19 @@ export const chartReferenceValues = (input: ChartInput, name: "x" | "y") =>
123
158
 
124
159
  /** The value axes of a chart with what they show: the data checks and the label room read them alike. */
125
160
  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" }];
161
+ if (input.kind === "bar")
162
+ return [
163
+ {
164
+ values: [
165
+ ...input.data.map((item) => item.value),
166
+ ...(input.series?.flatMap((s) => s.data.filter((v): v is number => v !== null)) ?? []),
167
+ ...(input.compare?.data.filter((v): v is number => v !== null) ?? []),
168
+ ],
169
+ axis: input.yAxis,
170
+ zero: true,
171
+ name: "y",
172
+ },
173
+ ];
127
174
  if (input.kind === "boxplot") return [{ values: input.data.flatMap((item) => item.values), axis: input.yAxis, name: "y" }];
128
175
  if (input.kind === "histogram") {
129
176
  if (!input.references?.length) return [{ values: input.data, name: "x" }];
@@ -232,15 +279,29 @@ const numberFormat = (format: ChartFormat | undefined) => (format && !("date" in
232
279
  /** The values a chart writes with its value format, for whole-number currency. */
233
280
  const formattedValues = (input: ChartInput): number[] => {
234
281
  switch (input.kind) {
282
+ case "waterfall":
283
+ return [input.start.value, ...input.steps.map((step) => step.value)];
284
+ case "area":
285
+ return input.series.flatMap((s) => s.data.filter((v): v is number => v !== null));
235
286
  case "line":
236
287
  case "scatter":
237
288
  return [...input.series.flatMap((series) => series.data.map((point) => point.y)), ...chartReferenceValues(input, "y")];
238
289
  case "bar":
239
- return [...input.data.map((item) => item.value), ...chartReferenceValues(input, "y")];
290
+ return [
291
+ ...input.data.map((item) => item.value),
292
+ ...(input.series?.flatMap((s) => s.data.filter((v): v is number => v !== null)) ?? []),
293
+ ...(input.compare?.data.filter((v): v is number => v !== null) ?? []),
294
+ ...chartReferenceValues(input, "y"),
295
+ ];
296
+ case "hbar":
297
+ return input.rows.flatMap((r) => r.values.filter((v): v is number => v !== null));
240
298
  case "pie":
241
299
  case "donut":
242
- case "heatmap":
243
300
  return input.data.map((item) => item.value);
301
+ case "heatmap":
302
+ return input.layout === "calendar"
303
+ ? input.data.flatMap((item) => (item[1] === null ? [] : [item[1]]))
304
+ : input.data.map((item) => item.value);
244
305
  case "barGauge":
245
306
  return [...input.data.flatMap((item) => [item.value, item.min, item.max]), input.min, input.max].filter(
246
307
  (value) => value !== undefined,
@@ -304,8 +365,11 @@ export function chartLegend(
304
365
  input: ChartInput,
305
366
  locale: string,
306
367
  ): { label: string; color: number; style?: "solid" | "dashed" | "dotted"; reference?: boolean }[] {
368
+ // These category renderers own their live legends, including prepared snapshots.
369
+ if (input.kind === "hbar" || (input.kind === "bar" && input.series)) return [];
307
370
  if (input.kind === "bar")
308
371
  return input.colorByBar && input.legend ? input.data.map((entry, index) => ({ label: entry.label, color: index % 8 })) : [];
372
+ if (input.kind === "area") return input.legend === false ? [] : input.series.map((s, i) => ({ label: s.label, color: i % 8 }));
309
373
  if (input.kind !== "line" && input.kind !== "scatter") return [];
310
374
  if (!(input.legend ?? input.series.length > 1)) return [];
311
375
  const t = labels.resolve([locale]).t;
@@ -334,6 +398,7 @@ export function chartRenderOptions(
334
398
  formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string };
335
399
  } = {},
336
400
  ): RenderOptions {
401
+ if (input.kind === "heatmap" && input.layout === "calendar") locale = input.locale ?? locale;
337
402
  const defaults = formats(input, locale);
338
403
  // Function formats must be measured with the same text the renderer will print.
339
404
  const format = {
@@ -346,6 +411,37 @@ export function chartRenderOptions(
346
411
  const width = options.width ?? WIDTH;
347
412
  const references = renderReferences(input);
348
413
  switch (input.kind) {
414
+ case "hbar":
415
+ return {
416
+ kind: "hbar",
417
+ locale,
418
+ series: input.series,
419
+ rows: input.rows,
420
+ legend: input.legend,
421
+ valueFormat: format.value,
422
+ };
423
+ case "waterfall":
424
+ return {
425
+ kind: "waterfall",
426
+ start: input.start,
427
+ steps: input.steps,
428
+ end: input.end,
429
+ displayWidth: options.width ?? 296,
430
+ locale,
431
+ format: format.value,
432
+ };
433
+ case "area":
434
+ return {
435
+ kind: "area",
436
+ layout: input.layout,
437
+ normalize: input.normalize,
438
+ x: input.x,
439
+ series: input.series,
440
+ displayWidth: options.width,
441
+ locale,
442
+ format: format.value,
443
+ legend: options.legend === "outside" ? false : input.legend,
444
+ };
349
445
  case "bar":
350
446
  case "boxplot": {
351
447
  const yAxis = referencedAxis(input, "y");
@@ -353,14 +449,20 @@ export function chartRenderOptions(
353
449
  if (input.kind === "bar")
354
450
  return {
355
451
  kind: "bar",
452
+ locale,
356
453
  data: input.data,
454
+ x: input.x,
455
+ series: input.series,
456
+ layout: input.layout,
457
+ normalize: input.normalize,
458
+ compare: input.compare,
357
459
  xAxis: input.xAxis,
358
460
  yAxis: axis(yAxis, options.formats?.y ?? format.tick),
359
461
  // A host's own function format writes printed values like its ticks and table cells.
360
462
  valueFormat: options.formats?.value ?? options.formats?.y ?? format.value,
361
463
  colorByBar: input.colorByBar,
362
464
  showValues: input.showValues,
363
- legend: options.legend === "outside" ? false : input.legend,
465
+ legend: input.series ? input.legend : options.legend === "outside" ? false : input.legend,
364
466
  references,
365
467
  ...(options.width === undefined ? {} : { displayWidth: options.width }),
366
468
  };
@@ -458,6 +560,17 @@ export function chartRenderOptions(
458
560
  thresholds: input.thresholds,
459
561
  };
460
562
  case "heatmap":
563
+ if (input.layout === "calendar")
564
+ return {
565
+ kind: "heatmap",
566
+ layout: "calendar",
567
+ data: input.data,
568
+ scale: input.scale,
569
+ label: input.label,
570
+ locale: input.locale ?? locale,
571
+ format: format.value,
572
+ displayWidth: options.width,
573
+ };
461
574
  return {
462
575
  kind: "heatmap",
463
576
  data: input.data,
@@ -501,6 +614,12 @@ const decode = (value: string) =>
501
614
  export const drawChart = (options: RenderOptions): string => {
502
615
  const common = { width: WIDTH, height: HEIGHT, inspect: true };
503
616
  switch (options.kind) {
617
+ case "hbar":
618
+ return renderHbarSvg({ ...options, ...common, height: hbarLayout(options).height });
619
+ case "waterfall":
620
+ return renderWaterfallSvg({ ...options, ...common });
621
+ case "area":
622
+ return renderAreaSvg({ ...options, ...common });
504
623
  case "bar":
505
624
  return renderBarChartSvg({ ...options, ...common, height: barChartLayout(options).height });
506
625
  case "line":
@@ -522,7 +641,7 @@ export const drawChart = (options: RenderOptions): string => {
522
641
  case "stat":
523
642
  return stat({ ...options, ...common });
524
643
  case "heatmap":
525
- return heatmap({ ...options, ...common });
644
+ return renderHeatmapSvg({ ...options, ...common });
526
645
  case "sparkline":
527
646
  return sparkline({ ...options, ...common });
528
647
  }
@@ -548,11 +667,26 @@ export const chartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex"> &
548
667
  `${datum.role === "outlier" ? "outlier:" : datum.role === "value" ? "value:" : ""}${datum.seriesIndex ?? 0}:${datum.index}`;
549
668
 
550
669
  export type ChartColumn = { id: string; label: string; numeric: boolean };
551
- export type ChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
670
+ export type ChartRow = {
671
+ key: string;
672
+ cells: Record<string, string>;
673
+ values: Record<string, string | number>;
674
+ tooltip?: { title?: string; rows: { label: string; value: string }[] };
675
+ };
552
676
  export type ChartTable = { columns: ChartColumn[]; rows: ChartRow[] };
553
677
 
554
678
  /** 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 }[] } {
679
+ export function chartTooltip(
680
+ columns: ChartColumn[],
681
+ row: ChartRow,
682
+ kind?: ChartInput["kind"],
683
+ ): { title?: string; rows: { label: string; value: string }[] } {
684
+ if (row.tooltip) return row.tooltip;
685
+ if (kind === "area")
686
+ return {
687
+ title: row.cells.x,
688
+ rows: [{ label: row.cells.series ?? "", value: `${row.cells.percent ? `${row.cells.percent} · ` : ""}${row.cells.value ?? "—"}` }],
689
+ };
556
690
  const [first, ...rest] = columns;
557
691
  const named = first && !first.numeric;
558
692
  return {
@@ -572,6 +706,7 @@ export function chartRows(
572
706
  locale: string,
573
707
  options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
574
708
  ) {
709
+ if (input.kind === "heatmap" && input.layout === "calendar") locale = input.locale ?? locale;
575
710
  const t = labels.resolve([locale]).t;
576
711
  const format = formats(input, locale);
577
712
  const valueFormat = input.tooltip?.format
@@ -580,12 +715,27 @@ export function chartRows(
580
715
  const xFormat = options.formats?.x ?? format.x.value;
581
716
  const series = chartSeries(input);
582
717
  const countFormat = (input.kind === "histogram" ? options.formats?.y : undefined) ?? chartNumberFormatters(undefined, locale).value;
583
- const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
718
+ const percent = new Intl.NumberFormat(locale, {
719
+ style: "percent",
720
+ minimumFractionDigits: input.kind === "area" ? 1 : 0,
721
+ maximumFractionDigits: 1,
722
+ });
584
723
  const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
585
724
  const columns = new Map<string, ChartColumn>();
586
725
  const rows: ChartRow[] = [];
726
+ if (input.kind === "waterfall") {
727
+ for (const [id, label, numeric] of [
728
+ ["label", t.category, false],
729
+ ["before", t.before, true],
730
+ ["inflow", t.inflow, true],
731
+ ["outflow", t.outflow, true],
732
+ ["after", t.after, true],
733
+ ] as const)
734
+ columns.set(id, { id, label, numeric });
735
+ }
587
736
  const named = series.some((entry) => entry.label);
588
737
  const add = (datum: ChartDatum): ChartRow => {
738
+ const tooltip = input.kind === "waterfall" ? { title: datum.label, rows: [] as { label: string; value: string }[] } : undefined;
589
739
  const cells: Record<string, string> = {};
590
740
  const values: Record<string, string | number> = {};
591
741
  const cell = (id: string, label: string, value: string | number, text: string) => {
@@ -593,33 +743,83 @@ export function chartRows(
593
743
  cells[id] = text;
594
744
  values[id] = value;
595
745
  };
746
+ if (input.kind === "area") cell("series", t.series, datum.label ?? "", datum.label ?? "");
596
747
  if (input.kind === "line" || input.kind === "scatter") {
597
748
  if (named || series.length > 1) {
598
749
  const name = series[datum.seriesIndex ?? 0]?.label ?? `${t.series} ${(datum.seriesIndex ?? 0) + 1}`;
599
750
  cell("series", t.series, name, name);
600
751
  }
601
- } else {
752
+ } else if (input.kind !== "area") {
602
753
  // Bars may hide some axis labels; the table names every category.
603
754
  const label =
604
755
  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
756
+ ? ((input.kind === "bar" && input.series
757
+ ? undefined
758
+ : input.data[datum.role === "outlier" ? (datum.seriesIndex ?? 0) : datum.index]?.label) ?? datum.label)
606
759
  : datum.label;
607
- if (label) cell("label", t.category, label, label);
760
+ if (label)
761
+ cell("label", input.kind === "heatmap" && input.layout === "calendar" ? calendarWords(locale).day : t.category, label, label);
608
762
  }
609
763
  for (const field of datum.values) {
610
764
  const value = field.value;
765
+ if (typeof value === "string" && input.kind === "area" && field.key === "x") {
766
+ cell("x", t.position, value, field.formatted ?? value);
767
+ continue;
768
+ }
611
769
  if (typeof value === "string" && input.kind === "heatmap" && (field.key === "x" || field.key === "y")) {
612
770
  cell(field.key, field.key.toUpperCase(), value, value);
613
771
  continue;
614
772
  }
773
+ if (input.kind === "hbar" || (input.kind === "bar" && input.series)) {
774
+ const seriesIndex = field.key.startsWith("series:") ? Number(field.key.slice(7)) : undefined;
775
+ const label =
776
+ seriesIndex === undefined
777
+ ? field.key === "previous" && input.kind === "bar"
778
+ ? (input.compare?.label ?? field.key)
779
+ : field.key === "total"
780
+ ? t.total
781
+ : field.key === "delta"
782
+ ? t.delta
783
+ : field.key
784
+ : input.kind === "hbar"
785
+ ? input.series[seriesIndex]!
786
+ : input.series![seriesIndex]!.label;
787
+ cell(field.key, label, value, field.formatted ?? String(value));
788
+ continue;
789
+ }
615
790
  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";
791
+ if (input.kind === "waterfall" && ["balance", "before", "inflow", "outflow", "change", "after"].includes(field.key)) {
792
+ const label =
793
+ field.key === "balance"
794
+ ? t.balance
795
+ : field.key === "before"
796
+ ? t.before
797
+ : field.key === "inflow"
798
+ ? t.inflow
799
+ : field.key === "outflow"
800
+ ? t.outflow
801
+ : field.key === "after"
802
+ ? t.after
803
+ : t.delta;
804
+ const arrow = field.key === "inflow" ? "↙ " : field.key === "outflow" ? "↗ " : field.key === "change" ? "= " : "";
805
+ const text = `${arrow}${valueFormat(field.key === "inflow" || field.key === "outflow" ? Math.abs(value) : value)}`;
806
+ // Totals occupy the ending balance column; zero changes are neutral inflows.
807
+ const id = field.key === "balance" ? "after" : field.key === "change" ? "inflow" : field.key;
808
+ cell(id, label, value, text);
809
+ if (field.key !== "before") tooltip?.rows.push({ label, value: text });
810
+ } else if (field.key === "x") {
811
+ const header =
812
+ (input.kind === "line" || input.kind === "scatter" ? input.xAxis?.label : undefined) ??
813
+ (input.kind === "area" ? t.position : "X");
618
814
  cell(
619
815
  "x",
620
816
  input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
621
817
  value,
622
- input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : xFormat(value),
818
+ input.kind === "sparkline" || input.kind === "stat"
819
+ ? String(value + 1)
820
+ : input.kind === "area"
821
+ ? (field.formatted ?? String(value))
822
+ : xFormat(value),
623
823
  );
624
824
  } else if (field.key === "y") {
625
825
  const header =
@@ -630,9 +830,17 @@ export function chartRows(
630
830
  } else if (field.key === "value")
631
831
  cell(
632
832
  "value",
633
- input.tooltip?.label ?? (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value,
833
+ input.tooltip?.label ??
834
+ (input.kind === "bar"
835
+ ? input.yAxis?.label
836
+ : input.kind === "heatmap" && input.layout === "calendar"
837
+ ? input.label
838
+ : undefined) ??
839
+ t.value,
634
840
  value,
635
- `${valueFormat(value)}${unit}`,
841
+ input.kind === "heatmap" && input.layout === "calendar"
842
+ ? (field.formatted ?? valueFormat(value))
843
+ : `${valueFormat(value)}${unit}`,
636
844
  );
637
845
  else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${valueFormat(value)}`);
638
846
  else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
@@ -648,7 +856,7 @@ export function chartRows(
648
856
  cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
649
857
  }
650
858
  }
651
- const row = { key: chartMarkKey(datum), cells, values };
859
+ const row = { key: chartMarkKey(datum), cells, values, ...(tooltip ? { tooltip } : {}) };
652
860
  rows.push(row);
653
861
  return row;
654
862
  };
@@ -665,9 +873,17 @@ export function chartTable(input: ChartInput, locale: string): ChartTable {
665
873
  /** Input values count against the budget, including observations summarized into boxes or bins. */
666
874
  export const chartMarks = (input: ChartInput): number => {
667
875
  switch (input.kind) {
876
+ case "waterfall":
877
+ return input.steps.length + 2;
878
+ case "area":
879
+ return input.series.reduce((sum, s) => sum + s.data.length, 0);
668
880
  case "line":
669
881
  case "scatter":
670
882
  return input.series.reduce((sum, series) => sum + series.data.length, 0);
883
+ case "bar":
884
+ return input.series ? (input.x?.length ?? 0) * (input.series.length + (input.compare ? 1 : 0)) : input.data.length;
885
+ case "hbar":
886
+ return input.rows.length * input.series.length;
671
887
  case "boxplot":
672
888
  return input.data.reduce((sum, box) => sum + box.values.length, 0);
673
889
  case "gauge":
@@ -1,6 +1,6 @@
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
4
  import { Lexer, type Tokens } from "marked";
5
5
  import type { z } from "zod";
6
6
  import {
@@ -457,6 +457,9 @@ export const chartBlockFrame = (body: string[], options: Pick<MarkdownChartOptio
457
457
  */
458
458
  export const notebookChartHeight = (input: ChartInput, locale: string): number | undefined => {
459
459
  const options = chartRenderOptions(input, locale, { legend: "outside" });
460
+ if (options.kind === "hbar") return hbarLayout(options).height;
461
+ if (options.kind === "heatmap" && options.layout === "calendar") return 280;
462
+ if (options.kind === "waterfall" || options.kind === "area") return 280;
460
463
  if (options.kind !== "bar") return undefined;
461
464
  const layout = barChartLayout(options);
462
465
  return layout.layout === "rows" ? layout.height : undefined;
@@ -487,17 +490,31 @@ export const renderNotebookChartFigure = (input: ChartInput, locale: string, opt
487
490
  key: ({ datum }) => chartMarkKey(datum),
488
491
  tooltip: ({ datum }) => {
489
492
  const row = rows.add(datum);
490
- return chartTooltip(rows.columns(), row);
493
+ return chartTooltip(rows.columns(), row, input.kind);
491
494
  },
492
495
  reference: ({ datum }) => chartReferenceMark(input, datum),
493
496
  });
494
497
  if (!snapshot.marks.length) return null;
495
498
  const legend = chartLegend(input, locale);
496
499
  const t = chartBlockMessages.resolve([locale]).t;
500
+ const valueField = input.kind === "area" ? (input.normalize ? "percent" : "value") : "y";
501
+ let lastX = -Infinity;
502
+ for (const mark of snapshot.marks) lastX = Math.max(lastX, mark.datum.anchor[0]);
503
+ const legendValue = (index: number) => {
504
+ const field = snapshot.marks
505
+ .find((mark) => mark.datum.seriesIndex === index && mark.datum.anchor[0] === lastX)
506
+ ?.datum.values.find((field) => field.key === valueField);
507
+ return field
508
+ ? (field.formatted ??
509
+ (typeof field.value === "number"
510
+ ? new Intl.NumberFormat(locale, { maximumFractionDigits: 6 }).format(field.value)
511
+ : field.value))
512
+ : "—";
513
+ };
497
514
  const legendHtml = legend.length
498
- ? `<ul class="k2b-chart-legend" aria-label="${escape(t.chartLegend)}">${legend.map((entry) => `<li><span class="k2b-chart-legend__swatch"${entry.style && entry.style !== "solid" ? ` data-style="${entry.style}"` : ""}${entry.reference ? ' data-reference="true"' : ""} style="--k2b-chart-legend-color:var(--stdlib-chart-c${(entry.color % 8) + 1})" aria-hidden="true"></span>${escape(entry.label)}</li>`).join("")}</ul>`
515
+ ? `<ul class="k2b-chart-legend" aria-label="${escape(t.chartLegend)}">${legend.map((entry, index) => `<li><span class="k2b-chart-legend__swatch"${entry.style && entry.style !== "solid" ? ` data-style="${entry.style}"` : ""}${entry.reference ? ' data-reference="true"' : ""} style="--k2b-chart-legend-color:var(--stdlib-chart-c${(entry.color % 8) + 1})" aria-hidden="true"></span>${escape(entry.label)}${input.kind === "area" ? `<b data-chart-legend-value data-series-index="${index}" data-field="${valueField}" data-default="${escape(legendValue(index))}">${escape(legendValue(index))}</b>` : ""}</li>`).join("")}</ul>`
499
516
  : "";
500
- const viewport = `<div class="k2b-chart" data-chart-kind="${snapshot.kind}"><div class="k2b-chart__svg"${snapshot.stretch ? ' data-stretch="true"' : ""} style="--k2b-chart-width:${snapshot.width}px;--k2b-chart-height:${snapshot.height}px">${snapshot.svg}</div></div>${legendHtml}`;
517
+ const viewport = `<div class="k2b-chart" data-chart-kind="${snapshot.kind}"><div class="k2b-chart__svg"${snapshot.stretch ? ' data-stretch="true"' : ""} style="${snapshot.minWidth ? "min-width:0;" : ""}--k2b-chart-width:${snapshot.width}px;--k2b-chart-height:${snapshot.height}px">${snapshot.svg}</div></div>${legendHtml}`;
501
518
  const height = notebookChartHeight(input, locale);
502
519
  const figure = notebookChartFrameHtml(height ? { ...input, height } : input, viewport, {
503
520
  legend: legend.length > 0,
@@ -1,6 +1,7 @@
1
1
  import { i18n } from "@k2b/stdlib";
2
2
  import { CopyButton, IconButton, LocaleProvider, prepareChartSnapshot, toast } from "@k2b/ui";
3
3
  import { type ChartTooltip, inspectChart } from "@k2b/ui/chart-inspection";
4
+ import { calendarInspectionNavigation, mountCalendarScroll } from "@k2b/ui/charts";
4
5
  import { createSignal, onCleanup, Show } from "solid-js";
5
6
  import { render } from "solid-js/web";
6
7
  import { type ChartInput, type ChartTable, chartMarkKey, chartReferenceMark, chartRenderOptions, chartRows, chartTooltip } from "../charts";
@@ -43,7 +44,7 @@ const draw = (input: ChartInput, locale: string): Drawn => {
43
44
  key: ({ datum }) => chartMarkKey(datum),
44
45
  tooltip: ({ datum }) => {
45
46
  const row = rows.add(datum);
46
- return chartTooltip(rows.columns(), row);
47
+ return chartTooltip(rows.columns(), row, input.kind);
47
48
  },
48
49
  reference: ({ datum }) => chartReferenceMark(input, datum),
49
50
  });
@@ -121,8 +122,12 @@ export function enhanceNotebookCharts(root: HTMLElement, locale: string, options
121
122
  chart.setAttribute("aria-label", figure.querySelector(".notebook-book-chart__title")?.textContent ?? "");
122
123
  chart.setAttribute("aria-description", t.interactiveChart);
123
124
  chart.dataset.interactive = "true";
125
+ if (input.kind === "heatmap" && input.layout === "calendar") cleanups.push(mountCalendarScroll(chart));
124
126
  const inspection = inspectChart(chart, {
125
127
  kind: input.kind,
128
+ nav: input.kind === "heatmap" && input.layout === "calendar" ? calendarInspectionNavigation : undefined,
129
+ locale,
130
+ legend: figure,
126
131
  format: ({ datum }) => lazy().tooltips.get(chartMarkKey(datum)) ?? { rows: [] },
127
132
  });
128
133
  const dispose = render(