@columenlabs/lumen-ui 0.1.95 → 0.1.96
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/allocation-body.d.ts +15 -0
- package/dist/allocation-body.js +104 -0
- package/dist/lumen-widget.js +4 -138
- package/dist/overview-charts.d.ts +18 -0
- package/dist/overview-charts.js +53 -0
- package/dist/overview.js +6 -50
- package/dist/progress-ring.d.ts +7 -0
- package/dist/progress-ring.js +11 -0
- package/dist/summary-cards.js +3 -10
- package/dist/widget-values.d.ts +20 -0
- package/dist/widget-values.js +55 -0
- package/package.json +2 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An allocation widget's body (bar list, donut, allocation bar). It draws with the charts library,
|
|
3
|
+
* so the widget frame loads it only when such a widget shows.
|
|
4
|
+
*/
|
|
5
|
+
import { type ViewCard } from '@columenlabs/lumen-ui-types/views';
|
|
6
|
+
import { type ViewCellValue, type ViewField } from './view-model.js';
|
|
7
|
+
export declare function AllocationBody<T>({ card, own, read, field, }: {
|
|
8
|
+
card: Extract<ViewCard, {
|
|
9
|
+
kind: 'allocation';
|
|
10
|
+
}>;
|
|
11
|
+
/** The widget's own rows (its filter applied). */
|
|
12
|
+
own: T[];
|
|
13
|
+
read: (f: ViewField<T>, r: T) => ViewCellValue;
|
|
14
|
+
field: (key: string) => ViewField<T> | undefined;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* An allocation widget's body (bar list, donut, allocation bar). It draws with the charts library,
|
|
4
|
+
* so the widget frame loads it only when such a widget shows.
|
|
5
|
+
*/
|
|
6
|
+
import { ALLOCATION_MAX, controlOn } from '@columenlabs/lumen-ui-types/views';
|
|
7
|
+
import { AllocationBar, Donut, Headline, RankedBars, STACK_MAX, StackedBars, } from './lumen-widget-charts.js';
|
|
8
|
+
import { useAmountsHidden } from './shell.js';
|
|
9
|
+
import { ChartTooltip, ChartTooltipContent } from './ui/chart.js';
|
|
10
|
+
import { label } from './view-model.js';
|
|
11
|
+
import { colorOf, exact, pct, short, sumBy } from './widget-values.js';
|
|
12
|
+
const Empty = ({ children = 'Nothing to show for these rows.' }) => (_jsx("p", { className: "py-6 text-center text-sm text-muted-foreground", children: children }));
|
|
13
|
+
export function AllocationBody({ card, own, read, field, }) {
|
|
14
|
+
const hidden = useAmountsHidden();
|
|
15
|
+
const by = field(card.by);
|
|
16
|
+
const value = field(card.value);
|
|
17
|
+
if (!by || !value)
|
|
18
|
+
return _jsx(Empty, { children: "This widget's fields are not in this view." });
|
|
19
|
+
const masked = hidden && !!value.amount;
|
|
20
|
+
const fmt = (v) => short(v, value.format, masked);
|
|
21
|
+
const sums = sumBy(own, by, value, read);
|
|
22
|
+
// An empty value reads "No sector", not "—"; counts say "1 sector", "3 sectors".
|
|
23
|
+
const shown = (name, f) => name === label(null) ? `No ${f.label.toLowerCase()}` : name;
|
|
24
|
+
const count = (n) => `${n} ${by.label.toLowerCase()}${n === 1 ? '' : 's'}`;
|
|
25
|
+
if (card.style === 'bar_list') {
|
|
26
|
+
const bars = sums
|
|
27
|
+
.filter((x) => x.v !== 0)
|
|
28
|
+
.sort((a, b) => (card.desc ? b.v - a.v : a.v - b.v))
|
|
29
|
+
.slice(0, card.n);
|
|
30
|
+
if (!bars.length)
|
|
31
|
+
return _jsx(Empty, {});
|
|
32
|
+
const net = bars.reduce((t, x) => t + x.v, 0);
|
|
33
|
+
const abs = bars.reduce((t, x) => t + Math.abs(x.v), 0);
|
|
34
|
+
const up = bars.filter((b) => b.v > 0).length;
|
|
35
|
+
// Split by: each bar's parts by a text field, its 5 largest overall coloured as a donut of
|
|
36
|
+
// that field colours them, the rest Other.
|
|
37
|
+
const stackF = !card.signed && card.stack ? field(card.stack) : undefined;
|
|
38
|
+
const stack = (() => {
|
|
39
|
+
if (!stackF || stackF.key === by.key)
|
|
40
|
+
return null;
|
|
41
|
+
const order = sumBy(own, stackF, value, read).filter((x) => x.v > 0);
|
|
42
|
+
// A split needs two known parts; with fewer (no sectors set yet) plain bars read better.
|
|
43
|
+
if (order.filter((x) => x.name !== label(null)).length < 2)
|
|
44
|
+
return null;
|
|
45
|
+
const top = order.slice(0, STACK_MAX).map((x) => x.name);
|
|
46
|
+
const parts = [...top, ...(order.length > STACK_MAX ? ['Other'] : [])].map((name, i) => ({
|
|
47
|
+
name: shown(name, stackF),
|
|
48
|
+
key: `p${i}`,
|
|
49
|
+
fill: name === 'Other' ? 'var(--muted-foreground)' : colorOf(name, i),
|
|
50
|
+
}));
|
|
51
|
+
const keyOf = (n) => `p${top.includes(n) ? top.indexOf(n) : top.length}`;
|
|
52
|
+
const rows = bars.map((b) => {
|
|
53
|
+
const r = { name: shown(b.name, by), total: b.v };
|
|
54
|
+
for (const x of own) {
|
|
55
|
+
if (label(read(by, x)) !== b.name)
|
|
56
|
+
continue;
|
|
57
|
+
const v = read(value, x);
|
|
58
|
+
if (typeof v !== 'number' || v <= 0)
|
|
59
|
+
continue;
|
|
60
|
+
const k = keyOf(label(read(stackF, x)));
|
|
61
|
+
r[k] = (r[k] ?? 0) + v;
|
|
62
|
+
}
|
|
63
|
+
return r;
|
|
64
|
+
});
|
|
65
|
+
return { rows, parts };
|
|
66
|
+
})();
|
|
67
|
+
return (_jsxs(_Fragment, { children: [_jsx(Headline, { value: `${card.signed && net > 0 ? '+' : ''}${fmt(net)}`, tone: card.signed ? (net >= 0 ? 'gain' : 'loss') : undefined, caption: card.signed
|
|
68
|
+
? `net · ${up} up, ${bars.length - up} down`
|
|
69
|
+
: `${value.label.toLowerCase()} across ${count(bars.length)}` }), stack ? (_jsx(StackedBars, { rows: stack.rows, parts: stack.parts, fmt: fmt, exact: (v) => exact(v, value.format, masked), share: (v) => pct(v, abs) })) : (_jsx(RankedBars, { id: card.id, items: bars.map((b, i) => ({
|
|
70
|
+
...b,
|
|
71
|
+
name: shown(b.name, by),
|
|
72
|
+
fill: card.signed ? (b.v >= 0 ? 'var(--gain)' : 'var(--loss)') : colorOf(b.name, i),
|
|
73
|
+
})), signed: card.signed, fmt: fmt, share: (v) => pct(Math.abs(v), abs) }))] }));
|
|
74
|
+
}
|
|
75
|
+
const parts = sums.filter((x) => x.v > 0).map((x) => ({ ...x, name: shown(x.name, by) }));
|
|
76
|
+
const total = parts.reduce((t, x) => t + x.v, 0);
|
|
77
|
+
const known = parts.filter((x) => x.name !== shown(label(null), by)).length;
|
|
78
|
+
if (!total)
|
|
79
|
+
return _jsx(Empty, {});
|
|
80
|
+
// Donut: `max` slices in all, Other the last. Allocation: the largest `max`, then Other.
|
|
81
|
+
const keep = card.style === 'donut' ? card.max - 1 : (card.max ?? ALLOCATION_MAX);
|
|
82
|
+
const limit = card.style === 'donut' ? card.max : keep;
|
|
83
|
+
const rest = parts.length > limit ? parts.slice(keep) : [];
|
|
84
|
+
const slices = rest.length
|
|
85
|
+
? [...parts.slice(0, keep), { name: 'Other', v: rest.reduce((t, x) => t + x.v, 0) }]
|
|
86
|
+
: parts;
|
|
87
|
+
const colored = slices.map((s, i) => ({
|
|
88
|
+
...s,
|
|
89
|
+
key: `s${i}`,
|
|
90
|
+
fill: s.name === 'Other' ? 'var(--muted-foreground)' : colorOf(s.name, i),
|
|
91
|
+
}));
|
|
92
|
+
if (card.style === 'donut')
|
|
93
|
+
return (_jsx(Donut, { items: colored, total: total, fmt: fmt, share: (v) => pct(v, total), caption: count(known), legend: controlOn(card, 'legend') }));
|
|
94
|
+
// Allocation: one stacked bar, no legend; each wide slice names itself, and hovering a slice
|
|
95
|
+
// gives its exact value and share, Other its 10 largest. The tooltip may leave the card.
|
|
96
|
+
const config = Object.fromEntries(colored.map((s) => [s.key, { label: s.name, color: s.fill }]));
|
|
97
|
+
const line = (name, v, fill) => (_jsxs("span", { className: "flex w-full items-center gap-2", children: [fill && _jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: fill } }), _jsx("span", { className: "truncate font-medium", children: name }), _jsxs("span", { className: "num ml-auto pl-3 whitespace-nowrap", children: [exact(v, value.format, masked), " \u00B7 ", pct(v, total)] })] }, name));
|
|
98
|
+
return (_jsxs("div", { className: "py-1", children: [_jsx(Headline, { value: fmt(total), caption: `${count(known)} · largest ${colored[0]?.name ?? ''} ${pct(colored[0]?.v ?? 0, total)}` }), _jsx(AllocationBar, { items: colored, total: total, config: config, share: (v) => pct(v, total), tooltip: _jsx(ChartTooltip, { cursor: false, shared: false, allowEscapeViewBox: { x: false, y: true }, wrapperStyle: { zIndex: 50 }, content: _jsx(ChartTooltipContent, { hideLabel: true, className: "min-w-48 max-w-72", formatter: (v, name) => {
|
|
99
|
+
const s = colored.find((c) => c.key === name);
|
|
100
|
+
if (s?.name !== 'Other')
|
|
101
|
+
return line(s?.name ?? String(name), Number(v), s?.fill);
|
|
102
|
+
return (_jsxs("span", { className: "flex w-full flex-col gap-1", children: [line(`Other (${rest.length})`, Number(v), s.fill), _jsxs("span", { className: "flex flex-col gap-0.5 border-t pt-1 text-muted-foreground", children: [rest.slice(0, 10).map((x) => line(x.name, x.v)), rest.length > 10 && _jsxs("span", { children: ["+", rest.length - 10, " more"] })] })] }));
|
|
103
|
+
} }) }) })] }));
|
|
104
|
+
}
|
package/dist/lumen-widget.js
CHANGED
|
@@ -6,7 +6,7 @@ import { OUTLOOK_INDEXES, marketWindows, } from '@columenlabs/lumen-ui-types/mar
|
|
|
6
6
|
// nothing new is fetched; market and price chart widgets read their own data. LumenLayout lays the
|
|
7
7
|
// cards out; this file draws the card frame every card shares (its header, ↻ and ⋯ menu), each
|
|
8
8
|
// widget, the Edit layout bar, Add widget (Pre-built and Build your own) and Edit widget.
|
|
9
|
-
import {
|
|
9
|
+
import { VIEW_CARD_KIND_NAMES, WIDGET_STYLES, controlOn, isDollarFormat, isOwnTile, isRowCard, isTableCard, trendBuckets, viewCardKinds, viewCardSchema, viewWidgetCatalog, widgetStyleId, widgetStyleOf, } from '@columenlabs/lumen-ui-types/views';
|
|
10
10
|
import { ActivityIcon, ArrowDownIcon, ArrowUpIcon, BarChart3Icon, CandlestickChartIcon, ChartLineIcon, ChartPieIcon, CheckIcon, CodeIcon, CopyIcon, DatabaseIcon, EyeOffIcon, FoldVerticalIcon, FunnelIcon, GripVerticalIcon, InfoIcon, LayoutGridIcon, ListOrderedIcon, MoreHorizontalIcon, PackageIcon, PencilIcon, PlusIcon, RefreshCwIcon, RotateCcwIcon, Rows3Icon, SparklesIcon, SquareIcon, TableIcon, Trash2Icon, TrendingUpIcon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
11
11
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
12
|
import { useAssistant, useAssistantName } from './assistant-context.js';
|
|
@@ -23,7 +23,6 @@ import { useGridCell } from './lumen-grid.js';
|
|
|
23
23
|
import { CardIdProvider, LinkDot, LinkPicker, linkVar, useLinks, } from './lumen-links.js';
|
|
24
24
|
import { LumenSelector } from './lumen-selector.js';
|
|
25
25
|
import { LumenFreshness, LumenLazy, LumenLoadFooter, LumenRefresh, LumenStates, } from './lumen-source.js';
|
|
26
|
-
import { AllocationBar, Donut, Headline, RankedBars, STACK_MAX, StackedBars, } from './lumen-widget-charts.js';
|
|
27
26
|
import { useHasMarket } from './market/live.js';
|
|
28
27
|
import { useBarsUrl, useLumenChart } from './market/lumen-chart.js';
|
|
29
28
|
import { maskAmount, useAmountsHidden } from './shell.js';
|
|
@@ -31,7 +30,6 @@ import { SummaryCardsEditor, summaryCardIssues } from './summary-cards.js';
|
|
|
31
30
|
import { Badge } from './ui/badge.js';
|
|
32
31
|
import { Button } from './ui/button.js';
|
|
33
32
|
import { Card } from './ui/card.js';
|
|
34
|
-
import { ChartTooltip, ChartTooltipContent } from './ui/chart.js';
|
|
35
33
|
import { Checkbox } from './ui/checkbox.js';
|
|
36
34
|
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, dialogBody, } from './ui/dialog.js';
|
|
37
35
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
@@ -49,12 +47,14 @@ import { computedOf, label, summaryCash, } from './view-model.js';
|
|
|
49
47
|
import { WidgetControlsEditor } from './widget-controls.js';
|
|
50
48
|
import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
|
|
51
49
|
import { dateFields } from './widget-settings.js';
|
|
50
|
+
import { COMPACT, MINUS } from './widget-values.js';
|
|
52
51
|
// Loaded when it first shows (lazy-part.tsx).
|
|
53
52
|
const MarketWidget = lazyPart(() => import('./market/market-widget.js').then((m) => m.MarketWidget), _jsx(Skeleton, { className: "h-full min-h-40 w-full" }));
|
|
54
53
|
// Loaded when it first shows (lazy-part.tsx).
|
|
55
54
|
const TrendBody = lazyPart(() => import('./lumen-trend.js').then((m) => m.TrendBody), _jsx(Skeleton, { className: "h-full min-h-40 w-full" }));
|
|
56
55
|
// Loaded when it first shows (lazy-part.tsx).
|
|
57
56
|
const GrowthChart = lazyPart(() => import('./growth-chart.js').then((m) => m.GrowthChart), _jsx(Skeleton, { className: "h-full min-h-40 w-full" }));
|
|
57
|
+
const AllocationBody = lazyPart(() => import('./allocation-body.js').then((m) => m.AllocationBody), _jsx(Skeleton, { className: "h-full min-h-32 w-full" }));
|
|
58
58
|
/** Each style's icon, by its id in `WIDGET_STYLES`; a style left out takes its kind's. */
|
|
59
59
|
const STYLE_ICONS = {
|
|
60
60
|
'table:table': TableIcon,
|
|
@@ -103,56 +103,10 @@ export function cardFilterFields(fields) {
|
|
|
103
103
|
},
|
|
104
104
|
}));
|
|
105
105
|
}
|
|
106
|
-
// ── Values ─────────────────────────────────────────────────────────────────────────────────────
|
|
107
|
-
const MINUS = '−';
|
|
108
|
-
const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
|
|
109
|
-
/** A chart value in short form: $12.3K, −4.1%, 1.2M; masked while Hide amounts is on. */
|
|
110
|
-
function short(v, format, hidden) {
|
|
111
|
-
if (hidden)
|
|
112
|
-
return maskAmount('0');
|
|
113
|
-
const sign = v < 0 ? MINUS : '';
|
|
114
|
-
const abs = Math.abs(v);
|
|
115
|
-
if (format === 'percent' || format === 'weight')
|
|
116
|
-
return `${sign}${abs.toFixed(1)}%`;
|
|
117
|
-
if (isDollarFormat(format))
|
|
118
|
-
return `${sign}$${COMPACT.format(abs)}`;
|
|
119
|
-
return `${sign}${COMPACT.format(abs)}`;
|
|
120
|
-
}
|
|
121
|
-
/** A chart value in full, for tooltips: $491,712.34, −4.1%; masked while Hide amounts is on. */
|
|
122
|
-
function exact(v, format, hidden) {
|
|
123
|
-
if (hidden)
|
|
124
|
-
return maskAmount('0');
|
|
125
|
-
const sign = v < 0 ? MINUS : '';
|
|
126
|
-
const abs = Math.abs(v);
|
|
127
|
-
if (format === 'percent' || format === 'weight')
|
|
128
|
-
return `${sign}${abs.toFixed(2)}%`;
|
|
129
|
-
const n = abs.toLocaleString('en-US', {
|
|
130
|
-
maximumFractionDigits: format === 'rounded' || format === 'price_rounded' ? 0 : 2,
|
|
131
|
-
useGrouping: format !== 'integer',
|
|
132
|
-
});
|
|
133
|
-
return `${sign}${isDollarFormat(format) ? '$' : ''}${n}`;
|
|
134
|
-
}
|
|
135
|
-
const pct = (v, total) => `${total ? ((v / total) * 100).toFixed(1) : '0.0'}%`;
|
|
136
|
-
/** One colour per name, by size, so the largest never share one; Cash is always grey. */
|
|
137
|
-
const PALETTE = [1, 2, 3, 4, 5, 6, 9, 7, 10].map((i) => `var(--chart-${i})`);
|
|
138
|
-
const colorOf = (name, i) => /cash/i.test(name) ? 'var(--chart-8)' : (PALETTE[i % PALETTE.length] ?? 'var(--chart-1)');
|
|
139
|
-
/** `value` summed by `by` over the rows, largest first; rows with no `by` read as —. */
|
|
140
|
-
function sumBy(rows, by, value, read) {
|
|
141
|
-
const m = new Map();
|
|
142
|
-
for (const r of rows) {
|
|
143
|
-
const v = read(value, r);
|
|
144
|
-
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
145
|
-
continue;
|
|
146
|
-
const k = label(read(by, r));
|
|
147
|
-
m.set(k, (m.get(k) ?? 0) + v);
|
|
148
|
-
}
|
|
149
|
-
return [...m].map(([name, v]) => ({ name, v })).sort((a, b) => b.v - a.v);
|
|
150
|
-
}
|
|
151
106
|
// ── Widget bodies ──────────────────────────────────────────────────────────────────────────────
|
|
152
107
|
const Empty = ({ children = 'Nothing to show for these rows.' }) => (_jsx("p", { className: "py-6 text-center text-sm text-muted-foreground", children: children }));
|
|
153
108
|
/** A chart, top-N or market widget's body over `rows` (the view's shown rows). */
|
|
154
109
|
export function WidgetBody({ card, domain, rows, fields, merge, onDetails, }) {
|
|
155
|
-
const hidden = useAmountsHidden();
|
|
156
110
|
const own = useMemo(() => filterByTokens(rows, fields, card.filter), [rows, fields, card.filter]);
|
|
157
111
|
// Shares and formulas read against every shown row, not just the card's.
|
|
158
112
|
const computed = useMemo(() => computedOf(domain, rows, null), [domain, rows]);
|
|
@@ -190,95 +144,7 @@ export function WidgetBody({ card, domain, rows, fields, merge, onDetails, }) {
|
|
|
190
144
|
}
|
|
191
145
|
if (card.kind !== 'allocation')
|
|
192
146
|
return null;
|
|
193
|
-
|
|
194
|
-
const value = field(card.value);
|
|
195
|
-
if (!by || !value)
|
|
196
|
-
return _jsx(Empty, { children: "This widget's fields are not in this view." });
|
|
197
|
-
const masked = hidden && !!value.amount;
|
|
198
|
-
const fmt = (v) => short(v, value.format, masked);
|
|
199
|
-
const sums = sumBy(own, by, value, read);
|
|
200
|
-
// An empty value reads "No sector", not "—"; counts say "1 sector", "3 sectors".
|
|
201
|
-
const shown = (name, f) => name === label(null) ? `No ${f.label.toLowerCase()}` : name;
|
|
202
|
-
const count = (n) => `${n} ${by.label.toLowerCase()}${n === 1 ? '' : 's'}`;
|
|
203
|
-
if (card.style === 'bar_list') {
|
|
204
|
-
const bars = sums
|
|
205
|
-
.filter((x) => x.v !== 0)
|
|
206
|
-
.sort((a, b) => (card.desc ? b.v - a.v : a.v - b.v))
|
|
207
|
-
.slice(0, card.n);
|
|
208
|
-
if (!bars.length)
|
|
209
|
-
return _jsx(Empty, {});
|
|
210
|
-
const net = bars.reduce((t, x) => t + x.v, 0);
|
|
211
|
-
const abs = bars.reduce((t, x) => t + Math.abs(x.v), 0);
|
|
212
|
-
const up = bars.filter((b) => b.v > 0).length;
|
|
213
|
-
// Split by: each bar's parts by a text field, its 5 largest overall coloured as a donut of
|
|
214
|
-
// that field colours them, the rest Other.
|
|
215
|
-
const stackF = !card.signed && card.stack ? field(card.stack) : undefined;
|
|
216
|
-
const stack = (() => {
|
|
217
|
-
if (!stackF || stackF.key === by.key)
|
|
218
|
-
return null;
|
|
219
|
-
const order = sumBy(own, stackF, value, read).filter((x) => x.v > 0);
|
|
220
|
-
// A split needs two known parts; with fewer (no sectors set yet) plain bars read better.
|
|
221
|
-
if (order.filter((x) => x.name !== label(null)).length < 2)
|
|
222
|
-
return null;
|
|
223
|
-
const top = order.slice(0, STACK_MAX).map((x) => x.name);
|
|
224
|
-
const parts = [...top, ...(order.length > STACK_MAX ? ['Other'] : [])].map((name, i) => ({
|
|
225
|
-
name: shown(name, stackF),
|
|
226
|
-
key: `p${i}`,
|
|
227
|
-
fill: name === 'Other' ? 'var(--muted-foreground)' : colorOf(name, i),
|
|
228
|
-
}));
|
|
229
|
-
const keyOf = (n) => `p${top.includes(n) ? top.indexOf(n) : top.length}`;
|
|
230
|
-
const rows = bars.map((b) => {
|
|
231
|
-
const r = { name: shown(b.name, by), total: b.v };
|
|
232
|
-
for (const x of own) {
|
|
233
|
-
if (label(read(by, x)) !== b.name)
|
|
234
|
-
continue;
|
|
235
|
-
const v = read(value, x);
|
|
236
|
-
if (typeof v !== 'number' || v <= 0)
|
|
237
|
-
continue;
|
|
238
|
-
const k = keyOf(label(read(stackF, x)));
|
|
239
|
-
r[k] = (r[k] ?? 0) + v;
|
|
240
|
-
}
|
|
241
|
-
return r;
|
|
242
|
-
});
|
|
243
|
-
return { rows, parts };
|
|
244
|
-
})();
|
|
245
|
-
return (_jsxs(_Fragment, { children: [_jsx(Headline, { value: `${card.signed && net > 0 ? '+' : ''}${fmt(net)}`, tone: card.signed ? (net >= 0 ? 'gain' : 'loss') : undefined, caption: card.signed
|
|
246
|
-
? `net · ${up} up, ${bars.length - up} down`
|
|
247
|
-
: `${value.label.toLowerCase()} across ${count(bars.length)}` }), stack ? (_jsx(StackedBars, { rows: stack.rows, parts: stack.parts, fmt: fmt, exact: (v) => exact(v, value.format, masked), share: (v) => pct(v, abs) })) : (_jsx(RankedBars, { id: card.id, items: bars.map((b, i) => ({
|
|
248
|
-
...b,
|
|
249
|
-
name: shown(b.name, by),
|
|
250
|
-
fill: card.signed ? (b.v >= 0 ? 'var(--gain)' : 'var(--loss)') : colorOf(b.name, i),
|
|
251
|
-
})), signed: card.signed, fmt: fmt, share: (v) => pct(Math.abs(v), abs) }))] }));
|
|
252
|
-
}
|
|
253
|
-
const parts = sums.filter((x) => x.v > 0).map((x) => ({ ...x, name: shown(x.name, by) }));
|
|
254
|
-
const total = parts.reduce((t, x) => t + x.v, 0);
|
|
255
|
-
const known = parts.filter((x) => x.name !== shown(label(null), by)).length;
|
|
256
|
-
if (!total)
|
|
257
|
-
return _jsx(Empty, {});
|
|
258
|
-
// Donut: `max` slices in all, Other the last. Allocation: the largest `max`, then Other.
|
|
259
|
-
const keep = card.style === 'donut' ? card.max - 1 : (card.max ?? ALLOCATION_MAX);
|
|
260
|
-
const limit = card.style === 'donut' ? card.max : keep;
|
|
261
|
-
const rest = parts.length > limit ? parts.slice(keep) : [];
|
|
262
|
-
const slices = rest.length
|
|
263
|
-
? [...parts.slice(0, keep), { name: 'Other', v: rest.reduce((t, x) => t + x.v, 0) }]
|
|
264
|
-
: parts;
|
|
265
|
-
const colored = slices.map((s, i) => ({
|
|
266
|
-
...s,
|
|
267
|
-
key: `s${i}`,
|
|
268
|
-
fill: s.name === 'Other' ? 'var(--muted-foreground)' : colorOf(s.name, i),
|
|
269
|
-
}));
|
|
270
|
-
if (card.style === 'donut')
|
|
271
|
-
return (_jsx(Donut, { items: colored, total: total, fmt: fmt, share: (v) => pct(v, total), caption: count(known), legend: controlOn(card, 'legend') }));
|
|
272
|
-
// Allocation: one stacked bar, no legend; each wide slice names itself, and hovering a slice
|
|
273
|
-
// gives its exact value and share, Other its 10 largest. The tooltip may leave the card.
|
|
274
|
-
const config = Object.fromEntries(colored.map((s) => [s.key, { label: s.name, color: s.fill }]));
|
|
275
|
-
const line = (name, v, fill) => (_jsxs("span", { className: "flex w-full items-center gap-2", children: [fill && _jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: fill } }), _jsx("span", { className: "truncate font-medium", children: name }), _jsxs("span", { className: "num ml-auto pl-3 whitespace-nowrap", children: [exact(v, value.format, masked), " \u00B7 ", pct(v, total)] })] }, name));
|
|
276
|
-
return (_jsxs("div", { className: "py-1", children: [_jsx(Headline, { value: fmt(total), caption: `${count(known)} · largest ${colored[0]?.name ?? ''} ${pct(colored[0]?.v ?? 0, total)}` }), _jsx(AllocationBar, { items: colored, total: total, config: config, share: (v) => pct(v, total), tooltip: _jsx(ChartTooltip, { cursor: false, shared: false, allowEscapeViewBox: { x: false, y: true }, wrapperStyle: { zIndex: 50 }, content: _jsx(ChartTooltipContent, { hideLabel: true, className: "min-w-48 max-w-72", formatter: (v, name) => {
|
|
277
|
-
const s = colored.find((c) => c.key === name);
|
|
278
|
-
if (s?.name !== 'Other')
|
|
279
|
-
return line(s?.name ?? String(name), Number(v), s?.fill);
|
|
280
|
-
return (_jsxs("span", { className: "flex w-full flex-col gap-1", children: [line(`Other (${rest.length})`, Number(v), s.fill), _jsxs("span", { className: "flex flex-col gap-0.5 border-t pt-1 text-muted-foreground", children: [rest.slice(0, 10).map((x) => line(x.name, x.v)), rest.length > 10 && _jsxs("span", { children: ["+", rest.length - 10, " more"] })] })] }));
|
|
281
|
-
} }) }) })] }));
|
|
147
|
+
return _jsx(AllocationBody, { card: card, own: own, read: read, field: field });
|
|
282
148
|
}
|
|
283
149
|
/** A top-N cell as its field reads, short where the field is short. */
|
|
284
150
|
function WidgetValue({ value, field }) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OverviewWidget } from './overview.js';
|
|
2
|
+
export declare function Ring({ ring, size }: {
|
|
3
|
+
ring: OverviewWidget & {
|
|
4
|
+
kind: 'ring';
|
|
5
|
+
};
|
|
6
|
+
size: number;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
export declare function Bars({ w }: {
|
|
9
|
+
w: OverviewWidget & {
|
|
10
|
+
kind: 'bars';
|
|
11
|
+
};
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
/** Busy blocks over a window: one stacked bar of idle and busy segments, in order. */
|
|
14
|
+
export declare function Timeline({ w }: {
|
|
15
|
+
w: OverviewWidget & {
|
|
16
|
+
kind: 'timeline';
|
|
17
|
+
};
|
|
18
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The Overview's chart widgets (ring, bars, timeline), drawn with Recharts in shadcn's
|
|
3
|
+
// ChartContainer; overview.tsx loads them only when a stage shows one.
|
|
4
|
+
import { Bar, BarChart, Cell, Label, Pie, PieChart, XAxis, YAxis } from 'recharts';
|
|
5
|
+
import { ChartContainer, ChartTooltip, ChartTooltipContent } from './ui/chart.js';
|
|
6
|
+
const FILL = {
|
|
7
|
+
ok: 'var(--gain)',
|
|
8
|
+
warn: 'var(--warning)',
|
|
9
|
+
bad: 'var(--destructive)',
|
|
10
|
+
idle: 'var(--primary)',
|
|
11
|
+
};
|
|
12
|
+
export function Ring({ ring, size }) {
|
|
13
|
+
return (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-3", children: [ring.rings.map((r) => {
|
|
14
|
+
const v = Math.max(0, Math.min(1, r.value));
|
|
15
|
+
const data = [
|
|
16
|
+
{ key: 'value', value: v, fill: FILL[r.tone ?? 'idle'] },
|
|
17
|
+
{ key: 'rest', value: 1 - v, fill: 'var(--muted)' },
|
|
18
|
+
];
|
|
19
|
+
return (_jsx(ChartContainer, { config: { value: { label: r.label } }, className: "aspect-square shrink-0", style: { width: size, height: size }, role: "img", "aria-label": `${r.label} ${r.text}`, children: _jsx(PieChart, { children: _jsxs(Pie, { data: data, dataKey: "value", nameKey: "key", innerRadius: size / 2 - 9, outerRadius: size / 2 - 2, startAngle: 90, endAngle: -270, strokeWidth: 0, isAnimationActive: false, children: [data.map((d) => (_jsx(Cell, { fill: d.fill }, d.key))), _jsx(Label, { content: ({ viewBox }) => {
|
|
20
|
+
if (!viewBox || !('cx' in viewBox))
|
|
21
|
+
return null;
|
|
22
|
+
const many = ring.rings.length > 1;
|
|
23
|
+
return (_jsxs("text", { x: viewBox.cx, y: viewBox.cy, textAnchor: "middle", children: [_jsx("tspan", { x: viewBox.cx, dy: many ? '-0.1em' : '0.35em', className: "fill-foreground font-num text-[11px]", children: r.text }), many && (_jsx("tspan", { x: viewBox.cx, dy: "1.2em", className: "fill-muted-foreground text-[9px]", children: r.label }))] }));
|
|
24
|
+
} })] }) }) }, r.label));
|
|
25
|
+
}), ring.detail && _jsx("div", { className: "min-w-36 flex-1", children: ring.detail })] }));
|
|
26
|
+
}
|
|
27
|
+
export function Bars({ w }) {
|
|
28
|
+
const config = { value: { label: 'Value', color: 'var(--chart-1)' } };
|
|
29
|
+
return (_jsx(ChartContainer, { config: config, className: "aspect-auto h-16 w-full", children: _jsxs(BarChart, { data: w.bars, margin: { top: 2, right: 0, bottom: 0, left: 0 }, children: [_jsx(XAxis, { dataKey: "label", hide: true }), _jsx(YAxis, { hide: true, domain: [0, 'dataMax'] }), _jsx(ChartTooltip, { cursor: false, content: _jsx(ChartTooltipContent, { hideIndicator: true, formatter: (v) => (w.format ? w.format(Number(v)) : String(v)) }) }), _jsx(Bar, { dataKey: "value", fill: "var(--color-value)", radius: 2, isAnimationActive: false })] }) }));
|
|
30
|
+
}
|
|
31
|
+
/** Busy blocks over a window: one stacked bar of idle and busy segments, in order. */
|
|
32
|
+
export function Timeline({ w }) {
|
|
33
|
+
const span = Math.max(1, w.to - w.from);
|
|
34
|
+
const segments = [];
|
|
35
|
+
let at = w.from;
|
|
36
|
+
const blocks = [...w.blocks]
|
|
37
|
+
.map((b) => ({ start: Math.max(w.from, b.start), end: Math.min(w.to, b.end) }))
|
|
38
|
+
.filter((b) => b.end > b.start)
|
|
39
|
+
.sort((a, b) => a.start - b.start);
|
|
40
|
+
for (const b of blocks) {
|
|
41
|
+
const start = Math.max(at, b.start);
|
|
42
|
+
if (b.end <= start)
|
|
43
|
+
continue;
|
|
44
|
+
if (start > at)
|
|
45
|
+
segments.push({ key: `s${segments.length}`, size: start - at, busy: false });
|
|
46
|
+
segments.push({ key: `s${segments.length}`, size: b.end - start, busy: true });
|
|
47
|
+
at = b.end;
|
|
48
|
+
}
|
|
49
|
+
if (at < w.to)
|
|
50
|
+
segments.push({ key: `s${segments.length}`, size: w.to - at, busy: false });
|
|
51
|
+
const row = Object.fromEntries(segments.map((s) => [s.key, s.size / span]));
|
|
52
|
+
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(ChartContainer, { config: {}, className: "aspect-auto h-6 w-full", children: _jsxs(BarChart, { data: [row], layout: "vertical", margin: { top: 0, right: 0, bottom: 0, left: 0 }, barCategoryGap: 0, children: [_jsx(XAxis, { type: "number", hide: true, domain: [0, 1] }), _jsx(YAxis, { type: "category", hide: true }), segments.map((s, i) => (_jsx(Bar, { dataKey: s.key, stackId: "t", fill: s.busy ? 'var(--chart-1)' : 'var(--muted)', radius: i === 0 ? [3, 0, 0, 3] : i === segments.length - 1 ? [0, 3, 3, 0] : 0, isAnimationActive: false }, s.key)))] }) }), _jsx("div", { className: "relative h-4 text-[10px] text-muted-foreground", children: w.ticks.map((t) => (_jsx("span", { className: "num absolute -translate-x-1/2 first:translate-x-0 last:-translate-x-full", style: { left: `${((t.at - w.from) / span) * 100}%` }, children: t.label }, t.at))) })] }));
|
|
53
|
+
}
|
package/dist/overview.js
CHANGED
|
@@ -8,24 +8,17 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
8
8
|
// Overview moves onto it later (Sources, Pipelines, Storage, Coverage).
|
|
9
9
|
import { ArrowRightIcon, TriangleAlertIcon } from 'lucide-react';
|
|
10
10
|
import { Fragment, useState } from 'react';
|
|
11
|
-
import {
|
|
11
|
+
import { lazyPart } from './lazy-part.js';
|
|
12
12
|
import { cn } from './lib/utils.js';
|
|
13
13
|
import { LumenLayout } from './lumen-layout.js';
|
|
14
14
|
import { Badge } from './ui/badge.js';
|
|
15
15
|
import { Button } from './ui/button.js';
|
|
16
|
-
import { ChartContainer, ChartTooltip, ChartTooltipContent } from './ui/chart.js';
|
|
17
16
|
const DOT = {
|
|
18
17
|
ok: 'bg-gain',
|
|
19
18
|
warn: 'bg-warning',
|
|
20
19
|
bad: 'bg-destructive',
|
|
21
20
|
idle: 'bg-muted-foreground',
|
|
22
21
|
};
|
|
23
|
-
const FILL = {
|
|
24
|
-
ok: 'var(--gain)',
|
|
25
|
-
warn: 'var(--warning)',
|
|
26
|
-
bad: 'var(--destructive)',
|
|
27
|
-
idle: 'var(--primary)',
|
|
28
|
-
};
|
|
29
22
|
/** A stage's status mark: a dot, or ▲ when it needs a look. */
|
|
30
23
|
export function ToneDot({ tone, className }) {
|
|
31
24
|
if (tone === 'warn' || tone === 'bad') {
|
|
@@ -37,48 +30,11 @@ export function ToneDot({ tone, className }) {
|
|
|
37
30
|
export function OverviewList({ items, }) {
|
|
38
31
|
return (_jsx("dl", { className: "flex min-w-0 flex-col gap-0.5 text-xs", children: items.map((it, i) => (_jsxs("div", { className: cn('flex justify-between gap-2', it.tone === 'warn' && 'text-warning', it.tone === 'bad' && 'text-destructive'), children: [_jsx("dt", { className: "truncate", children: it.label }), _jsx("dd", { className: "num shrink-0 text-muted-foreground", children: it.value })] }, i))) }));
|
|
39
32
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{ key: 'rest', value: 1 - v, fill: 'var(--muted)' },
|
|
46
|
-
];
|
|
47
|
-
return (_jsx(ChartContainer, { config: { value: { label: r.label } }, className: "aspect-square shrink-0", style: { width: size, height: size }, role: "img", "aria-label": `${r.label} ${r.text}`, children: _jsx(PieChart, { children: _jsxs(Pie, { data: data, dataKey: "value", nameKey: "key", innerRadius: size / 2 - 9, outerRadius: size / 2 - 2, startAngle: 90, endAngle: -270, strokeWidth: 0, isAnimationActive: false, children: [data.map((d) => (_jsx(Cell, { fill: d.fill }, d.key))), _jsx(Label, { content: ({ viewBox }) => {
|
|
48
|
-
if (!viewBox || !('cx' in viewBox))
|
|
49
|
-
return null;
|
|
50
|
-
const many = ring.rings.length > 1;
|
|
51
|
-
return (_jsxs("text", { x: viewBox.cx, y: viewBox.cy, textAnchor: "middle", children: [_jsx("tspan", { x: viewBox.cx, dy: many ? '-0.1em' : '0.35em', className: "fill-foreground font-num text-[11px]", children: r.text }), many && (_jsx("tspan", { x: viewBox.cx, dy: "1.2em", className: "fill-muted-foreground text-[9px]", children: r.label }))] }));
|
|
52
|
-
} })] }) }) }, r.label));
|
|
53
|
-
}), ring.detail && _jsx("div", { className: "min-w-36 flex-1", children: ring.detail })] }));
|
|
54
|
-
}
|
|
55
|
-
function Bars({ w }) {
|
|
56
|
-
const config = { value: { label: 'Value', color: 'var(--chart-1)' } };
|
|
57
|
-
return (_jsx(ChartContainer, { config: config, className: "aspect-auto h-16 w-full", children: _jsxs(BarChart, { data: w.bars, margin: { top: 2, right: 0, bottom: 0, left: 0 }, children: [_jsx(XAxis, { dataKey: "label", hide: true }), _jsx(YAxis, { hide: true, domain: [0, 'dataMax'] }), _jsx(ChartTooltip, { cursor: false, content: _jsx(ChartTooltipContent, { hideIndicator: true, formatter: (v) => (w.format ? w.format(Number(v)) : String(v)) }) }), _jsx(Bar, { dataKey: "value", fill: "var(--color-value)", radius: 2, isAnimationActive: false })] }) }));
|
|
58
|
-
}
|
|
59
|
-
/** Busy blocks over a window: one stacked bar of idle and busy segments, in order. */
|
|
60
|
-
function Timeline({ w }) {
|
|
61
|
-
const span = Math.max(1, w.to - w.from);
|
|
62
|
-
const segments = [];
|
|
63
|
-
let at = w.from;
|
|
64
|
-
const blocks = [...w.blocks]
|
|
65
|
-
.map((b) => ({ start: Math.max(w.from, b.start), end: Math.min(w.to, b.end) }))
|
|
66
|
-
.filter((b) => b.end > b.start)
|
|
67
|
-
.sort((a, b) => a.start - b.start);
|
|
68
|
-
for (const b of blocks) {
|
|
69
|
-
const start = Math.max(at, b.start);
|
|
70
|
-
if (b.end <= start)
|
|
71
|
-
continue;
|
|
72
|
-
if (start > at)
|
|
73
|
-
segments.push({ key: `s${segments.length}`, size: start - at, busy: false });
|
|
74
|
-
segments.push({ key: `s${segments.length}`, size: b.end - start, busy: true });
|
|
75
|
-
at = b.end;
|
|
76
|
-
}
|
|
77
|
-
if (at < w.to)
|
|
78
|
-
segments.push({ key: `s${segments.length}`, size: w.to - at, busy: false });
|
|
79
|
-
const row = Object.fromEntries(segments.map((s) => [s.key, s.size / span]));
|
|
80
|
-
return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(ChartContainer, { config: {}, className: "aspect-auto h-6 w-full", children: _jsxs(BarChart, { data: [row], layout: "vertical", margin: { top: 0, right: 0, bottom: 0, left: 0 }, barCategoryGap: 0, children: [_jsx(XAxis, { type: "number", hide: true, domain: [0, 1] }), _jsx(YAxis, { type: "category", hide: true }), segments.map((s, i) => (_jsx(Bar, { dataKey: s.key, stackId: "t", fill: s.busy ? 'var(--chart-1)' : 'var(--muted)', radius: i === 0 ? [3, 0, 0, 3] : i === segments.length - 1 ? [0, 3, 3, 0] : 0, isAnimationActive: false }, s.key)))] }) }), _jsx("div", { className: "relative h-4 text-[10px] text-muted-foreground", children: w.ticks.map((t) => (_jsx("span", { className: "num absolute -translate-x-1/2 first:translate-x-0 last:-translate-x-full", style: { left: `${((t.at - w.from) / span) * 100}%` }, children: t.label }, t.at))) })] }));
|
|
81
|
-
}
|
|
33
|
+
// The ring, bar and timeline widgets draw with the charts library, so they load only when a stage
|
|
34
|
+
// shows one.
|
|
35
|
+
const Ring = lazyPart(() => import('./overview-charts.js').then((m) => m.Ring));
|
|
36
|
+
const Bars = lazyPart(() => import('./overview-charts.js').then((m) => m.Bars));
|
|
37
|
+
const Timeline = lazyPart(() => import('./overview-charts.js').then((m) => m.Timeline));
|
|
82
38
|
/** A widget's body: the card's frame, title and ↻ come from LumenLayout. */
|
|
83
39
|
function WidgetBody({ w }) {
|
|
84
40
|
return (_jsxs("div", { "data-widget": w.id, className: "flex min-w-0 flex-col gap-1.5", children: [w.aside && _jsx("span", { className: "num text-xs text-muted-foreground", children: w.aside }), w.kind === 'stat' && (_jsxs(_Fragment, { children: [w.value !== undefined && (_jsx("span", { className: "num truncate text-xl leading-7 font-semibold", children: w.value })), w.detail] })), w.kind === 'ring' && _jsx(Ring, { ring: w, size: w.rings.length > 1 ? 64 : 56 }), w.kind === 'bars' && _jsx(Bars, { w: w }), w.kind === 'timeline' && _jsx(Timeline, { w: w }), w.caption && _jsx("span", { className: "text-xs text-muted-foreground", children: w.caption })] }));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { meterTone } from '@columenlabs/lumen-ui-types/views';
|
|
2
|
+
/** A number's share of its target as a ring, its percent in the middle (Recharts); a meter's
|
|
3
|
+
* tone colours it, else the first chart colour. */
|
|
4
|
+
export declare function ProgressRing({ value, tone, }: {
|
|
5
|
+
value: number | null;
|
|
6
|
+
tone: ReturnType<typeof meterTone>;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PolarAngleAxis, RadialBar, RadialBarChart } from 'recharts';
|
|
3
|
+
import { ChartContainer } from './ui/chart.js';
|
|
4
|
+
/** A number's share of its target as a ring, its percent in the middle (Recharts); a meter's
|
|
5
|
+
* tone colours it, else the first chart colour. */
|
|
6
|
+
export function ProgressRing({ value, tone, }) {
|
|
7
|
+
const config = {
|
|
8
|
+
v: { label: 'Progress', color: tone ? `var(--${tone})` : 'var(--chart-1)' },
|
|
9
|
+
};
|
|
10
|
+
return (_jsx(ChartContainer, { config: config, className: "aspect-square size-16 shrink-0", children: _jsxs(RadialBarChart, { data: [{ v: value ?? 0 }], startAngle: 90, endAngle: -270, innerRadius: 24, outerRadius: 32, children: [_jsx(PolarAngleAxis, { type: "number", domain: [0, 100], tick: false }), _jsx(RadialBar, { dataKey: "v", fill: "var(--color-v)", background: { fill: 'var(--muted)' }, cornerRadius: 8, isAnimationActive: false }), _jsx("text", { x: "50%", y: "50%", textAnchor: "middle", dominantBaseline: "middle", className: "num fill-foreground", fontSize: 12, fontWeight: 600, children: value === null ? '—' : `${value.toFixed(0)}%` })] }) }));
|
|
11
|
+
}
|
package/dist/summary-cards.js
CHANGED
|
@@ -12,10 +12,10 @@ import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordi
|
|
|
12
12
|
import { CSS } from '@dnd-kit/utilities';
|
|
13
13
|
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react';
|
|
14
14
|
import { useEffect, useMemo, useState } from 'react';
|
|
15
|
-
import { PolarAngleAxis, RadialBar, RadialBarChart } from 'recharts';
|
|
16
15
|
import { LumenConfirm } from './confirm.js';
|
|
17
16
|
import { IconButton } from './icon-button.js';
|
|
18
17
|
import { KIT } from './kit-config.js';
|
|
18
|
+
import { lazyPart } from './lazy-part.js';
|
|
19
19
|
import { cn } from './lib/utils.js';
|
|
20
20
|
import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
|
|
21
21
|
import { LumenSelector } from './lumen-selector.js';
|
|
@@ -23,7 +23,6 @@ import { ViewValueCell } from './lumen-view.js';
|
|
|
23
23
|
import { Tile } from './tile.js';
|
|
24
24
|
import { Badge } from './ui/badge.js';
|
|
25
25
|
import { Button } from './ui/button.js';
|
|
26
|
-
import { ChartContainer } from './ui/chart.js';
|
|
27
26
|
import { Input } from './ui/input.js';
|
|
28
27
|
import { Label } from './ui/label.js';
|
|
29
28
|
import { Progress } from './ui/progress.js';
|
|
@@ -115,20 +114,14 @@ const usageBands = () => ({
|
|
|
115
114
|
warn: KIT.meterLevels.watch * 100,
|
|
116
115
|
bad: KIT.meterLevels.alert * 100,
|
|
117
116
|
});
|
|
117
|
+
// The ring draws with the charts library, so it loads only when a card shows one.
|
|
118
|
+
const ProgressRing = lazyPart(() => import('./progress-ring.js').then((m) => m.ProgressRing), _jsx(Skeleton, { className: "size-16 shrink-0 rounded-full" }));
|
|
118
119
|
/** A meter's tone on a bar's fill (theme tokens, no glow). */
|
|
119
120
|
const TONE_BAR = {
|
|
120
121
|
success: '[&>[data-slot=progress-indicator]]:bg-success',
|
|
121
122
|
warning: '[&>[data-slot=progress-indicator]]:bg-warning',
|
|
122
123
|
destructive: '[&>[data-slot=progress-indicator]]:bg-destructive',
|
|
123
124
|
};
|
|
124
|
-
/** A number's share of its target as a ring, its percent in the middle (Recharts); a meter's
|
|
125
|
-
* tone colours it, else the first chart colour. */
|
|
126
|
-
function ProgressRing({ value, tone, }) {
|
|
127
|
-
const config = {
|
|
128
|
-
v: { label: 'Progress', color: tone ? `var(--${tone})` : 'var(--chart-1)' },
|
|
129
|
-
};
|
|
130
|
-
return (_jsx(ChartContainer, { config: config, className: "aspect-square size-16 shrink-0", children: _jsxs(RadialBarChart, { data: [{ v: value ?? 0 }], startAngle: 90, endAngle: -270, innerRadius: 24, outerRadius: 32, children: [_jsx(PolarAngleAxis, { type: "number", domain: [0, 100], tick: false }), _jsx(RadialBar, { dataKey: "v", fill: "var(--color-v)", background: { fill: 'var(--muted)' }, cornerRadius: 8, isAnimationActive: false }), _jsx("text", { x: "50%", y: "50%", textAnchor: "middle", dominantBaseline: "middle", className: "num fill-foreground", fontSize: 12, fontWeight: 600, children: value === null ? '—' : `${value.toFixed(0)}%` })] }) }));
|
|
131
|
-
}
|
|
132
125
|
/** A Summary's cards over its rows; null when it has none to show. */
|
|
133
126
|
export function SummaryTiles({ domain, tiles, rows, fields, tile, loading, }) {
|
|
134
127
|
const shown = tiles.filter((t) => isOwnTile(t) || tile);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a widget's values read: short and exact numbers, shares, one colour per name and sums by a
|
|
3
|
+
* field. Shared by the widget frame and its chart bodies, so a lazily loaded body never pulls the
|
|
4
|
+
* frame back in.
|
|
5
|
+
*/
|
|
6
|
+
import { type ViewFormat } from '@columenlabs/lumen-ui-types/views';
|
|
7
|
+
import { type ViewCellValue, type ViewField } from './view-model.js';
|
|
8
|
+
export declare const MINUS = "\u2212";
|
|
9
|
+
export declare const COMPACT: Intl.NumberFormat;
|
|
10
|
+
/** A chart value in short form: $12.3K, −4.1%, 1.2M; masked while Hide amounts is on. */
|
|
11
|
+
export declare function short(v: number, format: ViewFormat, hidden: boolean): string;
|
|
12
|
+
/** A chart value in full, for tooltips: $491,712.34, −4.1%; masked while Hide amounts is on. */
|
|
13
|
+
export declare function exact(v: number, format: ViewFormat, hidden: boolean): string;
|
|
14
|
+
export declare const pct: (v: number, total: number) => string;
|
|
15
|
+
export declare const colorOf: (name: string, i: number) => string;
|
|
16
|
+
/** `value` summed by `by` over the rows, largest first; rows with no `by` read as —. */
|
|
17
|
+
export declare function sumBy<T>(rows: T[], by: ViewField<T>, value: ViewField<T>, read: (f: ViewField<T>, r: T) => ViewCellValue): {
|
|
18
|
+
name: string;
|
|
19
|
+
v: number;
|
|
20
|
+
}[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a widget's values read: short and exact numbers, shares, one colour per name and sums by a
|
|
3
|
+
* field. Shared by the widget frame and its chart bodies, so a lazily loaded body never pulls the
|
|
4
|
+
* frame back in.
|
|
5
|
+
*/
|
|
6
|
+
import { isDollarFormat } from '@columenlabs/lumen-ui-types/views';
|
|
7
|
+
import { maskAmount } from './shell.js';
|
|
8
|
+
import { label } from './view-model.js';
|
|
9
|
+
export const MINUS = '−';
|
|
10
|
+
export const COMPACT = new Intl.NumberFormat('en-US', {
|
|
11
|
+
notation: 'compact',
|
|
12
|
+
maximumFractionDigits: 1,
|
|
13
|
+
});
|
|
14
|
+
/** A chart value in short form: $12.3K, −4.1%, 1.2M; masked while Hide amounts is on. */
|
|
15
|
+
export function short(v, format, hidden) {
|
|
16
|
+
if (hidden)
|
|
17
|
+
return maskAmount('0');
|
|
18
|
+
const sign = v < 0 ? MINUS : '';
|
|
19
|
+
const abs = Math.abs(v);
|
|
20
|
+
if (format === 'percent' || format === 'weight')
|
|
21
|
+
return `${sign}${abs.toFixed(1)}%`;
|
|
22
|
+
if (isDollarFormat(format))
|
|
23
|
+
return `${sign}$${COMPACT.format(abs)}`;
|
|
24
|
+
return `${sign}${COMPACT.format(abs)}`;
|
|
25
|
+
}
|
|
26
|
+
/** A chart value in full, for tooltips: $491,712.34, −4.1%; masked while Hide amounts is on. */
|
|
27
|
+
export function exact(v, format, hidden) {
|
|
28
|
+
if (hidden)
|
|
29
|
+
return maskAmount('0');
|
|
30
|
+
const sign = v < 0 ? MINUS : '';
|
|
31
|
+
const abs = Math.abs(v);
|
|
32
|
+
if (format === 'percent' || format === 'weight')
|
|
33
|
+
return `${sign}${abs.toFixed(2)}%`;
|
|
34
|
+
const n = abs.toLocaleString('en-US', {
|
|
35
|
+
maximumFractionDigits: format === 'rounded' || format === 'price_rounded' ? 0 : 2,
|
|
36
|
+
useGrouping: format !== 'integer',
|
|
37
|
+
});
|
|
38
|
+
return `${sign}${isDollarFormat(format) ? '$' : ''}${n}`;
|
|
39
|
+
}
|
|
40
|
+
export const pct = (v, total) => `${total ? ((v / total) * 100).toFixed(1) : '0.0'}%`;
|
|
41
|
+
/** One colour per name, by size, so the largest never share one; Cash is always grey. */
|
|
42
|
+
const PALETTE = [1, 2, 3, 4, 5, 6, 9, 7, 10].map((i) => `var(--chart-${i})`);
|
|
43
|
+
export const colorOf = (name, i) => /cash/i.test(name) ? 'var(--chart-8)' : (PALETTE[i % PALETTE.length] ?? 'var(--chart-1)');
|
|
44
|
+
/** `value` summed by `by` over the rows, largest first; rows with no `by` read as —. */
|
|
45
|
+
export function sumBy(rows, by, value, read) {
|
|
46
|
+
const m = new Map();
|
|
47
|
+
for (const r of rows) {
|
|
48
|
+
const v = read(value, r);
|
|
49
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
50
|
+
continue;
|
|
51
|
+
const k = label(read(by, r));
|
|
52
|
+
m.set(k, (m.get(k) ?? 0) + v);
|
|
53
|
+
}
|
|
54
|
+
return [...m].map(([name, v]) => ({ name, v })).sort((a, b) => b.v - a.v);
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.96",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@codemirror/lang-javascript": "6.2.5",
|
|
50
50
|
"@codemirror/lang-json": "6.0.2",
|
|
51
51
|
"@codemirror/lang-sql": "6.10.0",
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.96",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|