mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-01 16:48:19 +00:00
stats: bench the node-types donut until a real endpoint exists
The donut counted types among the top-10 nodes only, so the center read '10 NODES' as if the region had ten nodes, and its center text never sat quite right (ECharts titles anchor the main text, the subtext hangs below). There's no /stats/node-types endpoint to feed it properly yet (ticket filed), so the card shows 'Coming soon' and the donut builder goes away rather than lingering as dead code.
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, typeBarOption, donutOption } from "./chartOptions";
|
||||
import { observationsAreaOption, leaderboardOption, typeBarOption } from "./chartOptions";
|
||||
import { Card, ChartCard, StatCard } from "./cards";
|
||||
import { useLiveOverview } from "./useLiveStats";
|
||||
import { aggregatePresets, formatPreset } from "./transforms";
|
||||
@@ -90,20 +90,6 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
|
||||
[observerIds, onSelectObserver],
|
||||
);
|
||||
|
||||
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) }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [topNodes.data, colors]);
|
||||
const nodeTypeTotal = useMemo(() => nodeTypeData.reduce((a, d) => a + d.value, 0), [nodeTypeData]);
|
||||
// few categories, so a donut reads fine here — payload types stays a bar chart (10+ categories)
|
||||
const nodeTypeOption = useMemo(
|
||||
() => donutOption(nodeTypeData, colors, String(nodeTypeTotal), "NODES"),
|
||||
[nodeTypeData, nodeTypeTotal, colors],
|
||||
);
|
||||
|
||||
const presetRows = useMemo(
|
||||
() => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })),
|
||||
[radioPresets.data, colors],
|
||||
@@ -151,7 +137,13 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
|
||||
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} />
|
||||
{/* needs a /stats/node-types endpoint (ticket filed) — the old donut counted types among
|
||||
the top-10 nodes only, which read as the region's whole population */}
|
||||
<Card title="Node types">
|
||||
<div className="flex h-[208px] items-center justify-center font-mono text-[11px] text-text-dim">
|
||||
Coming soon
|
||||
</div>
|
||||
</Card>
|
||||
<ChartCard title="Radio presets" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
|
||||
|
||||
<Card title={<>Scopes · all regions</>}>
|
||||
|
||||
@@ -131,57 +131,6 @@ export function leaderboardOption(
|
||||
};
|
||||
}
|
||||
|
||||
// Donut for small category sets (node types — usually 3 or 4). The center total is a title block
|
||||
// anchored at the ring's x with textAlign center, which ECharts centers properly — the old graphic
|
||||
// text anchored its LEFT edge there and clipped against the ring.
|
||||
export function donutOption(
|
||||
items: { name: string; value: number; color?: string }[],
|
||||
c: ChartColors,
|
||||
centerValue: string,
|
||||
centerLabel: string,
|
||||
): EChartsOption {
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: "transparent",
|
||||
tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c} ({d}%)" },
|
||||
title: {
|
||||
text: centerValue,
|
||||
subtext: centerLabel,
|
||||
// anchor the block's center exactly on the pie's center (35%, 50%) — textAlign/-VerticalAlign
|
||||
// make left/top the anchor point instead of the block's top-left corner
|
||||
left: "35%",
|
||||
top: "50%",
|
||||
textAlign: "center",
|
||||
textVerticalAlign: "middle",
|
||||
itemGap: 2,
|
||||
textStyle: { color: c.textBright, fontFamily: MONO, fontSize: 21, fontWeight: 700 },
|
||||
subtextStyle: { color: c.textMuted, fontFamily: MONO, fontSize: 9 },
|
||||
},
|
||||
legend: {
|
||||
orient: "vertical",
|
||||
right: 10,
|
||||
top: "middle",
|
||||
itemWidth: 9,
|
||||
itemHeight: 9,
|
||||
itemGap: 7,
|
||||
textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 },
|
||||
inactiveColor: c.textDim,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["46%", "68%"],
|
||||
center: ["35%", "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] } })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Vertical bars for the payload-type breakdown. Replaced the old donut: with 10+ slivers the legend
|
||||
// needed scrolling, names truncated, and thin slices couldn't be compared by eye — bars label every
|
||||
// category inline and need no legend at all.
|
||||
|
||||
@@ -6,7 +6,6 @@ import * as echarts from "echarts/core";
|
||||
import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GraphicComponent,
|
||||
@@ -21,7 +20,6 @@ echarts.use([
|
||||
PieChart,
|
||||
GaugeChart,
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GraphicComponent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- poking into loose ECharts option shapes */
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { typeBarOption, leaderboardOption, donutOption } from "../../../src/features/stats/chartOptions";
|
||||
import { typeBarOption, leaderboardOption } from "../../../src/features/stats/chartOptions";
|
||||
import type { ChartColors } from "../../../src/features/stats/chartTheme";
|
||||
|
||||
const colors: ChartColors = {
|
||||
@@ -51,20 +51,6 @@ describe("typeBarOption", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("donutOption", () => {
|
||||
it("centers the total on the ring via a textAlign-centered title at the pie's x", () => {
|
||||
const opt = donutOption([{ name: "repeater", value: 3 }], colors, "10", "NODES") as Record<string, any>;
|
||||
// the old graphic-text approach anchored its left edge at the ring center and clipped
|
||||
expect(opt.graphic).toBeUndefined();
|
||||
expect(opt.title.textAlign).toBe("center");
|
||||
expect(opt.title.textVerticalAlign).toBe("middle");
|
||||
expect(opt.title.text).toBe("10");
|
||||
// anchor must sit exactly on the pie's center point
|
||||
expect(opt.title.left).toBe(opt.series[0].center[0]);
|
||||
expect(opt.title.top).toBe(opt.series[0].center[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaderboardOption", () => {
|
||||
it("left-aligns names at the card edge and truncates long ones to the label gutter", () => {
|
||||
const rows = [{ name: "A very long observer name that overflows", value: 5, color: "#abc" }];
|
||||
|
||||
Reference in New Issue
Block a user