diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 0c4da0b..f55ae00 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -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(); 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) {
- Payload types · {range}} height={208} option={payloadOption} isLoading={payload.isLoading} isError={payload.isError} isEmpty={payloadItems.length === 0} /> + Payload types · {range}} + right={{formatCount(payloadTotal)} obs} + height={208} + option={payloadOption} + isLoading={payload.isLoading} + isError={payload.isError} + isEmpty={payloadItems.length === 0} + /> Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> - + {nodeTypeTotal} nodes} + height={208} + option={nodeTypeOption} + isLoading={topNodes.isLoading} + isError={topNodes.isError} + isEmpty={nodeTypeData.length === 0} + /> Scopes · all regions}> diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index a0dbc05..07a4642 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -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), + }, }, ], }; diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index 44f2d0d..7e6a2f5 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -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. diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx index bd013a2..66917a0 100644 --- a/src/hooks/useTheme.tsx +++ b/src/hooks/useTheme.tsx @@ -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({ themeId: DEFAULT_THEME_ID, themes: [], setThemeId: () => {}, + paletteRev: 0, }); export function ThemeProvider({ children }: { children: ReactNode }) { const [themes, setThemes] = useState([]); + 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 ( - + {children} ); diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts new file mode 100644 index 0000000..99de588 --- /dev/null +++ b/tests/features/stats/chart-options.test.ts @@ -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; + 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; + 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; + const many = typeBarOption(items(10), colors) as Record; + expect(few.xAxis.axisLabel.rotate).toBe(0); + expect(many.xAxis.axisLabel.rotate).toBeGreaterThan(0); + }); +});