mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-16 10:22:34 +00:00
Focus the neighbour graph into an ego view on click
This commit is contained in:
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user