@k2b/editor 0.7.0 → 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 +2 -2
- package/src/chart-schema.ts +108 -24
- package/src/charts.ts +150 -16
- package/src/markdown/lib/chart-blocks.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/editor",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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",
|
package/src/chart-schema.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
9
|
-
export
|
|
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,24 @@ 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([
|
|
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
|
+
]);
|
|
40
50
|
|
|
41
51
|
export const chartParts = (limits?: ChartLimits) => {
|
|
42
52
|
const ChartLabel = limits ? z.string().trim().min(1).max(80) : z.union([z.string(), z.number().transform(String)]);
|
|
@@ -154,6 +164,27 @@ export const chartKinds = (limits?: ChartLimits) => {
|
|
|
154
164
|
const ChartKindCommon = { ...ChartCommon, tooltip: ChartTooltip.optional() };
|
|
155
165
|
const numberFormat = chartNumberFormatSchema();
|
|
156
166
|
return z.discriminatedUnion("kind", [
|
|
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
|
+
}),
|
|
157
188
|
z
|
|
158
189
|
.object({
|
|
159
190
|
kind: z.literal("area"),
|
|
@@ -175,17 +206,65 @@ export const chartKinds = (limits?: ChartLimits) => {
|
|
|
175
206
|
if (!Number.isFinite(sum)) ctx.addIssue({ code: "custom", path: ["x", i], message: "Area totals must be finite." });
|
|
176
207
|
});
|
|
177
208
|
}),
|
|
178
|
-
z
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}),
|
|
189
268
|
z.object({
|
|
190
269
|
kind: z.literal("line"),
|
|
191
270
|
...ChartKindCommon,
|
|
@@ -339,5 +418,10 @@ export const chartDateTime = (value: string): number | undefined => {
|
|
|
339
418
|
return date.toISOString().startsWith(`${year}-${month}-${day}T${hour}:${minute}:${second}`) ? date.getTime() : undefined;
|
|
340
419
|
};
|
|
341
420
|
|
|
342
|
-
|
|
343
|
-
|
|
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
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
extendDomainToNice,
|
|
16
16
|
gauge,
|
|
17
17
|
type HeatmapChartOptions,
|
|
18
|
+
type HorizontalBarOptions,
|
|
19
|
+
hbarLayout,
|
|
18
20
|
histogram,
|
|
19
21
|
line,
|
|
20
22
|
niceLogTicks,
|
|
@@ -22,10 +24,13 @@ import {
|
|
|
22
24
|
pie,
|
|
23
25
|
renderAreaSvg,
|
|
24
26
|
renderBarChartSvg,
|
|
27
|
+
renderHbarSvg,
|
|
25
28
|
renderHeatmapSvg,
|
|
29
|
+
renderWaterfallSvg,
|
|
26
30
|
scatter,
|
|
27
31
|
sparkline,
|
|
28
32
|
stat,
|
|
33
|
+
type WaterfallOptions,
|
|
29
34
|
} from "@k2b/ui/charts/core";
|
|
30
35
|
import { z } from "zod";
|
|
31
36
|
import { type ChartFormat, type ChartInput, type ChartNumberFormat, chartDateTime, chartKinds } from "./chart-schema";
|
|
@@ -33,15 +38,19 @@ import { type ChartFormat, type ChartInput, type ChartNumberFormat, chartDateTim
|
|
|
33
38
|
export * from "./chart-schema";
|
|
34
39
|
|
|
35
40
|
type RenderOptions = {
|
|
36
|
-
[K in ChartInput["kind"]]: { kind: K } & (K extends "
|
|
37
|
-
?
|
|
38
|
-
: K extends "
|
|
39
|
-
?
|
|
40
|
-
: K extends "
|
|
41
|
-
?
|
|
42
|
-
: K extends
|
|
43
|
-
?
|
|
44
|
-
:
|
|
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);
|
|
45
54
|
}[ChartInput["kind"]];
|
|
46
55
|
|
|
47
56
|
/**
|
|
@@ -57,7 +66,13 @@ const labels = /* @__PURE__ */ i18n.define({
|
|
|
57
66
|
en: {
|
|
58
67
|
category: "Category",
|
|
59
68
|
series: "Series",
|
|
69
|
+
balance: "Balance",
|
|
70
|
+
before: "Balance before",
|
|
71
|
+
inflow: "In",
|
|
72
|
+
outflow: "Out",
|
|
73
|
+
after: "Balance after",
|
|
60
74
|
value: "Value",
|
|
75
|
+
total: "Total",
|
|
61
76
|
share: "Share",
|
|
62
77
|
position: "Position",
|
|
63
78
|
from: "From",
|
|
@@ -75,7 +90,13 @@ const labels = /* @__PURE__ */ i18n.define({
|
|
|
75
90
|
de: {
|
|
76
91
|
category: "Kategorie",
|
|
77
92
|
series: "Datenreihe",
|
|
93
|
+
balance: "Stand",
|
|
94
|
+
before: "Stand davor",
|
|
95
|
+
inflow: "Rein",
|
|
96
|
+
outflow: "Raus",
|
|
97
|
+
after: "Stand danach",
|
|
78
98
|
value: "Wert",
|
|
99
|
+
total: "Gesamt",
|
|
79
100
|
share: "Anteil",
|
|
80
101
|
position: "Position",
|
|
81
102
|
from: "Von",
|
|
@@ -137,7 +158,19 @@ export const chartReferenceValues = (input: ChartInput, name: "x" | "y") =>
|
|
|
137
158
|
|
|
138
159
|
/** The value axes of a chart with what they show: the data checks and the label room read them alike. */
|
|
139
160
|
export const chartValueAxes = (input: ChartInput): { values: number[]; axis?: ChartAxisInput; zero?: boolean; name: "x" | "y" }[] => {
|
|
140
|
-
if (input.kind === "bar")
|
|
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
|
+
];
|
|
141
174
|
if (input.kind === "boxplot") return [{ values: input.data.flatMap((item) => item.values), axis: input.yAxis, name: "y" }];
|
|
142
175
|
if (input.kind === "histogram") {
|
|
143
176
|
if (!input.references?.length) return [{ values: input.data, name: "x" }];
|
|
@@ -246,13 +279,22 @@ const numberFormat = (format: ChartFormat | undefined) => (format && !("date" in
|
|
|
246
279
|
/** The values a chart writes with its value format, for whole-number currency. */
|
|
247
280
|
const formattedValues = (input: ChartInput): number[] => {
|
|
248
281
|
switch (input.kind) {
|
|
282
|
+
case "waterfall":
|
|
283
|
+
return [input.start.value, ...input.steps.map((step) => step.value)];
|
|
249
284
|
case "area":
|
|
250
285
|
return input.series.flatMap((s) => s.data.filter((v): v is number => v !== null));
|
|
251
286
|
case "line":
|
|
252
287
|
case "scatter":
|
|
253
288
|
return [...input.series.flatMap((series) => series.data.map((point) => point.y)), ...chartReferenceValues(input, "y")];
|
|
254
289
|
case "bar":
|
|
255
|
-
return [
|
|
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));
|
|
256
298
|
case "pie":
|
|
257
299
|
case "donut":
|
|
258
300
|
return input.data.map((item) => item.value);
|
|
@@ -323,6 +365,8 @@ export function chartLegend(
|
|
|
323
365
|
input: ChartInput,
|
|
324
366
|
locale: string,
|
|
325
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 [];
|
|
326
370
|
if (input.kind === "bar")
|
|
327
371
|
return input.colorByBar && input.legend ? input.data.map((entry, index) => ({ label: entry.label, color: index % 8 })) : [];
|
|
328
372
|
if (input.kind === "area") return input.legend === false ? [] : input.series.map((s, i) => ({ label: s.label, color: i % 8 }));
|
|
@@ -367,6 +411,25 @@ export function chartRenderOptions(
|
|
|
367
411
|
const width = options.width ?? WIDTH;
|
|
368
412
|
const references = renderReferences(input);
|
|
369
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
|
+
};
|
|
370
433
|
case "area":
|
|
371
434
|
return {
|
|
372
435
|
kind: "area",
|
|
@@ -386,14 +449,20 @@ export function chartRenderOptions(
|
|
|
386
449
|
if (input.kind === "bar")
|
|
387
450
|
return {
|
|
388
451
|
kind: "bar",
|
|
452
|
+
locale,
|
|
389
453
|
data: input.data,
|
|
454
|
+
x: input.x,
|
|
455
|
+
series: input.series,
|
|
456
|
+
layout: input.layout,
|
|
457
|
+
normalize: input.normalize,
|
|
458
|
+
compare: input.compare,
|
|
390
459
|
xAxis: input.xAxis,
|
|
391
460
|
yAxis: axis(yAxis, options.formats?.y ?? format.tick),
|
|
392
461
|
// A host's own function format writes printed values like its ticks and table cells.
|
|
393
462
|
valueFormat: options.formats?.value ?? options.formats?.y ?? format.value,
|
|
394
463
|
colorByBar: input.colorByBar,
|
|
395
464
|
showValues: input.showValues,
|
|
396
|
-
legend: options.legend === "outside" ? false : input.legend,
|
|
465
|
+
legend: input.series ? input.legend : options.legend === "outside" ? false : input.legend,
|
|
397
466
|
references,
|
|
398
467
|
...(options.width === undefined ? {} : { displayWidth: options.width }),
|
|
399
468
|
};
|
|
@@ -545,6 +614,10 @@ const decode = (value: string) =>
|
|
|
545
614
|
export const drawChart = (options: RenderOptions): string => {
|
|
546
615
|
const common = { width: WIDTH, height: HEIGHT, inspect: true };
|
|
547
616
|
switch (options.kind) {
|
|
617
|
+
case "hbar":
|
|
618
|
+
return renderHbarSvg({ ...options, ...common, height: hbarLayout(options).height });
|
|
619
|
+
case "waterfall":
|
|
620
|
+
return renderWaterfallSvg({ ...options, ...common });
|
|
548
621
|
case "area":
|
|
549
622
|
return renderAreaSvg({ ...options, ...common });
|
|
550
623
|
case "bar":
|
|
@@ -594,7 +667,12 @@ export const chartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex"> &
|
|
|
594
667
|
`${datum.role === "outlier" ? "outlier:" : datum.role === "value" ? "value:" : ""}${datum.seriesIndex ?? 0}:${datum.index}`;
|
|
595
668
|
|
|
596
669
|
export type ChartColumn = { id: string; label: string; numeric: boolean };
|
|
597
|
-
export type ChartRow = {
|
|
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
|
+
};
|
|
598
676
|
export type ChartTable = { columns: ChartColumn[]; rows: ChartRow[] };
|
|
599
677
|
|
|
600
678
|
/** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
|
|
@@ -603,6 +681,7 @@ export function chartTooltip(
|
|
|
603
681
|
row: ChartRow,
|
|
604
682
|
kind?: ChartInput["kind"],
|
|
605
683
|
): { title?: string; rows: { label: string; value: string }[] } {
|
|
684
|
+
if (row.tooltip) return row.tooltip;
|
|
606
685
|
if (kind === "area")
|
|
607
686
|
return {
|
|
608
687
|
title: row.cells.x,
|
|
@@ -644,8 +723,19 @@ export function chartRows(
|
|
|
644
723
|
const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
|
|
645
724
|
const columns = new Map<string, ChartColumn>();
|
|
646
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
|
+
}
|
|
647
736
|
const named = series.some((entry) => entry.label);
|
|
648
737
|
const add = (datum: ChartDatum): ChartRow => {
|
|
738
|
+
const tooltip = input.kind === "waterfall" ? { title: datum.label, rows: [] as { label: string; value: string }[] } : undefined;
|
|
649
739
|
const cells: Record<string, string> = {};
|
|
650
740
|
const values: Record<string, string | number> = {};
|
|
651
741
|
const cell = (id: string, label: string, value: string | number, text: string) => {
|
|
@@ -663,7 +753,9 @@ export function chartRows(
|
|
|
663
753
|
// Bars may hide some axis labels; the table names every category.
|
|
664
754
|
const label =
|
|
665
755
|
input.kind === "bar" || input.kind === "pie" || input.kind === "donut" || input.kind === "boxplot" || input.kind === "barGauge"
|
|
666
|
-
? input.
|
|
756
|
+
? ((input.kind === "bar" && input.series
|
|
757
|
+
? undefined
|
|
758
|
+
: input.data[datum.role === "outlier" ? (datum.seriesIndex ?? 0) : datum.index]?.label) ?? datum.label)
|
|
667
759
|
: datum.label;
|
|
668
760
|
if (label)
|
|
669
761
|
cell("label", input.kind === "heatmap" && input.layout === "calendar" ? calendarWords(locale).day : t.category, label, label);
|
|
@@ -678,8 +770,44 @@ export function chartRows(
|
|
|
678
770
|
cell(field.key, field.key.toUpperCase(), value, value);
|
|
679
771
|
continue;
|
|
680
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
|
+
}
|
|
681
790
|
if (typeof value !== "number" || field.key === "total") continue;
|
|
682
|
-
if (
|
|
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") {
|
|
683
811
|
const header =
|
|
684
812
|
(input.kind === "line" || input.kind === "scatter" ? input.xAxis?.label : undefined) ??
|
|
685
813
|
(input.kind === "area" ? t.position : "X");
|
|
@@ -728,7 +856,7 @@ export function chartRows(
|
|
|
728
856
|
cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
|
|
729
857
|
}
|
|
730
858
|
}
|
|
731
|
-
const row = { key: chartMarkKey(datum), cells, values };
|
|
859
|
+
const row = { key: chartMarkKey(datum), cells, values, ...(tooltip ? { tooltip } : {}) };
|
|
732
860
|
rows.push(row);
|
|
733
861
|
return row;
|
|
734
862
|
};
|
|
@@ -745,11 +873,17 @@ export function chartTable(input: ChartInput, locale: string): ChartTable {
|
|
|
745
873
|
/** Input values count against the budget, including observations summarized into boxes or bins. */
|
|
746
874
|
export const chartMarks = (input: ChartInput): number => {
|
|
747
875
|
switch (input.kind) {
|
|
876
|
+
case "waterfall":
|
|
877
|
+
return input.steps.length + 2;
|
|
748
878
|
case "area":
|
|
749
879
|
return input.series.reduce((sum, s) => sum + s.data.length, 0);
|
|
750
880
|
case "line":
|
|
751
881
|
case "scatter":
|
|
752
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;
|
|
753
887
|
case "boxplot":
|
|
754
888
|
return input.data.reduce((sum, box) => sum + box.values.length, 0);
|
|
755
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,8 +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;
|
|
460
461
|
if (options.kind === "heatmap" && options.layout === "calendar") return 280;
|
|
461
|
-
if (options.kind === "area") return 280;
|
|
462
|
+
if (options.kind === "waterfall" || options.kind === "area") return 280;
|
|
462
463
|
if (options.kind !== "bar") return undefined;
|
|
463
464
|
const layout = barChartLayout(options);
|
|
464
465
|
return layout.layout === "rows" ? layout.height : undefined;
|