Gate the neighbour graph to a region and size labels by busyness

This commit is contained in:
MrAlders0n
2026-07-11 22:11:16 -04:00
parent 991f501f7c
commit 6b0486f3f4
5 changed files with 61 additions and 9 deletions
+2 -1
View File
@@ -7,13 +7,14 @@ const nodeId = (n: NodeSummary) => n.id;
// Page the selected region's nodes 50 at a time for the map, so the canvas fills batch by batch
// instead of waiting for one big response. Thin wrapper over the shared useInfinitePages (which owns
// the auto-chain, dedup, and error handling). Loads once per region; WS updates keep nodes live.
export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string) {
export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string, opts?: { enabled?: boolean }) {
const { items, loadedCount, isPaging, isError } = useInfinitePages<NodeSummary>({
queryKey: ["map-nodes", regionKey],
// Always request neighborIds (just UUIDs) so the neighbor-lines toggle is a pure client-side
// render switch over already-loaded data — no refetch when toggling.
queryFn: (cursor) => getNodesPage(selectedIatas, { cursor, neighbors: true }),
getId: nodeId,
enabled: opts?.enabled,
});
return { nodes: items, loadedCount, isPaging, isError };
}
+11 -1
View File
@@ -14,7 +14,10 @@ const CAP = 1000;
export function NeighbourGraphTab() {
const { iatas, regionKey } = useRegion();
const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey);
// "All regions" is 5k+ nodes — too heavy for the canvas force layout, so gate the fetch off and
// prompt for a region instead of freezing the browser.
const isAll = regionKey === "*";
const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey, { enabled: !isAll });
const colors = useChartColors();
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -48,6 +51,13 @@ export function NeighbourGraphTab() {
const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]);
if (isAll)
return (
<EmptyState
title="Pick a region"
subtitle="All regions is 5,000+ nodes — choose a region from the REGION picker above, or narrow to an IATA, to view its mesh."
/>
);
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`} />;
+15 -3
View File
@@ -16,7 +16,7 @@ export interface GraphNode {
nodeTypeName: string;
degree: number;
symbolSize: number;
label?: { show: boolean };
label?: { show: boolean; fontSize?: number };
}
export interface GraphLink {
@@ -36,7 +36,16 @@ export interface NeighbourGraph {
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
const HUB_LABELS = 30; // only the biggest hubs get a persistent label, else 1000 nodes are a text wall
const MIN_LABEL = 9;
const MAX_LABEL = 16;
// Busier hubs get a louder label; sqrt so a few giant hubs don't dwarf the rest of the labelled set.
export function labelSize(degree: number, maxDegree: number): number {
if (maxDegree <= 0) return MIN_LABEL;
const t = Math.sqrt(degree) / Math.sqrt(maxDegree);
return Math.round(MIN_LABEL + (MAX_LABEL - MIN_LABEL) * t);
}
// 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.
@@ -62,7 +71,10 @@ export function buildNeighbourGraph(nodes: NodeSummary[], cap: number): Neighbou
nodeTypeName: n.nodeTypeName,
degree: n.knownNeighborCount,
symbolSize: symbolSize(n.knownNeighborCount, maxDegree),
label: i < HUB_LABELS && n.knownNeighborCount > 0 ? { show: true } : undefined,
label:
i < HUB_LABELS && n.knownNeighborCount > 0
? { show: true, fontSize: labelSize(n.knownNeighborCount, maxDegree) }
: undefined,
};
});
+7 -3
View File
@@ -13,6 +13,9 @@ interface UseInfinitePagesOptions<T> {
// auto-chain every page eagerly (default). false = load only the first page; the caller pulls the
// rest via loadMore() (e.g. on scroll) so a large dataset isn't fetched all at once.
auto?: boolean;
// false = don't fetch at all (idle query). Lets a caller gate a heavy load off — e.g. the neighbour
// graph skips the ~5k-node "All regions" fetch until a region is picked.
enabled?: boolean;
}
// Page through a cursor-paginated endpoint. By default it auto-chains page by page as each settles so
@@ -20,7 +23,7 @@ interface UseInfinitePagesOptions<T> {
// load only the first page and pull the rest on demand via loadMore(). Loads once per key (staleTime
// Infinity, no maxPages); dedupes by id because a non-unique cursor can repeat a row across a page
// boundary. Shared by the map and the entity tables.
export function useInfinitePages<T>({ queryKey, queryFn, getId, keepPrevious, auto = true }: UseInfinitePagesOptions<T>) {
export function useInfinitePages<T>({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions<T>) {
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } =
useInfiniteQuery({
queryKey,
@@ -28,6 +31,7 @@ export function useInfinitePages<T>({ queryKey, queryFn, getId, keepPrevious, au
getNextPageParam: (last) => last.nextCursor ?? undefined,
initialPageParam: undefined as number | undefined,
staleTime: Infinity,
enabled,
placeholderData: keepPrevious ? keepPreviousData : undefined,
});
@@ -41,8 +45,8 @@ export function useInfinitePages<T>({ queryKey, queryFn, getId, keepPrevious, au
// In auto mode, chain to the next page once the current settles — this streams rows batch by batch.
// In on-demand mode the caller drives loadMore() instead.
useEffect(() => {
if (auto) loadMore();
}, [auto, loadMore]);
if (auto && enabled) loadMore();
}, [auto, enabled, loadMore]);
const items = useMemo<T[]>(() => {
const seen = new Set<string>();
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity } from "../../../src/features/stats/neighbour-graph";
import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity, labelSize } from "../../../src/features/stats/neighbour-graph";
import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types";
function neighbor(overrides: Partial<NodeNeighbor>): NodeNeighbor {
@@ -145,6 +145,31 @@ describe("buildNeighbourGraph", () => {
expect(g.nodes).toHaveLength(1);
expect(g.links).toEqual([]);
});
it("gives busier hubs a larger label font than quieter ones", () => {
const g = buildNeighbourGraph(
[node({ id: "hub", knownNeighborCount: 40 }), node({ id: "small", knownNeighborCount: 2 })],
10,
);
const hub = g.nodes.find((n) => n.id === "hub")!;
const small = g.nodes.find((n) => n.id === "small")!;
expect(hub.label?.show).toBe(true);
expect(small.label?.show).toBe(true);
expect(hub.label!.fontSize!).toBeGreaterThan(small.label!.fontSize!);
});
});
describe("labelSize", () => {
it("grows with degree and is largest at the max", () => {
expect(labelSize(40, 40)).toBeGreaterThan(labelSize(5, 40));
expect(labelSize(20, 40)).toBeGreaterThanOrEqual(labelSize(5, 40));
});
it("clamps to a sane font range and survives maxDegree 0", () => {
expect(labelSize(0, 0)).toBeGreaterThanOrEqual(9);
expect(labelSize(0, 40)).toBeGreaterThanOrEqual(9);
expect(labelSize(1000, 1000)).toBeLessThanOrEqual(16);
});
});
describe("obsColor", () => {