Focus the neighbour graph into an ego view on click

This commit is contained in:
MrAlders0n
2026-07-11 19:53:41 -04:00
parent 25bd3ce30e
commit 45669d7c26
4 changed files with 128 additions and 113 deletions
+7 -55
View File
@@ -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<string, NeighbourWeight> | 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<EChartsInstance | null>(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 <EChart option={option} onEvents={onEvents} onInit={onInit} className="h-full w-full" />;
}
+25 -13
View File
@@ -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 <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
@@ -49,13 +55,19 @@ export function NeighbourGraphTab() {
return (
<div className="flex h-full min-h-0 flex-col">
{graph.capped && (
{ego ? (
<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.
Neighbourhood of <span className="text-text-normal">{selectedNode?.name ?? selectedId}</span> · {ego.nodes.length - 1} neighbours click empty space for the full mesh
</div>
) : (
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} />
<NeighbourGraph option={option} onSelect={setSelectedId} />
</div>
</div>
);
+57 -30
View File
@@ -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<string, NeighbourWeight> {
const folded = new Map<string, { obs: number; lastSeen: number }>();
): NeighbourGraph {
const folded = new Map<string, { name: string | null; nodeTypeName: string; obs: number; lastSeen: number }>();
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<string, NeighbourWeight> = {};
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<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
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,
},
],
};
+39 -15
View File
@@ -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>): 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);
});
});