@iloveagents/foundry-web-ui 0.23.0 → 0.24.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/chart.d.ts +1 -1
- package/dist/chart.js +1 -1
- package/dist/components/chart/category-bar-chart.js +11 -23
- package/dist/components/chart/chart-config.d.ts +49 -5
- package/dist/components/chart/chart-config.js +95 -22
- package/dist/components/chart/chart.d.ts +5 -3
- package/dist/components/chart/chart.js +4 -4
- package/dist/components/chart/donut-chart.d.ts +6 -5
- package/dist/components/chart/donut-chart.js +19 -18
- package/dist/components/chart/stat-card.js +9 -2
- package/dist/components/chart/time-series-chart.js +13 -2
- package/package.json +3 -3
package/dist/chart.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `@iloveagents/foundry-agent/msal`); import from the subpath.
|
|
8
8
|
*/
|
|
9
9
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, useChart, compactAxisTick, type ChartContainerProps, type ChartTooltipContentProps, type ChartLegendContentProps, } from "./components/chart/chart.js";
|
|
10
|
-
export { chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, type ChartConfig, type ChartSeriesConfig, } from "./components/chart/chart-config.js";
|
|
10
|
+
export { FOLD_COLOR, OTHER_LABEL, categoryColorResolver, chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, type ChartConfig, type ChartSeriesConfig, } from "./components/chart/chart-config.js";
|
|
11
11
|
export { StatCard, type StatCardProps } from "./components/chart/stat-card.js";
|
|
12
12
|
export { ChartCard, DashboardGrid, DashboardTile, type ChartCardProps, type DashboardGridProps, type DashboardTileProps, } from "./components/chart/chart-card.js";
|
|
13
13
|
export { TimeSeriesChart, type TimeSeriesChartProps, } from "./components/chart/time-series-chart.js";
|
package/dist/chart.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `@iloveagents/foundry-agent/msal`); import from the subpath.
|
|
8
8
|
*/
|
|
9
9
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, useChart, compactAxisTick, } from "./components/chart/chart.js";
|
|
10
|
-
export { chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, } from "./components/chart/chart-config.js";
|
|
10
|
+
export { FOLD_COLOR, OTHER_LABEL, categoryColorResolver, chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, } from "./components/chart/chart-config.js";
|
|
11
11
|
export { StatCard } from "./components/chart/stat-card.js";
|
|
12
12
|
export { ChartCard, DashboardGrid, DashboardTile, } from "./components/chart/chart-card.js";
|
|
13
13
|
export { TimeSeriesChart, } from "./components/chart/time-series-chart.js";
|
|
@@ -2,42 +2,30 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import * as RechartsPrimitive from "recharts";
|
|
4
4
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, compactAxisTick } from "./chart.js";
|
|
5
|
-
import { CHART_SLOT_COUNT,
|
|
6
|
-
|
|
5
|
+
import { CHART_SLOT_COUNT, categoryColorResolver, foldCategoryTail, seriesColor, } from "./chart-config.js";
|
|
6
|
+
const EMPTY_CONFIG = {};
|
|
7
|
+
export function CategoryBarChart({ data, categoryKey, valueKey, config = EMPTY_CONFIG, horizontal = true, maxBars, colorByCategory = false, height, width, valueFormatter, categoryFormatter, className, }) {
|
|
7
8
|
// Identity coloring caps at the palette (5 + Other); a single-hue ranked
|
|
8
9
|
// list caps at what stays readable. Both fold rather than overflow.
|
|
9
|
-
const
|
|
10
|
+
const barDefault = colorByCategory ? CHART_SLOT_COUNT : 12;
|
|
11
|
+
// A non-finite cap keeps THIS chart's default, not the palette-wide one.
|
|
12
|
+
const effectiveMax = Number.isFinite(maxBars) ? maxBars : barDefault;
|
|
10
13
|
const rows = React.useMemo(() => {
|
|
11
14
|
// This is a RANKED breakdown by contract, and the fold keeps the head —
|
|
12
15
|
// rank first so an unsorted input never folds its biggest categories.
|
|
13
16
|
const ranked = [...data].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
|
|
14
17
|
return foldCategoryTail(ranked, { categoryKey, valueKey, max: effectiveMax });
|
|
15
18
|
}, [data, categoryKey, valueKey, effectiveMax]);
|
|
16
|
-
// Identity
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (explicit)
|
|
21
|
-
return explicit;
|
|
22
|
-
// Declared identity first: a category's position in the CONFIG is
|
|
23
|
-
// immune to the caller filtering the data. Data order is only the
|
|
24
|
-
// fallback for undeclared categories.
|
|
25
|
-
// The measure's own entry (labels/colors for the value column) is
|
|
26
|
-
// not a category — it must not shift the identity order.
|
|
27
|
-
const configIndex = Object.keys(config)
|
|
28
|
-
.filter((key) => key !== valueKey)
|
|
29
|
-
.indexOf(category);
|
|
30
|
-
if (configIndex !== -1)
|
|
31
|
-
return chartSlotColor(configIndex);
|
|
32
|
-
const declared = data.findIndex((row) => String(row[categoryKey]) === category);
|
|
33
|
-
return chartSlotColor(declared === -1 ? CHART_SLOT_COUNT - 1 : declared);
|
|
34
|
-
}, [config, data, categoryKey]);
|
|
19
|
+
// Identity resolved from the ORIGINAL categories, with the measure's own
|
|
20
|
+
// config entry excluded — it labels the value column, it is not a
|
|
21
|
+
// category and must not shift the order.
|
|
22
|
+
const categoryColor = React.useMemo(() => categoryColorResolver(config, rows.map((row) => String(row[categoryKey] ?? "")), { exclude: valueKey }), [config, rows, categoryKey, valueKey]);
|
|
35
23
|
const fixed = width !== undefined;
|
|
36
24
|
const resolvedHeight = height ?? (horizontal ? Math.max(160, rows.length * 36 + 24) : 240);
|
|
37
25
|
const formatCategory = categoryFormatter ?? ((v) => String(v ?? ""));
|
|
38
26
|
return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height: resolvedHeight, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.BarChart, { data: rows, layout: horizontal ? "vertical" : "horizontal", width: width, height: fixed ? resolvedHeight : undefined, margin: { top: 4, right: 12, bottom: 0, left: 0 }, children: [horizontal ? (_jsxs(_Fragment, { children: [_jsx(RechartsPrimitive.XAxis, { type: "number", tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(RechartsPrimitive.YAxis, { type: "category", dataKey: categoryKey, width: 140, tickLine: false, axisLine: false, tickFormatter: formatCategory })] })) : (_jsxs(_Fragment, { children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: categoryKey, tickLine: false, axisLine: false, tickMargin: 8, tickFormatter: formatCategory }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick })] })), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: (label) => formatCategory(label), valueFormatter: valueFormatter, indicatorColor: colorByCategory ? (_key, label) => categoryColor(String(label)) : undefined }) }), _jsx(RechartsPrimitive.Bar, { dataKey: valueKey, fill: seriesColor(config, valueKey), maxBarSize: 28, radius: horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0], isAnimationActive: false, children: colorByCategory &&
|
|
39
27
|
rows.map((row, i) => {
|
|
40
28
|
const category = String(row[categoryKey] ?? i);
|
|
41
|
-
return _jsx(RechartsPrimitive.Cell, { fill: categoryColor(category) }, category);
|
|
29
|
+
return (_jsx(RechartsPrimitive.Cell, { fill: categoryColor(category) }, `${i}-${category}`));
|
|
42
30
|
}) })] }) }));
|
|
43
31
|
}
|
|
@@ -37,6 +37,37 @@ export declare const CHART_SLOT_COUNT = 5;
|
|
|
37
37
|
export declare function chartSlotColor(index: number): string;
|
|
38
38
|
/** Resolved color for a series: explicit config first, theme slot second. */
|
|
39
39
|
export declare function seriesColor(config: ChartConfig, key: string): string;
|
|
40
|
+
/** The label a folded tail wears, and the muted color that marks it. */
|
|
41
|
+
export declare const OTHER_LABEL = "Other";
|
|
42
|
+
export declare const FOLD_COLOR = "var(--muted-foreground)";
|
|
43
|
+
/**
|
|
44
|
+
* The color resolver for a CATEGORICAL chart, where every category is an
|
|
45
|
+
* identity rather than a series.
|
|
46
|
+
*
|
|
47
|
+
* Two requirements pull against each other, and both are real:
|
|
48
|
+
* - a DECLARED category must keep its color when a filter removes its
|
|
49
|
+
* neighbours (identity follows the entity, never its rank), and
|
|
50
|
+
* - a DRAWN category must never take the muted past-the-palette color
|
|
51
|
+
* just because the config also names categories this chart does not
|
|
52
|
+
* draw.
|
|
53
|
+
*
|
|
54
|
+
* Resolved by giving each side its own claim on the palette. A declared
|
|
55
|
+
* key OWNS its declared index — reserved whether or not it is drawn, which
|
|
56
|
+
* is what makes it stable — and undeclared drawn categories fill the slots
|
|
57
|
+
* no declaration claimed, in drawn order. Past the palette, or with every
|
|
58
|
+
* slot spoken for, a category takes the muted fold color rather than
|
|
59
|
+
* wrapping modulo five onto a color another category already owns.
|
|
60
|
+
*
|
|
61
|
+
* So: declare the categories whose colors must not move, and the rest sort
|
|
62
|
+
* themselves out.
|
|
63
|
+
*
|
|
64
|
+
* `exclude` drops the measure's own config entry (a bar chart labels its
|
|
65
|
+
* value column there); it is not a category and must not shift the order.
|
|
66
|
+
*/
|
|
67
|
+
export declare function categoryColorResolver(config: ChartConfig, categories: Iterable<string>, options?: {
|
|
68
|
+
exclude?: string;
|
|
69
|
+
otherLabel?: string;
|
|
70
|
+
}): (category: string) => string;
|
|
40
71
|
/** Resolved label for a series: explicit config first, the key itself second. */
|
|
41
72
|
export declare function seriesLabel(config: ChartConfig, key: string): React.ReactNode;
|
|
42
73
|
/**
|
|
@@ -46,8 +77,14 @@ export declare function seriesLabel(config: ChartConfig, key: string): React.Rea
|
|
|
46
77
|
* two sides can never disagree.
|
|
47
78
|
*/
|
|
48
79
|
export declare function seriesVarName(key: string): string;
|
|
49
|
-
/**
|
|
50
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The `var()` reference marks use for their series color.
|
|
82
|
+
*
|
|
83
|
+
* A key with no configured series resolves to an UNDEFINED custom property,
|
|
84
|
+
* which makes `fill` fall back to black and `stroke` to none — a silent
|
|
85
|
+
* black band. The fallback keeps an undeclared series visible.
|
|
86
|
+
*/
|
|
87
|
+
export declare function seriesVar(key: string, fallback?: string): string;
|
|
51
88
|
/**
|
|
52
89
|
* The per-series CSS custom properties a `ChartContainer` injects, so chart
|
|
53
90
|
* marks can reference {@link seriesVar} and pick up theme changes live.
|
|
@@ -63,9 +100,16 @@ export declare function formatCompactNumber(value: number): string;
|
|
|
63
100
|
*/
|
|
64
101
|
export declare function formatBucketLabel(bucket: unknown): string;
|
|
65
102
|
/**
|
|
66
|
-
* Fold a
|
|
67
|
-
*
|
|
68
|
-
*
|
|
103
|
+
* Fold a category list's tail into a single "Other" row once it exceeds
|
|
104
|
+
* `max` rows — the discipline for categorical charts: a 9th slice is never
|
|
105
|
+
* a 9th color.
|
|
106
|
+
*
|
|
107
|
+
* Ranks by value FIRST. The fold keeps the head, so on an unranked list it
|
|
108
|
+
* would swallow whatever happened to be sorted late — a donut of six
|
|
109
|
+
* equal-ish categories plus one dominant one at the end rendered as four
|
|
110
|
+
* slivers and an unlabeled 96% "Other". Ranking here rather than trusting
|
|
111
|
+
* callers makes that unreachable, including for charts a builder agent
|
|
112
|
+
* writes.
|
|
69
113
|
*/
|
|
70
114
|
export declare function foldCategoryTail<T extends Record<string, unknown>>(rows: T[], options: {
|
|
71
115
|
categoryKey: keyof T & string;
|
|
@@ -18,6 +18,64 @@ export function seriesColor(config, key) {
|
|
|
18
18
|
const declaredIndex = Object.keys(config).indexOf(key);
|
|
19
19
|
return chartSlotColor(declaredIndex === -1 ? 0 : declaredIndex);
|
|
20
20
|
}
|
|
21
|
+
/** The label a folded tail wears, and the muted color that marks it. */
|
|
22
|
+
export const OTHER_LABEL = "Other";
|
|
23
|
+
export const FOLD_COLOR = "var(--muted-foreground)";
|
|
24
|
+
/**
|
|
25
|
+
* The color resolver for a CATEGORICAL chart, where every category is an
|
|
26
|
+
* identity rather than a series.
|
|
27
|
+
*
|
|
28
|
+
* Two requirements pull against each other, and both are real:
|
|
29
|
+
* - a DECLARED category must keep its color when a filter removes its
|
|
30
|
+
* neighbours (identity follows the entity, never its rank), and
|
|
31
|
+
* - a DRAWN category must never take the muted past-the-palette color
|
|
32
|
+
* just because the config also names categories this chart does not
|
|
33
|
+
* draw.
|
|
34
|
+
*
|
|
35
|
+
* Resolved by giving each side its own claim on the palette. A declared
|
|
36
|
+
* key OWNS its declared index — reserved whether or not it is drawn, which
|
|
37
|
+
* is what makes it stable — and undeclared drawn categories fill the slots
|
|
38
|
+
* no declaration claimed, in drawn order. Past the palette, or with every
|
|
39
|
+
* slot spoken for, a category takes the muted fold color rather than
|
|
40
|
+
* wrapping modulo five onto a color another category already owns.
|
|
41
|
+
*
|
|
42
|
+
* So: declare the categories whose colors must not move, and the rest sort
|
|
43
|
+
* themselves out.
|
|
44
|
+
*
|
|
45
|
+
* `exclude` drops the measure's own config entry (a bar chart labels its
|
|
46
|
+
* value column there); it is not a category and must not shift the order.
|
|
47
|
+
*/
|
|
48
|
+
export function categoryColorResolver(config, categories, options = {}) {
|
|
49
|
+
const { exclude, otherLabel = OTHER_LABEL } = options;
|
|
50
|
+
const declared = Object.keys(config).filter((key) => key !== exclude && key !== otherLabel);
|
|
51
|
+
const slotOf = new Map();
|
|
52
|
+
declared.forEach((key, i) => slotOf.set(key, i));
|
|
53
|
+
// Whatever the declarations did not claim is free for the rest.
|
|
54
|
+
const claimed = new Set(slotOf.values());
|
|
55
|
+
const free = [];
|
|
56
|
+
for (let slot = 0; slot < CHART_SLOT_COUNT; slot++) {
|
|
57
|
+
if (!claimed.has(slot))
|
|
58
|
+
free.push(slot);
|
|
59
|
+
}
|
|
60
|
+
let next = 0;
|
|
61
|
+
for (const category of categories) {
|
|
62
|
+
if (category === otherLabel || slotOf.has(category))
|
|
63
|
+
continue;
|
|
64
|
+
const slot = free[next++];
|
|
65
|
+
slotOf.set(category, slot === undefined ? CHART_SLOT_COUNT : slot);
|
|
66
|
+
}
|
|
67
|
+
return (category) => {
|
|
68
|
+
const explicit = config[category]?.color;
|
|
69
|
+
if (explicit)
|
|
70
|
+
return explicit;
|
|
71
|
+
if (category === otherLabel)
|
|
72
|
+
return FOLD_COLOR;
|
|
73
|
+
const slot = slotOf.get(category);
|
|
74
|
+
if (slot === undefined || slot >= CHART_SLOT_COUNT)
|
|
75
|
+
return FOLD_COLOR;
|
|
76
|
+
return chartSlotColor(slot);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
21
79
|
/** Resolved label for a series: explicit config first, the key itself second. */
|
|
22
80
|
export function seriesLabel(config, key) {
|
|
23
81
|
return config[key]?.label ?? key;
|
|
@@ -31,18 +89,24 @@ export function seriesLabel(config, key) {
|
|
|
31
89
|
export function seriesVarName(key) {
|
|
32
90
|
const safe = key.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
33
91
|
if (safe === key)
|
|
34
|
-
return `--color-${key}`;
|
|
92
|
+
return `--chart-color-${key}`;
|
|
35
93
|
// Two keys may sanitize to the same ident ("metrics.tokens" vs
|
|
36
94
|
// "metrics tokens") — a short stable hash of the RAW key keeps them
|
|
37
95
|
// apart without leaking invalid characters.
|
|
38
96
|
let hash = 5381;
|
|
39
97
|
for (let i = 0; i < key.length; i++)
|
|
40
98
|
hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
|
|
41
|
-
return `--color-${safe}-${(hash >>> 0).toString(36)}`;
|
|
99
|
+
return `--chart-color-${safe}-${(hash >>> 0).toString(36)}`;
|
|
42
100
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The `var()` reference marks use for their series color.
|
|
103
|
+
*
|
|
104
|
+
* A key with no configured series resolves to an UNDEFINED custom property,
|
|
105
|
+
* which makes `fill` fall back to black and `stroke` to none — a silent
|
|
106
|
+
* black band. The fallback keeps an undeclared series visible.
|
|
107
|
+
*/
|
|
108
|
+
export function seriesVar(key, fallback = chartSlotColor(0)) {
|
|
109
|
+
return `var(${seriesVarName(key)}, ${fallback})`;
|
|
46
110
|
}
|
|
47
111
|
/**
|
|
48
112
|
* The per-series CSS custom properties a `ChartContainer` injects, so chart
|
|
@@ -88,27 +152,36 @@ export function formatBucketLabel(bucket) {
|
|
|
88
152
|
return bucket;
|
|
89
153
|
}
|
|
90
154
|
/**
|
|
91
|
-
* Fold a
|
|
92
|
-
*
|
|
93
|
-
*
|
|
155
|
+
* Fold a category list's tail into a single "Other" row once it exceeds
|
|
156
|
+
* `max` rows — the discipline for categorical charts: a 9th slice is never
|
|
157
|
+
* a 9th color.
|
|
158
|
+
*
|
|
159
|
+
* Ranks by value FIRST. The fold keeps the head, so on an unranked list it
|
|
160
|
+
* would swallow whatever happened to be sorted late — a donut of six
|
|
161
|
+
* equal-ish categories plus one dominant one at the end rendered as four
|
|
162
|
+
* slivers and an unlabeled 96% "Other". Ranking here rather than trusting
|
|
163
|
+
* callers makes that unreachable, including for charts a builder agent
|
|
164
|
+
* writes.
|
|
94
165
|
*/
|
|
95
166
|
export function foldCategoryTail(rows, options) {
|
|
96
|
-
const { categoryKey, valueKey, otherLabel =
|
|
97
|
-
// A cap below 2 cannot hold a fold (one real row + "Other" is the floor)
|
|
98
|
-
|
|
167
|
+
const { categoryKey, valueKey, otherLabel = OTHER_LABEL } = options;
|
|
168
|
+
// A cap below 2 cannot hold a fold (one real row + "Other" is the floor);
|
|
169
|
+
// a non-finite cap would fold everything into a single bucket.
|
|
170
|
+
const requested = options.max ?? CHART_SLOT_COUNT;
|
|
171
|
+
const max = Math.max(2, Number.isFinite(requested) ? requested : CHART_SLOT_COUNT);
|
|
99
172
|
if (rows.length <= max)
|
|
100
173
|
return rows;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
const
|
|
106
|
-
const
|
|
174
|
+
const ranked = [...rows].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
|
|
175
|
+
// A pre-existing "Other" row always joins the fold, so set it aside
|
|
176
|
+
// BEFORE taking the head: leaving it there would cost a real category
|
|
177
|
+
// its place for a row that gets folded away anyway.
|
|
178
|
+
const existingOther = ranked.filter((row) => String(row[categoryKey]) === otherLabel);
|
|
179
|
+
const real = ranked.filter((row) => String(row[categoryKey]) !== otherLabel);
|
|
180
|
+
const kept = real.slice(0, max - 1);
|
|
181
|
+
const folded = [...existingOther, ...real.slice(max - 1)];
|
|
182
|
+
if (!folded.length)
|
|
183
|
+
return kept;
|
|
107
184
|
const otherTotal = folded.reduce((sum, row) => sum + (Number(row[valueKey]) || 0), 0);
|
|
108
|
-
const other = {
|
|
109
|
-
...(tail[0] ?? folded[0]),
|
|
110
|
-
[categoryKey]: otherLabel,
|
|
111
|
-
[valueKey]: otherTotal,
|
|
112
|
-
};
|
|
185
|
+
const other = { ...folded[0], [categoryKey]: otherLabel, [valueKey]: otherTotal };
|
|
113
186
|
return [...kept, other];
|
|
114
187
|
}
|
|
@@ -3,9 +3,9 @@ import * as RechartsPrimitive from "recharts";
|
|
|
3
3
|
import { type ChartConfig } from "./chart-config.js";
|
|
4
4
|
/**
|
|
5
5
|
* The shadcn-style chart layer over recharts: `ChartContainer` injects one
|
|
6
|
-
* `--color-<key>` CSS variable per configured series (resolved from the
|
|
6
|
+
* `--chart-color-<key>` CSS variable per configured series (resolved from the
|
|
7
7
|
* theme's `--chart-1..5` slots unless overridden), so every mark inside can
|
|
8
|
-
* reference its series color with `
|
|
8
|
+
* reference its series color with `seriesVar(key)` and follow theme
|
|
9
9
|
* changes — light/dark and tenant themes alike — without re-rendering logic.
|
|
10
10
|
*
|
|
11
11
|
* Tooltip and legend CONTENT components read the same config through
|
|
@@ -48,6 +48,8 @@ export interface ChartTooltipContentProps {
|
|
|
48
48
|
valueFormatter?: (value: number) => React.ReactNode;
|
|
49
49
|
/** Hide the color indicator squares. */
|
|
50
50
|
hideIndicator?: boolean;
|
|
51
|
+
/** Drop the header row entirely (a donut names its slice on the row). */
|
|
52
|
+
hideLabel?: boolean;
|
|
51
53
|
/**
|
|
52
54
|
* Override the indicator color per row — for charts whose CELLS recolor
|
|
53
55
|
* the marks (colorByCategory) while the payload still carries the parent
|
|
@@ -59,7 +61,7 @@ export interface ChartTooltipContentProps {
|
|
|
59
61
|
* The hover layer's content. Rows are named from the chart config (the
|
|
60
62
|
* series key is a data column name — never show it raw when a label exists).
|
|
61
63
|
*/
|
|
62
|
-
declare function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator, indicatorColor, }: ChartTooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
64
|
+
declare function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator, hideLabel, indicatorColor, }: ChartTooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
63
65
|
export interface ChartLegendContentProps {
|
|
64
66
|
payload?: Array<{
|
|
65
67
|
dataKey?: string | number;
|
|
@@ -27,11 +27,11 @@ const ChartLegend = RechartsPrimitive.Legend;
|
|
|
27
27
|
* The hover layer's content. Rows are named from the chart config (the
|
|
28
28
|
* series key is a data column name — never show it raw when a label exists).
|
|
29
29
|
*/
|
|
30
|
-
function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator = false, indicatorColor, }) {
|
|
30
|
+
function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator = false, hideLabel = false, indicatorColor, }) {
|
|
31
31
|
const { config } = useChart();
|
|
32
32
|
if (!active || !payload?.length)
|
|
33
33
|
return null;
|
|
34
|
-
return (_jsxs("div", { className: cn("min-w-[9rem] rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md", className), children: [_jsx("div", { className: "mb-1.5 font-medium text-foreground", children: labelFormatter ? labelFormatter(label) : String(label ?? "") }), _jsx("div", { className: "grid gap-1", children: payload.map((entry, i) => {
|
|
34
|
+
return (_jsxs("div", { className: cn("min-w-[9rem] rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md", className), children: [!hideLabel && (_jsx("div", { className: "mb-1.5 font-medium text-foreground", children: labelFormatter ? labelFormatter(label) : String(label ?? "") })), _jsx("div", { className: "grid gap-1", children: payload.map((entry, i) => {
|
|
35
35
|
// `name` first: a Pie names entries by its nameKey (the category),
|
|
36
36
|
// while its dataKey is the measure column — the one label a donut
|
|
37
37
|
// row must NOT wear. Series charts set name = dataKey, so nothing
|
|
@@ -45,7 +45,7 @@ function ChartTooltipContent({ active, label, payload, className, labelFormatter
|
|
|
45
45
|
? valueFormatter(numeric)
|
|
46
46
|
: finite
|
|
47
47
|
? numeric.toLocaleString()
|
|
48
|
-
: String(entry.value ?? "") })] }, key));
|
|
48
|
+
: String(entry.value ?? "") })] }, `${i}-${key}`));
|
|
49
49
|
}) })] }));
|
|
50
50
|
}
|
|
51
51
|
/** Legend rows named from the chart config, color chips from the payload. */
|
|
@@ -55,7 +55,7 @@ function ChartLegendContent({ payload, className }) {
|
|
|
55
55
|
return null;
|
|
56
56
|
return (_jsx("div", { className: cn("flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3", className), children: payload.map((entry, i) => {
|
|
57
57
|
const key = String(entry.dataKey ?? entry.value ?? i);
|
|
58
|
-
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: entry.color ?? seriesVar(key) } }), _jsx("span", { children: seriesLabel(config, key) })] }, key));
|
|
58
|
+
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: entry.color ?? seriesVar(key) } }), _jsx("span", { children: seriesLabel(config, key) })] }, `${i}-${key}`));
|
|
59
59
|
}) }));
|
|
60
60
|
}
|
|
61
61
|
/** Compact y-axis tick formatter shared by the chart wrappers. */
|
|
@@ -2,11 +2,12 @@ import * as React from "react";
|
|
|
2
2
|
import { type ChartConfig } from "./chart-config.js";
|
|
3
3
|
/**
|
|
4
4
|
* Share of a whole ("tokens per model"). Slices are identities, so each
|
|
5
|
-
* takes
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* takes a theme slot resolved over the DRAWN slices — declared config keys
|
|
6
|
+
* first, the rest in drawn order — and there are only five slots,
|
|
7
|
+
* so `maxSlices` folds the tail into a muted "Other" (ranked first, so the
|
|
8
|
+
* fold can never swallow the largest slice) instead of inventing colors.
|
|
9
|
+
* The center carries the headline total; the legend carries names + shares,
|
|
10
|
+
* so the hover layer is confirmation, not the only way to read the chart.
|
|
10
11
|
*/
|
|
11
12
|
export interface DonutChartProps {
|
|
12
13
|
data: Array<Record<string, unknown>>;
|
|
@@ -3,35 +3,36 @@ import * as React from "react";
|
|
|
3
3
|
import * as RechartsPrimitive from "recharts";
|
|
4
4
|
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
5
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart.js";
|
|
6
|
-
import { CHART_SLOT_COUNT,
|
|
7
|
-
const
|
|
8
|
-
export function DonutChart({ data, categoryKey, valueKey, config =
|
|
6
|
+
import { CHART_SLOT_COUNT, categoryColorResolver, foldCategoryTail, formatCompactNumber, } from "./chart-config.js";
|
|
7
|
+
const EMPTY_CONFIG = {};
|
|
8
|
+
export function DonutChart({ data, categoryKey, valueKey, config = EMPTY_CONFIG, maxSlices = 5, centerLabel, centerValue, height = 240, width, valueFormatter, showLegend = true, className, }) {
|
|
9
9
|
// The donut's slices are identities and the palette has five slots —
|
|
10
10
|
// a cap above that would wrap colors modulo five.
|
|
11
11
|
const cappedSlices = Math.min(CHART_SLOT_COUNT, maxSlices);
|
|
12
|
+
// One expression for "this row's category", so the color resolver and
|
|
13
|
+
// the rendered cell can never disagree about a missing value.
|
|
14
|
+
const categoryOf = React.useCallback((row, i) => String(row[categoryKey] ?? i), [categoryKey]);
|
|
12
15
|
const rows = React.useMemo(() => foldCategoryTail(data, { categoryKey, valueKey, max: cappedSlices }), [data, categoryKey, valueKey, cappedSlices]);
|
|
13
|
-
const total = React.useMemo(() => rows.reduce((sum, row) =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const configIndex = Object.keys(config).indexOf(category);
|
|
22
|
-
return chartSlotColor(configIndex !== -1 ? configIndex : index);
|
|
23
|
-
}, [config, rows.length, data.length]);
|
|
16
|
+
const total = React.useMemo(() => rows.reduce((sum, row) => {
|
|
17
|
+
const value = Number(row[valueKey]);
|
|
18
|
+
return sum + (Number.isFinite(value) ? value : 0);
|
|
19
|
+
}, 0), [rows, valueKey]);
|
|
20
|
+
// Identity resolved over the DRAWN slices: the fold ranks, so a category
|
|
21
|
+
// late in the input can be promoted, and resolving from input order would
|
|
22
|
+
// paint that visible slice the muted past-the-palette grey.
|
|
23
|
+
const sliceColor = React.useMemo(() => categoryColorResolver(config, rows.map((row) => String(row[categoryKey] ?? ""))), [config, rows, categoryOf]);
|
|
24
24
|
const fixed = width !== undefined;
|
|
25
25
|
const sliceLabel = (category) => config[category]?.label ?? category;
|
|
26
|
-
return (_jsxs("div", { className: cn("flex flex-col", className), children: [_jsxs("div", { className: "relative", children: [_jsx(ChartContainer, { config: config, responsive: !fixed, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.PieChart, { width: width, height: fixed ? height : undefined, children: [_jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, {
|
|
26
|
+
return (_jsxs("div", { className: cn("flex flex-col", className), children: [_jsxs("div", { className: "relative", children: [_jsx(ChartContainer, { config: config, responsive: !fixed, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.PieChart, { width: width, height: fixed ? height : undefined, children: [_jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { hideLabel: true, valueFormatter: valueFormatter }) }), _jsx(RechartsPrimitive.Pie, { data: rows, dataKey: valueKey, nameKey: categoryKey, innerRadius: "62%", outerRadius: "88%", paddingAngle: 2, strokeWidth: 0, isAnimationActive: false, children: rows.map((row, i) => {
|
|
27
27
|
const category = String(row[categoryKey] ?? i);
|
|
28
|
-
return _jsx(RechartsPrimitive.Cell, { fill: sliceColor(category
|
|
28
|
+
return (_jsx(RechartsPrimitive.Cell, { fill: sliceColor(category) }, `${i}-${category}`));
|
|
29
29
|
}) })] }) }), _jsxs("div", { className: "pointer-events-none absolute inset-0 flex flex-col items-center justify-center", children: [_jsx("span", { className: "text-xl font-semibold tabular-nums", "data-donut-center": "", children: centerValue ?? formatCompactNumber(total) }), centerLabel && _jsx("span", { className: "text-[11px] text-muted-foreground", children: centerLabel })] })] }), showLegend && (_jsx("div", { className: "flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3", children: rows.map((row, i) => {
|
|
30
30
|
const category = String(row[categoryKey] ?? i);
|
|
31
|
-
const
|
|
31
|
+
const raw = Number(row[valueKey]);
|
|
32
|
+
const value = Number.isFinite(raw) ? raw : 0;
|
|
32
33
|
const pct = total > 0 ? (value / total) * 100 : 0;
|
|
33
34
|
// A real slice must never read as absent: below 1% says so.
|
|
34
35
|
const share = pct > 0 && pct < 1 ? "<1" : String(Math.round(pct));
|
|
35
|
-
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: sliceColor(category
|
|
36
|
+
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: sliceColor(category) } }), _jsx("span", { children: sliceLabel(category) }), _jsxs("span", { className: "tabular-nums", children: [share, "%"] })] }, `${i}-${category}`));
|
|
36
37
|
}) }))] }));
|
|
37
38
|
}
|
|
@@ -7,12 +7,19 @@ function formatValue(value, format) {
|
|
|
7
7
|
return "–";
|
|
8
8
|
if (typeof value === "string")
|
|
9
9
|
return value;
|
|
10
|
+
// Both branches take the same guard: a NaN must not render as "NaN" in
|
|
11
|
+
// one format and "–" in the other.
|
|
12
|
+
if (!Number.isFinite(value))
|
|
13
|
+
return "–";
|
|
10
14
|
return format === "plain" ? value.toLocaleString() : formatCompactNumber(value);
|
|
11
15
|
}
|
|
12
16
|
const StatCard = React.forwardRef(({ label, value, format = "compact", delta, deltaLabel, invertDelta = false, hint, icon, loading = false, className, ...props }, ref) => {
|
|
13
|
-
|
|
17
|
+
// A delta of Infinity is the natural result of dividing by a zero
|
|
18
|
+
// previous period, and "▲ ∞%" styled as GOOD is worse than no badge.
|
|
19
|
+
const hasDelta = delta !== undefined && Number.isFinite(delta);
|
|
20
|
+
const direction = !hasDelta || delta === 0 ? "flat" : delta > 0 ? "up" : "down";
|
|
14
21
|
const good = delta !== undefined && delta !== 0 && (invertDelta ? delta < 0 : delta > 0);
|
|
15
|
-
return (_jsxs("div", { ref: ref, "data-stat-card": "", className: cn("rounded-xl border border-border bg-card p-4 text-card-foreground", className), ...props, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-xs font-medium text-muted-foreground", children: label }), icon && _jsx("span", { className: "shrink-0 text-muted-foreground [&_svg]:size-4", children: icon })] }), _jsxs("div", { className: "mt-2 flex items-baseline gap-2", children: [loading ? (_jsx("span", { className: "h-7 w-20 animate-pulse rounded-md bg-muted", "data-stat-skeleton": "" })) : (_jsx("span", { className: "text-2xl font-semibold tabular-nums tracking-tight", "data-stat-value": "", children: formatValue(value, format) })), !loading &&
|
|
22
|
+
return (_jsxs("div", { ref: ref, "data-stat-card": "", className: cn("rounded-xl border border-border bg-card p-4 text-card-foreground", className), ...props, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-xs font-medium text-muted-foreground", children: label }), icon && _jsx("span", { className: "shrink-0 text-muted-foreground [&_svg]:size-4", children: icon })] }), _jsxs("div", { className: "mt-2 flex items-baseline gap-2", children: [loading ? (_jsx("span", { className: "h-7 w-20 animate-pulse rounded-md bg-muted", "data-stat-skeleton": "" })) : (_jsx("span", { className: "text-2xl font-semibold tabular-nums tracking-tight", "data-stat-value": "", children: formatValue(value, format) })), !loading && hasDelta && (_jsxs("span", { "data-stat-delta": direction === "flat" ? "flat" : good ? "good" : "bad", className: cn("inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium tabular-nums", direction === "flat"
|
|
16
23
|
? "bg-muted text-muted-foreground"
|
|
17
24
|
: good
|
|
18
25
|
? "bg-success/10 text-success"
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as RechartsPrimitive from "recharts";
|
|
3
3
|
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, compactAxisTick, } from "./chart.js";
|
|
4
|
-
import { formatBucketLabel, seriesVar } from "./chart-config.js";
|
|
4
|
+
import { chartSlotColor, formatBucketLabel, seriesVar } from "./chart-config.js";
|
|
5
5
|
export function TimeSeriesChart({ data, config, xKey = "bucket", series, kind = "area", stacked = false, height = 240, width, valueFormatter, xTickFormatter = formatBucketLabel, showLegend, className, }) {
|
|
6
6
|
const keys = series ?? Object.keys(config);
|
|
7
|
+
// A drawn key with no config takes the next slot AFTER the configured
|
|
8
|
+
// ones — the rendered index would collide with a configured series when
|
|
9
|
+
// an undeclared key is drawn first.
|
|
10
|
+
const configured = Object.keys(config);
|
|
11
|
+
const fallbackSlot = (key) => {
|
|
12
|
+
const declared = configured.indexOf(key);
|
|
13
|
+
if (declared !== -1)
|
|
14
|
+
return declared;
|
|
15
|
+
const undeclared = keys.filter((k) => !configured.includes(k)).indexOf(key);
|
|
16
|
+
return configured.length + Math.max(0, undeclared);
|
|
17
|
+
};
|
|
7
18
|
const legend = showLegend ?? keys.length > 1;
|
|
8
19
|
const fixed = width !== undefined;
|
|
9
20
|
const ChartRoot = kind === "line"
|
|
@@ -11,7 +22,7 @@ export function TimeSeriesChart({ data, config, xKey = "bucket", series, kind =
|
|
|
11
22
|
: kind === "bar"
|
|
12
23
|
? RechartsPrimitive.BarChart
|
|
13
24
|
: RechartsPrimitive.AreaChart;
|
|
14
|
-
return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(ChartRoot, { data: data, width: width, height: fixed ? height : undefined, margin: { top: 4, right: 8, bottom: 0, left: 0 }, children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: xKey, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 24, tickFormatter: xTickFormatter }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: xTickFormatter, valueFormatter: valueFormatter }) }), legend && _jsx(ChartLegend, { content: _jsx(ChartLegendContent, {}) }), keys.map((key) => kind === "line" ? (_jsx(RechartsPrimitive.Line, { type: "monotone", dataKey: key, stroke: seriesVar(key), strokeWidth: 2, dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)) : kind === "bar" ? (_jsx(RechartsPrimitive.Bar, { dataKey: key, stackId: stacked ? "stack" : undefined, fill: seriesVar(key), maxBarSize: 48, radius: stacked ? 0 : [4, 4, 0, 0], isAnimationActive: false }, key)) : (_jsx(RechartsPrimitive.Area, { type: "monotone", dataKey: key, stackId: stacked ? "stack" : undefined, stroke: seriesVar(key), fill: seriesVar(key), fillOpacity: 0.25, strokeWidth: 2,
|
|
25
|
+
return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(ChartRoot, { data: data, width: width, height: fixed ? height : undefined, margin: { top: 4, right: 8, bottom: 0, left: 0 }, children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: xKey, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 24, tickFormatter: xTickFormatter }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: xTickFormatter, valueFormatter: valueFormatter }) }), legend && _jsx(ChartLegend, { content: _jsx(ChartLegendContent, {}) }), keys.map((key) => kind === "line" ? (_jsx(RechartsPrimitive.Line, { type: "monotone", dataKey: key, stroke: seriesVar(key, chartSlotColor(fallbackSlot(key))), strokeWidth: 2, dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)) : kind === "bar" ? (_jsx(RechartsPrimitive.Bar, { dataKey: key, stackId: stacked ? "stack" : undefined, fill: seriesVar(key, chartSlotColor(fallbackSlot(key))), maxBarSize: 48, radius: stacked ? 0 : [4, 4, 0, 0], isAnimationActive: false }, key)) : (_jsx(RechartsPrimitive.Area, { type: "monotone", dataKey: key, stackId: stacked ? "stack" : undefined, stroke: seriesVar(key, chartSlotColor(fallbackSlot(key))), fill: seriesVar(key, chartSlotColor(fallbackSlot(key))), fillOpacity: 0.25, strokeWidth: 2,
|
|
15
26
|
// A one-or-two point window would otherwise draw a floating
|
|
16
27
|
// speck; dots make sparse data read as sparse, not broken.
|
|
17
28
|
dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)))] }) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iloveagents/foundry-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
|
|
6
6
|
"keywords": [
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
"recharts": "^3.10.1",
|
|
77
77
|
"remark-gfm": "^4.0.0",
|
|
78
78
|
"tailwind-merge": "^3.5.0",
|
|
79
|
-
"@iloveagents/foundry-agent": "^0.
|
|
80
|
-
"@iloveagents/foundry-web-primitives": "^0.
|
|
79
|
+
"@iloveagents/foundry-agent": "^0.24.0",
|
|
80
|
+
"@iloveagents/foundry-web-primitives": "^0.24.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@ag-ui/client": "^0.0.52",
|