@gntik-ai/charts 0.0.0-stage → 0.5.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/dist/index.d.ts +642 -0
- package/dist/index.js +2317 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -3
- package/src/styles.css +2 -0
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2317 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Area, AreaChart as AreaChart$1, Bar, BarChart as BarChart$1, CartesianGrid, ComposedChart, Funnel, FunnelChart as FunnelChart$1, Line, LineChart as LineChart$1, Pie, PieChart, PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart as RadarChart$1, ReferenceLine, ResponsiveContainer, Sankey, Tooltip, XAxis, YAxis } from "recharts";
|
|
3
|
+
import { useCallback, useId, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { currentTheme, observeTheme, tokenColor } from "@gntik-ai/tokens/runtime";
|
|
6
|
+
//#region src/format.ts
|
|
7
|
+
const nf = (opts) => new Intl.NumberFormat("en-US", opts);
|
|
8
|
+
/** Ready-made value formatters (en-US). */
|
|
9
|
+
const chartFmt = {
|
|
10
|
+
usd: (n) => "$" + nf({ maximumFractionDigits: 0 }).format(n),
|
|
11
|
+
usd2: (n) => "$" + nf({
|
|
12
|
+
minimumFractionDigits: 2,
|
|
13
|
+
maximumFractionDigits: 2
|
|
14
|
+
}).format(n),
|
|
15
|
+
usdCompact: (n) => "$" + nf({
|
|
16
|
+
notation: "compact",
|
|
17
|
+
maximumFractionDigits: 1
|
|
18
|
+
}).format(n),
|
|
19
|
+
num: (n) => nf().format(n),
|
|
20
|
+
compact: (n) => nf({
|
|
21
|
+
notation: "compact",
|
|
22
|
+
maximumFractionDigits: 1
|
|
23
|
+
}).format(n),
|
|
24
|
+
ms: (n) => nf().format(n) + " ms",
|
|
25
|
+
pct: (n) => `${n}%`,
|
|
26
|
+
/** Ratio (0–1) → percent with up to one decimal: `0.125` → `12.5%`. */
|
|
27
|
+
percent: (n) => nf({
|
|
28
|
+
style: "percent",
|
|
29
|
+
maximumFractionDigits: 1
|
|
30
|
+
}).format(n),
|
|
31
|
+
/** Signed ratio for deltas: `0.042` → `+4.2%`, `-0.1` → `−10%`. */
|
|
32
|
+
percentDelta: (n) => nf({
|
|
33
|
+
style: "percent",
|
|
34
|
+
maximumFractionDigits: 1,
|
|
35
|
+
signDisplay: "exceptZero"
|
|
36
|
+
}).format(n).replace("-", "−"),
|
|
37
|
+
/** Milliseconds → the two largest units: `850` → `850 ms`, `2400` → `2.4 s`, `3_723_000` → `1h 2m`. */
|
|
38
|
+
duration: (n) => formatDuration(n),
|
|
39
|
+
/** Seconds → like `duration`: `90` → `1m 30s`. */
|
|
40
|
+
durationS: (n) => formatDuration(n * 1e3),
|
|
41
|
+
/** Bytes → decimal SI units: `1536` → `1.5 kB`, `2_000_000_000` → `2 GB`. */
|
|
42
|
+
bytes: (n) => formatBytes(n)
|
|
43
|
+
};
|
|
44
|
+
function formatDuration(ms) {
|
|
45
|
+
if (!Number.isFinite(ms)) return "—";
|
|
46
|
+
const sign = ms < 0 ? "−" : "";
|
|
47
|
+
const a = Math.abs(ms);
|
|
48
|
+
if (a < 1e3) return `${sign}${nf({ maximumFractionDigits: a < 10 ? 2 : 0 }).format(a)} ms`;
|
|
49
|
+
if (a < 6e4) return `${sign}${nf({ maximumFractionDigits: 1 }).format(a / 1e3)} s`;
|
|
50
|
+
const parts = [
|
|
51
|
+
[864e5, "d"],
|
|
52
|
+
[36e5, "h"],
|
|
53
|
+
[6e4, "m"],
|
|
54
|
+
[1e3, "s"]
|
|
55
|
+
];
|
|
56
|
+
const total = Math.round(a / 1e3) * 1e3;
|
|
57
|
+
const first = parts.findIndex(([size]) => total >= size);
|
|
58
|
+
const out = [];
|
|
59
|
+
let rest = total;
|
|
60
|
+
for (const [size, unit] of parts.slice(first, first + 2)) {
|
|
61
|
+
const q = Math.floor(rest / size);
|
|
62
|
+
rest -= q * size;
|
|
63
|
+
if (q > 0) out.push(`${q}${unit}`);
|
|
64
|
+
}
|
|
65
|
+
return sign + out.join(" ");
|
|
66
|
+
}
|
|
67
|
+
const BYTE_UNITS = [
|
|
68
|
+
"B",
|
|
69
|
+
"kB",
|
|
70
|
+
"MB",
|
|
71
|
+
"GB",
|
|
72
|
+
"TB",
|
|
73
|
+
"PB"
|
|
74
|
+
];
|
|
75
|
+
function formatBytes(bytes) {
|
|
76
|
+
if (!Number.isFinite(bytes)) return "—";
|
|
77
|
+
const sign = bytes < 0 ? "−" : "";
|
|
78
|
+
let v = Math.abs(bytes);
|
|
79
|
+
let u = 0;
|
|
80
|
+
while (v >= 1e3 && u < BYTE_UNITS.length - 1) {
|
|
81
|
+
v /= 1e3;
|
|
82
|
+
u += 1;
|
|
83
|
+
}
|
|
84
|
+
return `${sign}${nf({ maximumFractionDigits: u === 0 ? 0 : 1 }).format(v)} ${BYTE_UNITS[u]}`;
|
|
85
|
+
}
|
|
86
|
+
const identity = (n) => String(n);
|
|
87
|
+
/** Applies a formatter to whatever Recharts hands over (number, string or range). */
|
|
88
|
+
function formatAny(value, fmt) {
|
|
89
|
+
if (typeof value === "number") return fmt(value);
|
|
90
|
+
if (Array.isArray(value)) return value.map((v) => formatAny(v, fmt)).join(" – ");
|
|
91
|
+
return value == null ? "" : String(value);
|
|
92
|
+
}
|
|
93
|
+
function cx(...parts) {
|
|
94
|
+
return parts.filter(Boolean).join(" ");
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/ChartLegend.tsx
|
|
98
|
+
const legendFocus = "rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring";
|
|
99
|
+
/** Interactive legend: each entry is a toggle button that shows/hides its series. */
|
|
100
|
+
function ChartLegend({ items, hidden, onToggle, className }) {
|
|
101
|
+
return /* @__PURE__ */ jsx("div", {
|
|
102
|
+
className: cx("flex flex-wrap items-center gap-x-4 gap-y-1.5", className),
|
|
103
|
+
children: items.map((it) => {
|
|
104
|
+
const off = hidden.has(it.key);
|
|
105
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
106
|
+
type: "button",
|
|
107
|
+
"aria-pressed": !off,
|
|
108
|
+
onClick: () => onToggle(it.key),
|
|
109
|
+
className: cx("group flex items-center gap-1.5 transition-opacity", off && "opacity-40", legendFocus),
|
|
110
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
111
|
+
"aria-hidden": true,
|
|
112
|
+
className: cx("h-2.5 w-2.5 shrink-0 rounded-[3px]", off && "grayscale"),
|
|
113
|
+
style: { background: it.color }
|
|
114
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
115
|
+
className: cx("text-[12.5px] text-muted-foreground transition-colors", off ? "line-through" : "group-hover:text-foreground"),
|
|
116
|
+
children: it.label
|
|
117
|
+
})]
|
|
118
|
+
}, it.key);
|
|
119
|
+
})
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Legend state: the set of hidden series keys plus a toggle. */
|
|
123
|
+
function useHiddenSeries() {
|
|
124
|
+
const [hidden, setHidden] = useState(() => /* @__PURE__ */ new Set());
|
|
125
|
+
return [hidden, useCallback((key) => {
|
|
126
|
+
setHidden((s) => {
|
|
127
|
+
const next = new Set(s);
|
|
128
|
+
if (next.has(key)) next.delete(key);
|
|
129
|
+
else next.add(key);
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
}, [])];
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/ChartTooltip.tsx
|
|
136
|
+
function swatchOf(entry) {
|
|
137
|
+
if (entry.color) return entry.color;
|
|
138
|
+
if (entry.fill) return entry.fill;
|
|
139
|
+
const inner = entry.payload;
|
|
140
|
+
if (inner && typeof inner === "object" && "fill" in inner) {
|
|
141
|
+
const fill = inner.fill;
|
|
142
|
+
return typeof fill === "string" ? fill : void 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Brand tooltip card: label header + one row per series, values in mono. */
|
|
146
|
+
function ChartTooltip({ active, payload, label, valueFormatter = identity, seriesFormatter, labelFormatter }) {
|
|
147
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
148
|
+
const rows = payload.filter((p) => p.value != null);
|
|
149
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
150
|
+
className: "min-w-[150px] rounded-md border border-border bg-popover px-3 py-2.5 font-sans shadow-md",
|
|
151
|
+
children: [label != null && label !== "" && /* @__PURE__ */ jsx("div", {
|
|
152
|
+
className: "mb-2 border-b border-border pb-2 text-[11px] font-medium text-muted-foreground",
|
|
153
|
+
children: labelFormatter ? labelFormatter(label) : label
|
|
154
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
155
|
+
className: "flex flex-col gap-1.5",
|
|
156
|
+
children: rows.map((p, i) => {
|
|
157
|
+
const name = p.name == null ? "" : String(p.name);
|
|
158
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
159
|
+
className: "flex items-center justify-between gap-5",
|
|
160
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
161
|
+
className: "flex min-w-0 items-center gap-2",
|
|
162
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
163
|
+
"aria-hidden": true,
|
|
164
|
+
className: "h-2.5 w-2.5 shrink-0 rounded-[3px]",
|
|
165
|
+
style: { background: swatchOf(p) }
|
|
166
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
167
|
+
className: "truncate text-[12px] text-muted-foreground",
|
|
168
|
+
children: name
|
|
169
|
+
})]
|
|
170
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
171
|
+
className: "font-mono text-[12px] font-semibold tabular-nums text-foreground",
|
|
172
|
+
children: seriesFormatter ? seriesFormatter(p.value, name) : formatAny(p.value, valueFormatter)
|
|
173
|
+
})]
|
|
174
|
+
}, `${name}-${i}`);
|
|
175
|
+
})
|
|
176
|
+
})]
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** Adapts Recharts 3 `<Tooltip content>` to the brand tooltip: `content={tooltipContent({ valueFormatter })}`. */
|
|
180
|
+
function tooltipContent(options) {
|
|
181
|
+
return function BrandTooltipContent(p) {
|
|
182
|
+
return /* @__PURE__ */ jsx(ChartTooltip, {
|
|
183
|
+
active: p.active,
|
|
184
|
+
payload: p.payload,
|
|
185
|
+
label: p.label,
|
|
186
|
+
...options
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/states.tsx
|
|
192
|
+
const stateBox = "flex w-full flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 text-center";
|
|
193
|
+
/** Placeholder for a chart without data. */
|
|
194
|
+
function ChartEmpty({ height = 240, children = "No data for this period", className }) {
|
|
195
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
196
|
+
"data-chart-state": "empty",
|
|
197
|
+
className: cx(stateBox, className),
|
|
198
|
+
style: { height },
|
|
199
|
+
children: [/* @__PURE__ */ jsxs("svg", {
|
|
200
|
+
"aria-hidden": true,
|
|
201
|
+
viewBox: "0 0 24 24",
|
|
202
|
+
className: "h-5 w-5 fill-none stroke-muted-foreground",
|
|
203
|
+
strokeWidth: 1.75,
|
|
204
|
+
children: [/* @__PURE__ */ jsx("path", { d: "M3 3v18h18" }), /* @__PURE__ */ jsx("path", {
|
|
205
|
+
d: "M7 15h2M12 11h2M17 7h2",
|
|
206
|
+
strokeLinecap: "round"
|
|
207
|
+
})]
|
|
208
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
209
|
+
className: "text-[13px] text-muted-foreground",
|
|
210
|
+
children
|
|
211
|
+
})]
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const SKELETON_BARS = [
|
|
215
|
+
42,
|
|
216
|
+
64,
|
|
217
|
+
50,
|
|
218
|
+
78,
|
|
219
|
+
58,
|
|
220
|
+
88,
|
|
221
|
+
70,
|
|
222
|
+
54
|
|
223
|
+
];
|
|
224
|
+
/** Skeleton plot (bars over a baseline); the pulse stops under prefers-reduced-motion. */
|
|
225
|
+
function ChartLoading({ height = 240, label = "Loading chart", className }) {
|
|
226
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
227
|
+
role: "status",
|
|
228
|
+
"aria-label": label,
|
|
229
|
+
"data-chart-state": "loading",
|
|
230
|
+
className: cx("flex w-full items-end gap-2 border-b border-border px-2", className),
|
|
231
|
+
style: { height },
|
|
232
|
+
children: [SKELETON_BARS.map((h, i) => /* @__PURE__ */ jsx("div", {
|
|
233
|
+
"aria-hidden": true,
|
|
234
|
+
className: "flex-1 animate-pulse rounded-t-sm bg-muted motion-reduce:animate-none",
|
|
235
|
+
style: { height: `${h}%` }
|
|
236
|
+
}, i)), /* @__PURE__ */ jsxs("span", {
|
|
237
|
+
className: "sr-only",
|
|
238
|
+
children: [label, "…"]
|
|
239
|
+
})]
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** Error placeholder with an optional retry action. */
|
|
243
|
+
function ChartError({ height = 240, children = "The chart could not be loaded.", onRetry, className }) {
|
|
244
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
245
|
+
role: "alert",
|
|
246
|
+
"data-chart-state": "error",
|
|
247
|
+
className: cx(stateBox, "border-destructive/40", className),
|
|
248
|
+
style: { height },
|
|
249
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
250
|
+
className: "text-[13px] text-destructive-text",
|
|
251
|
+
children
|
|
252
|
+
}), onRetry && /* @__PURE__ */ jsx("button", {
|
|
253
|
+
type: "button",
|
|
254
|
+
onClick: onRetry,
|
|
255
|
+
className: "h-8 rounded-md border border-border bg-card px-3 text-[12.5px] font-medium text-foreground transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring motion-reduce:transition-none",
|
|
256
|
+
children: "Retry"
|
|
257
|
+
})]
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** Resolves the effective state: `ready` with no rows is `empty`. */
|
|
261
|
+
function resolveState(state, rows) {
|
|
262
|
+
const s = state ?? "ready";
|
|
263
|
+
return s === "ready" && rows === 0 ? "empty" : s;
|
|
264
|
+
}
|
|
265
|
+
/** The placeholder for a non-ready state, or `null` when the chart should render. */
|
|
266
|
+
function chartStateView(state, { height, emptyMessage, errorMessage, onRetry }) {
|
|
267
|
+
if (state === "loading") return /* @__PURE__ */ jsx(ChartLoading, { height });
|
|
268
|
+
if (state === "empty") return /* @__PURE__ */ jsx(ChartEmpty, {
|
|
269
|
+
height,
|
|
270
|
+
children: emptyMessage
|
|
271
|
+
});
|
|
272
|
+
if (state === "error") return /* @__PURE__ */ jsx(ChartError, {
|
|
273
|
+
height,
|
|
274
|
+
onRetry,
|
|
275
|
+
children: errorMessage
|
|
276
|
+
});
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
/** Plain data table: a screen-reader fallback for a chart (visually hidden by default). */
|
|
280
|
+
function ChartDataTable({ caption, columns, rows, visible = false }) {
|
|
281
|
+
return /* @__PURE__ */ jsxs("table", {
|
|
282
|
+
"data-chart-table": "",
|
|
283
|
+
className: cx(visible ? "mt-3 w-full text-[12px]" : "sr-only"),
|
|
284
|
+
children: [
|
|
285
|
+
/* @__PURE__ */ jsx("caption", {
|
|
286
|
+
className: visible ? "mb-1 text-left text-muted-foreground" : void 0,
|
|
287
|
+
children: caption
|
|
288
|
+
}),
|
|
289
|
+
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((c, i) => /* @__PURE__ */ jsx("th", {
|
|
290
|
+
scope: "col",
|
|
291
|
+
className: visible ? "text-left font-medium text-muted-foreground" : void 0,
|
|
292
|
+
children: c
|
|
293
|
+
}, i)) }) }),
|
|
294
|
+
/* @__PURE__ */ jsx("tbody", { children: rows.map((r, i) => /* @__PURE__ */ jsx("tr", { children: r.map((cell, j) => j === 0 ? /* @__PURE__ */ jsx("th", {
|
|
295
|
+
scope: "row",
|
|
296
|
+
className: visible ? "text-left font-normal text-foreground" : void 0,
|
|
297
|
+
children: cell
|
|
298
|
+
}, j) : /* @__PURE__ */ jsx("td", {
|
|
299
|
+
className: visible ? "font-mono tabular-nums text-foreground" : void 0,
|
|
300
|
+
children: cell
|
|
301
|
+
}, j)) }, i)) })
|
|
302
|
+
]
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/shared.tsx
|
|
307
|
+
/** Reads a field from a datum without widening the public generic. */
|
|
308
|
+
function field(d, key) {
|
|
309
|
+
return d[key];
|
|
310
|
+
}
|
|
311
|
+
function numField(d, key) {
|
|
312
|
+
const v = field(d, key);
|
|
313
|
+
return typeof v === "number" ? v : 0;
|
|
314
|
+
}
|
|
315
|
+
function accessibleName(kind, explicit, title, series) {
|
|
316
|
+
return explicit ?? title ?? (series.length ? `${kind}: ${series.join(", ")}` : kind);
|
|
317
|
+
}
|
|
318
|
+
/** Shared axis styling: no tick lines, no axis line, muted labels. */
|
|
319
|
+
function axisProps(t) {
|
|
320
|
+
return {
|
|
321
|
+
stroke: t.axis,
|
|
322
|
+
tickLine: false,
|
|
323
|
+
axisLine: false,
|
|
324
|
+
tick: {
|
|
325
|
+
fill: t.text,
|
|
326
|
+
fontSize: 11
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const CHART_MARGIN = {
|
|
331
|
+
top: 6,
|
|
332
|
+
right: 10,
|
|
333
|
+
left: 0,
|
|
334
|
+
bottom: 0
|
|
335
|
+
};
|
|
336
|
+
/** Figure wrapper: accessible name, optional legend above, responsive plot area, state placeholders. */
|
|
337
|
+
function ChartFrame({ label, height, legend, className, state = "ready", table, footer, figureProps, emptyMessage, errorMessage, onRetry, children }) {
|
|
338
|
+
const placeholder = chartStateView(state, {
|
|
339
|
+
height,
|
|
340
|
+
emptyMessage,
|
|
341
|
+
errorMessage,
|
|
342
|
+
onRetry
|
|
343
|
+
});
|
|
344
|
+
return /* @__PURE__ */ jsx("div", {
|
|
345
|
+
...figureProps,
|
|
346
|
+
role: "figure",
|
|
347
|
+
"aria-label": label,
|
|
348
|
+
"aria-busy": state === "loading" || void 0,
|
|
349
|
+
className: cx("w-full font-sans", className),
|
|
350
|
+
children: placeholder ?? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
351
|
+
legend,
|
|
352
|
+
/* @__PURE__ */ jsx("div", {
|
|
353
|
+
className: "w-full",
|
|
354
|
+
style: { height },
|
|
355
|
+
children: /* @__PURE__ */ jsx(ResponsiveContainer, {
|
|
356
|
+
width: "100%",
|
|
357
|
+
height: "100%",
|
|
358
|
+
children
|
|
359
|
+
})
|
|
360
|
+
}),
|
|
361
|
+
footer,
|
|
362
|
+
table
|
|
363
|
+
] })
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** Data table fallback for an index × categories dataset. */
|
|
367
|
+
function cartesianTable(caption, data, index, categories, format) {
|
|
368
|
+
return /* @__PURE__ */ jsx(ChartDataTable, {
|
|
369
|
+
caption,
|
|
370
|
+
columns: [index, ...categories],
|
|
371
|
+
rows: data.map((d) => [String(field(d, index)), ...categories.map((c) => format(field(d, c), c))])
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/ChartBrush.tsx
|
|
376
|
+
/** Clamps a range to `[0, length - 1]`, ordered, at least `minSpan` points wide when possible. */
|
|
377
|
+
function clampRange(range, length, minSpan = 1) {
|
|
378
|
+
const last = Math.max(0, length - 1);
|
|
379
|
+
if (!range) return [0, last];
|
|
380
|
+
let start = Math.round(Math.min(range[0], range[1]));
|
|
381
|
+
let end = Math.round(Math.max(range[0], range[1]));
|
|
382
|
+
start = Math.min(Math.max(0, start), last);
|
|
383
|
+
end = Math.min(Math.max(0, end), last);
|
|
384
|
+
const span = Math.min(minSpan, last);
|
|
385
|
+
if (end - start < span) {
|
|
386
|
+
if (start + span <= last) end = start + span;
|
|
387
|
+
else start = Math.max(0, end - span);
|
|
388
|
+
}
|
|
389
|
+
return [start, end];
|
|
390
|
+
}
|
|
391
|
+
/** Controlled/uncontrolled brush range; `null` inner state means "everything". */
|
|
392
|
+
function useChartRange({ length, range, defaultRange, onRangeChange }) {
|
|
393
|
+
const [inner, setInner] = useState(defaultRange ?? null);
|
|
394
|
+
const current = clampRange(range ?? inner, length);
|
|
395
|
+
const set = (next) => {
|
|
396
|
+
const clamped = clampRange(next, length);
|
|
397
|
+
setInner(clamped);
|
|
398
|
+
onRangeChange?.(clamped);
|
|
399
|
+
};
|
|
400
|
+
return [current, set];
|
|
401
|
+
}
|
|
402
|
+
const thumbClass = cx("absolute top-1/2 z-10 h-7 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none rounded-sm border border-primary bg-card shadow-sm", "after:absolute after:inset-y-2 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-primary", "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring");
|
|
403
|
+
function previewPath(values) {
|
|
404
|
+
if (values.length < 2) return "";
|
|
405
|
+
const min = Math.min(...values);
|
|
406
|
+
const span = Math.max(...values) - min || 1;
|
|
407
|
+
return values.map((val, i) => `${i === 0 ? "M" : "L"}${(i / (values.length - 1) * 100).toFixed(2)},${(36 - (val - min) / span * 32).toFixed(2)}`).join(" ");
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Range brush under a time-series chart: two keyboard-accessible handles (`role="slider"`,
|
|
411
|
+
* Arrow keys ±1, PageUp/PageDown ±10%, Home/End) and a draggable window to pan.
|
|
412
|
+
*/
|
|
413
|
+
function ChartBrush({ length, range, onRangeChange, labelOf, preview, label = "Zoom range", className }) {
|
|
414
|
+
const track = useRef(null);
|
|
415
|
+
const drag = useRef(null);
|
|
416
|
+
const last = Math.max(1, length - 1);
|
|
417
|
+
const [start, end] = range;
|
|
418
|
+
const pct = (i) => `${i / last * 100}%`;
|
|
419
|
+
const full = start === 0 && end === length - 1;
|
|
420
|
+
const step = Math.max(1, Math.round(length / 10));
|
|
421
|
+
const indexAt = (clientX) => {
|
|
422
|
+
const rect = track.current?.getBoundingClientRect();
|
|
423
|
+
if (!rect || rect.width === 0) return 0;
|
|
424
|
+
return Math.round((clientX - rect.left) / rect.width * last);
|
|
425
|
+
};
|
|
426
|
+
const begin = (e) => {
|
|
427
|
+
const part = e.currentTarget.dataset.brushHandle ?? "window";
|
|
428
|
+
const kind = part === "start" || part === "end" ? part : "window";
|
|
429
|
+
e.preventDefault();
|
|
430
|
+
e.stopPropagation();
|
|
431
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
432
|
+
drag.current = {
|
|
433
|
+
kind,
|
|
434
|
+
originIndex: indexAt(e.clientX),
|
|
435
|
+
originRange: range
|
|
436
|
+
};
|
|
437
|
+
};
|
|
438
|
+
const move = (e) => {
|
|
439
|
+
const d = drag.current;
|
|
440
|
+
if (!d) return;
|
|
441
|
+
const i = indexAt(e.clientX);
|
|
442
|
+
const [s0, e0] = d.originRange;
|
|
443
|
+
if (d.kind === "start") onRangeChange([Math.min(i, e0 - 1), e0]);
|
|
444
|
+
else if (d.kind === "end") onRangeChange([s0, Math.max(i, s0 + 1)]);
|
|
445
|
+
else {
|
|
446
|
+
const delta = Math.min(Math.max(i - d.originIndex, -s0), length - 1 - e0);
|
|
447
|
+
onRangeChange([s0 + delta, e0 + delta]);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const stop = () => {
|
|
451
|
+
drag.current = null;
|
|
452
|
+
};
|
|
453
|
+
const onKey = (which) => (e) => {
|
|
454
|
+
const value = which === "start" ? start : end;
|
|
455
|
+
const min = which === "start" ? 0 : start + 1;
|
|
456
|
+
const max = which === "start" ? end - 1 : length - 1;
|
|
457
|
+
let next = null;
|
|
458
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = value - 1;
|
|
459
|
+
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = value + 1;
|
|
460
|
+
else if (e.key === "PageDown") next = value - step;
|
|
461
|
+
else if (e.key === "PageUp") next = value + step;
|
|
462
|
+
else if (e.key === "Home") next = min;
|
|
463
|
+
else if (e.key === "End") next = max;
|
|
464
|
+
if (next == null) return;
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
const clamped = Math.min(Math.max(next, min), max);
|
|
467
|
+
onRangeChange(which === "start" ? [clamped, end] : [start, clamped]);
|
|
468
|
+
};
|
|
469
|
+
const path = preview ? previewPath(preview) : "";
|
|
470
|
+
const thumb = (which) => {
|
|
471
|
+
const value = which === "start" ? start : end;
|
|
472
|
+
return /* @__PURE__ */ jsx("div", {
|
|
473
|
+
role: "slider",
|
|
474
|
+
tabIndex: 0,
|
|
475
|
+
"aria-label": which === "start" ? "Range start" : "Range end",
|
|
476
|
+
"aria-orientation": "horizontal",
|
|
477
|
+
"aria-valuemin": which === "start" ? 0 : start + 1,
|
|
478
|
+
"aria-valuemax": which === "start" ? end - 1 : length - 1,
|
|
479
|
+
"aria-valuenow": value,
|
|
480
|
+
"aria-valuetext": labelOf(value),
|
|
481
|
+
"data-brush-handle": which,
|
|
482
|
+
className: thumbClass,
|
|
483
|
+
style: { left: pct(value) },
|
|
484
|
+
onKeyDown: onKey(which),
|
|
485
|
+
onPointerDown: begin,
|
|
486
|
+
onPointerMove: move,
|
|
487
|
+
onPointerUp: stop,
|
|
488
|
+
onPointerCancel: stop
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
if (length < 2) return null;
|
|
492
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
493
|
+
role: "group",
|
|
494
|
+
"aria-label": label,
|
|
495
|
+
"data-chart-brush": "",
|
|
496
|
+
className: cx("mt-3 px-1", className),
|
|
497
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
498
|
+
ref: track,
|
|
499
|
+
className: "relative mx-1.5 h-10 rounded-md border border-border bg-secondary/30",
|
|
500
|
+
children: [
|
|
501
|
+
path && /* @__PURE__ */ jsx("svg", {
|
|
502
|
+
"aria-hidden": true,
|
|
503
|
+
viewBox: "0 0 100 40",
|
|
504
|
+
preserveAspectRatio: "none",
|
|
505
|
+
className: "absolute inset-0 h-full w-full",
|
|
506
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
507
|
+
d: path,
|
|
508
|
+
fill: "none",
|
|
509
|
+
stroke: "hsl(var(--muted-foreground))",
|
|
510
|
+
strokeOpacity: .6,
|
|
511
|
+
strokeWidth: 1,
|
|
512
|
+
vectorEffect: "non-scaling-stroke"
|
|
513
|
+
})
|
|
514
|
+
}),
|
|
515
|
+
/* @__PURE__ */ jsx("div", {
|
|
516
|
+
"aria-hidden": true,
|
|
517
|
+
"data-brush-window": "",
|
|
518
|
+
className: "absolute inset-y-0 cursor-grab touch-none border-y border-primary/60 bg-primary/14 active:cursor-grabbing",
|
|
519
|
+
style: {
|
|
520
|
+
left: pct(start),
|
|
521
|
+
width: `${(end - start) / last * 100}%`
|
|
522
|
+
},
|
|
523
|
+
onPointerDown: begin,
|
|
524
|
+
onPointerMove: move,
|
|
525
|
+
onPointerUp: stop,
|
|
526
|
+
onPointerCancel: stop
|
|
527
|
+
}),
|
|
528
|
+
thumb("start"),
|
|
529
|
+
thumb("end")
|
|
530
|
+
]
|
|
531
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
532
|
+
className: "mt-1.5 flex items-center justify-between gap-2 text-[11.5px] text-muted-foreground",
|
|
533
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
534
|
+
className: "font-mono tabular-nums",
|
|
535
|
+
children: [
|
|
536
|
+
labelOf(start),
|
|
537
|
+
" – ",
|
|
538
|
+
labelOf(end)
|
|
539
|
+
]
|
|
540
|
+
}), !full && /* @__PURE__ */ jsx("button", {
|
|
541
|
+
type: "button",
|
|
542
|
+
onClick: () => onRangeChange([0, length - 1]),
|
|
543
|
+
className: cx("px-1 font-medium text-foreground underline-offset-2 hover:underline", "rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"),
|
|
544
|
+
children: "Reset zoom"
|
|
545
|
+
})]
|
|
546
|
+
})]
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region src/live.ts
|
|
551
|
+
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
|
|
552
|
+
function subscribeReduced(onChange) {
|
|
553
|
+
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
|
554
|
+
const mq = window.matchMedia(REDUCED_QUERY);
|
|
555
|
+
mq.addEventListener?.("change", onChange);
|
|
556
|
+
return () => mq.removeEventListener?.("change", onChange);
|
|
557
|
+
}
|
|
558
|
+
const getReduced = () => typeof window !== "undefined" && window.matchMedia ? window.matchMedia(REDUCED_QUERY).matches : false;
|
|
559
|
+
/** `true` when the user asks for reduced motion (false on the server). */
|
|
560
|
+
function usePrefersReducedMotion() {
|
|
561
|
+
return useSyncExternalStore(subscribeReduced, getReduced, () => false);
|
|
562
|
+
}
|
|
563
|
+
function reducer(state, action) {
|
|
564
|
+
const paused = state.hover || state.focus || state.manual;
|
|
565
|
+
switch (action.type) {
|
|
566
|
+
case "append": {
|
|
567
|
+
if (action.points.length === 0) return state;
|
|
568
|
+
const live = [...state.live, ...action.points].slice(-action.size);
|
|
569
|
+
return {
|
|
570
|
+
...state,
|
|
571
|
+
live,
|
|
572
|
+
pending: paused ? Math.min(action.size, state.pending + action.points.length) : 0
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
case "reset": {
|
|
576
|
+
const live = action.points.slice(-action.size);
|
|
577
|
+
return {
|
|
578
|
+
...state,
|
|
579
|
+
live,
|
|
580
|
+
frozen: paused ? live : null,
|
|
581
|
+
pending: 0
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
case "flag": {
|
|
585
|
+
if (state[action.flag] === action.on) return state;
|
|
586
|
+
const next = {
|
|
587
|
+
...state,
|
|
588
|
+
[action.flag]: action.on
|
|
589
|
+
};
|
|
590
|
+
const nowPaused = next.hover || next.focus || next.manual;
|
|
591
|
+
if (nowPaused && !paused) return {
|
|
592
|
+
...next,
|
|
593
|
+
frozen: state.live,
|
|
594
|
+
pending: 0
|
|
595
|
+
};
|
|
596
|
+
if (!nowPaused && paused) return {
|
|
597
|
+
...next,
|
|
598
|
+
frozen: null,
|
|
599
|
+
pending: 0
|
|
600
|
+
};
|
|
601
|
+
return next;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* State for a live-updating chart: a sliding window of points, with updates frozen while the
|
|
607
|
+
* user hovers or focuses the chart (so a tooltip or a value does not move under them) and a
|
|
608
|
+
* manual pause. Render with `animate={!reducedMotion}` for smooth transitions only when the
|
|
609
|
+
* user has not asked for reduced motion.
|
|
610
|
+
*/
|
|
611
|
+
function useLiveSeries({ initial = [], windowSize = 60, pauseOnInteraction = true } = {}) {
|
|
612
|
+
const size = Math.max(1, Math.floor(windowSize));
|
|
613
|
+
const [state, dispatch] = useReducer(reducer, initial, (init) => ({
|
|
614
|
+
live: init.slice(-size),
|
|
615
|
+
frozen: null,
|
|
616
|
+
pending: 0,
|
|
617
|
+
hover: false,
|
|
618
|
+
focus: false,
|
|
619
|
+
manual: false
|
|
620
|
+
}));
|
|
621
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
622
|
+
const append = useCallback((points) => dispatch({
|
|
623
|
+
type: "append",
|
|
624
|
+
points: Array.isArray(points) ? points : [points],
|
|
625
|
+
size
|
|
626
|
+
}), [size]);
|
|
627
|
+
const reset = useCallback((points = []) => dispatch({
|
|
628
|
+
type: "reset",
|
|
629
|
+
points,
|
|
630
|
+
size
|
|
631
|
+
}), [size]);
|
|
632
|
+
const pause = useCallback(() => dispatch({
|
|
633
|
+
type: "flag",
|
|
634
|
+
flag: "manual",
|
|
635
|
+
on: true
|
|
636
|
+
}), []);
|
|
637
|
+
const resume = useCallback(() => dispatch({
|
|
638
|
+
type: "flag",
|
|
639
|
+
flag: "manual",
|
|
640
|
+
on: false
|
|
641
|
+
}), []);
|
|
642
|
+
const pauseProps = useMemo(() => ({
|
|
643
|
+
onPointerEnter: () => pauseOnInteraction && dispatch({
|
|
644
|
+
type: "flag",
|
|
645
|
+
flag: "hover",
|
|
646
|
+
on: true
|
|
647
|
+
}),
|
|
648
|
+
onPointerLeave: () => dispatch({
|
|
649
|
+
type: "flag",
|
|
650
|
+
flag: "hover",
|
|
651
|
+
on: false
|
|
652
|
+
}),
|
|
653
|
+
onFocus: () => pauseOnInteraction && dispatch({
|
|
654
|
+
type: "flag",
|
|
655
|
+
flag: "focus",
|
|
656
|
+
on: true
|
|
657
|
+
}),
|
|
658
|
+
onBlur: (e) => {
|
|
659
|
+
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
660
|
+
dispatch({
|
|
661
|
+
type: "flag",
|
|
662
|
+
flag: "focus",
|
|
663
|
+
on: false
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}), [pauseOnInteraction]);
|
|
667
|
+
const paused = state.hover || state.focus || state.manual;
|
|
668
|
+
return {
|
|
669
|
+
data: paused && state.frozen ? state.frozen : state.live,
|
|
670
|
+
append,
|
|
671
|
+
reset,
|
|
672
|
+
paused,
|
|
673
|
+
manuallyPaused: state.manual,
|
|
674
|
+
pause,
|
|
675
|
+
resume,
|
|
676
|
+
togglePaused: state.manual ? resume : pause,
|
|
677
|
+
pending: state.pending,
|
|
678
|
+
reducedMotion,
|
|
679
|
+
pauseProps
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/overlays.tsx
|
|
684
|
+
/** Stroke token and contrast-safe text token of each tone. */
|
|
685
|
+
const TONE_TOKENS = {
|
|
686
|
+
neutral: {
|
|
687
|
+
stroke: "--muted-foreground",
|
|
688
|
+
text: "--muted-foreground"
|
|
689
|
+
},
|
|
690
|
+
primary: {
|
|
691
|
+
stroke: "--primary",
|
|
692
|
+
text: "--primary-text"
|
|
693
|
+
},
|
|
694
|
+
success: {
|
|
695
|
+
stroke: "--success",
|
|
696
|
+
text: "--success-text"
|
|
697
|
+
},
|
|
698
|
+
warning: {
|
|
699
|
+
stroke: "--warning",
|
|
700
|
+
text: "--warning-text"
|
|
701
|
+
},
|
|
702
|
+
destructive: {
|
|
703
|
+
stroke: "--destructive",
|
|
704
|
+
text: "--destructive-text"
|
|
705
|
+
},
|
|
706
|
+
info: {
|
|
707
|
+
stroke: "--info",
|
|
708
|
+
text: "--foreground"
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
const v = (token) => `hsl(var(${token}))`;
|
|
712
|
+
/** A stable key for an annotation (x + label). */
|
|
713
|
+
const annotationKey = (a) => `${String(a.x)}::${a.label}`;
|
|
714
|
+
function boxOf(props, key = "viewBox") {
|
|
715
|
+
const vb = props?.[key];
|
|
716
|
+
if (!vb || typeof vb.x !== "number" || typeof vb.y !== "number") return null;
|
|
717
|
+
return {
|
|
718
|
+
x: vb.x,
|
|
719
|
+
y: vb.y,
|
|
720
|
+
width: vb.width ?? 0,
|
|
721
|
+
height: vb.height ?? 0
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function plotRightOf(props) {
|
|
725
|
+
const parent = boxOf(props, "parentViewBox");
|
|
726
|
+
return parent ? parent.x + parent.width : void 0;
|
|
727
|
+
}
|
|
728
|
+
function ThresholdLabel(props) {
|
|
729
|
+
const { label, tone, vertical } = props;
|
|
730
|
+
const box = boxOf(props);
|
|
731
|
+
if (!box) return null;
|
|
732
|
+
const fill = v(TONE_TOKENS[tone].text);
|
|
733
|
+
if (vertical) return /* @__PURE__ */ jsx("text", {
|
|
734
|
+
x: box.x + 4,
|
|
735
|
+
y: box.y + 12,
|
|
736
|
+
fill,
|
|
737
|
+
fontSize: 11,
|
|
738
|
+
fontWeight: 500,
|
|
739
|
+
"data-chart-threshold-label": "",
|
|
740
|
+
children: label
|
|
741
|
+
});
|
|
742
|
+
return /* @__PURE__ */ jsx("text", {
|
|
743
|
+
x: box.x + box.width - 4,
|
|
744
|
+
y: box.y - 5,
|
|
745
|
+
textAnchor: "end",
|
|
746
|
+
fill,
|
|
747
|
+
fontSize: 11,
|
|
748
|
+
fontWeight: 500,
|
|
749
|
+
"data-chart-threshold-label": "",
|
|
750
|
+
children: label
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
function AnnotationMarker({ box, annotation, active, onActive, plotRight }) {
|
|
754
|
+
const tone = annotation.tone ?? "neutral";
|
|
755
|
+
const key = annotationKey(annotation);
|
|
756
|
+
const tip = annotation.description ? `${annotation.label}: ${annotation.description}` : annotation.label;
|
|
757
|
+
const tipWidth = Math.min(240, Math.max(90, tip.length * 6.2 + 16));
|
|
758
|
+
const tipX = plotRight != null && box.x + tipWidth + 12 > plotRight ? box.x - tipWidth - 8 : box.x + 8;
|
|
759
|
+
return /* @__PURE__ */ jsxs("g", {
|
|
760
|
+
"aria-hidden": true,
|
|
761
|
+
"data-chart-annotation": key,
|
|
762
|
+
"data-active": active || void 0,
|
|
763
|
+
onPointerEnter: () => onActive(key),
|
|
764
|
+
onPointerLeave: () => onActive(null),
|
|
765
|
+
style: { cursor: "default" },
|
|
766
|
+
children: [
|
|
767
|
+
/* @__PURE__ */ jsx("title", { children: tip }),
|
|
768
|
+
/* @__PURE__ */ jsx("path", {
|
|
769
|
+
d: `M${box.x},${box.y} l7,4 l-7,4 z`,
|
|
770
|
+
fill: v(TONE_TOKENS[tone].stroke)
|
|
771
|
+
}),
|
|
772
|
+
/* @__PURE__ */ jsx("text", {
|
|
773
|
+
x: box.x + 10,
|
|
774
|
+
y: box.y + 8,
|
|
775
|
+
fill: v(TONE_TOKENS[tone].text),
|
|
776
|
+
fontSize: 11,
|
|
777
|
+
fontWeight: active ? 600 : 500,
|
|
778
|
+
children: annotation.label
|
|
779
|
+
}),
|
|
780
|
+
/* @__PURE__ */ jsx("rect", {
|
|
781
|
+
x: box.x - 6,
|
|
782
|
+
y: box.y,
|
|
783
|
+
width: 12,
|
|
784
|
+
height: Math.max(box.height, 1),
|
|
785
|
+
fill: "transparent"
|
|
786
|
+
}),
|
|
787
|
+
active && /* @__PURE__ */ jsxs("g", {
|
|
788
|
+
"data-chart-annotation-tooltip": "",
|
|
789
|
+
children: [/* @__PURE__ */ jsx("rect", {
|
|
790
|
+
x: tipX,
|
|
791
|
+
y: box.y + 14,
|
|
792
|
+
width: tipWidth,
|
|
793
|
+
height: 24,
|
|
794
|
+
rx: 6,
|
|
795
|
+
fill: v("--popover"),
|
|
796
|
+
stroke: v("--border")
|
|
797
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
798
|
+
x: tipX + 8,
|
|
799
|
+
y: box.y + 30,
|
|
800
|
+
fill: v("--popover-foreground"),
|
|
801
|
+
fontSize: 11.5,
|
|
802
|
+
children: tip
|
|
803
|
+
})]
|
|
804
|
+
})
|
|
805
|
+
]
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
/** Recharts clones a label element with the line's `viewBox` (and the plot's `parentViewBox`). */
|
|
809
|
+
function AnnotationLabel(props) {
|
|
810
|
+
const box = boxOf(props);
|
|
811
|
+
if (!box) return null;
|
|
812
|
+
return /* @__PURE__ */ jsx(AnnotationMarker, {
|
|
813
|
+
box,
|
|
814
|
+
plotRight: plotRightOf(props),
|
|
815
|
+
annotation: props.annotation,
|
|
816
|
+
active: props.active,
|
|
817
|
+
onActive: props.onActive
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Recharts `<ReferenceLine>` elements for thresholds and annotations. Returned as an array so
|
|
822
|
+
* they are direct children of the chart.
|
|
823
|
+
*/
|
|
824
|
+
function referenceElements({ thresholds = [], annotations = [], vertical = false, categories, activeAnnotation, onActiveAnnotation, yAxisIdOf }) {
|
|
825
|
+
const out = [];
|
|
826
|
+
thresholds.forEach((th, i) => {
|
|
827
|
+
const tone = th.tone ?? "neutral";
|
|
828
|
+
const pos = vertical ? { x: th.value } : { y: th.value };
|
|
829
|
+
out.push(/* @__PURE__ */ jsx(ReferenceLine, {
|
|
830
|
+
...pos,
|
|
831
|
+
...yAxisIdOf ? { yAxisId: yAxisIdOf(th.axis ?? "left") } : {},
|
|
832
|
+
stroke: v(TONE_TOKENS[tone].stroke),
|
|
833
|
+
strokeWidth: 1.5,
|
|
834
|
+
strokeDasharray: "6 4",
|
|
835
|
+
ifOverflow: "extendDomain",
|
|
836
|
+
label: /* @__PURE__ */ jsx(ThresholdLabel, {
|
|
837
|
+
label: th.label,
|
|
838
|
+
tone,
|
|
839
|
+
vertical
|
|
840
|
+
})
|
|
841
|
+
}, `threshold-${i}`));
|
|
842
|
+
});
|
|
843
|
+
annotations.forEach((a) => {
|
|
844
|
+
if (!categories.has(String(a.x))) return;
|
|
845
|
+
const key = annotationKey(a);
|
|
846
|
+
const active = activeAnnotation === key;
|
|
847
|
+
const pos = vertical ? { y: a.x } : { x: a.x };
|
|
848
|
+
out.push(/* @__PURE__ */ jsx(ReferenceLine, {
|
|
849
|
+
...pos,
|
|
850
|
+
...yAxisIdOf ? { yAxisId: yAxisIdOf("left") } : {},
|
|
851
|
+
stroke: v(TONE_TOKENS[a.tone ?? "neutral"].stroke),
|
|
852
|
+
strokeWidth: active ? 2 : 1.5,
|
|
853
|
+
strokeDasharray: "2 3",
|
|
854
|
+
label: /* @__PURE__ */ jsx(AnnotationLabel, {
|
|
855
|
+
annotation: a,
|
|
856
|
+
active,
|
|
857
|
+
onActive: onActiveAnnotation
|
|
858
|
+
})
|
|
859
|
+
}, `annotation-${key}`));
|
|
860
|
+
});
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Text list of the thresholds and annotations under a chart: the keyboard and screen-reader
|
|
865
|
+
* route to the same information as the in-plot markers. Focusing or hovering an annotation
|
|
866
|
+
* highlights its marker and opens its tooltip in the plot.
|
|
867
|
+
*/
|
|
868
|
+
function ChartMarkers({ thresholds = [], annotations = [], valueFormatter, active = null, onActiveChange, className }) {
|
|
869
|
+
const id = useId();
|
|
870
|
+
if (thresholds.length === 0 && annotations.length === 0) return null;
|
|
871
|
+
const fmt = (n) => valueFormatter ? valueFormatter(n) : String(n);
|
|
872
|
+
return /* @__PURE__ */ jsxs("ul", {
|
|
873
|
+
"aria-label": "Chart markers",
|
|
874
|
+
className: cx("mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 px-1 text-[12px] text-muted-foreground", className),
|
|
875
|
+
children: [thresholds.map((th, i) => /* @__PURE__ */ jsxs("li", {
|
|
876
|
+
className: "flex items-center gap-1.5",
|
|
877
|
+
"data-chart-marker": "threshold",
|
|
878
|
+
children: [/* @__PURE__ */ jsx("svg", {
|
|
879
|
+
"aria-hidden": true,
|
|
880
|
+
width: "18",
|
|
881
|
+
height: "4",
|
|
882
|
+
className: "shrink-0",
|
|
883
|
+
children: /* @__PURE__ */ jsx("line", {
|
|
884
|
+
x1: "0",
|
|
885
|
+
y1: "2",
|
|
886
|
+
x2: "18",
|
|
887
|
+
y2: "2",
|
|
888
|
+
stroke: v(TONE_TOKENS[th.tone ?? "neutral"].stroke),
|
|
889
|
+
strokeWidth: 1.5,
|
|
890
|
+
strokeDasharray: "6 4"
|
|
891
|
+
})
|
|
892
|
+
}), /* @__PURE__ */ jsxs("span", { children: [
|
|
893
|
+
/* @__PURE__ */ jsx("span", {
|
|
894
|
+
className: "sr-only",
|
|
895
|
+
children: "Threshold:"
|
|
896
|
+
}),
|
|
897
|
+
" ",
|
|
898
|
+
th.label,
|
|
899
|
+
" ",
|
|
900
|
+
/* @__PURE__ */ jsxs("span", {
|
|
901
|
+
className: "font-mono tabular-nums",
|
|
902
|
+
children: [
|
|
903
|
+
"(",
|
|
904
|
+
fmt(th.value),
|
|
905
|
+
")"
|
|
906
|
+
]
|
|
907
|
+
})
|
|
908
|
+
] })]
|
|
909
|
+
}, `t-${i}`)), annotations.map((a) => {
|
|
910
|
+
const key = annotationKey(a);
|
|
911
|
+
const descId = `${id}-${key}`;
|
|
912
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
913
|
+
"data-chart-marker": "annotation",
|
|
914
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
915
|
+
type: "button",
|
|
916
|
+
"aria-describedby": a.description ? descId : void 0,
|
|
917
|
+
onFocus: () => onActiveChange?.(key),
|
|
918
|
+
onBlur: () => onActiveChange?.(null),
|
|
919
|
+
onPointerEnter: () => onActiveChange?.(key),
|
|
920
|
+
onPointerLeave: () => onActiveChange?.(null),
|
|
921
|
+
onKeyDown: (e) => {
|
|
922
|
+
if (e.key === "Escape") onActiveChange?.(null);
|
|
923
|
+
},
|
|
924
|
+
className: cx("flex items-center gap-1.5 hover:text-foreground", active === key && "text-foreground", legendFocus),
|
|
925
|
+
children: [/* @__PURE__ */ jsx("svg", {
|
|
926
|
+
"aria-hidden": true,
|
|
927
|
+
width: "10",
|
|
928
|
+
height: "10",
|
|
929
|
+
viewBox: "0 0 10 10",
|
|
930
|
+
className: "shrink-0",
|
|
931
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
932
|
+
d: "M1,1 l8,4 l-8,4 z",
|
|
933
|
+
fill: v(TONE_TOKENS[a.tone ?? "neutral"].stroke)
|
|
934
|
+
})
|
|
935
|
+
}), /* @__PURE__ */ jsxs("span", { children: [
|
|
936
|
+
/* @__PURE__ */ jsx("span", {
|
|
937
|
+
className: "sr-only",
|
|
938
|
+
children: `Marker at ${String(a.x)}:`
|
|
939
|
+
}),
|
|
940
|
+
" ",
|
|
941
|
+
a.label
|
|
942
|
+
] })]
|
|
943
|
+
}), a.description && /* @__PURE__ */ jsx("span", {
|
|
944
|
+
id: descId,
|
|
945
|
+
className: "sr-only",
|
|
946
|
+
children: a.description
|
|
947
|
+
})]
|
|
948
|
+
}, key);
|
|
949
|
+
})]
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
/** State of the highlighted annotation (shared by the plot markers and the marker list). */
|
|
953
|
+
function useActiveAnnotation() {
|
|
954
|
+
return useState(null);
|
|
955
|
+
}
|
|
956
|
+
//#endregion
|
|
957
|
+
//#region src/cartesian-extras.tsx
|
|
958
|
+
function useCartesianExtras(data, index, { thresholds = [], annotations = [], brush = false, range, defaultRange, onRangeChange, animate = false }, { valueFormatter, previewKey, vertical = false, yAxisIdOf } = {}) {
|
|
959
|
+
const [current, setRange] = useChartRange({
|
|
960
|
+
length: data.length,
|
|
961
|
+
range,
|
|
962
|
+
defaultRange,
|
|
963
|
+
onRangeChange
|
|
964
|
+
});
|
|
965
|
+
const [active, setActive] = useActiveAnnotation();
|
|
966
|
+
const reduced = usePrefersReducedMotion();
|
|
967
|
+
const view = brush ? data.slice(current[0], current[1] + 1) : data;
|
|
968
|
+
const categories = new Set(view.map((d) => String(field(d, index))));
|
|
969
|
+
const shown = annotations.filter((a) => categories.has(String(a.x)));
|
|
970
|
+
const references = referenceElements({
|
|
971
|
+
thresholds,
|
|
972
|
+
annotations: shown,
|
|
973
|
+
vertical,
|
|
974
|
+
categories,
|
|
975
|
+
activeAnnotation: active,
|
|
976
|
+
onActiveAnnotation: setActive,
|
|
977
|
+
yAxisIdOf
|
|
978
|
+
});
|
|
979
|
+
const labelOf = (i) => {
|
|
980
|
+
const d = data[i];
|
|
981
|
+
return d ? String(field(d, index)) : "";
|
|
982
|
+
};
|
|
983
|
+
return {
|
|
984
|
+
view,
|
|
985
|
+
references,
|
|
986
|
+
footer: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ChartMarkers, {
|
|
987
|
+
thresholds,
|
|
988
|
+
annotations: shown,
|
|
989
|
+
valueFormatter,
|
|
990
|
+
active: active && shown.some((a) => annotationKey(a) === active) ? active : null,
|
|
991
|
+
onActiveChange: setActive
|
|
992
|
+
}), brush && /* @__PURE__ */ jsx(ChartBrush, {
|
|
993
|
+
length: data.length,
|
|
994
|
+
range: current,
|
|
995
|
+
onRangeChange: setRange,
|
|
996
|
+
labelOf,
|
|
997
|
+
preview: previewKey ? data.map((d) => numField(d, previewKey)) : void 0
|
|
998
|
+
})] }),
|
|
999
|
+
animation: {
|
|
1000
|
+
isAnimationActive: animate && !reduced,
|
|
1001
|
+
animationDuration: 300
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
//#endregion
|
|
1006
|
+
//#region src/theme.ts
|
|
1007
|
+
/** Series colour name → brand token. Green primary is the hero; the rest are categorical accents. */
|
|
1008
|
+
const SERIES_TOKENS = {
|
|
1009
|
+
primary: "--primary",
|
|
1010
|
+
emerald: "--brand-accent",
|
|
1011
|
+
violet: "--category-violet",
|
|
1012
|
+
cyan: "--category-cyan",
|
|
1013
|
+
amber: "--category-amber",
|
|
1014
|
+
rose: "--category-rose",
|
|
1015
|
+
info: "--info"
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* Default series order: primary first, then the categorical accents ordered so every adjacent
|
|
1019
|
+
* pair differs in luminance by ≥ 1.5:1 in all three themes (see `palette.ts`).
|
|
1020
|
+
*/
|
|
1021
|
+
const CHART_COLORS = [
|
|
1022
|
+
"primary",
|
|
1023
|
+
"violet",
|
|
1024
|
+
"cyan",
|
|
1025
|
+
"rose",
|
|
1026
|
+
"amber"
|
|
1027
|
+
];
|
|
1028
|
+
/** `hsl(var(--token))`: resolved by the browser (SVG fill/stroke and inline styles), so it is SSR-safe and follows theme switches. */
|
|
1029
|
+
const cssVarColor = (token, alpha) => alpha == null ? `hsl(var(${token}))` : `hsl(var(${token}) / ${alpha})`;
|
|
1030
|
+
function buildTheme(theme, c) {
|
|
1031
|
+
return {
|
|
1032
|
+
theme,
|
|
1033
|
+
grid: c("--border"),
|
|
1034
|
+
axis: c("--muted-foreground", .5),
|
|
1035
|
+
text: c("--muted-foreground"),
|
|
1036
|
+
cursor: c("--muted-foreground", .32),
|
|
1037
|
+
barCursor: c("--secondary", .55),
|
|
1038
|
+
surface: c("--card"),
|
|
1039
|
+
color: (name, alpha) => c(SERIES_TOKENS[name] ?? SERIES_TOKENS.primary, alpha)
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
const varThemes = /* @__PURE__ */ new Map();
|
|
1043
|
+
let resolvedSnapshot = null;
|
|
1044
|
+
function varTheme(theme) {
|
|
1045
|
+
let t = varThemes.get(theme);
|
|
1046
|
+
if (!t) {
|
|
1047
|
+
t = buildTheme(theme, cssVarColor);
|
|
1048
|
+
varThemes.set(theme, t);
|
|
1049
|
+
}
|
|
1050
|
+
return t;
|
|
1051
|
+
}
|
|
1052
|
+
function subscribe(onChange) {
|
|
1053
|
+
return observeTheme(() => {
|
|
1054
|
+
resolvedSnapshot = null;
|
|
1055
|
+
onChange();
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const SERVER_THEME = varTheme("dark");
|
|
1059
|
+
const getServerSnapshot = () => SERVER_THEME;
|
|
1060
|
+
const getVarSnapshot = () => varTheme(currentTheme());
|
|
1061
|
+
function getResolvedSnapshot() {
|
|
1062
|
+
const theme = currentTheme();
|
|
1063
|
+
if (!resolvedSnapshot || resolvedSnapshot.theme !== theme) resolvedSnapshot = buildTheme(theme, (token, alpha) => tokenColor(token, alpha));
|
|
1064
|
+
return resolvedSnapshot;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Brand colours for Recharts. By default every colour is a `hsl(var(--token))` string: SVG
|
|
1068
|
+
* fill/stroke attributes and inline styles resolve it, so server and client render the same
|
|
1069
|
+
* markup and a theme switch re-skins the chart without re-rendering. `theme` follows the
|
|
1070
|
+
* <html> class after mount. With `{ resolved: true }` colours are read from the live CSS
|
|
1071
|
+
* variables once mounted (the server and hydration render use the `var()` strings).
|
|
1072
|
+
*/
|
|
1073
|
+
function useChartTheme({ resolved = false } = {}) {
|
|
1074
|
+
return useSyncExternalStore(subscribe, resolved ? getResolvedSnapshot : getVarSnapshot, getServerSnapshot);
|
|
1075
|
+
}
|
|
1076
|
+
/** Colour for the i-th series, cycling through `colors`. */
|
|
1077
|
+
function seriesColor(t, colors, i) {
|
|
1078
|
+
const name = colors.length ? colors[i % colors.length] : void 0;
|
|
1079
|
+
return t.color(name ?? "primary");
|
|
1080
|
+
}
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region src/AreaChart.tsx
|
|
1083
|
+
/** Area chart: flat fills (no gradient), no entry animation, interactive legend. */
|
|
1084
|
+
function AreaChart({ data, index, categories, colors = CHART_COLORS, valueFormatter = identity, stacked = false, showLegend = true, showGrid = true, showYAxis = true, curve = "monotone", height = 288, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false, thresholds, annotations, brush, range, defaultRange, onRangeChange, animate }) {
|
|
1085
|
+
const t = useChartTheme();
|
|
1086
|
+
const [hidden, toggle] = useHiddenSeries();
|
|
1087
|
+
const x = useCartesianExtras(data, index, {
|
|
1088
|
+
thresholds,
|
|
1089
|
+
annotations,
|
|
1090
|
+
brush,
|
|
1091
|
+
range,
|
|
1092
|
+
defaultRange,
|
|
1093
|
+
onRangeChange,
|
|
1094
|
+
animate
|
|
1095
|
+
}, {
|
|
1096
|
+
valueFormatter,
|
|
1097
|
+
previewKey: categories[0]
|
|
1098
|
+
});
|
|
1099
|
+
const col = (i) => seriesColor(t, colors, i);
|
|
1100
|
+
const legend = categories.map((c, i) => ({
|
|
1101
|
+
key: c,
|
|
1102
|
+
label: c,
|
|
1103
|
+
color: col(i)
|
|
1104
|
+
}));
|
|
1105
|
+
const name = accessibleName("Area chart", ariaLabel, title, categories);
|
|
1106
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
1107
|
+
label: name,
|
|
1108
|
+
state: resolveState(state, data.length),
|
|
1109
|
+
emptyMessage,
|
|
1110
|
+
errorMessage,
|
|
1111
|
+
onRetry,
|
|
1112
|
+
table: dataTable && cartesianTable(name, x.view, index, categories, (v) => formatAny(v, valueFormatter)),
|
|
1113
|
+
height,
|
|
1114
|
+
className,
|
|
1115
|
+
footer: x.footer,
|
|
1116
|
+
legend: showLegend && /* @__PURE__ */ jsx(ChartLegend, {
|
|
1117
|
+
className: "mb-3 px-1",
|
|
1118
|
+
items: legend,
|
|
1119
|
+
hidden,
|
|
1120
|
+
onToggle: toggle
|
|
1121
|
+
}),
|
|
1122
|
+
children: /* @__PURE__ */ jsxs(AreaChart$1, {
|
|
1123
|
+
data: x.view,
|
|
1124
|
+
margin: CHART_MARGIN,
|
|
1125
|
+
accessibilityLayer: true,
|
|
1126
|
+
title,
|
|
1127
|
+
desc: description,
|
|
1128
|
+
children: [
|
|
1129
|
+
showGrid && /* @__PURE__ */ jsx(CartesianGrid, {
|
|
1130
|
+
strokeDasharray: "3 3",
|
|
1131
|
+
stroke: t.grid,
|
|
1132
|
+
vertical: false
|
|
1133
|
+
}),
|
|
1134
|
+
/* @__PURE__ */ jsx(XAxis, {
|
|
1135
|
+
dataKey: index,
|
|
1136
|
+
...axisProps(t),
|
|
1137
|
+
dy: 8,
|
|
1138
|
+
minTickGap: 20
|
|
1139
|
+
}),
|
|
1140
|
+
showYAxis && /* @__PURE__ */ jsx(YAxis, {
|
|
1141
|
+
...axisProps(t),
|
|
1142
|
+
width: 50,
|
|
1143
|
+
tickFormatter: valueFormatter
|
|
1144
|
+
}),
|
|
1145
|
+
x.references,
|
|
1146
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
1147
|
+
isAnimationActive: false,
|
|
1148
|
+
cursor: {
|
|
1149
|
+
stroke: t.cursor,
|
|
1150
|
+
strokeWidth: 1
|
|
1151
|
+
},
|
|
1152
|
+
content: tooltipContent({ valueFormatter })
|
|
1153
|
+
}),
|
|
1154
|
+
categories.map((c, i) => /* @__PURE__ */ jsx(Area, {
|
|
1155
|
+
type: curve,
|
|
1156
|
+
dataKey: c,
|
|
1157
|
+
name: c,
|
|
1158
|
+
hide: hidden.has(c),
|
|
1159
|
+
stackId: stacked ? "1" : void 0,
|
|
1160
|
+
stroke: col(i),
|
|
1161
|
+
strokeWidth: 2,
|
|
1162
|
+
fill: col(i),
|
|
1163
|
+
fillOpacity: stacked ? .82 : .14,
|
|
1164
|
+
dot: false,
|
|
1165
|
+
activeDot: {
|
|
1166
|
+
r: 3.5,
|
|
1167
|
+
strokeWidth: 0
|
|
1168
|
+
},
|
|
1169
|
+
...x.animation
|
|
1170
|
+
}, c))
|
|
1171
|
+
]
|
|
1172
|
+
})
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
//#endregion
|
|
1176
|
+
//#region src/BarChart.tsx
|
|
1177
|
+
/** Bar chart: vertical, horizontal or stacked; flat fills, no animation. */
|
|
1178
|
+
function BarChart({ data, index, categories, colors = CHART_COLORS, valueFormatter = identity, stacked = false, layout = "horizontal", showLegend = true, showGrid = true, showYAxis = true, height = 288, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false, thresholds, annotations, brush, range, defaultRange, onRangeChange, animate }) {
|
|
1179
|
+
const t = useChartTheme();
|
|
1180
|
+
const [hidden, toggle] = useHiddenSeries();
|
|
1181
|
+
const col = (i) => seriesColor(t, colors, i);
|
|
1182
|
+
const legend = categories.map((c, i) => ({
|
|
1183
|
+
key: c,
|
|
1184
|
+
label: c,
|
|
1185
|
+
color: col(i)
|
|
1186
|
+
}));
|
|
1187
|
+
const vertical = layout === "vertical";
|
|
1188
|
+
const x = useCartesianExtras(data, index, {
|
|
1189
|
+
thresholds,
|
|
1190
|
+
annotations,
|
|
1191
|
+
brush,
|
|
1192
|
+
range,
|
|
1193
|
+
defaultRange,
|
|
1194
|
+
onRangeChange,
|
|
1195
|
+
animate
|
|
1196
|
+
}, {
|
|
1197
|
+
valueFormatter,
|
|
1198
|
+
previewKey: categories[0],
|
|
1199
|
+
vertical
|
|
1200
|
+
});
|
|
1201
|
+
const radius = stacked ? 0 : vertical ? [
|
|
1202
|
+
0,
|
|
1203
|
+
3,
|
|
1204
|
+
3,
|
|
1205
|
+
0
|
|
1206
|
+
] : [
|
|
1207
|
+
3,
|
|
1208
|
+
3,
|
|
1209
|
+
0,
|
|
1210
|
+
0
|
|
1211
|
+
];
|
|
1212
|
+
const name = accessibleName("Bar chart", ariaLabel, title, categories);
|
|
1213
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
1214
|
+
label: name,
|
|
1215
|
+
state: resolveState(state, data.length),
|
|
1216
|
+
emptyMessage,
|
|
1217
|
+
errorMessage,
|
|
1218
|
+
onRetry,
|
|
1219
|
+
table: dataTable && cartesianTable(name, x.view, index, categories, (v) => formatAny(v, valueFormatter)),
|
|
1220
|
+
height,
|
|
1221
|
+
className,
|
|
1222
|
+
footer: x.footer,
|
|
1223
|
+
legend: showLegend && categories.length > 1 && /* @__PURE__ */ jsx(ChartLegend, {
|
|
1224
|
+
className: "mb-3 px-1",
|
|
1225
|
+
items: legend,
|
|
1226
|
+
hidden,
|
|
1227
|
+
onToggle: toggle
|
|
1228
|
+
}),
|
|
1229
|
+
children: /* @__PURE__ */ jsxs(BarChart$1, {
|
|
1230
|
+
data: x.view,
|
|
1231
|
+
layout,
|
|
1232
|
+
margin: CHART_MARGIN,
|
|
1233
|
+
barCategoryGap: vertical ? "22%" : "28%",
|
|
1234
|
+
accessibilityLayer: true,
|
|
1235
|
+
title,
|
|
1236
|
+
desc: description,
|
|
1237
|
+
children: [
|
|
1238
|
+
showGrid && /* @__PURE__ */ jsx(CartesianGrid, {
|
|
1239
|
+
strokeDasharray: "3 3",
|
|
1240
|
+
stroke: t.grid,
|
|
1241
|
+
vertical,
|
|
1242
|
+
horizontal: !vertical
|
|
1243
|
+
}),
|
|
1244
|
+
vertical ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(XAxis, {
|
|
1245
|
+
type: "number",
|
|
1246
|
+
...axisProps(t),
|
|
1247
|
+
tickFormatter: valueFormatter
|
|
1248
|
+
}), /* @__PURE__ */ jsx(YAxis, {
|
|
1249
|
+
type: "category",
|
|
1250
|
+
dataKey: index,
|
|
1251
|
+
...axisProps(t),
|
|
1252
|
+
width: 92
|
|
1253
|
+
})] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(XAxis, {
|
|
1254
|
+
dataKey: index,
|
|
1255
|
+
...axisProps(t),
|
|
1256
|
+
dy: 8,
|
|
1257
|
+
minTickGap: 16
|
|
1258
|
+
}), showYAxis && /* @__PURE__ */ jsx(YAxis, {
|
|
1259
|
+
...axisProps(t),
|
|
1260
|
+
width: 50,
|
|
1261
|
+
tickFormatter: valueFormatter
|
|
1262
|
+
})] }),
|
|
1263
|
+
x.references,
|
|
1264
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
1265
|
+
isAnimationActive: false,
|
|
1266
|
+
cursor: { fill: t.barCursor },
|
|
1267
|
+
content: tooltipContent({ valueFormatter })
|
|
1268
|
+
}),
|
|
1269
|
+
categories.map((c, i) => /* @__PURE__ */ jsx(Bar, {
|
|
1270
|
+
dataKey: c,
|
|
1271
|
+
name: c,
|
|
1272
|
+
hide: hidden.has(c),
|
|
1273
|
+
stackId: stacked ? "1" : void 0,
|
|
1274
|
+
fill: col(i),
|
|
1275
|
+
radius,
|
|
1276
|
+
maxBarSize: vertical ? 22 : 46,
|
|
1277
|
+
...x.animation
|
|
1278
|
+
}, c))
|
|
1279
|
+
]
|
|
1280
|
+
})
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
//#endregion
|
|
1284
|
+
//#region src/LineChart.tsx
|
|
1285
|
+
const asTick = (v) => typeof v === "number" ? v : String(v);
|
|
1286
|
+
/** Line chart: 2px strokes, no dots until hover, no animation. */
|
|
1287
|
+
function LineChart({ data, index, categories, colors = CHART_COLORS, valueFormatter = identity, showLegend = true, showGrid = true, showYAxis = true, startEndOnly = false, curve = "monotone", height = 288, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false, thresholds, annotations, brush, range, defaultRange, onRangeChange, animate }) {
|
|
1288
|
+
const t = useChartTheme();
|
|
1289
|
+
const [hidden, toggle] = useHiddenSeries();
|
|
1290
|
+
const x = useCartesianExtras(data, index, {
|
|
1291
|
+
thresholds,
|
|
1292
|
+
annotations,
|
|
1293
|
+
brush,
|
|
1294
|
+
range,
|
|
1295
|
+
defaultRange,
|
|
1296
|
+
onRangeChange,
|
|
1297
|
+
animate
|
|
1298
|
+
}, {
|
|
1299
|
+
valueFormatter,
|
|
1300
|
+
previewKey: categories[0]
|
|
1301
|
+
});
|
|
1302
|
+
const col = (i) => seriesColor(t, colors, i);
|
|
1303
|
+
const legend = categories.map((c, i) => ({
|
|
1304
|
+
key: c,
|
|
1305
|
+
label: c,
|
|
1306
|
+
color: col(i)
|
|
1307
|
+
}));
|
|
1308
|
+
const first = x.view[0];
|
|
1309
|
+
const last = x.view[x.view.length - 1];
|
|
1310
|
+
const xTicks = startEndOnly && first && last ? [asTick(field(first, index)), asTick(field(last, index))] : void 0;
|
|
1311
|
+
const name = accessibleName("Line chart", ariaLabel, title, categories);
|
|
1312
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
1313
|
+
label: name,
|
|
1314
|
+
state: resolveState(state, data.length),
|
|
1315
|
+
emptyMessage,
|
|
1316
|
+
errorMessage,
|
|
1317
|
+
onRetry,
|
|
1318
|
+
table: dataTable && cartesianTable(name, x.view, index, categories, (v) => formatAny(v, valueFormatter)),
|
|
1319
|
+
height,
|
|
1320
|
+
className,
|
|
1321
|
+
footer: x.footer,
|
|
1322
|
+
legend: showLegend && /* @__PURE__ */ jsx(ChartLegend, {
|
|
1323
|
+
className: "mb-3 px-1",
|
|
1324
|
+
items: legend,
|
|
1325
|
+
hidden,
|
|
1326
|
+
onToggle: toggle
|
|
1327
|
+
}),
|
|
1328
|
+
children: /* @__PURE__ */ jsxs(LineChart$1, {
|
|
1329
|
+
data: x.view,
|
|
1330
|
+
margin: CHART_MARGIN,
|
|
1331
|
+
accessibilityLayer: true,
|
|
1332
|
+
title,
|
|
1333
|
+
desc: description,
|
|
1334
|
+
children: [
|
|
1335
|
+
showGrid && /* @__PURE__ */ jsx(CartesianGrid, {
|
|
1336
|
+
strokeDasharray: "3 3",
|
|
1337
|
+
stroke: t.grid,
|
|
1338
|
+
vertical: false
|
|
1339
|
+
}),
|
|
1340
|
+
/* @__PURE__ */ jsx(XAxis, {
|
|
1341
|
+
dataKey: index,
|
|
1342
|
+
...axisProps(t),
|
|
1343
|
+
dy: 8,
|
|
1344
|
+
minTickGap: startEndOnly ? 0 : 20,
|
|
1345
|
+
ticks: xTicks,
|
|
1346
|
+
interval: startEndOnly ? "preserveStartEnd" : void 0
|
|
1347
|
+
}),
|
|
1348
|
+
showYAxis && /* @__PURE__ */ jsx(YAxis, {
|
|
1349
|
+
...axisProps(t),
|
|
1350
|
+
width: 50,
|
|
1351
|
+
tickFormatter: valueFormatter
|
|
1352
|
+
}),
|
|
1353
|
+
x.references,
|
|
1354
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
1355
|
+
isAnimationActive: false,
|
|
1356
|
+
cursor: {
|
|
1357
|
+
stroke: t.cursor,
|
|
1358
|
+
strokeWidth: 1
|
|
1359
|
+
},
|
|
1360
|
+
content: tooltipContent({ valueFormatter })
|
|
1361
|
+
}),
|
|
1362
|
+
categories.map((c, i) => /* @__PURE__ */ jsx(Line, {
|
|
1363
|
+
type: curve,
|
|
1364
|
+
dataKey: c,
|
|
1365
|
+
name: c,
|
|
1366
|
+
hide: hidden.has(c),
|
|
1367
|
+
stroke: col(i),
|
|
1368
|
+
strokeWidth: 2,
|
|
1369
|
+
dot: false,
|
|
1370
|
+
activeDot: {
|
|
1371
|
+
r: 3.5,
|
|
1372
|
+
strokeWidth: 0
|
|
1373
|
+
},
|
|
1374
|
+
...x.animation
|
|
1375
|
+
}, c))
|
|
1376
|
+
]
|
|
1377
|
+
})
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
//#endregion
|
|
1381
|
+
//#region src/ComboChart.tsx
|
|
1382
|
+
/** Combo chart: bars on the left axis, a line on the right axis. */
|
|
1383
|
+
function ComboChart({ data, index, barSeries, lineSeries, barColor = "primary", lineColor = "amber", barFormatter = identity, lineFormatter = identity, showLegend = true, showGrid = true, height = 300, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false, thresholds, annotations, brush, range, defaultRange, onRangeChange, animate }) {
|
|
1384
|
+
const t = useChartTheme();
|
|
1385
|
+
const [hidden, toggle] = useHiddenSeries();
|
|
1386
|
+
const x = useCartesianExtras(data, index, {
|
|
1387
|
+
thresholds,
|
|
1388
|
+
annotations,
|
|
1389
|
+
brush,
|
|
1390
|
+
range,
|
|
1391
|
+
defaultRange,
|
|
1392
|
+
onRangeChange,
|
|
1393
|
+
animate
|
|
1394
|
+
}, {
|
|
1395
|
+
valueFormatter: barFormatter,
|
|
1396
|
+
previewKey: barSeries,
|
|
1397
|
+
yAxisIdOf: (axis) => axis
|
|
1398
|
+
});
|
|
1399
|
+
const bc = t.color(barColor);
|
|
1400
|
+
const lc = t.color(lineColor);
|
|
1401
|
+
const legend = [{
|
|
1402
|
+
key: barSeries,
|
|
1403
|
+
label: barSeries,
|
|
1404
|
+
color: bc
|
|
1405
|
+
}, {
|
|
1406
|
+
key: lineSeries,
|
|
1407
|
+
label: lineSeries,
|
|
1408
|
+
color: lc
|
|
1409
|
+
}];
|
|
1410
|
+
const fmtSeries = (v, name) => formatAny(v, name === lineSeries ? lineFormatter : barFormatter);
|
|
1411
|
+
const name = accessibleName("Combo chart", ariaLabel, title, [barSeries, lineSeries]);
|
|
1412
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
1413
|
+
label: name,
|
|
1414
|
+
state: resolveState(state, data.length),
|
|
1415
|
+
emptyMessage,
|
|
1416
|
+
errorMessage,
|
|
1417
|
+
onRetry,
|
|
1418
|
+
table: dataTable && cartesianTable(name, x.view, index, [barSeries, lineSeries], fmtSeries),
|
|
1419
|
+
height,
|
|
1420
|
+
className,
|
|
1421
|
+
footer: x.footer,
|
|
1422
|
+
legend: showLegend && /* @__PURE__ */ jsx(ChartLegend, {
|
|
1423
|
+
className: "mb-3 px-1",
|
|
1424
|
+
items: legend,
|
|
1425
|
+
hidden,
|
|
1426
|
+
onToggle: toggle
|
|
1427
|
+
}),
|
|
1428
|
+
children: /* @__PURE__ */ jsxs(ComposedChart, {
|
|
1429
|
+
data: x.view,
|
|
1430
|
+
margin: {
|
|
1431
|
+
top: 6,
|
|
1432
|
+
right: 6,
|
|
1433
|
+
left: 0,
|
|
1434
|
+
bottom: 0
|
|
1435
|
+
},
|
|
1436
|
+
barCategoryGap: "30%",
|
|
1437
|
+
accessibilityLayer: true,
|
|
1438
|
+
title,
|
|
1439
|
+
desc: description,
|
|
1440
|
+
children: [
|
|
1441
|
+
showGrid && /* @__PURE__ */ jsx(CartesianGrid, {
|
|
1442
|
+
strokeDasharray: "3 3",
|
|
1443
|
+
stroke: t.grid,
|
|
1444
|
+
vertical: false
|
|
1445
|
+
}),
|
|
1446
|
+
/* @__PURE__ */ jsx(XAxis, {
|
|
1447
|
+
dataKey: index,
|
|
1448
|
+
...axisProps(t),
|
|
1449
|
+
dy: 8,
|
|
1450
|
+
minTickGap: 16
|
|
1451
|
+
}),
|
|
1452
|
+
/* @__PURE__ */ jsx(YAxis, {
|
|
1453
|
+
yAxisId: "left",
|
|
1454
|
+
...axisProps(t),
|
|
1455
|
+
width: 48,
|
|
1456
|
+
tickFormatter: barFormatter
|
|
1457
|
+
}),
|
|
1458
|
+
/* @__PURE__ */ jsx(YAxis, {
|
|
1459
|
+
yAxisId: "right",
|
|
1460
|
+
orientation: "right",
|
|
1461
|
+
...axisProps(t),
|
|
1462
|
+
width: 48,
|
|
1463
|
+
tickFormatter: lineFormatter
|
|
1464
|
+
}),
|
|
1465
|
+
x.references,
|
|
1466
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
1467
|
+
isAnimationActive: false,
|
|
1468
|
+
cursor: { fill: t.barCursor },
|
|
1469
|
+
content: tooltipContent({ seriesFormatter: fmtSeries })
|
|
1470
|
+
}),
|
|
1471
|
+
/* @__PURE__ */ jsx(Bar, {
|
|
1472
|
+
yAxisId: "left",
|
|
1473
|
+
dataKey: barSeries,
|
|
1474
|
+
name: barSeries,
|
|
1475
|
+
hide: hidden.has(barSeries),
|
|
1476
|
+
fill: bc,
|
|
1477
|
+
radius: [
|
|
1478
|
+
3,
|
|
1479
|
+
3,
|
|
1480
|
+
0,
|
|
1481
|
+
0
|
|
1482
|
+
],
|
|
1483
|
+
maxBarSize: 42,
|
|
1484
|
+
...x.animation
|
|
1485
|
+
}),
|
|
1486
|
+
/* @__PURE__ */ jsx(Line, {
|
|
1487
|
+
yAxisId: "right",
|
|
1488
|
+
type: "monotone",
|
|
1489
|
+
dataKey: lineSeries,
|
|
1490
|
+
name: lineSeries,
|
|
1491
|
+
hide: hidden.has(lineSeries),
|
|
1492
|
+
stroke: lc,
|
|
1493
|
+
strokeWidth: 2,
|
|
1494
|
+
dot: false,
|
|
1495
|
+
activeDot: {
|
|
1496
|
+
r: 3.5,
|
|
1497
|
+
strokeWidth: 0
|
|
1498
|
+
},
|
|
1499
|
+
...x.animation
|
|
1500
|
+
})
|
|
1501
|
+
]
|
|
1502
|
+
})
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region src/DonutChart.tsx
|
|
1507
|
+
/** Donut / pie with a clickable value legend; hidden slices drop out of the total. */
|
|
1508
|
+
function DonutChart({ data, index, category, colors = CHART_COLORS, valueFormatter = identity, variant = "donut", showLegend = true, centerLabel, height = 264, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false }) {
|
|
1509
|
+
const t = useChartTheme();
|
|
1510
|
+
const [hidden, toggle] = useHiddenSeries();
|
|
1511
|
+
const slices = data.map((d, i) => ({
|
|
1512
|
+
name: String(field(d, index)),
|
|
1513
|
+
value: numField(d, category),
|
|
1514
|
+
fill: seriesColor(t, colors, i)
|
|
1515
|
+
}));
|
|
1516
|
+
const shown = slices.filter((s) => !hidden.has(s.name));
|
|
1517
|
+
const total = shown.reduce((sum, s) => sum + s.value, 0);
|
|
1518
|
+
const isDonut = variant === "donut";
|
|
1519
|
+
const name = accessibleName(isDonut ? "Donut chart" : "Pie chart", ariaLabel, title, slices.map((s) => s.name));
|
|
1520
|
+
const effective = resolveState(state, slices.length);
|
|
1521
|
+
const placeholder = chartStateView(effective, {
|
|
1522
|
+
height,
|
|
1523
|
+
emptyMessage,
|
|
1524
|
+
errorMessage,
|
|
1525
|
+
onRetry
|
|
1526
|
+
});
|
|
1527
|
+
if (placeholder) return /* @__PURE__ */ jsx("div", {
|
|
1528
|
+
role: "figure",
|
|
1529
|
+
"aria-label": name,
|
|
1530
|
+
"aria-busy": effective === "loading" || void 0,
|
|
1531
|
+
className: cx("w-full font-sans", className),
|
|
1532
|
+
children: placeholder
|
|
1533
|
+
});
|
|
1534
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1535
|
+
role: "figure",
|
|
1536
|
+
"aria-label": name,
|
|
1537
|
+
className: cx("flex flex-col items-center gap-6 font-sans sm:flex-row", className),
|
|
1538
|
+
children: [
|
|
1539
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1540
|
+
className: "relative shrink-0",
|
|
1541
|
+
style: {
|
|
1542
|
+
width: height,
|
|
1543
|
+
height
|
|
1544
|
+
},
|
|
1545
|
+
children: [/* @__PURE__ */ jsx(ResponsiveContainer, {
|
|
1546
|
+
width: "100%",
|
|
1547
|
+
height: "100%",
|
|
1548
|
+
children: /* @__PURE__ */ jsxs(PieChart, {
|
|
1549
|
+
accessibilityLayer: true,
|
|
1550
|
+
title,
|
|
1551
|
+
desc: description,
|
|
1552
|
+
children: [/* @__PURE__ */ jsx(Pie, {
|
|
1553
|
+
data: shown,
|
|
1554
|
+
dataKey: "value",
|
|
1555
|
+
nameKey: "name",
|
|
1556
|
+
cx: "50%",
|
|
1557
|
+
cy: "50%",
|
|
1558
|
+
innerRadius: isDonut ? "64%" : 0,
|
|
1559
|
+
outerRadius: "94%",
|
|
1560
|
+
paddingAngle: shown.length > 1 ? 1.5 : 0,
|
|
1561
|
+
stroke: t.surface,
|
|
1562
|
+
strokeWidth: 2,
|
|
1563
|
+
isAnimationActive: false
|
|
1564
|
+
}), /* @__PURE__ */ jsx(Tooltip, {
|
|
1565
|
+
isAnimationActive: false,
|
|
1566
|
+
content: tooltipContent({ valueFormatter })
|
|
1567
|
+
})]
|
|
1568
|
+
})
|
|
1569
|
+
}), isDonut && centerLabel && /* @__PURE__ */ jsxs("div", {
|
|
1570
|
+
className: "pointer-events-none absolute inset-0 flex flex-col items-center justify-center",
|
|
1571
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1572
|
+
className: "font-mono text-[20px] font-semibold tabular-nums text-foreground",
|
|
1573
|
+
children: valueFormatter(total)
|
|
1574
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1575
|
+
className: "mt-0.5 text-[11px] text-muted-foreground",
|
|
1576
|
+
children: centerLabel
|
|
1577
|
+
})]
|
|
1578
|
+
})]
|
|
1579
|
+
}),
|
|
1580
|
+
showLegend && /* @__PURE__ */ jsx("div", {
|
|
1581
|
+
className: "flex w-full min-w-0 flex-1 flex-col gap-2",
|
|
1582
|
+
children: slices.map((s) => {
|
|
1583
|
+
const off = hidden.has(s.name);
|
|
1584
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
1585
|
+
type: "button",
|
|
1586
|
+
"aria-pressed": !off,
|
|
1587
|
+
onClick: () => toggle(s.name),
|
|
1588
|
+
className: cx("group flex items-center justify-between gap-3", off && "opacity-40", "rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"),
|
|
1589
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
1590
|
+
className: "flex min-w-0 items-center gap-2",
|
|
1591
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1592
|
+
"aria-hidden": true,
|
|
1593
|
+
className: cx("h-2.5 w-2.5 shrink-0 rounded-[3px]", off && "grayscale"),
|
|
1594
|
+
style: { background: s.fill }
|
|
1595
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1596
|
+
className: cx("truncate text-[12.5px] text-muted-foreground", off ? "line-through" : "transition-colors group-hover:text-foreground"),
|
|
1597
|
+
children: s.name
|
|
1598
|
+
})]
|
|
1599
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1600
|
+
className: "shrink-0 font-mono text-[12px] font-medium tabular-nums text-foreground",
|
|
1601
|
+
children: valueFormatter(s.value)
|
|
1602
|
+
})]
|
|
1603
|
+
}, s.name);
|
|
1604
|
+
})
|
|
1605
|
+
}),
|
|
1606
|
+
dataTable && /* @__PURE__ */ jsx(ChartDataTable, {
|
|
1607
|
+
caption: name,
|
|
1608
|
+
columns: [index, category],
|
|
1609
|
+
rows: slices.map((sl) => [sl.name, valueFormatter(sl.value)])
|
|
1610
|
+
})
|
|
1611
|
+
]
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
//#endregion
|
|
1615
|
+
//#region src/BarList.tsx
|
|
1616
|
+
const BAR_LIST_BG = {
|
|
1617
|
+
primary: "bg-primary/20",
|
|
1618
|
+
violet: "bg-category-violet/20",
|
|
1619
|
+
cyan: "bg-category-cyan/20",
|
|
1620
|
+
amber: "bg-category-amber/20",
|
|
1621
|
+
rose: "bg-category-rose/20"
|
|
1622
|
+
};
|
|
1623
|
+
/** Axis-less ranked list: label inside a token-tinted bar, value on the right. */
|
|
1624
|
+
function BarList({ data, index, category, valueFormatter = identity, color = "primary", sortOrder = "desc", className, "aria-label": ariaLabel = "Bar list", state, emptyMessage, errorMessage, onRetry }) {
|
|
1625
|
+
const effective = resolveState(state, data.length);
|
|
1626
|
+
const placeholder = chartStateView(effective, {
|
|
1627
|
+
height: 160,
|
|
1628
|
+
emptyMessage,
|
|
1629
|
+
errorMessage,
|
|
1630
|
+
onRetry
|
|
1631
|
+
});
|
|
1632
|
+
if (placeholder) return /* @__PURE__ */ jsx("div", {
|
|
1633
|
+
role: "figure",
|
|
1634
|
+
"aria-label": ariaLabel,
|
|
1635
|
+
"aria-busy": effective === "loading" || void 0,
|
|
1636
|
+
className: cx("font-sans", className),
|
|
1637
|
+
children: placeholder
|
|
1638
|
+
});
|
|
1639
|
+
const rows = data.slice();
|
|
1640
|
+
if (sortOrder === "desc") rows.sort((a, b) => numField(b, category) - numField(a, category));
|
|
1641
|
+
else if (sortOrder === "asc") rows.sort((a, b) => numField(a, category) - numField(b, category));
|
|
1642
|
+
const max = Math.max(...rows.map((d) => numField(d, category)), 1);
|
|
1643
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
1644
|
+
"aria-label": ariaLabel,
|
|
1645
|
+
className: cx("flex flex-col gap-2 font-sans", className),
|
|
1646
|
+
children: rows.map((r, i) => {
|
|
1647
|
+
const value = numField(r, category);
|
|
1648
|
+
const pct = Math.max(value / max * 100, 1.5);
|
|
1649
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
1650
|
+
className: "flex items-center gap-3",
|
|
1651
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
1652
|
+
className: "relative h-9 min-w-0 flex-1",
|
|
1653
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1654
|
+
"aria-hidden": true,
|
|
1655
|
+
className: cx("absolute inset-y-0 left-0 rounded-md", BAR_LIST_BG[color]),
|
|
1656
|
+
style: { width: `${pct}%` }
|
|
1657
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1658
|
+
className: "relative z-10 flex h-full items-center px-2.5",
|
|
1659
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
1660
|
+
className: "truncate text-[13px] text-foreground",
|
|
1661
|
+
children: String(field(r, index))
|
|
1662
|
+
})
|
|
1663
|
+
})]
|
|
1664
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1665
|
+
className: "w-16 shrink-0 text-right font-mono text-[13px] tabular-nums text-foreground",
|
|
1666
|
+
children: valueFormatter(value)
|
|
1667
|
+
})]
|
|
1668
|
+
}, i);
|
|
1669
|
+
})
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
//#endregion
|
|
1673
|
+
//#region src/palette.ts
|
|
1674
|
+
/**
|
|
1675
|
+
* Data-viz palette built ONLY from the frozen brand tokens.
|
|
1676
|
+
*
|
|
1677
|
+
* Categorical: `SAFE_CHART_COLORS` orders the five accents so that every adjacent pair differs
|
|
1678
|
+
* in relative luminance by at least 1.5:1 in dark, light and high_contrast (asserted by
|
|
1679
|
+
* `palette.test.ts` against `@gntik-ai/tokens/brand.css`). It is the default `CHART_COLORS`
|
|
1680
|
+
* order of every chart; this name is kept as an alias.
|
|
1681
|
+
*
|
|
1682
|
+
* Colour-blind safety: hue alone is never the only channel. Charts pair colour with position,
|
|
1683
|
+
* labels or the legend text; use at most five categorical series, prefer a sequential ramp
|
|
1684
|
+
* (one hue, varying lightness) for ordered data, and a diverging ramp (destructive ↔ muted ↔
|
|
1685
|
+
* primary, i.e. red ↔ green, which deutan/protan viewers confuse) only together with value
|
|
1686
|
+
* labels, a signed legend or the `dataTable` fallback — lightness still separates the ends.
|
|
1687
|
+
*/
|
|
1688
|
+
const SAFE_CHART_COLORS = CHART_COLORS;
|
|
1689
|
+
const round = (n) => Math.round(n * 1e3) / 1e3;
|
|
1690
|
+
const pct = (n) => `${Math.round(n * 1e3) / 10}%`;
|
|
1691
|
+
const solid = (token) => `hsl(var(${token}))`;
|
|
1692
|
+
/** Mixes `token` into `base` at `weight` (0–1); `weight` 1 is the plain token. */
|
|
1693
|
+
function mix(token, base, weight, mode) {
|
|
1694
|
+
if (weight >= 1) return solid(token);
|
|
1695
|
+
if (mode === "alpha") return `hsl(var(${token}) / ${round(weight)})`;
|
|
1696
|
+
return `color-mix(in oklab, ${solid(token)} ${pct(weight)}, ${solid(base)})`;
|
|
1697
|
+
}
|
|
1698
|
+
function clamp01(n) {
|
|
1699
|
+
return Math.min(1, Math.max(0, n));
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* `n` colours from faint to the full token, for ordered magnitudes (heatmaps, choropleths,
|
|
1703
|
+
* funnel stages). Every value is a CSS colour string usable as SVG fill/stroke or inline style.
|
|
1704
|
+
*/
|
|
1705
|
+
function sequentialRamp(n, options = {}) {
|
|
1706
|
+
const { color = "primary", surface = "--card", mode = "mix", min = .14 } = options;
|
|
1707
|
+
const token = SERIES_TOKENS[color];
|
|
1708
|
+
const count = Math.max(0, Math.floor(n));
|
|
1709
|
+
const lo = clamp01(min);
|
|
1710
|
+
return Array.from({ length: count }, (_, i) => {
|
|
1711
|
+
const t = count === 1 ? 1 : lo + (1 - lo) * i / (count - 1);
|
|
1712
|
+
return mix(token, surface, t, mode);
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* `n` colours from `negative` (index 0) through the neutral `mid` to `positive` (last index),
|
|
1717
|
+
* for signed values around a meaningful centre (deltas, budgets, sentiment). With an odd `n`
|
|
1718
|
+
* the middle step is the plain neutral token.
|
|
1719
|
+
*/
|
|
1720
|
+
function divergingRamp(n, options = {}) {
|
|
1721
|
+
const { negative = "--destructive", positive = "--primary", mid = "--muted", mode = "mix", min = .18 } = options;
|
|
1722
|
+
const count = Math.max(0, Math.floor(n));
|
|
1723
|
+
const lo = clamp01(min);
|
|
1724
|
+
const dMin = count % 2 ? 2 / (count - 1) : 1 / (count - 1);
|
|
1725
|
+
return Array.from({ length: count }, (_, i) => {
|
|
1726
|
+
if (count === 1) return solid(mid);
|
|
1727
|
+
const d = i / (count - 1) * 2 - 1;
|
|
1728
|
+
const a = Math.abs(d);
|
|
1729
|
+
if (a < 1e-9) return solid(mid);
|
|
1730
|
+
const strength = a >= 1 - 1e-9 ? 1 : lo + (1 - lo) * (a - dMin) / (1 - dMin);
|
|
1731
|
+
return mix(d < 0 ? negative : positive, mid, strength, mode);
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Index of the ramp step for `value`. `domain` is `[min, max]` (sequential) or
|
|
1736
|
+
* `[min, mid, max]` (diverging: `mid` maps to the centre of the ramp).
|
|
1737
|
+
*/
|
|
1738
|
+
function rampIndex(value, domain, steps) {
|
|
1739
|
+
if (steps <= 1 || !Number.isFinite(value)) return 0;
|
|
1740
|
+
let t;
|
|
1741
|
+
if (domain.length === 3) {
|
|
1742
|
+
const [lo, mid, hi] = domain;
|
|
1743
|
+
t = value <= mid ? .5 * (mid === lo ? 1 : (value - lo) / (mid - lo)) : .5 + .5 * (hi === mid ? 1 : (value - mid) / (hi - mid));
|
|
1744
|
+
} else {
|
|
1745
|
+
const [lo, hi] = domain;
|
|
1746
|
+
t = hi === lo ? 1 : (value - lo) / (hi - lo);
|
|
1747
|
+
}
|
|
1748
|
+
return Math.min(steps - 1, Math.floor(clamp01(t) * steps));
|
|
1749
|
+
}
|
|
1750
|
+
/** Colour of `value` on `ramp` (see `rampIndex`). */
|
|
1751
|
+
function rampColor(value, domain, ramp) {
|
|
1752
|
+
return ramp[rampIndex(value, domain, ramp.length)] ?? ramp[0] ?? solid("--muted");
|
|
1753
|
+
}
|
|
1754
|
+
//#endregion
|
|
1755
|
+
//#region src/FunnelChart.tsx
|
|
1756
|
+
/** Stage maths: conversion against the first stage and drop-off against the previous one. */
|
|
1757
|
+
function funnelStages(values, fills = []) {
|
|
1758
|
+
const first = values[0]?.value ?? 0;
|
|
1759
|
+
return values.map((s, i) => {
|
|
1760
|
+
const prev = i === 0 ? s.value : values[i - 1]?.value ?? 0;
|
|
1761
|
+
return {
|
|
1762
|
+
name: s.name,
|
|
1763
|
+
value: s.value,
|
|
1764
|
+
conversion: first > 0 ? s.value / first : 0,
|
|
1765
|
+
dropOff: i === 0 || prev <= 0 ? 0 : Math.max(0, (prev - s.value) / prev),
|
|
1766
|
+
fill: fills[i] ?? fills[fills.length - 1] ?? "hsl(var(--primary))"
|
|
1767
|
+
};
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
/** Staged conversion funnel: trapezoids in a one-hue ramp plus a stage list with drop-off. */
|
|
1771
|
+
function FunnelChart({ data, index, category, color = "primary", valueFormatter = identity, showStages = true, height = 260, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false }) {
|
|
1772
|
+
const t = useChartTheme();
|
|
1773
|
+
const ramp = sequentialRamp(Math.max(data.length, 1), {
|
|
1774
|
+
color,
|
|
1775
|
+
min: .35
|
|
1776
|
+
}).reverse();
|
|
1777
|
+
const stages = funnelStages(data.map((d) => ({
|
|
1778
|
+
name: String(field(d, index)),
|
|
1779
|
+
value: numField(d, category)
|
|
1780
|
+
})), ramp);
|
|
1781
|
+
const name = accessibleName("Funnel chart", ariaLabel, title, stages.map((s) => s.name));
|
|
1782
|
+
const effective = resolveState(state, data.length);
|
|
1783
|
+
const placeholder = chartStateView(effective, {
|
|
1784
|
+
height,
|
|
1785
|
+
emptyMessage,
|
|
1786
|
+
errorMessage,
|
|
1787
|
+
onRetry
|
|
1788
|
+
});
|
|
1789
|
+
const byName = new Map(stages.map((s) => [s.name, s]));
|
|
1790
|
+
const fmtStage = (v, stage) => {
|
|
1791
|
+
const s = byName.get(stage);
|
|
1792
|
+
const base = typeof v === "number" ? valueFormatter(v) : String(v ?? "");
|
|
1793
|
+
return s ? `${base} · ${chartFmt.percent(s.conversion)}` : base;
|
|
1794
|
+
};
|
|
1795
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1796
|
+
role: "figure",
|
|
1797
|
+
"aria-label": name,
|
|
1798
|
+
"aria-busy": effective === "loading" || void 0,
|
|
1799
|
+
className: cx("w-full font-sans", className),
|
|
1800
|
+
children: placeholder ?? /* @__PURE__ */ jsxs("div", {
|
|
1801
|
+
className: "flex flex-col gap-5 sm:flex-row sm:items-center",
|
|
1802
|
+
children: [
|
|
1803
|
+
/* @__PURE__ */ jsx("div", {
|
|
1804
|
+
className: "min-w-0 flex-1",
|
|
1805
|
+
style: { height },
|
|
1806
|
+
children: /* @__PURE__ */ jsx(ResponsiveContainer, {
|
|
1807
|
+
width: "100%",
|
|
1808
|
+
height: "100%",
|
|
1809
|
+
children: /* @__PURE__ */ jsxs(FunnelChart$1, {
|
|
1810
|
+
margin: {
|
|
1811
|
+
top: 4,
|
|
1812
|
+
right: 4,
|
|
1813
|
+
bottom: 4,
|
|
1814
|
+
left: 4
|
|
1815
|
+
},
|
|
1816
|
+
accessibilityLayer: true,
|
|
1817
|
+
title,
|
|
1818
|
+
desc: description,
|
|
1819
|
+
children: [/* @__PURE__ */ jsx(Funnel, {
|
|
1820
|
+
data: stages,
|
|
1821
|
+
dataKey: "value",
|
|
1822
|
+
nameKey: "name",
|
|
1823
|
+
stroke: t.surface,
|
|
1824
|
+
strokeWidth: 2,
|
|
1825
|
+
lastShapeType: "rectangle",
|
|
1826
|
+
isAnimationActive: false
|
|
1827
|
+
}), /* @__PURE__ */ jsx(Tooltip, {
|
|
1828
|
+
isAnimationActive: false,
|
|
1829
|
+
content: tooltipContent({ seriesFormatter: fmtStage })
|
|
1830
|
+
})]
|
|
1831
|
+
})
|
|
1832
|
+
})
|
|
1833
|
+
}),
|
|
1834
|
+
showStages && /* @__PURE__ */ jsx("ol", {
|
|
1835
|
+
"aria-label": "Stages",
|
|
1836
|
+
className: "flex w-full flex-col gap-2 sm:w-64",
|
|
1837
|
+
children: stages.map((s, i) => /* @__PURE__ */ jsxs("li", {
|
|
1838
|
+
className: "flex items-center justify-between gap-3 border-b border-border pb-2 last:border-b-0",
|
|
1839
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
1840
|
+
className: "flex min-w-0 items-center gap-2",
|
|
1841
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1842
|
+
"aria-hidden": true,
|
|
1843
|
+
className: "h-2.5 w-2.5 shrink-0 rounded-[3px]",
|
|
1844
|
+
style: { background: s.fill }
|
|
1845
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1846
|
+
className: "truncate text-[12.5px] text-foreground",
|
|
1847
|
+
children: s.name
|
|
1848
|
+
})]
|
|
1849
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
1850
|
+
className: "flex shrink-0 items-baseline gap-2 font-mono text-[12px] tabular-nums",
|
|
1851
|
+
children: [
|
|
1852
|
+
/* @__PURE__ */ jsx("span", {
|
|
1853
|
+
className: "font-medium text-foreground",
|
|
1854
|
+
children: valueFormatter(s.value)
|
|
1855
|
+
}),
|
|
1856
|
+
/* @__PURE__ */ jsx("span", {
|
|
1857
|
+
className: "text-muted-foreground",
|
|
1858
|
+
children: chartFmt.percent(s.conversion)
|
|
1859
|
+
}),
|
|
1860
|
+
i > 0 && /* @__PURE__ */ jsxs("span", {
|
|
1861
|
+
className: "text-destructive-text",
|
|
1862
|
+
children: [
|
|
1863
|
+
/* @__PURE__ */ jsx("span", {
|
|
1864
|
+
className: "sr-only",
|
|
1865
|
+
children: "drop-off "
|
|
1866
|
+
}),
|
|
1867
|
+
"−",
|
|
1868
|
+
chartFmt.percent(s.dropOff)
|
|
1869
|
+
]
|
|
1870
|
+
})
|
|
1871
|
+
]
|
|
1872
|
+
})]
|
|
1873
|
+
}, s.name))
|
|
1874
|
+
}),
|
|
1875
|
+
dataTable && /* @__PURE__ */ jsx(ChartDataTable, {
|
|
1876
|
+
caption: name,
|
|
1877
|
+
columns: [
|
|
1878
|
+
"Stage",
|
|
1879
|
+
category,
|
|
1880
|
+
"Conversion",
|
|
1881
|
+
"Drop-off"
|
|
1882
|
+
],
|
|
1883
|
+
rows: stages.map((s) => [
|
|
1884
|
+
s.name,
|
|
1885
|
+
valueFormatter(s.value),
|
|
1886
|
+
chartFmt.percent(s.conversion),
|
|
1887
|
+
chartFmt.percent(s.dropOff)
|
|
1888
|
+
])
|
|
1889
|
+
})
|
|
1890
|
+
]
|
|
1891
|
+
})
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
//#endregion
|
|
1895
|
+
//#region src/SankeyChart.tsx
|
|
1896
|
+
/** Turns name-or-index links into the index links Recharts expects; drops unknown endpoints and non-positive values. */
|
|
1897
|
+
function resolveSankeyLinks(nodes, links) {
|
|
1898
|
+
const byName = new Map(nodes.map((n, i) => [n.name, i]));
|
|
1899
|
+
const idx = (ref) => typeof ref === "number" ? ref >= 0 && ref < nodes.length ? ref : void 0 : byName.get(ref);
|
|
1900
|
+
const out = [];
|
|
1901
|
+
for (const l of links) {
|
|
1902
|
+
const source = idx(l.source);
|
|
1903
|
+
const target = idx(l.target);
|
|
1904
|
+
if (source === void 0 || target === void 0 || source === target || !(l.value > 0)) continue;
|
|
1905
|
+
out.push({
|
|
1906
|
+
source,
|
|
1907
|
+
target,
|
|
1908
|
+
value: l.value
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
/** Flow diagram: nodes as token-coloured bars, links tinted with their source colour. */
|
|
1914
|
+
function SankeyChart({ nodes, links, colors = SAFE_CHART_COLORS, valueFormatter = identity, height = 320, nodeWidth = 10, nodePadding = 18, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false }) {
|
|
1915
|
+
const t = useChartTheme();
|
|
1916
|
+
const resolved = resolveSankeyLinks(nodes, links);
|
|
1917
|
+
const fills = nodes.map((n, i) => n.color ? t.color(n.color) : seriesColor(t, colors, i));
|
|
1918
|
+
const sources = new Set(resolved.map((l) => l.source));
|
|
1919
|
+
const name = accessibleName("Sankey chart", ariaLabel, title, nodes.map((n) => n.name));
|
|
1920
|
+
const data = {
|
|
1921
|
+
nodes: nodes.map((n, i) => ({
|
|
1922
|
+
name: n.name,
|
|
1923
|
+
fill: fills[i]
|
|
1924
|
+
})),
|
|
1925
|
+
links: resolved
|
|
1926
|
+
};
|
|
1927
|
+
const renderNode = ({ x, y, width, height: h, index }) => {
|
|
1928
|
+
const sink = !sources.has(index);
|
|
1929
|
+
return /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
|
|
1930
|
+
x,
|
|
1931
|
+
y,
|
|
1932
|
+
width,
|
|
1933
|
+
height: Math.max(h, 1),
|
|
1934
|
+
rx: 2,
|
|
1935
|
+
fill: fills[index]
|
|
1936
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
1937
|
+
x: sink ? x - 6 : x + width + 6,
|
|
1938
|
+
y: y + h / 2,
|
|
1939
|
+
dy: "0.35em",
|
|
1940
|
+
textAnchor: sink ? "end" : "start",
|
|
1941
|
+
fontSize: 11,
|
|
1942
|
+
fill: t.text,
|
|
1943
|
+
children: nodes[index]?.name
|
|
1944
|
+
})] });
|
|
1945
|
+
};
|
|
1946
|
+
const renderLink = (p) => {
|
|
1947
|
+
const source = resolved[p.index]?.source ?? 0;
|
|
1948
|
+
return /* @__PURE__ */ jsx("path", {
|
|
1949
|
+
d: `M${p.sourceX},${p.sourceY} C${p.sourceControlX},${p.sourceY} ${p.targetControlX},${p.targetY} ${p.targetX},${p.targetY}`,
|
|
1950
|
+
fill: "none",
|
|
1951
|
+
stroke: fills[source] ?? t.color("primary"),
|
|
1952
|
+
strokeOpacity: .28,
|
|
1953
|
+
strokeWidth: Math.max(p.linkWidth, 1)
|
|
1954
|
+
});
|
|
1955
|
+
};
|
|
1956
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
1957
|
+
label: name,
|
|
1958
|
+
state: resolveState(state, resolved.length),
|
|
1959
|
+
emptyMessage,
|
|
1960
|
+
errorMessage,
|
|
1961
|
+
onRetry,
|
|
1962
|
+
height,
|
|
1963
|
+
className,
|
|
1964
|
+
table: dataTable && /* @__PURE__ */ jsx(ChartDataTable, {
|
|
1965
|
+
caption: name,
|
|
1966
|
+
columns: [
|
|
1967
|
+
"From",
|
|
1968
|
+
"To",
|
|
1969
|
+
"Value"
|
|
1970
|
+
],
|
|
1971
|
+
rows: resolved.map((l) => [
|
|
1972
|
+
nodes[l.source]?.name ?? "",
|
|
1973
|
+
nodes[l.target]?.name ?? "",
|
|
1974
|
+
valueFormatter(l.value)
|
|
1975
|
+
])
|
|
1976
|
+
}),
|
|
1977
|
+
children: /* @__PURE__ */ jsx(Sankey, {
|
|
1978
|
+
data,
|
|
1979
|
+
nameKey: "name",
|
|
1980
|
+
nodeWidth,
|
|
1981
|
+
nodePadding,
|
|
1982
|
+
margin: {
|
|
1983
|
+
top: 6,
|
|
1984
|
+
right: 96,
|
|
1985
|
+
bottom: 6,
|
|
1986
|
+
left: 6
|
|
1987
|
+
},
|
|
1988
|
+
node: renderNode,
|
|
1989
|
+
link: renderLink,
|
|
1990
|
+
accessibilityLayer: true,
|
|
1991
|
+
title,
|
|
1992
|
+
desc: description,
|
|
1993
|
+
children: /* @__PURE__ */ jsx(Tooltip, {
|
|
1994
|
+
isAnimationActive: false,
|
|
1995
|
+
content: tooltipContent({ valueFormatter })
|
|
1996
|
+
})
|
|
1997
|
+
})
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
//#endregion
|
|
2001
|
+
//#region src/RadarChart.tsx
|
|
2002
|
+
/** Multi-axis scores: one translucent polygon per category over a polygonal grid. */
|
|
2003
|
+
function RadarChart({ data, index, categories, colors = SAFE_CHART_COLORS, valueFormatter = identity, max, showLegend = true, showGrid = true, showRadiusAxis = false, height = 300, title, description, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = false }) {
|
|
2004
|
+
const t = useChartTheme();
|
|
2005
|
+
const [hidden, toggle] = useHiddenSeries();
|
|
2006
|
+
const col = (i) => seriesColor(t, colors, i);
|
|
2007
|
+
const legend = categories.map((c, i) => ({
|
|
2008
|
+
key: c,
|
|
2009
|
+
label: c,
|
|
2010
|
+
color: col(i)
|
|
2011
|
+
}));
|
|
2012
|
+
const name = accessibleName("Radar chart", ariaLabel, title, categories);
|
|
2013
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
2014
|
+
label: name,
|
|
2015
|
+
state: resolveState(state, data.length),
|
|
2016
|
+
emptyMessage,
|
|
2017
|
+
errorMessage,
|
|
2018
|
+
onRetry,
|
|
2019
|
+
table: dataTable && cartesianTable(name, data, index, categories, (v) => formatAny(v, valueFormatter)),
|
|
2020
|
+
height,
|
|
2021
|
+
className,
|
|
2022
|
+
legend: showLegend && /* @__PURE__ */ jsx(ChartLegend, {
|
|
2023
|
+
className: "mb-3 px-1",
|
|
2024
|
+
items: legend,
|
|
2025
|
+
hidden,
|
|
2026
|
+
onToggle: toggle
|
|
2027
|
+
}),
|
|
2028
|
+
children: /* @__PURE__ */ jsxs(RadarChart$1, {
|
|
2029
|
+
data,
|
|
2030
|
+
outerRadius: "76%",
|
|
2031
|
+
accessibilityLayer: true,
|
|
2032
|
+
title,
|
|
2033
|
+
desc: description,
|
|
2034
|
+
children: [
|
|
2035
|
+
showGrid && /* @__PURE__ */ jsx(PolarGrid, { stroke: t.grid }),
|
|
2036
|
+
/* @__PURE__ */ jsx(PolarAngleAxis, {
|
|
2037
|
+
dataKey: index,
|
|
2038
|
+
tick: {
|
|
2039
|
+
fill: t.text,
|
|
2040
|
+
fontSize: 11
|
|
2041
|
+
}
|
|
2042
|
+
}),
|
|
2043
|
+
/* @__PURE__ */ jsx(PolarRadiusAxis, {
|
|
2044
|
+
domain: [0, max ?? "auto"],
|
|
2045
|
+
angle: 90,
|
|
2046
|
+
axisLine: false,
|
|
2047
|
+
tick: showRadiusAxis ? {
|
|
2048
|
+
fill: t.text,
|
|
2049
|
+
fontSize: 10
|
|
2050
|
+
} : false,
|
|
2051
|
+
tickFormatter: valueFormatter
|
|
2052
|
+
}),
|
|
2053
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
2054
|
+
isAnimationActive: false,
|
|
2055
|
+
content: tooltipContent({ valueFormatter })
|
|
2056
|
+
}),
|
|
2057
|
+
categories.map((c, i) => /* @__PURE__ */ jsx(Radar, {
|
|
2058
|
+
dataKey: c,
|
|
2059
|
+
name: c,
|
|
2060
|
+
hide: hidden.has(c),
|
|
2061
|
+
stroke: col(i),
|
|
2062
|
+
strokeWidth: 2,
|
|
2063
|
+
fill: col(i),
|
|
2064
|
+
fillOpacity: .14,
|
|
2065
|
+
dot: {
|
|
2066
|
+
r: 2.5,
|
|
2067
|
+
strokeWidth: 0,
|
|
2068
|
+
fill: col(i)
|
|
2069
|
+
},
|
|
2070
|
+
isAnimationActive: false
|
|
2071
|
+
}, c))
|
|
2072
|
+
]
|
|
2073
|
+
})
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
//#endregion
|
|
2077
|
+
//#region src/Heatmap.tsx
|
|
2078
|
+
function extent(values) {
|
|
2079
|
+
if (!values.length) return [0, 1];
|
|
2080
|
+
return [Math.min(...values), Math.max(...values)];
|
|
2081
|
+
}
|
|
2082
|
+
/** Column-label row height + inter-row gap, used to fit rows into a fixed `height`. */
|
|
2083
|
+
const LABEL_ROW = 20;
|
|
2084
|
+
const GAP = 3;
|
|
2085
|
+
/** Cell height that fits `rows` rows in `height` px (clamped to 16–48), or undefined without a height. */
|
|
2086
|
+
function cellHeightFor(height, rows) {
|
|
2087
|
+
if (height === void 0) return void 0;
|
|
2088
|
+
const n = Math.max(rows, 1);
|
|
2089
|
+
return Math.min(48, Math.max(16, Math.floor((height - LABEL_ROW - GAP * n) / n)));
|
|
2090
|
+
}
|
|
2091
|
+
const cellFocus = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring";
|
|
2092
|
+
/**
|
|
2093
|
+
* Cohort / time grid coloured with a sequential or diverging token ramp. The colour grid is
|
|
2094
|
+
* a single keyboard stop (arrow keys move the active cell, Home/End jump within the row) with a
|
|
2095
|
+
* live readout; screen readers get the full data table (`dataTable`, on by default).
|
|
2096
|
+
*/
|
|
2097
|
+
function Heatmap({ data, index, categories, scale = "sequential", domain, steps, color = "primary", valueFormatter = identity, showValues = false, showScale = true, cellHeight: cellHeightProp = 28, height, rowHeader, title, className, "aria-label": ariaLabel, state, emptyMessage, errorMessage, onRetry, dataTable = true }) {
|
|
2098
|
+
const id = useId();
|
|
2099
|
+
const cellHeight = cellHeightFor(height, data.length) ?? cellHeightProp;
|
|
2100
|
+
const [active, setActive] = useState(null);
|
|
2101
|
+
const name = accessibleName("Heatmap", ariaLabel, title, []);
|
|
2102
|
+
const diverging = scale === "diverging";
|
|
2103
|
+
const count = steps ?? (diverging ? 7 : 6);
|
|
2104
|
+
const ramp = diverging ? divergingRamp(count) : sequentialRamp(count, { color });
|
|
2105
|
+
const value = (r, c) => {
|
|
2106
|
+
const row = data[r];
|
|
2107
|
+
const key = categories[c];
|
|
2108
|
+
if (!row || key === void 0) return null;
|
|
2109
|
+
const v = field(row, key);
|
|
2110
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
2111
|
+
};
|
|
2112
|
+
const all = data.flatMap((_, r) => categories.map((__, c) => value(r, c))).filter((v) => v !== null);
|
|
2113
|
+
const [lo, hi] = extent(all);
|
|
2114
|
+
const dom = domain ?? (diverging ? [
|
|
2115
|
+
Math.min(lo, 0),
|
|
2116
|
+
0,
|
|
2117
|
+
Math.max(hi, 0)
|
|
2118
|
+
] : [lo, hi]);
|
|
2119
|
+
const effective = resolveState(state, all.length);
|
|
2120
|
+
const placeholder = chartStateView(effective, {
|
|
2121
|
+
height: height ?? Math.max(data.length, 4) * (cellHeight + 3),
|
|
2122
|
+
emptyMessage,
|
|
2123
|
+
errorMessage,
|
|
2124
|
+
onRetry
|
|
2125
|
+
});
|
|
2126
|
+
const cellStyle = (v) => ({ fill: ramp[rampIndex(v, dom, count)] ?? ramp[0] });
|
|
2127
|
+
const rowLabel = (r) => {
|
|
2128
|
+
const row = data[r];
|
|
2129
|
+
return row ? String(field(row, index)) : "";
|
|
2130
|
+
};
|
|
2131
|
+
const describe = ([r, c]) => {
|
|
2132
|
+
const v = value(r, c);
|
|
2133
|
+
return `${rowLabel(r)} · ${categories[c] ?? ""}: ${v === null ? "no data" : valueFormatter(v)}`;
|
|
2134
|
+
};
|
|
2135
|
+
const onKeyDown = (e) => {
|
|
2136
|
+
const [r, c] = active ?? [0, 0];
|
|
2137
|
+
const lastR = data.length - 1;
|
|
2138
|
+
const lastC = categories.length - 1;
|
|
2139
|
+
const to = {
|
|
2140
|
+
ArrowRight: [r, Math.min(lastC, c + 1)],
|
|
2141
|
+
ArrowLeft: [r, Math.max(0, c - 1)],
|
|
2142
|
+
ArrowDown: [Math.min(lastR, r + 1), c],
|
|
2143
|
+
ArrowUp: [Math.max(0, r - 1), c],
|
|
2144
|
+
Home: [r, 0],
|
|
2145
|
+
End: [r, lastC]
|
|
2146
|
+
}[e.key];
|
|
2147
|
+
if (to) {
|
|
2148
|
+
e.preventDefault();
|
|
2149
|
+
setActive(active ? to : [0, 0]);
|
|
2150
|
+
} else if (e.key === "Escape") setActive(null);
|
|
2151
|
+
};
|
|
2152
|
+
if (placeholder) return /* @__PURE__ */ jsx("div", {
|
|
2153
|
+
role: "figure",
|
|
2154
|
+
"aria-label": name,
|
|
2155
|
+
"aria-busy": effective === "loading" || void 0,
|
|
2156
|
+
className: cx("w-full font-sans", className),
|
|
2157
|
+
children: placeholder
|
|
2158
|
+
});
|
|
2159
|
+
const columns = `minmax(4.5rem, max-content) repeat(${categories.length}, minmax(1.75rem, 1fr))`;
|
|
2160
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2161
|
+
role: "figure",
|
|
2162
|
+
"aria-label": name,
|
|
2163
|
+
className: cx("w-full font-sans", className),
|
|
2164
|
+
children: [
|
|
2165
|
+
/* @__PURE__ */ jsx("div", {
|
|
2166
|
+
role: "group",
|
|
2167
|
+
tabIndex: 0,
|
|
2168
|
+
"aria-label": `${name}: colour grid, use the arrow keys to read cells`,
|
|
2169
|
+
onKeyDown,
|
|
2170
|
+
onFocus: () => setActive((a) => a ?? [0, 0]),
|
|
2171
|
+
onBlur: () => setActive(null),
|
|
2172
|
+
onMouseLeave: () => setActive(null),
|
|
2173
|
+
className: cx("overflow-x-auto rounded-md", cellFocus),
|
|
2174
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2175
|
+
"aria-hidden": true,
|
|
2176
|
+
className: "grid gap-[3px]",
|
|
2177
|
+
style: { gridTemplateColumns: columns },
|
|
2178
|
+
children: [
|
|
2179
|
+
/* @__PURE__ */ jsx("div", {}),
|
|
2180
|
+
categories.map((c) => /* @__PURE__ */ jsx("div", {
|
|
2181
|
+
className: "truncate pb-1 text-center text-[11px] text-muted-foreground",
|
|
2182
|
+
children: c
|
|
2183
|
+
}, c)),
|
|
2184
|
+
data.map((_, r) => [/* @__PURE__ */ jsx("div", {
|
|
2185
|
+
className: "flex items-center truncate pr-2 text-[11.5px] text-muted-foreground",
|
|
2186
|
+
style: { height: cellHeight },
|
|
2187
|
+
children: rowLabel(r)
|
|
2188
|
+
}, `h${r}`), ...categories.map((c, ci) => {
|
|
2189
|
+
const v = value(r, ci);
|
|
2190
|
+
const s = v === null ? null : cellStyle(v);
|
|
2191
|
+
const on = active?.[0] === r && active[1] === ci;
|
|
2192
|
+
return /* @__PURE__ */ jsx("div", {
|
|
2193
|
+
"data-cell": `${r}-${ci}`,
|
|
2194
|
+
"data-fill": s?.fill,
|
|
2195
|
+
onMouseEnter: () => setActive([r, ci]),
|
|
2196
|
+
className: cx("flex items-center justify-center rounded-[3px] font-mono text-[10.5px] tabular-nums", !s && "border border-dashed border-border", on && "outline-2 outline-offset-1 outline-foreground"),
|
|
2197
|
+
style: {
|
|
2198
|
+
height: cellHeight,
|
|
2199
|
+
background: s?.fill
|
|
2200
|
+
},
|
|
2201
|
+
children: showValues && v !== null ? /* @__PURE__ */ jsx("span", {
|
|
2202
|
+
className: "rounded-[2px] bg-card px-1 leading-4 text-foreground",
|
|
2203
|
+
children: valueFormatter(v)
|
|
2204
|
+
}) : null
|
|
2205
|
+
}, `${r}-${c}`);
|
|
2206
|
+
})])
|
|
2207
|
+
]
|
|
2208
|
+
})
|
|
2209
|
+
}),
|
|
2210
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2211
|
+
className: "mt-2 flex flex-wrap items-center justify-between gap-3",
|
|
2212
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
2213
|
+
id: `${id}-readout`,
|
|
2214
|
+
"data-heatmap-readout": "",
|
|
2215
|
+
"aria-live": "polite",
|
|
2216
|
+
className: "min-h-[1.25rem] text-[12px] text-muted-foreground",
|
|
2217
|
+
children: active ? describe(active) : ""
|
|
2218
|
+
}), showScale && /* @__PURE__ */ jsxs("div", {
|
|
2219
|
+
"aria-hidden": true,
|
|
2220
|
+
className: "flex items-center gap-1.5 text-[11px] text-muted-foreground",
|
|
2221
|
+
children: [
|
|
2222
|
+
/* @__PURE__ */ jsx("span", {
|
|
2223
|
+
className: "font-mono tabular-nums",
|
|
2224
|
+
children: valueFormatter(dom[0])
|
|
2225
|
+
}),
|
|
2226
|
+
/* @__PURE__ */ jsx("span", {
|
|
2227
|
+
className: "flex",
|
|
2228
|
+
children: ramp.map((f, i) => /* @__PURE__ */ jsx("span", {
|
|
2229
|
+
className: "h-2.5 w-4 first:rounded-l-sm last:rounded-r-sm",
|
|
2230
|
+
style: { background: f }
|
|
2231
|
+
}, i))
|
|
2232
|
+
}),
|
|
2233
|
+
/* @__PURE__ */ jsx("span", {
|
|
2234
|
+
className: "font-mono tabular-nums",
|
|
2235
|
+
children: valueFormatter(dom[dom.length - 1] ?? dom[0])
|
|
2236
|
+
})
|
|
2237
|
+
]
|
|
2238
|
+
})]
|
|
2239
|
+
}),
|
|
2240
|
+
dataTable && /* @__PURE__ */ jsx(ChartDataTable, {
|
|
2241
|
+
caption: name,
|
|
2242
|
+
columns: [rowHeader ?? index, ...categories],
|
|
2243
|
+
rows: data.map((_, r) => [rowLabel(r), ...categories.map((__, c) => {
|
|
2244
|
+
const v = value(r, c);
|
|
2245
|
+
return v === null ? "—" : valueFormatter(v);
|
|
2246
|
+
})])
|
|
2247
|
+
})
|
|
2248
|
+
]
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
//#endregion
|
|
2252
|
+
//#region src/MiniBar.tsx
|
|
2253
|
+
const TONE_TOKEN = {
|
|
2254
|
+
primary: "--primary",
|
|
2255
|
+
success: "--success",
|
|
2256
|
+
warning: "--warning",
|
|
2257
|
+
destructive: "--destructive",
|
|
2258
|
+
info: "--info",
|
|
2259
|
+
neutral: "--muted-foreground"
|
|
2260
|
+
};
|
|
2261
|
+
const fmtDefault = (v) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(v);
|
|
2262
|
+
/** "Deployments: 12 bars, latest 9, low 2, high 14, total 84". */
|
|
2263
|
+
function describeBars(data, label = "Bars", format = fmtDefault) {
|
|
2264
|
+
const last = data[data.length - 1];
|
|
2265
|
+
if (last === void 0) return `${label}: no data`;
|
|
2266
|
+
const total = data.reduce((s, v) => s + v, 0);
|
|
2267
|
+
return `${label}: ${data.length} bars, latest ${format(last)}, low ${format(Math.min(...data))}, high ${format(Math.max(...data))}, total ${format(total)}`;
|
|
2268
|
+
}
|
|
2269
|
+
function highlightIndex(data, h) {
|
|
2270
|
+
if (typeof h === "number") return h;
|
|
2271
|
+
if (h === "last") return data.length - 1;
|
|
2272
|
+
if (h === "max") return data.indexOf(Math.max(...data));
|
|
2273
|
+
if (h === "min") return data.indexOf(Math.min(...data));
|
|
2274
|
+
return -1;
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Sparkline-sized bar chart (pure SVG, no axes): drop-in next to `Sparkline variant="bar"`,
|
|
2278
|
+
* with a highlighted bar, per-bar hover titles and a generated `role="img"` summary.
|
|
2279
|
+
*/
|
|
2280
|
+
function MiniBar({ data, labels, tone = "primary", highlight = "last", width = 120, height = 32, label, formatValue = fmtDefault, className, ...props }) {
|
|
2281
|
+
const name = props["aria-label"] ?? describeBars(data, label, formatValue);
|
|
2282
|
+
const token = TONE_TOKEN[tone];
|
|
2283
|
+
const n = data.length;
|
|
2284
|
+
const lo = n ? Math.min(0, ...data) : 0;
|
|
2285
|
+
const span = (n ? Math.max(0, ...data) : 0) - lo || 1;
|
|
2286
|
+
const zero = height - (0 - lo) / span * height;
|
|
2287
|
+
const slot = n ? width / n : width;
|
|
2288
|
+
const gap = Math.min(2, slot * .25);
|
|
2289
|
+
const hl = highlightIndex(data, highlight);
|
|
2290
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
2291
|
+
role: "img",
|
|
2292
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
2293
|
+
width,
|
|
2294
|
+
height,
|
|
2295
|
+
className: cx("inline-block shrink-0 overflow-visible align-middle", className),
|
|
2296
|
+
...props,
|
|
2297
|
+
"aria-label": name,
|
|
2298
|
+
children: data.map((v, i) => {
|
|
2299
|
+
const h = Math.max(1, Math.abs(v) / span * height);
|
|
2300
|
+
const y = v >= 0 ? zero - h : zero;
|
|
2301
|
+
return /* @__PURE__ */ jsx("rect", {
|
|
2302
|
+
"data-highlight": i === hl || void 0,
|
|
2303
|
+
x: i * slot + gap / 2,
|
|
2304
|
+
y,
|
|
2305
|
+
width: Math.max(1, slot - gap),
|
|
2306
|
+
height: h,
|
|
2307
|
+
rx: 1,
|
|
2308
|
+
fill: i === hl || hl < 0 ? `hsl(var(${token}))` : `hsl(var(${token}) / 0.4)`,
|
|
2309
|
+
children: /* @__PURE__ */ jsx("title", { children: `${labels?.[i] ?? `#${i + 1}`}: ${formatValue(v)}` })
|
|
2310
|
+
}, i);
|
|
2311
|
+
})
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
//#endregion
|
|
2315
|
+
export { AreaChart, BarChart, BarList, CHART_COLORS, ChartBrush, ChartDataTable, ChartEmpty, ChartError, ChartLegend, ChartLoading, ChartMarkers, ChartTooltip, ComboChart, DonutChart, FunnelChart, Heatmap, LineChart, MiniBar, RadarChart, SAFE_CHART_COLORS, SERIES_TOKENS, SankeyChart, TONE_TOKENS, cellHeightFor, chartFmt, clampRange, describeBars, divergingRamp, funnelStages, rampColor, rampIndex, resolveSankeyLinks, sequentialRamp, tooltipContent, useChartRange, useChartTheme, useHiddenSeries, useLiveSeries, usePrefersReducedMotion };
|
|
2316
|
+
|
|
2317
|
+
//# sourceMappingURL=index.js.map
|