Add hourly sparklines to analytics stat cards (#134)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-02 13:37:26 -04:00
1 parent 30a8f94dbd
commit f6caab663c
14 files changed
+130 -31

No files matched your search

+10 -6
View File
@@ -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
+12 -3
View File
@@ -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">
+10 -4
View File
@@ -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} />
+9 -4
View File
@@ -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} />
+42 -8
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 };
});
}
+1 -1
View File
@@ -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,