mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 14:11:24 +00:00
Add hourly sparklines to analytics stat cards (#134)
This commit is contained in:
1 parent
30a8f94dbd
commit
f6caab663c
14 files changed
+130
-31
No files matched your search
@@ -108,8 +108,12 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
[scopesData],
|
||||
);
|
||||
|
||||
const obsSpark = useMemo(() => spark(ov, (v) => v.observations), [ov]);
|
||||
const observerSpark = useMemo(() => spark(ov, (v) => v.activeObservers), [ov]);
|
||||
const sparks = useMemo(() => ({
|
||||
packets: spark(ov, (v) => v.uniquePackets),
|
||||
observations: spark(ov, (v) => v.observations),
|
||||
observers: spark(ov, (v) => v.activeObservers),
|
||||
iatas: spark(ov, (v) => v.activeIatas),
|
||||
}), [ov]);
|
||||
|
||||
// top-row KPIs are the last 24 rollable hours, so they lag the clock by 35–95 min; range only drives the charts below
|
||||
const ovWindow = ov
|
||||
@@ -119,10 +123,10 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(ov?.summary.uniquePackets)} />
|
||||
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(ov?.summary.observations)} spark={obsSpark} />
|
||||
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={ov?.summary.activeObservers ?? "—"} spark={observerSpark} />
|
||||
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={ov?.summary.activeIatas ?? "—"} />
|
||||
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(ov?.summary.uniquePackets)} spark={sparks.packets} />
|
||||
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(ov?.summary.observations)} spark={sparks.observations} />
|
||||
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={ov?.summary.activeObservers ?? "—"} spark={sparks.observers} />
|
||||
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={ov?.summary.activeIatas ?? "—"} spark={sparks.iatas} />
|
||||
</div>
|
||||
|
||||
<ChartCard
|
||||
|
||||
@@ -32,6 +32,15 @@ export function PathsTab({ range }: { range: StatsRange }) {
|
||||
};
|
||||
}, [data, c, categories, hours, t]);
|
||||
const state = { isLoading: loading, isError: query.isError };
|
||||
const sparks = useMemo(() => {
|
||||
const counts = hours.map(({ oneByte, twoByte, threeByte }) =>
|
||||
oneByte === null || twoByte === null || threeByte === null ? null : { hashed: oneByte + twoByte + threeByte, multi: twoByte + threeByte });
|
||||
return {
|
||||
receptions: hours.map((h) => h.receptions),
|
||||
hashed: counts.map((n) => n?.hashed ?? null),
|
||||
multiShare: counts.map((n) => n?.hashed ? n.multi / n.hashed : null),
|
||||
};
|
||||
}, [hours]);
|
||||
const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0;
|
||||
const largest = data?.pathLengths.at(-1)?.entries;
|
||||
|
||||
@@ -39,9 +48,9 @@ export function PathsTab({ range }: { range: StatsRange }) {
|
||||
<SectionInfo text={[t("paths.subtitle"), t("paths.measurement"), data && t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
|
||||
{query.isError && <p role="alert" className="text-sm text-danger">{t("paths.error")}</p>}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} />
|
||||
<StatCard label={t("paths.withHashPaths")} value={data ? formatCount(data.hashed) : "—"} accent={c.green} />
|
||||
<StatCard label={t("paths.multiByteShare")} value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.secondary} />
|
||||
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks.receptions} />
|
||||
<StatCard label={t("paths.withHashPaths")} value={data ? formatCount(data.hashed) : "—"} accent={c.green} spark={sparks.hashed} />
|
||||
<StatCard label={t("paths.multiByteShare")} value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.secondary} spark={sparks.multiShare} />
|
||||
<StatCard label={t("paths.mostEntries")} value={largest ?? "—"} accent={c.warn} />
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
|
||||
|
||||
@@ -23,16 +23,22 @@ export function SignalTab({ range }: { range: StatsRange }) {
|
||||
snrTrend: signalTrendOption(hours, "snr", c, t), rssiTrend: signalTrendOption(hours, "rssi", c, t), coverage: signalCoverageOption(data, c, t),
|
||||
}), [data, hours, c, t]);
|
||||
const state = { isLoading: loading, isError: query.isError };
|
||||
const sparks = useMemo(() => data && {
|
||||
receptions: hours.map((h) => h.receptions),
|
||||
snr: hours.map((h) => h.snrAverage),
|
||||
rssi: hours.map((h) => h.rssiAverage),
|
||||
reported: hours.map((h) => h.receptions !== null),
|
||||
}, [data, hours]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
|
||||
<SectionInfo text={[t("signal.subtitle"), t("signal.measurement"), data && t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
|
||||
{query.isError && <p role="alert" className="text-sm text-danger">{t("signal.error")}</p>}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} />
|
||||
<StatCard label={t("signal.meanSnr")} value={average(data?.snr.average, "dB")} accent={c.secondary} />
|
||||
<StatCard label={t("signal.meanRssi")} value={average(data?.rssi.average, "dBm")} accent={c.green} />
|
||||
<StatCard label={t("signal.hoursWithRecords")} value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.warn} />
|
||||
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.receptions} />
|
||||
<StatCard label={t("signal.meanSnr")} value={average(data?.snr.average, "dB")} accent={c.secondary} spark={sparks?.snr} />
|
||||
<StatCard label={t("signal.meanRssi")} value={average(data?.rssi.average, "dBm")} accent={c.green} spark={sparks?.rssi} />
|
||||
<StatCard label={t("signal.hoursWithRecords")} value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.warn} presence={sparks?.reported} />
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
|
||||
<ChartCard title={t("signal.histogramTitle", { metric: "SNR", unit: "dB" })} option={charts.snr} height={280} isEmpty={!data?.snr.samples} {...state} />
|
||||
|
||||
@@ -24,16 +24,21 @@ export function TrafficTab({ range }: { range: StatsRange }) {
|
||||
return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: t("traffic.shareDescription") } } };
|
||||
}, [model, colors, t]);
|
||||
const value = (number: number) => unavailable ? "—" : formatCount(number);
|
||||
const sparks = useMemo(() => unavailable ? null : {
|
||||
totals: model.hours.map((h) => h.total),
|
||||
areas: model.hours.map((h) => h.areas),
|
||||
reported: model.hours.map((h) => h.total !== null),
|
||||
}, [model, unavailable]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
|
||||
<SectionInfo text={[t("traffic.subtitle"), t("traffic.measurement")]} />
|
||||
{query.isError && <p role="alert" className="text-sm text-danger">{t("traffic.error")}</p>}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.primary} sublabel={t(`stats.ranges.${range}`)} />
|
||||
<StatCard label={t("traffic.reportingIatas")} value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.green} />
|
||||
<StatCard label={t("traffic.busiestHour")} value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.secondary} />
|
||||
<StatCard label={t("traffic.hoursWithRecords")} value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.warn} />
|
||||
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.totals} />
|
||||
<StatCard label={t("traffic.reportingIatas")} value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.green} spark={sparks?.areas} />
|
||||
<StatCard label={t("traffic.busiestHour")} value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.secondary} spark={sparks?.totals} markPeak />
|
||||
<StatCard label={t("traffic.hoursWithRecords")} value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.warn} presence={sparks?.reported} />
|
||||
</div>
|
||||
<ChartCard title={t("traffic.trendTitle")} right={<span className="text-[10px] text-text-muted">{t("traffic.hourlyRecords")}</span>} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
|
||||
<ChartCard title={t("traffic.hourlyTitle", { range: t(`stats.ranges.${range}`) })} option={heatmap} height={Math.max(150, model.days.length * 15 + 72)} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
|
||||
|
||||
@@ -26,41 +26,75 @@ export function Card({
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({ values, color }: { values: (number | null)[]; color: string }) {
|
||||
const SPARK_W = 120;
|
||||
const SPARK_H = 20;
|
||||
const sparkSpacer = <div className="mt-1.5 h-[20px]" />;
|
||||
|
||||
function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) {
|
||||
const known = values.filter((v): v is number => v !== null);
|
||||
if (values.length < 2 || known.length < 2) return <div className="mt-1.5 h-[20px]" />;
|
||||
const w = 120;
|
||||
const h = 20;
|
||||
if (values.length < 2 || known.length < 2) return sparkSpacer;
|
||||
const max = Math.max(...known);
|
||||
const min = Math.min(...known);
|
||||
const range = max - min || 1;
|
||||
const x = (i: number) => (i / (values.length - 1)) * SPARK_W;
|
||||
const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2);
|
||||
// A null hour breaks the line, so an outage shows as a gap rather than a straight join.
|
||||
const runs: string[][] = [[]];
|
||||
values.forEach((v, i) => {
|
||||
if (v === null) runs.push([]);
|
||||
else runs[runs.length - 1]!.push(`${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`);
|
||||
else runs[runs.length - 1]!.push(`${x(i)},${y(v)}`);
|
||||
});
|
||||
const peak = values.indexOf(max);
|
||||
return (
|
||||
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="mt-1.5" aria-hidden>
|
||||
<svg width="100%" height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} preserveAspectRatio="none" className="mt-1.5 overflow-visible" aria-hidden>
|
||||
{runs.filter((run) => run.length > 1).map((run) => (
|
||||
<polyline key={run[0]} fill="none" stroke={color} strokeWidth="1.5" points={run.join(" ")} />
|
||||
))}
|
||||
{markPeak && (
|
||||
// zero-length round-capped stroke: stays a circle under preserveAspectRatio="none"
|
||||
<line data-peak x1={x(peak)} x2={x(peak)} y1={y(max)} y2={y(max)} stroke={color} strokeWidth="5" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// One bar per run of hours that reported, so gaps read at a glance.
|
||||
function PresenceStrip({ hours, color }: { hours: boolean[]; color: string }) {
|
||||
if (hours.length === 0) return sparkSpacer;
|
||||
const slot = SPARK_W / hours.length;
|
||||
const bars: { start: number; length: number }[] = [];
|
||||
hours.forEach((on, i) => {
|
||||
const last = bars.at(-1);
|
||||
if (!on) return;
|
||||
if (last && last.start + last.length === i) last.length++;
|
||||
else bars.push({ start: i, length: 1 });
|
||||
});
|
||||
return (
|
||||
<svg width="100%" height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} preserveAspectRatio="none" className="mt-1.5" aria-hidden>
|
||||
<line x1="0" x2={SPARK_W} y1={SPARK_H / 2} y2={SPARK_H / 2} stroke={color} strokeWidth="1" opacity="0.25" />
|
||||
{bars.map((bar) => (
|
||||
<rect key={bar.start} x={bar.start * slot} y={SPARK_H / 2 - 4} width={bar.length * slot} height="8" fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// KPI tile: label, big mono value, optional sparkline + sub-label.
|
||||
// KPI tile: label, big mono value, optional sparkline (or hourly presence strip) + sub-label.
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
spark,
|
||||
markPeak,
|
||||
presence,
|
||||
sublabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
accent: string; // CSS color for the sparkline, e.g. "var(--color-primary)"
|
||||
spark?: (number | null)[];
|
||||
markPeak?: boolean;
|
||||
presence?: boolean[];
|
||||
sublabel?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -70,7 +104,7 @@ export function StatCard({
|
||||
{sublabel && <span className="min-w-0 max-w-full truncate font-mono text-[9px] text-text-dim">{sublabel}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-xl font-bold tabular-nums text-text-bright sm:text-2xl">{value}</div>
|
||||
{spark ? <Sparkline values={spark} color={accent} /> : <div className="mt-1.5 h-[20px]" />}
|
||||
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} /> : sparkSpacer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function pathHours(data: PathStats | undefined) {
|
||||
const rows = new Map(data.hourly.map((row) => [row.hour, row]));
|
||||
return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => {
|
||||
const hour = first + i * HOUR, row = rows.get(hour);
|
||||
return { hour, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null };
|
||||
return { hour, receptions: row?.receptions ?? null, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function trafficModel(points: ObservationPoint[], range: StatsRange, asOf
|
||||
const hours = Array.from({ length }, (_, i) => {
|
||||
const hour = start + i * HOUR;
|
||||
const bucket = buckets.get(hour);
|
||||
return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null };
|
||||
return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null, areas: bucket ? bucket.size : null };
|
||||
});
|
||||
const series = names.map((name) => ({
|
||||
name, total: totals.get(name) ?? 0,
|
||||
|
||||
Reference in new issue
Block a user