stats: replace the type donuts with vertical bar charts

The payload-types donut was buckling: 10+ slivers, a scrollable legend
with truncated names, and a center total that clipped at narrower
widths. Bars label every category inline, show counts on top, and the
total moved into the card header. Node types gets the same treatment
for consistency.

Also fix charts starting on the fallback palette when a saved theme
loads: themeId is already the saved id before the CSS vars land, so
useChartColors now keys on a paletteRev that bumps on every applyTheme.
This commit is contained in:
MrAlders0n
2026-06-10 06:46:13 -04:00
parent 714b32fd22
commit 115f6a83cc
5 changed files with 121 additions and 52 deletions
+28 -13
View File
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useChartColors, type ChartColors } from "./chartTheme";
import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes } from "./useStats";
import { observationsAreaOption, leaderboardOption, donutOption } from "./chartOptions";
import { observationsAreaOption, leaderboardOption, typeBarOption } from "./chartOptions";
import { Card, ChartCard, StatCard } from "./cards";
import { useLiveOverview } from "./useLiveStats";
import { aggregatePresets, formatPreset } from "./transforms";
@@ -65,14 +65,14 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]);
const payloadItems = useMemo(
() => (payload.data ?? []).map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })),
() =>
(payload.data ?? [])
.map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count }))
.sort((a, b) => b.value - a.value),
[payload.data],
);
const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]);
const payloadOption = useMemo(
() => donutOption(payloadItems, colors, formatCount(payloadTotal), "OBS"),
[payloadItems, payloadTotal, colors],
);
const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]);
const observerRows = useMemo(
() => (topObservers.data ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })),
@@ -93,13 +93,12 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
const nodeTypeData = useMemo(() => {
const counts = new Map<string, number>();
for (const n of topNodes.data ?? []) counts.set(n.nodeTypeName, (counts.get(n.nodeTypeName) ?? 0) + 1);
return [...counts.entries()].map(([name, value]) => ({ name, value, color: nodeTypeColor(name, colors) }));
return [...counts.entries()]
.map(([name, value]) => ({ name, value, color: nodeTypeColor(name, colors) }))
.sort((a, b) => b.value - a.value);
}, [topNodes.data, colors]);
const nodeTypeTotal = useMemo(() => nodeTypeData.reduce((a, d) => a + d.value, 0), [nodeTypeData]);
const nodeTypeOption = useMemo(
() => donutOption(nodeTypeData, colors, String(nodeTypeTotal), "NODES"),
[nodeTypeData, nodeTypeTotal, colors],
);
const nodeTypeOption = useMemo(() => typeBarOption(nodeTypeData, colors), [nodeTypeData, colors]);
const presetRows = useMemo(
() => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })),
@@ -138,9 +137,25 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Top nodes" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<ChartCard title={<>Payload types · {range}</>} height={208} option={payloadOption} isLoading={payload.isLoading} isError={payload.isError} isEmpty={payloadItems.length === 0} />
<ChartCard
title={<>Payload types · {range}</>}
right={<span className="font-mono text-[10px] text-text-muted">{formatCount(payloadTotal)} obs</span>}
height={208}
option={payloadOption}
isLoading={payload.isLoading}
isError={payload.isError}
isEmpty={payloadItems.length === 0}
/>
<ChartCard title={<>Top observers · {range}</>} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
<ChartCard title="Node types · top" height={208} option={nodeTypeOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeTypeData.length === 0} />
<ChartCard
title="Node types · top"
right={<span className="font-mono text-[10px] text-text-muted">{nodeTypeTotal} nodes</span>}
height={208}
option={nodeTypeOption}
isLoading={topNodes.isLoading}
isError={topNodes.isError}
isEmpty={nodeTypeData.length === 0}
/>
<ChartCard title="Radio presets" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
<Card title={<>Scopes · all regions</>}>
+27 -32
View File
@@ -1,5 +1,6 @@
import type { EChartsOption } from "./echarts-setup";
import { type ChartColors, tooltipStyle, withAlpha } from "./chartTheme";
import { formatCount } from "../../lib/formatters";
import type { TelemetryPoint } from "./types";
const MONO = "JetBrains Mono, monospace";
@@ -120,47 +121,41 @@ export function leaderboardOption(
};
}
export function donutOption(
// Vertical bars for the type breakdowns (payload types, node types). Replaced the old donuts: with
// 10+ slivers the legend needed scrolling, names truncated, and the center total clipped at narrow
// widths — bars label every category inline and need no legend at all.
export function typeBarOption(
items: { name: string; value: number; color?: string }[],
c: ChartColors,
centerValue: string,
centerLabel: string,
): EChartsOption {
const crowded = items.length > 5;
return {
animation: false,
backgroundColor: "transparent",
tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c} ({d}%)" },
legend: {
type: "scroll",
orient: "vertical",
right: 4,
top: "middle",
itemWidth: 9,
itemHeight: 9,
itemGap: 7,
// cap label width so long names (e.g. "anonymous_request") can't overrun into the donut
formatter: (name: string) => (name.length > 15 ? `${name.slice(0, 14)}` : name),
textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 },
pageIconColor: c.textMuted,
pageIconInactiveColor: c.textDim,
pageTextStyle: { color: c.textMuted, fontFamily: MONO, fontSize: 9 },
inactiveColor: c.textDim,
grid: { left: 44, right: 10, top: 18, bottom: crowded ? 52 : 24 },
tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c}" },
xAxis: {
type: "category",
data: items.map((it) => it.name),
axisLine: { lineStyle: { color: c.border } },
axisTick: { show: false },
// slant only when there are enough categories for labels to collide
axisLabel: { color: c.textNormal, fontFamily: MONO, fontSize: 9, interval: 0, rotate: crowded ? 36 : 0, width: 92, overflow: "truncate" },
},
// centered on the donut hole (matches series center x=27%); textAlign/VerticalAlign anchor on the point
graphic: [
{ type: "text", left: "27%", top: "47%", style: { text: centerValue, fill: c.textBright, font: `700 21px ${MONO}`, textAlign: "center", textVerticalAlign: "middle" } },
{ type: "text", left: "27%", top: "59%", style: { text: centerLabel, fill: c.textMuted, font: `9px ${MONO}`, textAlign: "center", textVerticalAlign: "middle" } },
],
yAxis: valueAxis(c),
series: [
{
type: "pie",
radius: ["46%", "68%"],
center: ["27%", "50%"],
avoidLabelOverlap: false,
itemStyle: { borderColor: c.bgSurface, borderWidth: 2, borderRadius: 4 },
label: { show: false },
emphasis: { scaleSize: 5 },
data: items.map((it, i) => ({ name: it.name, value: it.value, itemStyle: { color: it.color ?? c.series[i % c.series.length] } })),
type: "bar",
barMaxWidth: 28,
data: items.map((it, i) => ({ value: it.value, itemStyle: { color: it.color ?? c.series[i % c.series.length], borderRadius: [4, 4, 0, 0] } })),
label: {
show: true,
position: "top",
color: c.textBright,
fontFamily: MONO,
fontSize: 9,
formatter: (p: { value: number }) => formatCount(p.value),
},
},
],
};
+7 -6
View File
@@ -3,8 +3,8 @@ import { useTheme } from "../../hooks/useTheme";
// ECharts paints to a canvas and can't inherit our CSS variables, so we read the active palette's
// resolved `--color-*` tokens (defined in index.css `@theme`, which always resolve — palette value or
// fallback) and hand them to the option builders. `useChartColors()` re-reads whenever the theme id
// changes, so every chart recolors on theme switch.
// fallback) and hand them to the option builders. `useChartColors()` re-reads whenever a palette is
// applied (initial saved-theme load and every switch), so charts always match the active theme.
export interface ChartColors {
primary: string;
@@ -93,10 +93,11 @@ export function readChartColors(): ChartColors {
}
export function useChartColors(): ChartColors {
const { themeId } = useTheme();
// themeId changes after the palette CSS vars are applied, so re-reading here is correct.
// eslint-disable-next-line react-hooks/exhaustive-deps -- themeId is the re-read trigger
return useMemo(() => readChartColors(), [themeId]);
const { paletteRev } = useTheme();
// paletteRev bumps after each applyTheme, including the initial saved-theme load (which themeId
// alone misses — it's already the saved id before the CSS vars land).
// eslint-disable-next-line react-hooks/exhaustive-deps -- paletteRev is the re-read trigger
return useMemo(() => readChartColors(), [paletteRev]);
}
// A reusable ECharts tooltip style block bound to the active palette.
+8 -1
View File
@@ -10,16 +10,21 @@ interface ThemeCtx {
themeId: string;
themes: Theme[];
setThemeId: (id: string) => void;
// bumps every time CSS vars are (re)applied — themeId alone misses the initial load, where the
// saved id is already in state before the vars land, so readers of computed styles key on this
paletteRev: number;
}
const ThemeContext = createContext<ThemeCtx>({
themeId: DEFAULT_THEME_ID,
themes: [],
setThemeId: () => {},
paletteRev: 0,
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themes, setThemes] = useState<Theme[]>([]);
const [paletteRev, setPaletteRev] = useState(0);
const [themeId, setThemeIdState] = useState(
() => localStorage.getItem(STORAGE_KEY) ?? DEFAULT_THEME_ID,
);
@@ -31,6 +36,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const match = loaded.find((t) => t.id === saved) ?? loaded[0];
if (!match) return;
applyTheme(match);
setPaletteRev((r) => r + 1);
setThemeIdState(match.id);
});
}, []);
@@ -40,6 +46,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const match = themes.find((t) => t.id === id);
if (!match) return;
applyTheme(match);
setPaletteRev((r) => r + 1);
localStorage.setItem(STORAGE_KEY, id);
setThemeIdState(id);
},
@@ -47,7 +54,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
);
return (
<ThemeContext.Provider value={{ themeId, themes, setThemeId }}>
<ThemeContext.Provider value={{ themeId, themes, setThemeId, paletteRev }}>
{children}
</ThemeContext.Provider>
);
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { typeBarOption } from "../../../src/features/stats/chartOptions";
import type { ChartColors } from "../../../src/features/stats/chartTheme";
const colors: ChartColors = {
primary: "#3b82f6",
primaryDim: "#1e40af",
secondary: "#a78bfa",
green: "#22c55e",
warn: "#f59e0b",
danger: "#ef4444",
textBright: "#fff",
textNormal: "#ccc",
textMuted: "#999",
textDim: "#666",
bgBase: "#000",
bgSurface: "#111",
bgRaised: "#222",
border: "#333",
borderSubtle: "#2a2a2a",
series: ["#s0", "#s1", "#s2"],
};
const items = (n: number) =>
Array.from({ length: n }, (_, i) => ({ name: `type_${i}`, value: (n - i) * 10 }));
describe("typeBarOption", () => {
it("builds vertical bars: categories on x, one bar per item in order", () => {
const opt = typeBarOption(items(3), colors) as Record<string, any>;
expect(opt.xAxis.type).toBe("category");
expect(opt.xAxis.data).toEqual(["type_0", "type_1", "type_2"]);
expect(opt.series[0].type).toBe("bar");
expect(opt.series[0].data.map((d: { value: number }) => d.value)).toEqual([30, 20, 10]);
});
it("keeps explicit item colors and cycles the palette for the rest", () => {
const opt = typeBarOption(
[{ name: "a", value: 1, color: "#abc" }, { name: "b", value: 2 }],
colors,
) as Record<string, any>;
expect(opt.series[0].data[0].itemStyle.color).toBe("#abc");
expect(opt.series[0].data[1].itemStyle.color).toBe("#s1");
});
it("slants x labels only when categories are crowded", () => {
const few = typeBarOption(items(4), colors) as Record<string, any>;
const many = typeBarOption(items(10), colors) as Record<string, any>;
expect(few.xAxis.axisLabel.rotate).toBe(0);
expect(many.xAxis.axisLabel.rotate).toBeGreaterThan(0);
});
});