@oh-my-pi/omp-stats 18.3.5 → 18.4.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/CHANGELOG.md +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface BarListItem {
|
|
4
|
+
key: string;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
value: number;
|
|
7
|
+
/** Formatted figure; defaults to `value.toLocaleString()`. */
|
|
8
|
+
display?: string;
|
|
9
|
+
color?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BarListProps {
|
|
13
|
+
items: readonly BarListItem[];
|
|
14
|
+
/** Scale maximum; defaults to the largest value. */
|
|
15
|
+
max?: number;
|
|
16
|
+
onSelect?: (key: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Ranked rows with a proportional tinted fill behind each label. */
|
|
20
|
+
export function BarList({ items, max, onSelect }: BarListProps) {
|
|
21
|
+
const top = max ?? Math.max(0, ...items.map(i => i.value));
|
|
22
|
+
return (
|
|
23
|
+
<div className="bar-list">
|
|
24
|
+
{items.map(item => {
|
|
25
|
+
const pct = top > 0 ? Math.max(0.5, (item.value / top) * 100) : 0;
|
|
26
|
+
const content = (
|
|
27
|
+
<>
|
|
28
|
+
<span
|
|
29
|
+
className="bar-list-fill"
|
|
30
|
+
style={{ width: `${pct}%`, background: item.color ?? "var(--chart-primary)" }}
|
|
31
|
+
/>
|
|
32
|
+
<span className="bar-list-label">{item.label}</span>
|
|
33
|
+
<span className="bar-list-value">{item.display ?? item.value.toLocaleString()}</span>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
return onSelect ? (
|
|
37
|
+
<button key={item.key} type="button" className="bar-list-row" onClick={() => onSelect(item.key)}>
|
|
38
|
+
{content}
|
|
39
|
+
</button>
|
|
40
|
+
) : (
|
|
41
|
+
<div key={item.key} className="bar-list-row">
|
|
42
|
+
{content}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
})}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core SVG chart: categorical x slots, stacked or grouped bars, stacked areas
|
|
3
|
+
* and lines, an optional right axis, a hover cursor and a floating tooltip.
|
|
4
|
+
* Marks are crisp and unsmoothed — square bars, straight segments.
|
|
5
|
+
*
|
|
6
|
+
* `TimeChart` wraps this for bucketed time series; use `Chart` directly for
|
|
7
|
+
* categorical axes (hour of day, model names).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Fragment, type ReactNode, useId, useMemo, useState } from "react";
|
|
11
|
+
import { formatCompact } from "../data/formatters";
|
|
12
|
+
import type { ChartKind, ChartSeries, ReferenceLine } from "./types";
|
|
13
|
+
import { useWidth } from "./useWidth";
|
|
14
|
+
|
|
15
|
+
export interface ChartProps {
|
|
16
|
+
/** Number of x positions; every series' `values` has this length. */
|
|
17
|
+
slots: number;
|
|
18
|
+
/** Axis label for a slot. */
|
|
19
|
+
tickLabel: (index: number) => string;
|
|
20
|
+
/** Tooltip heading for a slot; defaults to `tickLabel`. */
|
|
21
|
+
tooltipTitle?: (index: number) => string;
|
|
22
|
+
series: readonly ChartSeries[];
|
|
23
|
+
/** Mark for series without their own `kind`. Default `"bars"`. */
|
|
24
|
+
kind?: ChartKind;
|
|
25
|
+
/** Stack left-axis bars/areas. Default true. */
|
|
26
|
+
stacked?: boolean;
|
|
27
|
+
/** Plot height in px (axes included). Default 220. */
|
|
28
|
+
height?: number;
|
|
29
|
+
/** Left-axis and tooltip formatter. Default compact number. */
|
|
30
|
+
format?: (value: number) => string;
|
|
31
|
+
formatRight?: (value: number) => string;
|
|
32
|
+
/** Tooltip value formatter (left axis); defaults to `format`. Use for full precision. */
|
|
33
|
+
formatTooltip?: (value: number) => string;
|
|
34
|
+
/** Fixed axis maxima (e.g. 1 for fractions); otherwise nice-rounded from data. */
|
|
35
|
+
yMax?: number;
|
|
36
|
+
yMaxRight?: number;
|
|
37
|
+
/** Series keys to omit (legend toggles). */
|
|
38
|
+
hidden?: ReadonlySet<string>;
|
|
39
|
+
/** Show a total row in the tooltip. Default: stacked with 2+ series. */
|
|
40
|
+
showTotal?: boolean;
|
|
41
|
+
references?: readonly ReferenceLine[];
|
|
42
|
+
onSelect?: (index: number) => void;
|
|
43
|
+
/** Shown over empty axes when every visible value is zero. */
|
|
44
|
+
emptyLabel?: ReactNode;
|
|
45
|
+
/** Extra tooltip rows for a slot (below the series rows). */
|
|
46
|
+
tooltipExtra?: (index: number) => ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PAD_TOP = 10;
|
|
50
|
+
const PAD_BOTTOM = 24;
|
|
51
|
+
const MIN_TICK_SPACING = 68;
|
|
52
|
+
const Y_TICKS = 4;
|
|
53
|
+
|
|
54
|
+
export function Chart({
|
|
55
|
+
slots,
|
|
56
|
+
tickLabel,
|
|
57
|
+
tooltipTitle,
|
|
58
|
+
series,
|
|
59
|
+
kind = "bars",
|
|
60
|
+
stacked = true,
|
|
61
|
+
height = 220,
|
|
62
|
+
format = formatCompact,
|
|
63
|
+
formatRight,
|
|
64
|
+
formatTooltip,
|
|
65
|
+
yMax,
|
|
66
|
+
yMaxRight,
|
|
67
|
+
hidden,
|
|
68
|
+
showTotal,
|
|
69
|
+
references,
|
|
70
|
+
onSelect,
|
|
71
|
+
emptyLabel = "No data in this range",
|
|
72
|
+
tooltipExtra,
|
|
73
|
+
}: ChartProps) {
|
|
74
|
+
const [ref, width] = useWidth<HTMLDivElement>();
|
|
75
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
76
|
+
const patternId = useId().replace(/:/g, "");
|
|
77
|
+
|
|
78
|
+
const visible = useMemo(() => series.filter(s => !hidden?.has(s.key)), [series, hidden]);
|
|
79
|
+
|
|
80
|
+
const layout = useMemo(() => {
|
|
81
|
+
const left = visible.filter(s => s.axis !== "right");
|
|
82
|
+
const right = visible.filter(s => s.axis === "right");
|
|
83
|
+
const stackable = (s: ChartSeries) => stacked && (s.kind ?? kind) !== "line";
|
|
84
|
+
|
|
85
|
+
// Per-slot stack bases for stacked left series, in series order (first = bottom).
|
|
86
|
+
const bases = new Map<string, number[]>();
|
|
87
|
+
const tops = Array.from({ length: slots }, () => 0);
|
|
88
|
+
let leftMax = 0;
|
|
89
|
+
for (const s of left) {
|
|
90
|
+
if (stackable(s)) {
|
|
91
|
+
const base = tops.slice();
|
|
92
|
+
bases.set(s.key, base);
|
|
93
|
+
for (let i = 0; i < slots; i++) tops[i] += s.values[i] ?? 0;
|
|
94
|
+
} else {
|
|
95
|
+
for (let i = 0; i < slots; i++) leftMax = Math.max(leftMax, s.values[i] ?? 0);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const top of tops) leftMax = Math.max(leftMax, top);
|
|
99
|
+
let rightMax = 0;
|
|
100
|
+
for (const s of right) for (let i = 0; i < slots; i++) rightMax = Math.max(rightMax, s.values[i] ?? 0);
|
|
101
|
+
for (const r of references ?? []) {
|
|
102
|
+
if (r.axis === "right") rightMax = Math.max(rightMax, r.value);
|
|
103
|
+
else leftMax = Math.max(leftMax, r.value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const leftScale = niceScale(yMax ?? leftMax);
|
|
107
|
+
const rightScale = right.length > 0 ? niceScale(yMaxRight ?? rightMax) : null;
|
|
108
|
+
const empty = visible.every(s => s.values.every(v => !v));
|
|
109
|
+
return { left, right, bases, stackable, leftScale, rightScale, empty };
|
|
110
|
+
}, [visible, slots, stacked, kind, yMax, yMaxRight, references]);
|
|
111
|
+
|
|
112
|
+
const leftLabels = layout.leftScale.ticks.map(t => format(t));
|
|
113
|
+
const rightLabels = layout.rightScale?.ticks.map(t => (formatRight ?? format)(t)) ?? [];
|
|
114
|
+
const padLeft = Math.max(28, labelWidth(leftLabels) + 12);
|
|
115
|
+
const padRight = layout.rightScale ? Math.max(28, labelWidth(rightLabels) + 12) : 8;
|
|
116
|
+
const plotW = Math.max(0, width - padLeft - padRight);
|
|
117
|
+
const plotH = height - PAD_TOP - PAD_BOTTOM;
|
|
118
|
+
const slotW = slots > 0 ? plotW / slots : 0;
|
|
119
|
+
const yLeft = (v: number) => PAD_TOP + plotH - (v / layout.leftScale.max) * plotH;
|
|
120
|
+
const yRight = (v: number) => PAD_TOP + plotH - (v / (layout.rightScale?.max ?? 1)) * plotH;
|
|
121
|
+
const cx = (i: number) => padLeft + (i + 0.5) * slotW;
|
|
122
|
+
|
|
123
|
+
const barSeries = visible.filter(s => (s.kind ?? kind) === "bars");
|
|
124
|
+
const groupedBars = barSeries.filter(s => !layout.stackable(s) || s.axis === "right");
|
|
125
|
+
const barW = Math.max(1, Math.min(56, slotW * (slotW > 6 ? 0.72 : 0.9)));
|
|
126
|
+
const tickEvery = Math.max(1, Math.ceil(slots / Math.max(1, Math.floor(plotW / MIN_TICK_SPACING))));
|
|
127
|
+
|
|
128
|
+
const onMove = (event: React.PointerEvent<SVGRectElement>) => {
|
|
129
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
130
|
+
const i = Math.floor((event.clientX - box.left) / (box.width / slots));
|
|
131
|
+
setHover(i >= 0 && i < slots ? i : null);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const hoverRows =
|
|
135
|
+
hover === null
|
|
136
|
+
? []
|
|
137
|
+
: visible
|
|
138
|
+
.filter(s => s.tooltip !== false)
|
|
139
|
+
.map(s => ({ s, v: s.values[hover] }))
|
|
140
|
+
// With several series, zero rows are noise ("model X: 0"); a lone series shows its zero.
|
|
141
|
+
.filter(
|
|
142
|
+
(row): row is { s: ChartSeries; v: number } =>
|
|
143
|
+
row.v !== null && row.v !== undefined && (row.v !== 0 || visible.length === 1),
|
|
144
|
+
)
|
|
145
|
+
.sort((a, b) => (visible.length > 1 ? b.v - a.v : 0));
|
|
146
|
+
const total = hoverRows.filter(r => r.s.axis !== "right").reduce((sum, r) => sum + r.v, 0);
|
|
147
|
+
const withTotal = showTotal ?? (stacked && layout.left.length > 1);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="chart" ref={ref} data-hovering={hover !== null} style={{ height }}>
|
|
151
|
+
{width > 0 && (
|
|
152
|
+
<svg width={width} height={height} role="img">
|
|
153
|
+
<defs>
|
|
154
|
+
{visible
|
|
155
|
+
.filter(s => s.pattern === "hatch")
|
|
156
|
+
.map(s => (
|
|
157
|
+
<pattern
|
|
158
|
+
key={s.key}
|
|
159
|
+
id={`${patternId}-${cssId(s.key)}`}
|
|
160
|
+
width="5"
|
|
161
|
+
height="5"
|
|
162
|
+
patternUnits="userSpaceOnUse"
|
|
163
|
+
patternTransform="rotate(45)"
|
|
164
|
+
>
|
|
165
|
+
<rect width="5" height="5" fill={s.color} fillOpacity="0.14" />
|
|
166
|
+
<rect width="1.6" height="5" fill={s.color} />
|
|
167
|
+
</pattern>
|
|
168
|
+
))}
|
|
169
|
+
</defs>
|
|
170
|
+
|
|
171
|
+
<g className="chart-grid">
|
|
172
|
+
{layout.leftScale.ticks.map(t => (
|
|
173
|
+
<line key={t} x1={padLeft} x2={padLeft + plotW} y1={yLeft(t)} y2={yLeft(t)} />
|
|
174
|
+
))}
|
|
175
|
+
</g>
|
|
176
|
+
<g className="chart-axis">
|
|
177
|
+
{layout.leftScale.ticks.map((t, i) => (
|
|
178
|
+
<text key={t} x={padLeft - 8} y={yLeft(t)} dy="0.32em" textAnchor="end">
|
|
179
|
+
{leftLabels[i]}
|
|
180
|
+
</text>
|
|
181
|
+
))}
|
|
182
|
+
{layout.rightScale?.ticks.map((t, i) => (
|
|
183
|
+
<text key={t} x={padLeft + plotW + 8} y={yRight(t)} dy="0.32em" textAnchor="start">
|
|
184
|
+
{rightLabels[i]}
|
|
185
|
+
</text>
|
|
186
|
+
))}
|
|
187
|
+
{Array.from({ length: slots }, (_, i) =>
|
|
188
|
+
i % tickEvery === 0 ? (
|
|
189
|
+
<text key={i} x={cx(i)} y={height - 6} textAnchor="middle">
|
|
190
|
+
{tickLabel(i)}
|
|
191
|
+
</text>
|
|
192
|
+
) : null,
|
|
193
|
+
)}
|
|
194
|
+
</g>
|
|
195
|
+
|
|
196
|
+
{hover !== null && (
|
|
197
|
+
<rect className="chart-cursor" x={padLeft + hover * slotW} y={PAD_TOP} width={slotW} height={plotH} />
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Areas first so bars and lines draw above them. */}
|
|
201
|
+
{visible
|
|
202
|
+
.filter(s => (s.kind ?? kind) === "area")
|
|
203
|
+
.map(s => {
|
|
204
|
+
const y = s.axis === "right" ? yRight : yLeft;
|
|
205
|
+
const base = layout.bases.get(s.key);
|
|
206
|
+
const top = (i: number) => (base?.[i] ?? 0) + (s.values[i] ?? 0);
|
|
207
|
+
const line = pathThrough(slots, cx, i => y(top(i)));
|
|
208
|
+
const floor = Array.from({ length: slots }, (_, j) => slots - 1 - j)
|
|
209
|
+
.map(i => `L${cx(i).toFixed(1)},${y(base?.[i] ?? 0).toFixed(1)}`)
|
|
210
|
+
.join("");
|
|
211
|
+
const fill = s.pattern === "hatch" ? `url(#${patternId}-${cssId(s.key)})` : s.color;
|
|
212
|
+
return (
|
|
213
|
+
<g key={s.key}>
|
|
214
|
+
<path d={`${line}${floor}Z`} fill={fill} fillOpacity={s.pattern ? 1 : 0.2} />
|
|
215
|
+
<path className="chart-line" d={line} stroke={s.color} />
|
|
216
|
+
</g>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
|
|
220
|
+
{barSeries.map(s => {
|
|
221
|
+
const y = s.axis === "right" ? yRight : yLeft;
|
|
222
|
+
const base = layout.bases.get(s.key);
|
|
223
|
+
const group = groupedBars.indexOf(s);
|
|
224
|
+
const subW = group >= 0 ? barW / groupedBars.length : barW;
|
|
225
|
+
const fill = s.pattern === "hatch" ? `url(#${patternId}-${cssId(s.key)})` : s.color;
|
|
226
|
+
return (
|
|
227
|
+
<g key={s.key}>
|
|
228
|
+
{s.values.map((v, i) => {
|
|
229
|
+
if (!v) return null;
|
|
230
|
+
const b = group >= 0 ? 0 : (base?.[i] ?? 0);
|
|
231
|
+
const y0 = y(b);
|
|
232
|
+
const y1 = y(b + v);
|
|
233
|
+
const x = cx(i) - barW / 2 + (group >= 0 ? group * subW : 0);
|
|
234
|
+
return (
|
|
235
|
+
<rect
|
|
236
|
+
key={i}
|
|
237
|
+
className="chart-bar"
|
|
238
|
+
data-hover={hover === i}
|
|
239
|
+
x={x}
|
|
240
|
+
y={Math.min(y0, y1)}
|
|
241
|
+
width={Math.max(1, subW - (group >= 0 && groupedBars.length > 1 ? 1 : 0))}
|
|
242
|
+
height={Math.max(v > 0 ? 1 : 0, Math.abs(y0 - y1))}
|
|
243
|
+
fill={fill}
|
|
244
|
+
/>
|
|
245
|
+
);
|
|
246
|
+
})}
|
|
247
|
+
</g>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
|
|
251
|
+
{visible
|
|
252
|
+
.filter(s => (s.kind ?? kind) === "line")
|
|
253
|
+
.map(s => {
|
|
254
|
+
const y = s.axis === "right" ? yRight : yLeft;
|
|
255
|
+
const at = (i: number) => {
|
|
256
|
+
const v = s.values[i];
|
|
257
|
+
return v === null || v === undefined ? null : y(v);
|
|
258
|
+
};
|
|
259
|
+
return (
|
|
260
|
+
<g key={s.key}>
|
|
261
|
+
<path
|
|
262
|
+
className="chart-line"
|
|
263
|
+
d={pathThrough(slots, cx, at)}
|
|
264
|
+
stroke={s.color}
|
|
265
|
+
strokeDasharray={s.dashed ? "4 3" : undefined}
|
|
266
|
+
/>
|
|
267
|
+
{/* A point with no neighbours draws no segment; mark it. */}
|
|
268
|
+
{s.values.map((_, i) => {
|
|
269
|
+
const yi = at(i);
|
|
270
|
+
if (
|
|
271
|
+
yi === null ||
|
|
272
|
+
(i > 0 && at(i - 1) !== null) ||
|
|
273
|
+
(i < slots - 1 && at(i + 1) !== null)
|
|
274
|
+
) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return <circle key={i} cx={cx(i)} cy={yi} r={2.5} fill={s.color} />;
|
|
278
|
+
})}
|
|
279
|
+
</g>
|
|
280
|
+
);
|
|
281
|
+
})}
|
|
282
|
+
|
|
283
|
+
{references?.map(r => {
|
|
284
|
+
const y = r.axis === "right" ? yRight(r.value) : yLeft(r.value);
|
|
285
|
+
return (
|
|
286
|
+
<g key={`${r.axis ?? "left"}:${r.value}`} className="chart-axis">
|
|
287
|
+
<line
|
|
288
|
+
x1={padLeft}
|
|
289
|
+
x2={padLeft + plotW}
|
|
290
|
+
y1={y}
|
|
291
|
+
y2={y}
|
|
292
|
+
stroke="var(--line-4)"
|
|
293
|
+
strokeDasharray="3 3"
|
|
294
|
+
/>
|
|
295
|
+
{r.label && (
|
|
296
|
+
<text x={padLeft + plotW - 4} y={y - 5} textAnchor="end">
|
|
297
|
+
{r.label}
|
|
298
|
+
</text>
|
|
299
|
+
)}
|
|
300
|
+
</g>
|
|
301
|
+
);
|
|
302
|
+
})}
|
|
303
|
+
|
|
304
|
+
<line className="chart-baseline" x1={padLeft} x2={padLeft + plotW} y1={yLeft(0)} y2={yLeft(0)} />
|
|
305
|
+
|
|
306
|
+
<rect
|
|
307
|
+
x={padLeft}
|
|
308
|
+
y={PAD_TOP}
|
|
309
|
+
width={plotW}
|
|
310
|
+
height={plotH}
|
|
311
|
+
fill="transparent"
|
|
312
|
+
onPointerMove={onMove}
|
|
313
|
+
onPointerLeave={() => setHover(null)}
|
|
314
|
+
onClick={onSelect && hover !== null ? () => onSelect(hover) : undefined}
|
|
315
|
+
style={{ cursor: onSelect ? "pointer" : "crosshair" }}
|
|
316
|
+
/>
|
|
317
|
+
</svg>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{layout.empty && <div className="chart-empty">{emptyLabel}</div>}
|
|
321
|
+
|
|
322
|
+
{hover !== null && !layout.empty && (
|
|
323
|
+
<div
|
|
324
|
+
className="chart-tooltip"
|
|
325
|
+
style={
|
|
326
|
+
cx(hover) > width / 2
|
|
327
|
+
? { right: width - cx(hover) + Math.min(slotW / 2, 24) + 8, top: PAD_TOP }
|
|
328
|
+
: { left: cx(hover) + Math.min(slotW / 2, 24) + 8, top: PAD_TOP }
|
|
329
|
+
}
|
|
330
|
+
>
|
|
331
|
+
<div className="chart-tooltip-title">{(tooltipTitle ?? tickLabel)(hover)}</div>
|
|
332
|
+
{hoverRows.map(({ s, v }) => (
|
|
333
|
+
<div key={s.key} className="chart-tooltip-row">
|
|
334
|
+
<span className="swatch" style={{ background: s.color }} />
|
|
335
|
+
<span className="chart-tooltip-label">{s.label}</span>
|
|
336
|
+
<span className="chart-tooltip-value">
|
|
337
|
+
{s.axis === "right" ? (formatRight ?? format)(v) : (formatTooltip ?? format)(v)}
|
|
338
|
+
</span>
|
|
339
|
+
</div>
|
|
340
|
+
))}
|
|
341
|
+
{withTotal && hoverRows.length > 1 && (
|
|
342
|
+
<div className="chart-tooltip-row chart-tooltip-total">
|
|
343
|
+
<span className="chart-tooltip-label">Total</span>
|
|
344
|
+
<span className="chart-tooltip-value">{(formatTooltip ?? format)(total)}</span>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
{tooltipExtra && <Fragment>{tooltipExtra(hover)}</Fragment>}
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Round an axis maximum up to 1/2/2.5/5 × 10ⁿ and return evenly spaced ticks from 0. */
|
|
355
|
+
export function niceScale(max: number): { max: number; ticks: number[] } {
|
|
356
|
+
if (!(max > 0)) return { max: 1, ticks: [0] };
|
|
357
|
+
const rough = max / Y_TICKS;
|
|
358
|
+
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
|
359
|
+
const step = [1, 2, 2.5, 5, 10].map(m => m * magnitude).find(s => s >= rough) ?? 10 * magnitude;
|
|
360
|
+
const top = Math.ceil(max / step) * step;
|
|
361
|
+
const ticks: number[] = [];
|
|
362
|
+
for (let t = 0; t <= top + step / 2; t += step) ticks.push(Number(t.toPrecision(12)));
|
|
363
|
+
return { max: top, ticks };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function labelWidth(labels: readonly string[]): number {
|
|
367
|
+
return labels.reduce((w, label) => Math.max(w, label.length * 6.3), 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Straight-segment path through slot centers; `null` breaks the line. */
|
|
371
|
+
function pathThrough(slots: number, x: (i: number) => number, y: (i: number) => number | null): string {
|
|
372
|
+
let d = "";
|
|
373
|
+
let pen = false;
|
|
374
|
+
for (let i = 0; i < slots; i++) {
|
|
375
|
+
const yi = y(i);
|
|
376
|
+
if (yi === null) {
|
|
377
|
+
pen = false;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
d += `${pen ? "L" : "M"}${x(i).toFixed(1)},${yi.toFixed(1)}`;
|
|
381
|
+
pen = true;
|
|
382
|
+
}
|
|
383
|
+
return d;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function cssId(key: string): string {
|
|
387
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
388
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface LegendItem {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
color: string;
|
|
7
|
+
/** Optional right-hand figure (e.g. the series total). */
|
|
8
|
+
value?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LegendProps {
|
|
12
|
+
items: readonly LegendItem[];
|
|
13
|
+
/** Keys currently hidden; with `onToggle` the items become buttons. */
|
|
14
|
+
hidden?: ReadonlySet<string>;
|
|
15
|
+
onToggle?: (key: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Legend({ items, hidden, onToggle }: LegendProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="legend">
|
|
21
|
+
{items.map(item => {
|
|
22
|
+
const body = (
|
|
23
|
+
<>
|
|
24
|
+
<span className="swatch" style={{ background: item.color }} />
|
|
25
|
+
<span className="truncate">{item.label}</span>
|
|
26
|
+
{item.value && <span className="legend-value">{item.value}</span>}
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
return onToggle ? (
|
|
30
|
+
<button
|
|
31
|
+
key={item.key}
|
|
32
|
+
type="button"
|
|
33
|
+
className="legend-item"
|
|
34
|
+
data-off={hidden?.has(item.key) ?? false}
|
|
35
|
+
onClick={() => onToggle(item.key)}
|
|
36
|
+
title={item.label}
|
|
37
|
+
>
|
|
38
|
+
{body}
|
|
39
|
+
</button>
|
|
40
|
+
) : (
|
|
41
|
+
<span key={item.key} className="legend-item" title={item.label}>
|
|
42
|
+
{body}
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
})}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Hidden-key set for toggleable legends. */
|
|
51
|
+
export function useHiddenSeries(): [ReadonlySet<string>, (key: string) => void] {
|
|
52
|
+
const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set());
|
|
53
|
+
const toggle = useCallback((key: string) => {
|
|
54
|
+
setHidden(prev => {
|
|
55
|
+
const next = new Set(prev);
|
|
56
|
+
if (!next.delete(key)) next.add(key);
|
|
57
|
+
return next;
|
|
58
|
+
});
|
|
59
|
+
}, []);
|
|
60
|
+
return [hidden, toggle];
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface ShareSegment {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
value: number;
|
|
5
|
+
color: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Single horizontal bar split proportionally among segments (zero segments omitted). */
|
|
9
|
+
export function ShareBar({ segments, height = 8 }: { segments: readonly ShareSegment[]; height?: number }) {
|
|
10
|
+
const total = segments.reduce((sum, s) => sum + Math.max(0, s.value), 0);
|
|
11
|
+
return (
|
|
12
|
+
<div className="share-bar" style={{ height }} role="img">
|
|
13
|
+
{total > 0 &&
|
|
14
|
+
segments
|
|
15
|
+
.filter(s => s.value > 0)
|
|
16
|
+
.map(s => (
|
|
17
|
+
<div
|
|
18
|
+
key={s.key}
|
|
19
|
+
className="share-bar-seg"
|
|
20
|
+
style={{ flexGrow: s.value / total, background: s.color }}
|
|
21
|
+
title={`${s.label}: ${((s.value / total) * 100).toFixed(1)}%`}
|
|
22
|
+
/>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useWidth } from "./useWidth";
|
|
2
|
+
|
|
3
|
+
export interface SparklineProps {
|
|
4
|
+
values: readonly number[];
|
|
5
|
+
color?: string;
|
|
6
|
+
height?: number;
|
|
7
|
+
/** Fixed width; omit to fill the container. */
|
|
8
|
+
width?: number;
|
|
9
|
+
/** Fill under the line. Default true. */
|
|
10
|
+
area?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Axis-free trend line for stat tiles and table cells. */
|
|
14
|
+
export function Sparkline({ values, color = "var(--chart-primary)", height = 28, width, area = true }: SparklineProps) {
|
|
15
|
+
const [ref, measured] = useWidth<HTMLDivElement>();
|
|
16
|
+
const w = width ?? measured;
|
|
17
|
+
const max = Math.max(0, ...values);
|
|
18
|
+
const n = values.length;
|
|
19
|
+
let line = "";
|
|
20
|
+
if (w > 0 && n > 0) {
|
|
21
|
+
const step = n > 1 ? w / (n - 1) : 0;
|
|
22
|
+
for (let i = 0; i < n; i++) {
|
|
23
|
+
const x = n > 1 ? i * step : w / 2;
|
|
24
|
+
const y = max > 0 ? height - 2 - (values[i] / max) * (height - 4) : height - 2;
|
|
25
|
+
line += `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<div ref={ref} className="sparkline" style={{ width: width ?? "100%", height }}>
|
|
30
|
+
{w > 0 && n > 0 && (
|
|
31
|
+
<svg width={w} height={height} aria-hidden="true">
|
|
32
|
+
{area && <path className="area" d={`${line}L${w},${height}L0,${height}Z`} fill={color} />}
|
|
33
|
+
<path className="chart-line" d={line} stroke={color} />
|
|
34
|
+
</svg>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { formatBucket, formatTick } from "../data/range";
|
|
2
|
+
import { Chart, type ChartProps } from "./Chart";
|
|
3
|
+
|
|
4
|
+
export interface TimeChartProps extends Omit<ChartProps, "slots" | "tickLabel" | "tooltipTitle" | "onSelect"> {
|
|
5
|
+
/** Ascending bucket starts (see `bucketAxis`); series values align with them. */
|
|
6
|
+
buckets: readonly number[];
|
|
7
|
+
bucketMs: number;
|
|
8
|
+
onSelectBucket?: (bucketStart: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** {@link Chart} over a bucketed time axis. */
|
|
12
|
+
export function TimeChart({ buckets, bucketMs, onSelectBucket, ...rest }: TimeChartProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Chart
|
|
15
|
+
{...rest}
|
|
16
|
+
slots={buckets.length}
|
|
17
|
+
tickLabel={i => formatTick(buckets[i], bucketMs)}
|
|
18
|
+
tooltipTitle={i => formatBucket(buckets[i], bucketMs)}
|
|
19
|
+
onSelect={onSelectBucket ? i => onSelectBucket(buckets[i]) : undefined}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** One plotted series. `values` align index-for-index with the chart's x slots. */
|
|
2
|
+
export interface ChartSeries {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
color: string;
|
|
6
|
+
/** `null` leaves a gap (lines) or an empty slot (bars). */
|
|
7
|
+
values: readonly (number | null)[];
|
|
8
|
+
/** Overrides the chart-level `kind` for this series (e.g. a line over bars). */
|
|
9
|
+
kind?: ChartKind;
|
|
10
|
+
/** Plot against the right axis (uses `formatRight`). Right-axis series never stack. */
|
|
11
|
+
axis?: "left" | "right";
|
|
12
|
+
/** Diagonal hatching instead of a solid fill (bars/areas). */
|
|
13
|
+
pattern?: "hatch";
|
|
14
|
+
/** Dashed stroke (lines). */
|
|
15
|
+
dashed?: boolean;
|
|
16
|
+
/** Leave this series out of the tooltip (e.g. a trend line that repeats a bar). */
|
|
17
|
+
tooltip?: false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ChartKind = "bars" | "area" | "line";
|
|
21
|
+
|
|
22
|
+
/** Horizontal guide drawn across the plot (e.g. a 100% capacity line). */
|
|
23
|
+
export interface ReferenceLine {
|
|
24
|
+
value: number;
|
|
25
|
+
label?: string;
|
|
26
|
+
axis?: "left" | "right";
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** Track an element's content width (ResizeObserver). Returns 0 until measured. */
|
|
4
|
+
export function useWidth<T extends HTMLElement>(): [RefObject<T | null>, number] {
|
|
5
|
+
const ref = useRef<T | null>(null);
|
|
6
|
+
const [width, setWidth] = useState(0);
|
|
7
|
+
useLayoutEffect(() => {
|
|
8
|
+
const el = ref.current;
|
|
9
|
+
if (!el) return;
|
|
10
|
+
setWidth(el.clientWidth);
|
|
11
|
+
const observer = new ResizeObserver(entries => {
|
|
12
|
+
const next = Math.round(entries[0]?.contentRect.width ?? 0);
|
|
13
|
+
setWidth(prev => (prev === next ? prev : next));
|
|
14
|
+
});
|
|
15
|
+
observer.observe(el);
|
|
16
|
+
return () => observer.disconnect();
|
|
17
|
+
}, []);
|
|
18
|
+
return [ref, width];
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Categorical series colors. Every multi-series chart and table swatch draws
|
|
3
|
+
* from {@link SERIES_COLORS}; ranking keeps the busiest series on the most
|
|
4
|
+
* legible hues and the same key on the same hue across charts of one page.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Ten mid-luminance hues that read on both the black and the white chassis. */
|
|
8
|
+
export const SERIES_COLORS = [
|
|
9
|
+
"#5ad8e6", // cyan
|
|
10
|
+
"#ed4abf", // pink
|
|
11
|
+
"#9d7bff", // violet
|
|
12
|
+
"#f5b54a", // amber
|
|
13
|
+
"#4ade80", // green
|
|
14
|
+
"#5b8cff", // blue
|
|
15
|
+
"#ff7a59", // coral
|
|
16
|
+
"#2dd4bf", // teal
|
|
17
|
+
"#c3e94f", // lime
|
|
18
|
+
"#fb7185", // rose
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Neutral used for "Other" rollups and unknown buckets. */
|
|
22
|
+
export const OTHER_COLOR = "#6c6c74";
|
|
23
|
+
|
|
24
|
+
/** Stable identity of a model across providers: `model::provider`. */
|
|
25
|
+
export function modelKey(model: string, provider: string): string {
|
|
26
|
+
return `${model}::${provider}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Assign colors by descending weight (ties broken by key) so a model's hue is
|
|
31
|
+
* determined by its rank, not by response order. Used by every per-model view.
|
|
32
|
+
*/
|
|
33
|
+
export function buildModelColorLookup(
|
|
34
|
+
records: readonly { model: string; provider: string; totalRequests: number }[],
|
|
35
|
+
): Map<string, string> {
|
|
36
|
+
return buildColorLookup(
|
|
37
|
+
records.map(record => ({ key: modelKey(record.model, record.provider), weight: record.totalRequests })),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Generic form of {@link buildModelColorLookup} for any keyed, weighted series. */
|
|
42
|
+
export function buildColorLookup(items: readonly { key: string; weight: number }[]): Map<string, string> {
|
|
43
|
+
const ranked = [...items].sort((a, b) => b.weight - a.weight || a.key.localeCompare(b.key));
|
|
44
|
+
return new Map(ranked.map((item, index) => [item.key, SERIES_COLORS[index % SERIES_COLORS.length]]));
|
|
45
|
+
}
|