@cosmicdrift/kumiko-renderer-web 0.348.1 → 0.349.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.
|
@@ -4,6 +4,7 @@ import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extension
|
|
|
4
4
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { EmbeddedFormProvider } from "../primitives/index.js";
|
|
6
6
|
import { PageSection } from "../primitives/layout.js";
|
|
7
|
+
import { formatMoney } from "../primitives/money-input.js";
|
|
7
8
|
import { Skeleton } from "../ui/skeleton.js";
|
|
8
9
|
import { SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
9
10
|
import { DashboardListTable } from "../widgets/dashboard-list.js";
|
|
@@ -41,7 +42,14 @@ function useDashboardFormats() {
|
|
|
41
42
|
timeStyle: "short",
|
|
42
43
|
timeZone,
|
|
43
44
|
});
|
|
45
|
+
const medium = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone });
|
|
46
|
+
const formatMinor = (amountMinor, currency, fractionDigits) => formatMoney(amountMinor, currency, locale, fractionDigits);
|
|
44
47
|
return {
|
|
48
|
+
formatMedium: (atMs) => medium.format(atMs),
|
|
49
|
+
formatMinor,
|
|
50
|
+
formatPanelValue: (valueFormat) => valueFormat === undefined
|
|
51
|
+
? (value) => number.format(value)
|
|
52
|
+
: (value) => formatMinor(value, valueFormat.currency, valueFormat.fractionDigits),
|
|
45
53
|
formatNumber: (value) => number.format(value),
|
|
46
54
|
formatPercent: (fraction) => percent.format(fraction),
|
|
47
55
|
formatDay: (atMs) => day.format(atMs),
|
|
@@ -49,6 +57,65 @@ function useDashboardFormats() {
|
|
|
49
57
|
formatDateTime: (atMs) => dateTime.format(atMs),
|
|
50
58
|
};
|
|
51
59
|
}
|
|
60
|
+
const MAX_TEXT_PARAM_DEPTH = 4;
|
|
61
|
+
function isRecord(value) {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
function isDashboardI18nText(value) {
|
|
65
|
+
return isRecord(value) && typeof value["i18nKey"] === "string";
|
|
66
|
+
}
|
|
67
|
+
function isMoneyParam(value) {
|
|
68
|
+
return (isRecord(value) &&
|
|
69
|
+
value["kind"] === "money" &&
|
|
70
|
+
typeof value["amountMinor"] === "number" &&
|
|
71
|
+
typeof value["currency"] === "string");
|
|
72
|
+
}
|
|
73
|
+
function isDateParam(value) {
|
|
74
|
+
return isRecord(value) && value["kind"] === "date" && typeof value["atMs"] === "number";
|
|
75
|
+
}
|
|
76
|
+
function isTextParam(value) {
|
|
77
|
+
return (typeof value === "string" ||
|
|
78
|
+
typeof value === "number" ||
|
|
79
|
+
isDashboardI18nText(value) ||
|
|
80
|
+
isMoneyParam(value) ||
|
|
81
|
+
isDateParam(value));
|
|
82
|
+
}
|
|
83
|
+
function resolveTextParam(param, translate, formats, depth) {
|
|
84
|
+
if (typeof param === "string" || typeof param === "number")
|
|
85
|
+
return param;
|
|
86
|
+
if (isMoneyParam(param))
|
|
87
|
+
return formats.formatMinor(param.amountMinor, param.currency);
|
|
88
|
+
if (isDateParam(param))
|
|
89
|
+
return formats.formatMedium(param.atMs);
|
|
90
|
+
if (isDashboardI18nText(param)) {
|
|
91
|
+
// Too deep: show the key instead of recursing further.
|
|
92
|
+
return depth >= MAX_TEXT_PARAM_DEPTH
|
|
93
|
+
? param.i18nKey
|
|
94
|
+
: resolveI18nText(param, translate, formats, depth + 1);
|
|
95
|
+
}
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
function resolveI18nText(text, translate, formats, depth) {
|
|
99
|
+
if (text.i18nParams === undefined)
|
|
100
|
+
return translate(text.i18nKey);
|
|
101
|
+
const params = {};
|
|
102
|
+
for (const [name, param] of Object.entries(text.i18nParams)) {
|
|
103
|
+
if (isTextParam(param))
|
|
104
|
+
params[name] = resolveTextParam(param, translate, formats, depth);
|
|
105
|
+
}
|
|
106
|
+
return translate(text.i18nKey, params);
|
|
107
|
+
}
|
|
108
|
+
/** Plain strings pass through; DashboardI18nText is translated (nested params first).
|
|
109
|
+
* Anything else yields undefined so callers fall back to their own placeholder. */
|
|
110
|
+
function resolveDashboardText(value, translate, formats) {
|
|
111
|
+
if (typeof value === "string")
|
|
112
|
+
return value;
|
|
113
|
+
if (typeof value === "number")
|
|
114
|
+
return String(value);
|
|
115
|
+
if (isDashboardI18nText(value))
|
|
116
|
+
return resolveI18nText(value, translate, formats, 1);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
52
119
|
function PanelShell({ testId, title, subtitle, children, }) {
|
|
53
120
|
return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle }))] }), _jsx("div", { className: "grow p-4", children: children })] }));
|
|
54
121
|
}
|
|
@@ -100,8 +167,13 @@ function readSparkline(panel, record) {
|
|
|
100
167
|
function readTone(raw) {
|
|
101
168
|
return typeof raw === "string" && STAT_TONES.has(raw) ? raw : undefined;
|
|
102
169
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
170
|
+
function fallbackText(raw, placeholder) {
|
|
171
|
+
if (raw === undefined || raw === null || isRecord(raw))
|
|
172
|
+
return placeholder;
|
|
173
|
+
return String(raw);
|
|
174
|
+
}
|
|
175
|
+
function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }) {
|
|
176
|
+
const formats = useDashboardFormats();
|
|
105
177
|
// Resolved HERE (not in a separate always-rendered child) so `icon` on
|
|
106
178
|
// <StatCard> is `undefined` — not a React element that renders empty —
|
|
107
179
|
// when the icon name isn't registered. StatCard gates its accent chip on
|
|
@@ -129,10 +201,12 @@ function StatPanelBody({ panel, label, screenId, screenParams, variant, }) {
|
|
|
129
201
|
(rawValue === 0 ? undefined : panel.tone);
|
|
130
202
|
const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
|
|
131
203
|
const delta = readDelta(panel, record);
|
|
132
|
-
const value = typeof rawValue === "number"
|
|
204
|
+
const value = typeof rawValue === "number"
|
|
205
|
+
? formats.formatPanelValue(panel.valueFormat)(rawValue)
|
|
206
|
+
: (resolveDashboardText(rawValue, translate, formats) ?? fallbackText(rawValue, "—"));
|
|
133
207
|
const spark = readSparkline(panel, record);
|
|
134
208
|
const testId = `dashboard-panel-${panel.id}`;
|
|
135
|
-
const subText = sub
|
|
209
|
+
const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
|
|
136
210
|
if (variant === "strip") {
|
|
137
211
|
return (_jsx(StatStripCell, { label: label, value: value, tone: tone ?? "default", ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
138
212
|
}
|
|
@@ -153,9 +227,15 @@ function readDelta(panel, record) {
|
|
|
153
227
|
function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
154
228
|
const testId = `dashboard-panel-${panel.id}`;
|
|
155
229
|
if (label === undefined) {
|
|
156
|
-
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, variant: "strip" }, stat.id))) }));
|
|
230
|
+
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
|
|
157
231
|
}
|
|
158
|
-
return (_jsx(SectionCard, { title: label, testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, variant: "card" }, stat.id))) }) }));
|
|
232
|
+
return (_jsx(SectionCard, { title: label, testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
233
|
+
}
|
|
234
|
+
function resolveMarkers(markers, translate, formats) {
|
|
235
|
+
return markers?.map((marker) => ({
|
|
236
|
+
atMs: marker.atMs,
|
|
237
|
+
label: resolveDashboardText(marker.label, translate, formats) ?? "",
|
|
238
|
+
}));
|
|
159
239
|
}
|
|
160
240
|
// A plain bar chart ("changes per day") ships `points` without `series`.
|
|
161
241
|
const SINGLE_SERIES_KEY = "value";
|
|
@@ -196,12 +276,14 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
196
276
|
return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
|
|
197
277
|
const series = chartSeries(data, label, translate);
|
|
198
278
|
const { startMs, endMs } = windowOf(data, series);
|
|
279
|
+
const markers = resolveMarkers(data.markers, translate, formats);
|
|
280
|
+
const formatValue = formats.formatPanelValue(panel.valueFormat);
|
|
199
281
|
if (panel.chart === "segment-bars") {
|
|
200
282
|
return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
|
|
201
283
|
...row,
|
|
202
284
|
label: translate(row.label),
|
|
203
285
|
segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
|
|
204
|
-
})), tones: tones, ariaLabel: label, formatValue:
|
|
286
|
+
})), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
205
287
|
}
|
|
206
288
|
if (panel.chart === "stacked-bars") {
|
|
207
289
|
const times = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
@@ -211,13 +293,15 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
211
293
|
const prev = all[i - 1];
|
|
212
294
|
return prev === undefined ? [] : [atMs - prev];
|
|
213
295
|
}), DAY_MS);
|
|
214
|
-
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue:
|
|
296
|
+
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
|
|
215
297
|
}
|
|
216
298
|
if (panel.chart === "stacked-area") {
|
|
217
299
|
return (_jsx(StackedAreaChart, { series: series, windowStartMs: startMs, windowEndMs: endMs, ...(data.todayMs !== undefined &&
|
|
218
|
-
data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers:
|
|
300
|
+
data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers: markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, testId: testId }));
|
|
219
301
|
}
|
|
220
|
-
return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers:
|
|
302
|
+
return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
|
|
303
|
+
yAxis: { ticks: 3, format: formatValue },
|
|
304
|
+
}), ariaLabel: label, testId: testId }));
|
|
221
305
|
} }));
|
|
222
306
|
}
|
|
223
307
|
function ListPanelBody({ panel, label, screenParams, translate, }) {
|
|
@@ -236,19 +320,27 @@ function ListPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
236
320
|
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, formatDateTime: formatDateTime, translate: translate })) }));
|
|
237
321
|
}
|
|
238
322
|
function FeedPanelBody({ panel, label, screenParams, translate, }) {
|
|
323
|
+
const formats = useDashboardFormats();
|
|
239
324
|
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
|
|
240
|
-
const rows = (data.rows ?? []).map((row, i) =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
325
|
+
const rows = (data.rows ?? []).map((row, i) => {
|
|
326
|
+
const trailing = resolveDashboardText(row.trailing, translate, formats);
|
|
327
|
+
return {
|
|
328
|
+
id: String(i),
|
|
329
|
+
primary: resolveDashboardText(row.primary, translate, formats) ?? "—",
|
|
330
|
+
...(trailing !== undefined && { trailing }),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
244
333
|
return _jsx(FeedList, { rows: rows });
|
|
245
334
|
} }));
|
|
246
335
|
}
|
|
247
336
|
function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
|
|
337
|
+
const formats = useDashboardFormats();
|
|
248
338
|
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
|
|
249
339
|
const rows = (data.rows ?? []).map((row, i) => ({
|
|
250
340
|
id: String(i),
|
|
251
|
-
|
|
341
|
+
label: resolveDashboardText(row.label, translate, formats) ?? "—",
|
|
342
|
+
value: resolveDashboardText(row.value, translate, formats) ?? "—",
|
|
343
|
+
fraction: row.fraction,
|
|
252
344
|
}));
|
|
253
345
|
return _jsx(ProgressList, { rows: rows });
|
|
254
346
|
} }));
|
|
@@ -333,7 +425,7 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
|
333
425
|
}
|
|
334
426
|
const text = label ?? "";
|
|
335
427
|
if (panel.kind === "stat") {
|
|
336
|
-
return (_jsx(StatPanelBody, { panel: panel, label: text, screenId: screenId, screenParams: screenParams, variant: "card" }));
|
|
428
|
+
return (_jsx(StatPanelBody, { panel: panel, label: text, screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }));
|
|
337
429
|
}
|
|
338
430
|
if (panel.kind === "chart") {
|
|
339
431
|
return (_jsx(ChartPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
|
|
@@ -14,4 +14,4 @@ export type MoneyInputProps = {
|
|
|
14
14
|
readonly hasError?: boolean;
|
|
15
15
|
};
|
|
16
16
|
export declare function MoneyInput({ id, name, value, onChange, currency, locale, disabled, required, hasError, }: MoneyInputProps): ReactNode;
|
|
17
|
-
export declare function formatMoney(amountMinor: number, currency: string, locale?: string): string;
|
|
17
|
+
export declare function formatMoney(amountMinor: number, currency: string, locale?: string, fractionDigits?: number): string;
|
|
@@ -88,16 +88,17 @@ export function MoneyInput({ id, name, value, onChange, currency, locale, disabl
|
|
|
88
88
|
// rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
|
|
89
89
|
// no ErrorBoundary in this render tree that would take down the whole
|
|
90
90
|
// page instead of just this cell.
|
|
91
|
-
export function formatMoney(amountMinor, currency, locale) {
|
|
91
|
+
export function formatMoney(amountMinor, currency, locale, fractionDigits) {
|
|
92
92
|
if (!/^[A-Za-z]{3}$/.test(currency))
|
|
93
93
|
return String(amountMinor);
|
|
94
94
|
const decimals = currencyDecimals(currency);
|
|
95
|
+
const displayDigits = fractionDigits ?? decimals;
|
|
95
96
|
const resolvedLocale = resolveSafeLocale(locale);
|
|
96
97
|
return new Intl.NumberFormat(resolvedLocale, {
|
|
97
98
|
style: "currency",
|
|
98
99
|
currency,
|
|
99
|
-
minimumFractionDigits:
|
|
100
|
-
maximumFractionDigits:
|
|
100
|
+
minimumFractionDigits: displayDigits,
|
|
101
|
+
maximumFractionDigits: displayDigits,
|
|
101
102
|
}).format(amountMinor / 10 ** decimals);
|
|
102
103
|
}
|
|
103
104
|
// The currency symbol is rendered as a separate adornment, so the field
|
package/dist/widgets/charts.d.ts
CHANGED
|
@@ -133,7 +133,7 @@ export declare function SegmentBarChart({ rows, tones, ariaLabel, formatValue, t
|
|
|
133
133
|
}): ReactNode;
|
|
134
134
|
/** Stacked bands over time. Right of `todayMs` is the forecast: lighter
|
|
135
135
|
* fill plus a vertical "today" line. */
|
|
136
|
-
export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, emptyContent, testId, }: {
|
|
136
|
+
export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, emptyContent, testId, }: {
|
|
137
137
|
readonly series: readonly ChartSeries[];
|
|
138
138
|
readonly windowStartMs: number;
|
|
139
139
|
readonly windowEndMs: number;
|
|
@@ -145,6 +145,8 @@ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, t
|
|
|
145
145
|
readonly formatBucketLabel: (atMs: number) => string;
|
|
146
146
|
readonly formatValue?: (value: number) => string;
|
|
147
147
|
readonly formatMarkerTime?: (atMs: number) => string;
|
|
148
|
+
/** Fixed width per bucket; the plot scrolls horizontally, y ticks stay put. */
|
|
149
|
+
readonly scrollable?: boolean;
|
|
148
150
|
readonly emptyContent?: ReactNode;
|
|
149
151
|
readonly testId?: string;
|
|
150
152
|
}): ReactNode;
|
package/dist/widgets/charts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useId } from "react";
|
|
2
|
+
import { useEffect, useId, useRef } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import { STATUS_TONE_TEXT } from "./status-badge.js";
|
|
5
5
|
// Inline-SVG-Charts — kein Chart-Dep. Farben ausschließlich über die
|
|
@@ -241,17 +241,35 @@ function MarkerPins({ markers, windowStartMs, windowEndMs, }) {
|
|
|
241
241
|
function MarkerLegend({ markers, formatMarkerTime, }) {
|
|
242
242
|
return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] }, `${marker.atMs}-${marker.label}`))) }));
|
|
243
243
|
}
|
|
244
|
+
const SCROLLABLE_BUCKET_WIDTH_PX = 48;
|
|
245
|
+
function bucketLabelTransform(index, count) {
|
|
246
|
+
if (index === 0)
|
|
247
|
+
return "";
|
|
248
|
+
return index === count - 1 ? "-translate-x-full" : "-translate-x-1/2";
|
|
249
|
+
}
|
|
244
250
|
/** Stacked bands over time. Right of `todayMs` is the forecast: lighter
|
|
245
251
|
* fill plus a vertical "today" line. */
|
|
246
|
-
export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, emptyContent, testId, }) {
|
|
252
|
+
export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, emptyContent, testId, }) {
|
|
247
253
|
const clipId = useId();
|
|
254
|
+
const scrollRef = useRef(null);
|
|
248
255
|
const times = bucketTimes(series);
|
|
256
|
+
const bucketCount = times.length;
|
|
257
|
+
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
258
|
+
const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
|
|
259
|
+
const todayFraction = todayMs !== undefined ? fractionOf(todayMs) : undefined;
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
const el = scrollRef.current;
|
|
262
|
+
if (!scrollable || el === null || bucketCount === 0)
|
|
263
|
+
return;
|
|
264
|
+
el.scrollLeft =
|
|
265
|
+
todayFraction === undefined
|
|
266
|
+
? el.scrollWidth
|
|
267
|
+
: Math.max(0, todayFraction * el.scrollWidth - el.clientWidth / 2);
|
|
268
|
+
}, [scrollable, todayFraction, bucketCount]);
|
|
249
269
|
if (times.length === 0)
|
|
250
270
|
return _jsx("div", { "data-testid": testId, children: emptyContent });
|
|
251
271
|
const width = 300;
|
|
252
272
|
const height = 120;
|
|
253
|
-
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
254
|
-
const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
|
|
255
273
|
const totals = times.map((atMs) => series.reduce((sum, s) => sum + valueAt(s, atMs), 0));
|
|
256
274
|
const isSingleBucket = times.length === 1;
|
|
257
275
|
// Doubling keeps a lone bucket away from the top edge of the y-scale.
|
|
@@ -275,10 +293,13 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
|
|
|
275
293
|
dot: isSingleBucket && bucketHeight > 0 ? clampDotY(yOf(bucketTop), height) : undefined,
|
|
276
294
|
};
|
|
277
295
|
});
|
|
278
|
-
const todayFraction = todayMs !== undefined ? fractionOf(todayMs) : undefined;
|
|
279
296
|
const todayX = (todayFraction ?? 1) * width;
|
|
280
297
|
const renderBands = (fillOpacity) => bands.map((band) => band.dot === undefined ? (_jsx("path", { d: band.d, fill: band.color, fillOpacity: fillOpacity, stroke: "none" }, band.key)) : (_jsx("circle", { "data-testid": "stacked-area-single-point", cx: width / 2, cy: band.dot, r: SINGLE_POINT_RADIUS, fill: band.color, fillOpacity: fillOpacity }, band.key)));
|
|
281
|
-
|
|
298
|
+
const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })), todayFraction !== undefined && (_jsx("line", { "data-testid": "chart-today-line", x1: todayX, x2: todayX, y1: 0, y2: height, stroke: "var(--color-foreground)", strokeWidth: 1, strokeDasharray: "3 3", vectorEffect: "non-scaling-stroke" }))] }), scrollable ? (_jsx("div", { "data-testid": "chart-bucket-labels", className: "relative mt-1 h-4 text-[11px] text-muted-foreground", children: times.map((atMs, i) => (_jsx("span", { className: cn("absolute whitespace-nowrap", bucketLabelTransform(i, times.length)), style: { left: percent(fractionOf(atMs)) }, children: formatBucketLabel(atMs) }, atMs))) })) : (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: formatBucketLabel(windowStartMs) }), _jsx("span", { children: formatBucketLabel(windowEndMs) })] })), markers !== undefined && markers.length > 0 && (_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }))] }));
|
|
299
|
+
return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex gap-2", children: [_jsx(YTicks, { max: max, formatValue: formatValue, className: cn("h-32 pb-0", scrollable && "mt-4") }), scrollable ? (_jsx("div", { ref: scrollRef, "data-testid": "chart-scroll-container", className: "grow overflow-x-auto pt-4", children: _jsx("div", { "data-testid": "chart-scroll-content", className: "relative", style: {
|
|
300
|
+
minWidth: "100%",
|
|
301
|
+
width: `${times.length * SCROLLABLE_BUCKET_WIDTH_PX}px`,
|
|
302
|
+
}, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
|
|
282
303
|
key: s.key,
|
|
283
304
|
label: s.label,
|
|
284
305
|
color: chartColor(s.key, i, tones),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.349.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
26
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
27
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
23
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.349.0",
|
|
24
|
+
"@cosmicdrift/kumiko-framework": "0.349.0",
|
|
25
|
+
"@cosmicdrift/kumiko-headless": "0.349.0",
|
|
26
|
+
"@cosmicdrift/kumiko-renderer": "0.349.0",
|
|
27
|
+
"@cosmicdrift/kumiko-types": "0.349.0",
|
|
28
28
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
29
29
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
30
30
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@types/react-dom": "^19.2.3",
|
|
73
73
|
"jsdom": "^29.1.1",
|
|
74
74
|
"tailwindcss": "^4.3.0",
|
|
75
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
75
|
+
"@cosmicdrift/kumiko-locale-de": "0.349.0"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.349.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Dashboard panel texts are translatable (i18nKey/i18nParams with nested, money and date params), panels get a currency valueFormat and stacked-area charts can scroll",
|
|
6
|
+
"detail": "Dashboard panels: translatable texts, currency format, scrollable stacked-area\nQuery handlers can send `{ i18nKey, i18nParams? }` (`DashboardI18nText`) wherever a dashboard panel shows text: stat value and sub line, feed `primary`/`trailing`, progress-list `label`/`value`, chart marker labels. Plain strings are shown unchanged. Params may be strings, numbers, nested `DashboardI18nText` (resolved first, e.g. a duration built from two plural keys), `{ kind: \"money\", amountMinor, currency }` (user locale) or `{ kind: \"date\", atMs }` (medium date, user time zone).\n`valueFormat: { kind: \"currency\", currency, fractionDigits? }` on stat and chart panels formats minor-unit values as currency in stat values, y ticks, legend totals and tooltips. `scrollable: true` on a `stacked-area` chart gives each bucket a fixed width, scrolls the plot horizontally with the y ticks fixed on the left, and opens at \"today\". The boot validator rejects an invalid currency code, `fractionDigits` outside 0..4 and `scrollable` on other chart kinds. `formatMoney` takes an optional `fractionDigits`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.342.0",
|
|
4
10
|
"type": "improvement",
|