diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx index 09ab8ec..3f703d0 100644 --- a/src/features/stats/NeighbourGraph.tsx +++ b/src/features/stats/NeighbourGraph.tsx @@ -1,34 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useMemo } 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"; +import type { EChartsInstance, EChartsOption } from "./echarts-setup"; interface Props { - graph: NeighbourGraphData; - colors: ChartColors; - selectedId: string | null; - focusWeights: Record | null; + option: EChartsOption; 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]); - +// Presentational force graph. The caller swaps the whole option between the full mesh and a node's ego +// view, so this stays dumb: render the option, report node clicks, and treat a bare-canvas click as +// "back to the full mesh". No emphasis/dispatch, so dragging a node never flickers. +export function NeighbourGraph({ option, onSelect }: Props) { 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); }); @@ -48,37 +32,5 @@ export function NeighbourGraph({ graph, colors, selectedId, focusWeights, onSele [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 index 1726486..77d62ea 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -4,12 +4,12 @@ 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 { buildNeighbourGraph, buildEgoGraph, neighbourGraphOption } 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. +// query (same cache), so the whole region still loads — this only caps what the full mesh draws. const CAP = 1000; export function NeighbourGraphTab() { @@ -26,21 +26,27 @@ export function NeighbourGraphTab() { } const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]); + const selectedNode = useMemo( + () => (selectedId ? nodes.find((n) => n.id === selectedId) ?? null : null), + [selectedId, 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. + // Selected node's neighbours (shared cache with the map + node panel); dataUpdatedAt stands in for + // "now" so the freshness fade is pure at render time. 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], - ); + const ego = useMemo(() => { + if (!selectedId || !neighbours) return null; + // fall back to a bare centre if the node was heard from another region (not in the loaded set) + const center = selectedNode ?? { id: selectedId, name: null, nodeTypeName: "" }; + return buildEgoGraph(center, neighbours, dataUpdatedAt); + }, [selectedId, selectedNode, neighbours, dataUpdatedAt]); + + const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]); if (isError) return ; // build only once the pager settles, or the force layout would restart on every streamed page @@ -49,13 +55,19 @@ export function NeighbourGraphTab() { return (
- {graph.capped && ( + {ego ? (
- Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. + Neighbourhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbours — click empty space for the full mesh
+ ) : ( + 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/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 4179905..5ad8b89 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -22,6 +22,8 @@ export interface GraphNode { export interface GraphLink { source: number; // index into GraphNode[] target: number; + obs?: number; // weighted-edge fields, set on the ego view's edges (drive colour + freshness fade) + ageDays?: number; } export interface NeighbourGraph { @@ -104,34 +106,50 @@ export function ageOpacity(ageDays: number): number { return AGE.freshOp + (AGE.staleOp - AGE.freshOp) * t; } -export interface NeighbourWeight { - obs: number; - ageDays: number; +const CENTER_SIZE = 30; +const NEIGHBOUR_SIZE = 14; + +function egoNode(id: string, name: string | null, nodeTypeName: string, size: number, degree: number): GraphNode { + const cat = (NODE_TYPE_NAMES as readonly string[]).indexOf(nodeTypeName); + return { + id, + name: name ?? id.slice(0, 6), + category: cat === -1 ? OTHER_CATEGORY : cat, + nodeTypeName, + degree, + symbolSize: size, + label: { show: true }, + }; } -// 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( +// The focused view: one centre node with its neighbours fanned out around it. Neighbours come from +// GET /nodes/{id}/neighbors (one row per neighbour+iata), folded per neighbour — obs summed, age from +// the freshest lastSeen. Edges carry those weights so the option can colour/fade them like the map. +export function buildEgoGraph( + center: { id: string; name: string | null; nodeTypeName: string }, neighbors: NodeNeighbor[], - selfId: string, now: number, -): Record { - const folded = new Map(); +): NeighbourGraph { + const folded = new Map(); for (const nb of neighbors) { - if (nb.id === selfId) continue; + if (nb.id === center.id) 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 }); + folded.set(nb.id, { name: nb.name ?? null, nodeTypeName: nb.nodeTypeName, 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) }; + + const nodes: GraphNode[] = [egoNode(center.id, center.name, center.nodeTypeName, CENTER_SIZE, folded.size)]; + const links: GraphLink[] = []; + for (const [id, n] of folded) { + // push the link first so target points at the node's about-to-be index + links.push({ source: 0, target: nodes.length, obs: n.obs, ageDays: Math.max(0, (now - n.lastSeen) / 86_400_000) }); + nodes.push(egoNode(id, n.name, n.nodeTypeName, NEIGHBOUR_SIZE, 0)); } - return out; + return { nodes, links, total: nodes.length, capped: false }; } // One legend/category per device type (in NODE_TYPES order) plus an "Other" bucket for unknowns; the @@ -143,10 +161,22 @@ function graphCategories(c: ChartColors) { ]; } -// 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 +// The themed ECharts force-graph option, for both the full mesh and the ego (single-node focus) view. +// No hover-adjacency emphasis: it toggles on/off as a dragged node lags the cursor, which flickers the +// graph. Focus is instead the ego view (opts.ego), a clean re-render the caller swaps in. +export function neighbourGraphOption( + graph: NeighbourGraph, + c: ChartColors, + opts: { ego?: boolean } = {}, +): EChartsOption { + const ego = !!opts.ego; + const big = graph.nodes.length > 500; // settle without animating once the full mesh gets dense + // weighted edges (ego view) get an obs→colour, freshness→opacity line; plain mesh edges stay uniform + const links = graph.links.map((l) => + l.obs != null + ? { ...l, lineStyle: { color: obsColor(l.obs, c), opacity: ageOpacity(l.ageDays ?? 0), width: 1.8 } } + : l, + ); return { animation: false, backgroundColor: "transparent", @@ -157,12 +187,13 @@ export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): ECh 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 + if (obs == null) return ""; // uniform mesh 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"}`; + const type = d.nodeTypeName || "unknown"; + return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`; }, }, legend: [ @@ -185,20 +216,16 @@ export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): ECh 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 } }, + force: ego + ? { repulsion: 320, edgeLength: 120, gravity: 0.05, friction: 0.15, layoutAnimation: true } + : { repulsion: big ? 60 : 120, edgeLength: big ? [20, 60] : [40, 90], gravity: 0.08, friction: 0.2, layoutAnimation: !big }, + emphasis: { focus: "none", scale: false }, 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, + links, }, ], }; diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts index 46d6682..363d045 100644 --- a/tests/features/stats/neighbour-graph.test.ts +++ b/tests/features/stats/neighbour-graph.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildNeighbourGraph, obsColor, ageOpacity, foldNeighbourWeights } from "../../../src/features/stats/neighbour-graph"; +import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity } from "../../../src/features/stats/neighbour-graph"; import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function neighbor(overrides: Partial): NodeNeighbor { @@ -180,33 +180,57 @@ describe("ageOpacity", () => { }); }); -describe("foldNeighbourWeights", () => { +describe("buildEgoGraph", () => { const NOW = 10 * DAY; + const center = { id: "c", name: "Center", nodeTypeName: "companion" }; - it("returns an empty map for no neighbours", () => { - expect(foldNeighbourWeights([], "self", NOW)).toEqual({}); + it("puts the center first with its neighbours fanned out from it", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a" }), neighbor({ id: "b" })], NOW); + expect(g.nodes.map((n) => n.id)).toEqual(["c", "a", "b"]); + expect(g.links).toHaveLength(2); + expect(g.links.every((l) => l.source === 0)).toBe(true); + expect(g.nodes[0]!.category).toBe(0); // companion }); - it("sums observations and takes the freshest lastSeen across per-iata rows", () => { - const w = foldNeighbourWeights( + it("shows a label on every node", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a" })], NOW); + expect(g.nodes.every((n) => n.label?.show)).toBe(true); + }); + + it("folds per-iata rows: obs summed, freshest lastSeen wins", () => { + const g = buildEgoGraph( + center, [ 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) + expect(g.nodes.filter((n) => n.id === "a")).toHaveLength(1); + const link = g.links.find((l) => g.nodes[l.target]!.id === "a")!; + expect(link.obs).toBe(8); + expect(link.ageDays).toBeCloseTo(1); }); - it("excludes the selected node's own rows", () => { - const w = foldNeighbourWeights([neighbor({ id: "self", observationCount: 4 })], "self", NOW); - expect(w).toEqual({}); + it("excludes the center's own rows", () => { + const g = buildEgoGraph(center, [neighbor({ id: "c" })], NOW); + expect(g.nodes).toHaveLength(1); + expect(g.links).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); + it("returns just the center when there are no neighbours", () => { + const g = buildEgoGraph(center, [], NOW); + expect(g.nodes.map((n) => n.id)).toEqual(["c"]); + expect(g.links).toEqual([]); + }); + + it("never reports a negative edge age", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a", lastSeen: NOW + 3 * DAY })], NOW); + expect(g.links[0]!.ageDays).toBe(0); + }); + + it("maps a neighbour's node type to a category index", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a", nodeTypeName: "sensor" })], NOW); + expect(g.nodes.find((n) => n.id === "a")!.category).toBe(3); }); });