Add a neighbour graph under the renamed Analytics tab

This commit is contained in:
MrAlders0n
2026-07-11 18:56:58 -04:00
parent 92806faaa4
commit 25bd3ce30e
17 changed files with 640 additions and 34 deletions
+5 -4
View File
@@ -120,7 +120,8 @@ function AppInner() {
const isMobile = useIsMobile();
// The URL is the single source of truth for the active tab — back/forward just work, and an
// unknown ?tab value falls back to Packets instead of rendering a blank pane.
const tabParam = searchParams.get("tab");
// "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working.
const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab");
const activeTab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as string) : "Packets";
// Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection).
const [initialSelection] = useState(() => computeInitialSelection(searchParams));
@@ -171,7 +172,7 @@ function AppInner() {
const next = new URLSearchParams(prev);
next.set("tab", tab);
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Stats") {
if (tab !== "Analytics") {
next.delete("statsTab");
next.delete("observerId");
next.delete("range");
@@ -194,7 +195,7 @@ function AppInner() {
setOverlayPacketHash(null);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", "Stats");
next.set("tab", "Analytics");
next.set("statsTab", "observer");
next.set("observerId", id);
return next;
@@ -220,7 +221,7 @@ function AppInner() {
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
Traces: <TraceList onAnalyze={setOverlayPacketHash} onViewNode={setOverlayNodeId} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Stats: <StatsOverview wsManager={wsManager} />,
Analytics: <StatsOverview wsManager={wsManager} />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { BottomSheet } from "./BottomSheet";
// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Stats"] as const;
const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const;
// inline SVGs, 20px / 1.6 stroke to match the rest of the icons
function Icon({ name }: { name: string }) {
+10
View File
@@ -0,0 +1,10 @@
// Shared thresholds for styling neighbour links by observation count and freshness. Both the map's
// line layer (useMapNeighbors) and the Analytics neighbour graph read these, so the two views can't
// drift apart.
// Observation counts are heavily right-skewed, so colour on a log10 axis: ~1 obs red, ~20 yellow,
// ~150+ green. Stops are log10(count) values.
export const OBS_STOPS = { danger: 0, warn: 1.3, green: 2.18 } as const;
// A link's opacity fades with age — solid when fresh, faint by ~4 weeks (matches the 30-day retention).
export const AGE = { freshDays: 0, freshOp: 0.9, staleDays: 28, staleOp: 0.35 } as const;
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import type { Map as MapLibreMap, GeoJSONSource, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl";
import type { FeatureCollection, LineString } from "geojson";
import type { NeighborEdgeProps } from "./node-geojson";
import { OBS_STOPS, AGE } from "./neighbor-thresholds";
import { NEIGHBORS_SOURCE_ID, NEIGHBORS_LINE_LAYER_ID, NODES_CLUSTER_LAYER_ID } from "./types";
type EdgeFC = FeatureCollection<LineString, NeighborEdgeProps>;
@@ -14,7 +15,7 @@ function paletteVar(name: string, fallback: string): string {
// faint by ~4 weeks (matches the 30-day retention). Ambient "on" edges keep the flat selected/dim split.
const NEIGHBOR_OPACITY = [
"case", ["has", "obs"],
["interpolate", ["linear"], ["get", "ageDays"], 0, 0.9, 28, 0.35],
["interpolate", ["linear"], ["get", "ageDays"], AGE.freshDays, AGE.freshOp, AGE.staleDays, AGE.staleOp],
["case", ["get", "selected"], 0.9, 0.3],
] as ExpressionSpecification;
@@ -23,7 +24,7 @@ const NEIGHBOR_OPACITY = [
function neighborLineColor(danger: string, warn: string, green: string, primary: string): ExpressionSpecification {
return [
"case", ["has", "obs"],
["interpolate", ["linear"], ["log10", ["max", 1, ["get", "obs"]]], 0, danger, 1.3, warn, 2.18, green],
["interpolate", ["linear"], ["log10", ["max", 1, ["get", "obs"]]], OBS_STOPS.danger, danger, OBS_STOPS.warn, warn, OBS_STOPS.green, green],
primary,
] as ExpressionSpecification;
}
+9 -1
View File
@@ -7,19 +7,27 @@ interface EChartProps {
style?: React.CSSProperties;
// Map of ECharts event name -> handler (e.g. { click: (p) => ... }). Kept stable by the caller.
onEvents?: Record<string, (params: unknown) => void>;
// Called once with the instance after init, for callers that need imperative control (e.g. the
// neighbour graph dispatches highlight/downplay so selection never re-runs the force layout).
onInit?: (chart: EChartsInstance) => void;
}
// Thin React wrapper over the core ECharts API: init once, resize via ResizeObserver, dispose on
// unmount, and re-apply the (memoized) option with notMerge so theme/data swaps fully replace state.
// Hand-rolled on purpose — we avoid the echarts-for-react dependency.
export function EChart({ option, className, style, onEvents }: EChartProps) {
export function EChart({ option, className, style, onEvents, onInit }: EChartProps) {
const elRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsInstance | null>(null);
const onInitRef = useRef(onInit);
useEffect(() => {
onInitRef.current = onInit;
}, [onInit]);
useEffect(() => {
if (!elRef.current) return;
const chart = echarts.init(elRef.current, null, { renderer: "canvas" });
chartRef.current = chart;
onInitRef.current?.(chart);
const ro = new ResizeObserver(() => chart.resize());
ro.observe(elRef.current);
return () => {
+1 -11
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useChartColors, type ChartColors } from "./chartTheme";
import { useChartColors, nodeTypeColor } from "./chartTheme";
import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats";
import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions";
import { Card, ChartCard, StatCard } from "./cards";
@@ -23,16 +23,6 @@ function aggregateByHour(points: ObservationPoint[]) {
return [...byHour.values()].sort((a, b) => a.hour - b.hour);
}
function nodeTypeColor(typeName: string, c: ChartColors): string {
switch (typeName) {
case "companion": return c.primary;
case "repeater": return c.green;
case "room_server": return c.secondary;
case "sensor": return c.warn;
default: return c.primaryDim;
}
}
interface MeshTabProps {
range: StatsRange;
onSelectObserver: (observerId: string) => void;
+84
View File
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { EChart } from "./EChart";
import type { EChartsInstance } from "./echarts-setup";
import {
neighbourGraphOption,
obsColor,
ageOpacity,
type NeighbourGraph as NeighbourGraphData,
type NeighbourWeight,
} from "./neighbour-graph";
import type { ChartColors } from "./chartTheme";
interface Props {
graph: NeighbourGraphData;
colors: ChartColors;
selectedId: string | null;
focusWeights: Record<string, NeighbourWeight> | null;
onSelect: (id: string | null) => void;
}
// Presentational force graph. The structural option is memoized on [graph, colors] only, so it (and
// the force layout) rebuilds only for a genuinely new mesh or theme. Selection styling is applied
// imperatively — a link-only merge plus dispatchAction — so it never disturbs settled node positions.
export function NeighbourGraph({ graph, colors, selectedId, focusWeights, onSelect }: Props) {
const chartRef = useRef<EChartsInstance | null>(null);
const option = useMemo(() => neighbourGraphOption(graph, colors), [graph, colors]);
const onInit = useCallback(
(chart: EChartsInstance) => {
chartRef.current = chart;
// clicking empty canvas clears the selection
chart.getZr().on("click", (e: { target?: unknown }) => {
if (!e.target) onSelect(null);
});
},
[onSelect],
);
const onEvents = useMemo(
() => ({
click: (p: unknown) => {
const param = p as { dataType?: string; data?: { id?: string } };
if (param.dataType === "edge") return;
const id = param.data?.id;
if (id) onSelect(id);
},
}),
[onSelect],
);
// Selection styling in one pass: recolour the selected node's edges by obs/freshness (link-only
// merge, so node positions survive), then spotlight its adjacency and dim the rest. `option` is a
// dep so both re-apply after a theme/mesh rebuild replaces the chart state.
useEffect(() => {
const chart = chartRef.current;
if (!chart || chart.isDisposed()) return; // a prior instance may linger across a dev remount
const links =
selectedId && focusWeights
? graph.links.map((l) => {
const a = graph.nodes[l.source]!.id;
const b = graph.nodes[l.target]!.id;
const otherId = a === selectedId ? b : b === selectedId ? a : null;
const w = otherId ? focusWeights[otherId] : undefined;
if (!w) return l;
return {
...l,
obs: w.obs,
ageDays: w.ageDays,
lineStyle: { color: obsColor(w.obs, colors), opacity: ageOpacity(w.ageDays), width: 1.8 },
};
})
: graph.links;
chart.setOption({ series: [{ links }] }, { notMerge: false, lazyUpdate: true });
chart.dispatchAction({ type: "downplay", seriesIndex: 0 });
if (selectedId) {
const idx = graph.nodes.findIndex((n) => n.id === selectedId);
if (idx >= 0) chart.dispatchAction({ type: "highlight", seriesIndex: 0, dataIndex: idx });
}
}, [selectedId, focusWeights, graph, option, colors]);
return <EChart option={option} onEvents={onEvents} onInit={onInit} className="h-full w-full" />;
}
+62
View File
@@ -0,0 +1,62 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRegion } from "../../hooks/useRegion";
import { useMapNodesData } from "../map/useMapNodesData";
import { getNodeNeighbors } from "../../api/client";
import { useChartColors } from "./chartTheme";
import { buildNeighbourGraph, foldNeighbourWeights } from "./neighbour-graph";
import { NeighbourGraph } from "./NeighbourGraph";
import { EmptyState } from "../../components/EmptyState";
// Most-connected nodes rendered; past this the canvas force layout bogs down. Reuses the map's node
// query (same cache), so the whole region still loads — this only caps what the graph draws.
const CAP = 1000;
export function NeighbourGraphTab() {
const { iatas, regionKey } = useRegion();
const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey);
const colors = useChartColors();
const [selectedId, setSelectedId] = useState<string | null>(null);
// a different region is a different mesh — drop any stale selection (adjust-during-render, no effect)
const [region, setRegion] = useState(regionKey);
if (region !== regionKey) {
setRegion(regionKey);
setSelectedId(null);
}
const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]);
// Weighted edges for the selected node come from the detail endpoint (shared cache with the map +
// node panel), coloured by obs count and faded by freshness like the map's spotlight.
const { data: neighbours, dataUpdatedAt } = useQuery({
queryKey: ["node-neighbors", selectedId],
queryFn: () => getNodeNeighbors(selectedId!),
enabled: !!selectedId,
staleTime: 30_000,
});
// dataUpdatedAt (the fetch time) stands in for "now" — freshness relative to when we pulled the
// data, and pure at render time unlike Date.now().
const focusWeights = useMemo(
() => (selectedId && neighbours ? foldNeighbourWeights(neighbours, selectedId, dataUpdatedAt) : null),
[selectedId, neighbours, dataUpdatedAt],
);
if (isError) return <EmptyState title="Neighbour Graph" subtitle="Failed to load nodes" />;
// build only once the pager settles, or the force layout would restart on every streamed page
if (isPaging) return <EmptyState title="Loading mesh…" subtitle={`${loadedCount} nodes`} />;
if (graph.nodes.length === 0) return <EmptyState title="Neighbour Graph" subtitle="No nodes in this region" />;
return (
<div className="flex h-full min-h-0 flex-col">
{graph.capped && (
<div className="shrink-0 border-b border-border bg-bg-surface px-4 py-2 text-center text-xs font-mono text-text-muted">
Showing the {CAP} most-connected of {graph.total} nodes narrow to an IATA to see the rest.
</div>
)}
<div className="min-h-0 flex-1">
<NeighbourGraph graph={graph} colors={colors} selectedId={selectedId} focusWeights={focusWeights} onSelect={setSelectedId} />
</div>
</div>
);
}
+5 -4
View File
@@ -4,9 +4,10 @@ import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
import { ObserverTab } from "./ObserverTab";
import { NeighbourGraphTab } from "./NeighbourGraphTab";
import type { StatsRange, StatsTab } from "./types";
const TABS: StatsTab[] = ["mesh", "observer"];
const TABS: StatsTab[] = ["mesh", "observer", "graph"];
const RANGES: StatsRange[] = ["24h", "7d", "30d"];
const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh");
@@ -50,11 +51,11 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
<div className="flex min-h-0 flex-1 flex-col">
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} />
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" ? (
<MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
) : (
{tab === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />}
{tab === "observer" && (
<ObserverTab range={range} selectedObserverId={observerId} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
)}
{tab === "graph" && <NeighbourGraphTab />}
</div>
</div>
);
+25 -6
View File
@@ -21,9 +21,23 @@ function ObserverIcon() {
);
}
function GraphIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<circle cx="7" cy="7" r="1.7" />
<circle cx="2.4" cy="3" r="1.3" />
<circle cx="11.6" cy="3.4" r="1.3" />
<circle cx="4" cy="12" r="1.3" />
<circle cx="11" cy="11" r="1.3" />
<path d="M3.3 3.7 5.6 6M10.4 4 8.4 6M5.2 8.2 4.3 10.7M8.5 8.1 10.2 9.9" strokeOpacity="0.7" />
</svg>
);
}
const TAB_OPTIONS = [
{ value: "mesh", label: "Mesh", icon: <MeshIcon /> },
{ value: "observer", label: "Observer", icon: <ObserverIcon /> },
{ value: "graph", label: "Neighbour Graph", icon: <GraphIcon /> },
];
const RANGE_OPTIONS = [
@@ -49,12 +63,17 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
ariaLabel="Stats section"
size="md"
/>
<Segmented
options={RANGE_OPTIONS}
value={range}
onChange={(v) => onRangeChange(v as StatsRange)}
ariaLabel="Time range"
/>
{/* the graph is topology, not time-series — no range to pick */}
{tab === "graph" ? (
<span />
) : (
<Segmented
options={RANGE_OPTIONS}
value={range}
onChange={(v) => onRangeChange(v as StatsRange)}
ariaLabel="Time range"
/>
)}
</div>
);
}
+13 -1
View File
@@ -53,7 +53,7 @@ export function withAlpha(color: string, a: number): string {
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function blend(a: string, b: string, t = 0.5): string {
export function blend(a: string, b: string, t = 0.5): string {
const [r1, g1, b1] = parseColor(a);
const [r2, g2, b2] = parseColor(b);
const mix = (x: number, y: number) => Math.round(x + (y - x) * t);
@@ -100,6 +100,18 @@ export function useChartColors(): ChartColors {
return useMemo(() => readChartColors(), [paletteRev]);
}
// Per-device-type colour, shared by the Mesh "Node types" donut and the neighbour graph so the two
// views stay in sync. Unknown types fall back to a dim primary.
export function nodeTypeColor(typeName: string, c: ChartColors): string {
switch (typeName) {
case "companion": return c.primary;
case "repeater": return c.green;
case "room_server": return c.secondary;
case "sensor": return c.warn;
default: return c.primaryDim;
}
}
// A reusable ECharts tooltip style block bound to the active palette.
export function tooltipStyle(c: ChartColors) {
return {
+2 -1
View File
@@ -3,7 +3,7 @@
// the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps
// the core API directly instead.
import * as echarts from "echarts/core";
import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts";
import { LineChart, BarChart, PieChart, GaugeChart, GraphChart } from "echarts/charts";
import {
GridComponent,
TitleComponent,
@@ -20,6 +20,7 @@ echarts.use([
BarChart,
PieChart,
GaugeChart,
GraphChart,
GridComponent,
TitleComponent,
TooltipComponent,
+205
View File
@@ -0,0 +1,205 @@
import type { NodeSummary, NodeNeighbor } from "../nodes/types";
import { NODE_TYPE_NAMES, NODE_TYPES } from "../../lib/node-types";
import { blend, nodeTypeColor, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme";
import { OBS_STOPS, AGE } from "../map/neighbor-thresholds";
import type { EChartsOption } from "./echarts-setup";
const MONO = "JetBrains Mono, monospace";
// Pure, render-free transform from the region's nodes into an ECharts force-graph shape. Kept
// maplibre- and echarts-free so it stays unit-testable (mirrors features/map/node-geojson.ts).
export interface GraphNode {
id: string;
name: string;
category: number; // index into the node-type categories, or OTHER_CATEGORY for unknown types
nodeTypeName: string;
degree: number;
symbolSize: number;
label?: { show: boolean };
}
export interface GraphLink {
source: number; // index into GraphNode[]
target: number;
}
export interface NeighbourGraph {
nodes: GraphNode[];
links: GraphLink[];
total: number; // nodes before the cap, so callers can show "showing N of total"
capped: boolean;
}
const OTHER_CATEGORY = NODE_TYPE_NAMES.length;
const MIN_SIZE = 6;
const MAX_SIZE = 34;
const HUB_LABELS = 20; // only the biggest hubs get a persistent label, else 1000 nodes are a text wall
// Keep the top-`cap` most-connected nodes and their internal edges. Unlike the map's edge builder we
// do NOT require coordinates — the graph is non-geographic, so unlocated nodes belong here too.
export function buildNeighbourGraph(nodes: NodeSummary[], cap: number): NeighbourGraph {
const total = nodes.length;
// rank by neighbour count, id tie-break so the kept set + indices are stable across re-renders
const ranked = [...nodes].sort(
(a, b) => b.knownNeighborCount - a.knownNeighborCount || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
const kept = ranked.slice(0, cap);
const capped = total > kept.length;
const indexById = new Map<string, number>();
kept.forEach((n, i) => indexById.set(n.id, i));
const maxDegree = kept.reduce((m, n) => Math.max(m, n.knownNeighborCount), 0);
const graphNodes: GraphNode[] = kept.map((n, i) => {
const cat = (NODE_TYPE_NAMES as readonly string[]).indexOf(n.nodeTypeName);
return {
id: n.id,
name: n.name ?? n.id.slice(0, 6),
category: cat === -1 ? OTHER_CATEGORY : cat,
nodeTypeName: n.nodeTypeName,
degree: n.knownNeighborCount,
symbolSize: symbolSize(n.knownNeighborCount, maxDegree),
label: i < HUB_LABELS && n.knownNeighborCount > 0 ? { show: true } : undefined,
};
});
const seen = new Set<string>();
const links: GraphLink[] = [];
for (const n of kept) {
if (!n.neighborIds) continue;
const from = indexById.get(n.id)!;
for (const otherId of n.neighborIds) {
if (otherId === n.id) continue; // no self-loops
const to = indexById.get(otherId);
if (to === undefined) continue; // skip edges to capped-out / foreign nodes
const key = n.id < otherId ? `${n.id}|${otherId}` : `${otherId}|${n.id}`;
if (seen.has(key)) continue; // undirected — one line per pair
seen.add(key);
links.push({ source: from, target: to });
}
}
return { nodes: graphNodes, links, total, capped };
}
// sqrt so a few high-degree hubs don't dwarf everything else; floor keeps degree-0 nodes clickable.
function symbolSize(degree: number, maxDegree: number): number {
if (maxDegree <= 0) return MIN_SIZE;
const t = Math.sqrt(degree) / Math.sqrt(maxDegree);
return MIN_SIZE + (MAX_SIZE - MIN_SIZE) * t;
}
// Observation count → colour, log10 axis red→yellow→green (ports the map's line-colour expression).
export function obsColor(obs: number, c: { danger: string; warn: string; green: string }): string {
const x = Math.log10(Math.max(1, obs));
if (x <= OBS_STOPS.warn) return blend(c.danger, c.warn, x / OBS_STOPS.warn);
const t = Math.min(1, (x - OBS_STOPS.warn) / (OBS_STOPS.green - OBS_STOPS.warn));
return blend(c.warn, c.green, t);
}
// Link age (days) → opacity, solid when fresh, faint by ~4 weeks (ports the map's opacity expression).
export function ageOpacity(ageDays: number): number {
const t = Math.max(0, Math.min(1, ageDays / AGE.staleDays));
return AGE.freshOp + (AGE.staleOp - AGE.freshOp) * t;
}
export interface NeighbourWeight {
obs: number;
ageDays: number;
}
// Fold the /nodes/{id}/neighbors rows (one per neighbour+iata) into a per-neighbour weight: obs summed
// across iatas, age from the freshest lastSeen. Same reduction the map uses in buildFocusedNeighborEdges.
export function foldNeighbourWeights(
neighbors: NodeNeighbor[],
selfId: string,
now: number,
): Record<string, NeighbourWeight> {
const folded = new Map<string, { obs: number; lastSeen: number }>();
for (const nb of neighbors) {
if (nb.id === selfId) continue;
const prev = folded.get(nb.id);
if (prev) {
prev.obs += nb.observationCount;
prev.lastSeen = Math.max(prev.lastSeen, nb.lastSeen);
} else {
folded.set(nb.id, { obs: nb.observationCount, lastSeen: nb.lastSeen });
}
}
const out: Record<string, NeighbourWeight> = {};
for (const [id, w] of folded) {
out[id] = { obs: w.obs, ageDays: Math.max(0, (now - w.lastSeen) / 86_400_000) };
}
return out;
}
// One legend/category per device type (in NODE_TYPES order) plus an "Other" bucket for unknowns; the
// GraphNode.category index lines up with this list.
function graphCategories(c: ChartColors) {
return [
...NODE_TYPES.map((t) => ({ name: t.label, itemStyle: { color: nodeTypeColor(t.name, c) } })),
{ name: "Other", itemStyle: { color: c.primaryDim } },
];
}
// The themed ECharts force-graph option. Selection styling is applied imperatively (dispatchAction +
// link-only merge) so it never rebuilds this option — see NeighbourGraph.tsx.
export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): EChartsOption {
const big = graph.nodes.length > 500; // settle without animating once the graph gets dense
return {
animation: false,
backgroundColor: "transparent",
tooltip: {
...tooltipStyle(c),
trigger: "item",
formatter: (p: unknown) => {
const param = p as { dataType?: string; data: Record<string, unknown> };
if (param.dataType === "edge") {
const obs = param.data.obs as number | undefined;
if (obs == null) return ""; // ambient (non-selected) edge — nothing to show
const days = Math.round((param.data.ageDays as number) ?? 0);
return `${obs} obs · ${days === 0 ? "seen today" : `seen ${days}d ago`}`;
}
const d = param.data as unknown as GraphNode;
return `${d.name}\n${d.nodeTypeName} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}`;
},
},
legend: [
{
data: graphCategories(c).map((cat) => cat.name),
bottom: 4,
left: "center",
icon: "circle",
itemWidth: 9,
itemHeight: 9,
textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 },
inactiveColor: c.textDim,
},
],
series: [
{
type: "graph",
layout: "force",
roam: true,
draggable: true,
scaleLimit: { min: 0.2, max: 8 },
categories: graphCategories(c),
force: {
repulsion: big ? 60 : 120,
edgeLength: big ? [20, 60] : [40, 90],
gravity: 0.08,
friction: 0.2,
layoutAnimation: !big,
},
emphasis: { focus: "adjacency", scale: false, label: { show: true }, lineStyle: { width: 1.6 } },
label: { show: false, position: "right", color: c.textNormal, fontFamily: MONO, fontSize: 9 },
labelLayout: { hideOverlap: true },
lineStyle: { color: withAlpha(c.textMuted, 0.22), width: 0.6 },
itemStyle: { borderColor: c.bgBase, borderWidth: 0.5 },
data: graph.nodes,
links: graph.links,
},
],
};
}
+1 -1
View File
@@ -78,7 +78,7 @@ export interface ObserverTelemetry {
}
// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "observer";
export type StatsTab = "mesh" | "observer" | "graph";
export type StatsRange = "24h" | "7d" | "30d";
export const RANGE_MS: Record<StatsRange, number> = {
+1 -1
View File
@@ -14,4 +14,4 @@ export const WS_RECONNECT_MAX_MS = 30_000;
export const WS_RECONNECT_JITTER = 0.25;
// app tab names, in display order; the ?tab URL param is validated against this list
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Stats"] as const;
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const;
+1 -1
View File
@@ -24,7 +24,7 @@ describe("BottomNav", () => {
});
it("highlights More when an overflow tab is active", () => {
render(<BottomNav activeTab="Stats" onTabChange={() => {}} />);
render(<BottomNav activeTab="Analytics" onTabChange={() => {}} />);
const more = screen.getByText("More").closest("button")!;
expect(more.className).toContain("text-primary");
});
@@ -0,0 +1,212 @@
import { describe, it, expect } from "vitest";
import { buildNeighbourGraph, obsColor, ageOpacity, foldNeighbourWeights } from "../../../src/features/stats/neighbour-graph";
import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types";
function neighbor(overrides: Partial<NodeNeighbor>): NodeNeighbor {
return {
id: "nb",
publicKey: "pk",
nodeType: 1,
nodeTypeName: "repeater",
iata: "YYZ",
observationCount: 1,
firstSeen: 0,
lastSeen: 0,
...overrides,
};
}
const DAY = 86_400_000;
function node(overrides: Partial<NodeSummary>): NodeSummary {
return {
id: "n1",
publicKey: "pk",
nodeType: 1,
nodeTypeName: "repeater",
name: "Node 1",
lat: 45,
lng: -75,
iatas: [],
knownNeighborCount: 0,
...overrides,
};
}
// obsColor takes explicit palette colours so the test is theme-independent.
const C = { danger: "#ff0000", warn: "#ffff00", green: "#00ff00" };
describe("buildNeighbourGraph", () => {
it("returns an empty graph for no nodes", () => {
expect(buildNeighbourGraph([], 1000)).toEqual({ nodes: [], links: [], total: 0, capped: false });
});
it("includes unlocated nodes (unlike the map's coordinate-gated edges)", () => {
const g = buildNeighbourGraph([node({ id: "a", lat: null, lng: null })], 1000);
expect(g.nodes).toHaveLength(1);
expect(g.nodes[0]!.id).toBe("a");
});
it("ranks by neighbour count and keeps only the top `cap`", () => {
const g = buildNeighbourGraph(
[
node({ id: "low", knownNeighborCount: 1 }),
node({ id: "high", knownNeighborCount: 5 }),
node({ id: "mid", knownNeighborCount: 3 }),
],
2,
);
expect(g.total).toBe(3);
expect(g.capped).toBe(true);
expect(g.nodes.map((n) => n.id)).toEqual(["high", "mid"]);
});
it("is not capped when total <= cap", () => {
const g = buildNeighbourGraph([node({ id: "a" }), node({ id: "b" })], 5);
expect(g.capped).toBe(false);
expect(g.total).toBe(2);
});
it("breaks equal-degree ties by id so indices are deterministic", () => {
const g = buildNeighbourGraph(
[node({ id: "b", knownNeighborCount: 2 }), node({ id: "a", knownNeighborCount: 2 })],
5,
);
expect(g.nodes.map((n) => n.id)).toEqual(["a", "b"]);
});
it("emits each undirected pair once even when both nodes list each other", () => {
const g = buildNeighbourGraph(
[node({ id: "a", neighborIds: ["b"] }), node({ id: "b", neighborIds: ["a"] })],
5,
);
expect(g.links).toHaveLength(1);
});
it("drops self-loops", () => {
const g = buildNeighbourGraph([node({ id: "a", neighborIds: ["a"] })], 5);
expect(g.links).toEqual([]);
});
it("drops edges to ids outside the kept set (foreign or capped-out)", () => {
const g = buildNeighbourGraph(
[
node({ id: "keep", knownNeighborCount: 9, neighborIds: ["gone", "foreign"] }),
node({ id: "gone", knownNeighborCount: 0 }),
],
1, // only "keep" survives the cap
);
expect(g.links).toEqual([]);
});
it("resolves link source/target to indices of the correct kept nodes", () => {
const g = buildNeighbourGraph(
[node({ id: "a", neighborIds: ["b"] }), node({ id: "b" })],
5,
);
expect(g.links).toHaveLength(1);
const { source, target } = g.links[0]!;
const ids = [g.nodes[source]!.id, g.nodes[target]!.id].sort();
expect(ids).toEqual(["a", "b"]);
});
it("maps node type to a category index, bucketing unknowns to 'Other'", () => {
const g = buildNeighbourGraph(
[
node({ id: "c", nodeTypeName: "companion" }),
node({ id: "r", nodeTypeName: "repeater" }),
node({ id: "rs", nodeTypeName: "room_server" }),
node({ id: "s", nodeTypeName: "sensor" }),
node({ id: "x", nodeTypeName: "mystery" }),
],
10,
);
const cat = Object.fromEntries(g.nodes.map((n) => [n.id, n.category]));
expect(cat).toEqual({ c: 0, r: 1, rs: 2, s: 3, x: 4 });
});
it("sizes nodes monotonically by degree, with a floor for degree 0", () => {
const g = buildNeighbourGraph(
[
node({ id: "hub", knownNeighborCount: 40 }),
node({ id: "mid", knownNeighborCount: 8 }),
node({ id: "leaf", knownNeighborCount: 0 }),
],
10,
);
const size = Object.fromEntries(g.nodes.map((n) => [n.id, n.symbolSize]));
expect(size.hub).toBeGreaterThan(size.mid!);
expect(size.mid).toBeGreaterThan(size.leaf!);
expect(size.leaf).toBeGreaterThan(0);
});
it("keeps a node with no neighborIds but contributes no links", () => {
const g = buildNeighbourGraph([node({ id: "a", neighborIds: undefined })], 5);
expect(g.nodes).toHaveLength(1);
expect(g.links).toEqual([]);
});
});
describe("obsColor", () => {
it("is red (danger) at one observation and below", () => {
expect(obsColor(1, C)).toBe("rgb(255, 0, 0)");
expect(obsColor(0, C)).toBe("rgb(255, 0, 0)");
});
it("saturates to green for high observation counts", () => {
expect(obsColor(1000, C)).toBe("rgb(0, 255, 0)");
});
it("interpolates strictly between the endpoints for mid counts", () => {
const mid = obsColor(5, C);
expect(mid).not.toBe("rgb(255, 0, 0)");
expect(mid).not.toBe("rgb(0, 255, 0)");
});
});
describe("ageOpacity", () => {
it("is nearly solid for fresh links", () => {
expect(ageOpacity(0)).toBeCloseTo(0.9);
expect(ageOpacity(-5)).toBeCloseTo(0.9); // clamped
});
it("fades to the floor by ~4 weeks and stays there", () => {
expect(ageOpacity(28)).toBeCloseTo(0.35);
expect(ageOpacity(56)).toBeCloseTo(0.35); // clamped
});
it("interpolates linearly in between", () => {
expect(ageOpacity(14)).toBeCloseTo(0.625);
});
});
describe("foldNeighbourWeights", () => {
const NOW = 10 * DAY;
it("returns an empty map for no neighbours", () => {
expect(foldNeighbourWeights([], "self", NOW)).toEqual({});
});
it("sums observations and takes the freshest lastSeen across per-iata rows", () => {
const w = foldNeighbourWeights(
[
neighbor({ id: "a", iata: "YYZ", observationCount: 3, lastSeen: 8 * DAY }),
neighbor({ id: "a", iata: "YUL", observationCount: 5, lastSeen: 9 * DAY }),
],
"self",
NOW,
);
expect(w.a!.obs).toBe(8);
expect(w.a!.ageDays).toBeCloseTo(1); // NOW - 9d (the freshest)
});
it("excludes the selected node's own rows", () => {
const w = foldNeighbourWeights([neighbor({ id: "self", observationCount: 4 })], "self", NOW);
expect(w).toEqual({});
});
it("never reports a negative age for a future lastSeen", () => {
const w = foldNeighbourWeights([neighbor({ id: "a", lastSeen: NOW + 5 * DAY })], "self", NOW);
expect(w.a!.ageDays).toBe(0);
});
});