mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-16 10:22:34 +00:00
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:
@@ -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</>}>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user