mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-09 07:03:43 +00:00
Gate the neighbour graph to a region and size labels by busyness
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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`} />;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user