From 6b0486f3f44ed366eef7718bbd9135cb41a5678a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 22:11:16 -0400 Subject: [PATCH] Gate the neighbour graph to a region and size labels by busyness --- src/features/map/useMapNodesData.ts | 3 ++- src/features/stats/NeighbourGraphTab.tsx | 12 ++++++++- src/features/stats/neighbour-graph.ts | 18 ++++++++++--- src/hooks/useInfinitePages.ts | 10 +++++--- tests/features/stats/neighbour-graph.test.ts | 27 +++++++++++++++++++- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/features/map/useMapNodesData.ts b/src/features/map/useMapNodesData.ts index 28faecb..e720d72 100644 --- a/src/features/map/useMapNodesData.ts +++ b/src/features/map/useMapNodesData.ts @@ -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({ 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 }; } diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index 77d62ea..0b89d0c 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -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(null); @@ -48,6 +51,13 @@ export function NeighbourGraphTab() { const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]); + if (isAll) + return ( + + ); if (isError) return ; // build only once the pager settles, or the force layout would restart on every streamed page if (isPaging) return ; diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 5ad8b89..4f4813b 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -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, }; }); diff --git a/src/hooks/useInfinitePages.ts b/src/hooks/useInfinitePages.ts index dcd45e9..3184c12 100644 --- a/src/hooks/useInfinitePages.ts +++ b/src/hooks/useInfinitePages.ts @@ -13,6 +13,9 @@ interface UseInfinitePagesOptions { // 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 { // 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({ queryKey, queryFn, getId, keepPrevious, auto = true }: UseInfinitePagesOptions) { +export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions) { const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } = useInfiniteQuery({ queryKey, @@ -28,6 +31,7 @@ export function useInfinitePages({ 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({ 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(() => { const seen = new Set(); diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts index 363d045..cd63512 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, 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 { @@ -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", () => {