From 25bd3ce30ec0fc5c81ecfd8020835b56907779b3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 18:56:58 -0400 Subject: [PATCH] Add a neighbour graph under the renamed Analytics tab --- src/App.tsx | 9 +- src/components/BottomNav.tsx | 2 +- src/features/map/neighbor-thresholds.ts | 10 + src/features/map/useMapNeighbors.ts | 5 +- src/features/stats/EChart.tsx | 10 +- src/features/stats/MeshTab.tsx | 12 +- src/features/stats/NeighbourGraph.tsx | 84 ++++++++ src/features/stats/NeighbourGraphTab.tsx | 62 ++++++ src/features/stats/StatsOverview.tsx | 9 +- src/features/stats/StatsSubHeader.tsx | 31 ++- src/features/stats/chartTheme.ts | 14 +- src/features/stats/echarts-setup.ts | 3 +- src/features/stats/neighbour-graph.ts | 205 ++++++++++++++++++ src/features/stats/types.ts | 2 +- src/lib/constants.ts | 2 +- tests/components/BottomNav.test.tsx | 2 +- tests/features/stats/neighbour-graph.test.ts | 212 +++++++++++++++++++ 17 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 src/features/map/neighbor-thresholds.ts create mode 100644 src/features/stats/NeighbourGraph.tsx create mode 100644 src/features/stats/NeighbourGraphTab.tsx create mode 100644 src/features/stats/neighbour-graph.ts create mode 100644 tests/features/stats/neighbour-graph.test.ts diff --git a/src/App.tsx b/src/App.tsx index afb2274..b07947f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: , Channels: , - Stats: , + Analytics: , Map: , }; diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 5f8898f..477c08a 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -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 }) { diff --git a/src/features/map/neighbor-thresholds.ts b/src/features/map/neighbor-thresholds.ts new file mode 100644 index 0000000..c19557c --- /dev/null +++ b/src/features/map/neighbor-thresholds.ts @@ -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; diff --git a/src/features/map/useMapNeighbors.ts b/src/features/map/useMapNeighbors.ts index a2163ce..d6db109 100644 --- a/src/features/map/useMapNeighbors.ts +++ b/src/features/map/useMapNeighbors.ts @@ -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; @@ -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; } diff --git a/src/features/stats/EChart.tsx b/src/features/stats/EChart.tsx index 71b9f27..2c2abcc 100644 --- a/src/features/stats/EChart.tsx +++ b/src/features/stats/EChart.tsx @@ -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 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(null); const chartRef = useRef(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 () => { diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index f2ec64f..47a5585 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -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; diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx new file mode 100644 index 0000000..09ab8ec --- /dev/null +++ b/src/features/stats/NeighbourGraph.tsx @@ -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 | 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(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 ; +} diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx new file mode 100644 index 0000000..1726486 --- /dev/null +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -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(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 ; + // build only once the pager settles, or the force layout would restart on every streamed page + if (isPaging) return ; + if (graph.nodes.length === 0) return ; + + return ( +
+ {graph.capped && ( +
+ Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. +
+ )} +
+ +
+
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index bd78f3f..9397ada 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -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) {
- {tab === "mesh" ? ( - - ) : ( + {tab === "mesh" && } + {tab === "observer" && ( )} + {tab === "graph" && }
); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 0d2840b..e3dd448 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -21,9 +21,23 @@ function ObserverIcon() { ); } +function GraphIcon() { + return ( + + + + + + + + + ); +} + const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "observer", label: "Observer", icon: }, + { value: "graph", label: "Neighbour Graph", icon: }, ]; const RANGE_OPTIONS = [ @@ -49,12 +63,17 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props ariaLabel="Stats section" size="md" /> - onRangeChange(v as StatsRange)} - ariaLabel="Time range" - /> + {/* the graph is topology, not time-series — no range to pick */} + {tab === "graph" ? ( + + ) : ( + onRangeChange(v as StatsRange)} + ariaLabel="Time range" + /> + )} ); } diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index 7e6a2f5..745c9e5 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -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 { diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index 319b6f5..b41cf57 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -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, diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts new file mode 100644 index 0000000..4179905 --- /dev/null +++ b/src/features/stats/neighbour-graph.ts @@ -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(); + 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(); + 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 { + const folded = new Map(); + 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 = {}; + 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 }; + 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, + }, + ], + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index d09650c..a517233 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -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 = { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 3d2fa49..7c8f15d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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; diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index fe2c1bd..3ae3aed 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -24,7 +24,7 @@ describe("BottomNav", () => { }); it("highlights More when an overflow tab is active", () => { - render( {}} />); + render( {}} />); const more = screen.getByText("More").closest("button")!; expect(more.className).toContain("text-primary"); }); diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts new file mode 100644 index 0000000..46d6682 --- /dev/null +++ b/tests/features/stats/neighbour-graph.test.ts @@ -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 { + 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 { + 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); + }); +});