mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-09 15:05:34 +00:00
Add a neighbour graph under the renamed Analytics tab
This commit is contained in:
+5
-4
@@ -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} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user