From c3979326aa95359fcc466afd1bb2bf3d0f3b16ac Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 22:19:05 -0400 Subject: [PATCH] Add a name search that spotlights matches and dims the rest of the mesh --- src/features/stats/NeighbourGraph.tsx | 33 +++++++++++++++++--- src/features/stats/NeighbourGraphTab.tsx | 29 ++++++++++++----- src/features/stats/neighbour-graph.ts | 6 ++++ tests/features/stats/neighbour-graph.test.ts | 16 +++++++++- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx index 3f703d0..4127998 100644 --- a/src/features/stats/NeighbourGraph.tsx +++ b/src/features/stats/NeighbourGraph.tsx @@ -1,18 +1,24 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { EChart } from "./EChart"; import type { EChartsInstance, EChartsOption } from "./echarts-setup"; +import { nodeNameMatches, type GraphNode } from "./neighbour-graph"; interface Props { option: EChartsOption; + nodes: GraphNode[]; + search: string; onSelect: (id: string | null) => void; } // 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) { +// view; this stays dumb apart from the search overlay. No emphasis/dispatch, so dragging never flickers. +export function NeighbourGraph({ option, nodes, search, onSelect }: Props) { + const chartRef = useRef(null); + const onInit = useCallback( (chart: EChartsInstance) => { + chartRef.current = chart; + // clicking empty canvas returns to the full mesh chart.getZr().on("click", (e: { target?: unknown }) => { if (!e.target) onSelect(null); }); @@ -32,5 +38,24 @@ export function NeighbourGraph({ option, onSelect }: Props) { [onSelect], ); + // Search: highlight matches, dim the rest — a per-node itemStyle/label merge only. The layout is at + // equilibrium, so a style-only merge (no x/y change) leaves node positions put — no relayout, no + // flicker. `option` is a dep so styling re-applies after a region/ego swap replaces the chart state. + useEffect(() => { + const chart = chartRef.current; + if (!chart || chart.isDisposed()) return; + const q = search.trim(); + const data = nodes.map((n) => { + if (!q) return { ...n, itemStyle: { opacity: 1 }, label: n.label ?? { show: false } }; + const match = nodeNameMatches(n.name, q); + return { + ...n, + itemStyle: { opacity: match ? 1 : 0.06 }, + label: match ? { show: true, fontSize: 13 } : { show: false }, + }; + }); + chart.setOption({ series: [{ data }] }, { notMerge: false }); + }, [search, nodes, option]); + return ; } diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index 0b89d0c..952566b 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useRegion } from "../../hooks/useRegion"; import { useMapNodesData } from "../map/useMapNodesData"; @@ -7,11 +7,14 @@ import { useChartColors } from "./chartTheme"; import { buildNeighbourGraph, buildEgoGraph, neighbourGraphOption } from "./neighbour-graph"; import { NeighbourGraph } from "./NeighbourGraph"; import { EmptyState } from "../../components/EmptyState"; +import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; // 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 full mesh draws. const CAP = 1000; +const SEARCH_FIELDS: SearchFieldOption[] = [{ value: "name", label: "Name" }]; + export function NeighbourGraphTab() { const { iatas, regionKey } = useRegion(); // "All regions" is 5k+ nodes — too heavy for the canvas force layout, so gate the fetch off and @@ -20,14 +23,23 @@ export function NeighbourGraphTab() { const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey, { enabled: !isAll }); const colors = useChartColors(); const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(""); + const [searchField, setSearchField] = useState("name"); - // a different region is a different mesh — drop any stale selection (adjust-during-render, no effect) + // a different region is a different mesh — drop any stale selection/search (adjust-during-render) const [region, setRegion] = useState(regionKey); if (region !== regionKey) { setRegion(regionKey); setSelectedId(null); + setSearch(""); } + // focusing a node clears the search so the ego view isn't dimmed by a stale query + const handleSelect = useCallback((id: string | null) => { + setSelectedId(id); + if (id) setSearch(""); + }, []); + const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]); const selectedNode = useMemo( () => (selectedId ? nodes.find((n) => n.id === selectedId) ?? null : null), @@ -70,14 +82,17 @@ export function NeighbourGraphTab() { Neighbourhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbours — click empty space for the full mesh ) : ( - graph.capped && ( -
- Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. +
+ + {graph.capped ? `Showing ${CAP} of ${graph.total} nodes — narrow to an IATA for the rest` : `${graph.total} nodes`} + +
+
- ) +
)}
- +
); diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 4f4813b..6586c4f 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -40,6 +40,12 @@ const HUB_LABELS = 30; // only the biggest hubs get a persistent label, else 100 const MIN_LABEL = 9; const MAX_LABEL = 16; +// Case-insensitive substring match for the graph search; an empty query matches nothing. +export function nodeNameMatches(name: string, query: string): boolean { + const q = query.trim().toLowerCase(); + return q.length > 0 && name.toLowerCase().includes(q); +} + // 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; diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts index cd63512..f3251f1 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, labelSize } from "../../../src/features/stats/neighbour-graph"; +import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity, labelSize, nodeNameMatches } from "../../../src/features/stats/neighbour-graph"; import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function neighbor(overrides: Partial): NodeNeighbor { @@ -159,6 +159,20 @@ describe("buildNeighbourGraph", () => { }); }); +describe("nodeNameMatches", () => { + it("matches a case-insensitive substring", () => { + expect(nodeNameMatches("McCall_Lake", "call")).toBe(true); + expect(nodeNameMatches("YWK Repeater", "repe")).toBe(true); + }); + it("does not match a missing substring", () => { + expect(nodeNameMatches("McCall_Lake", "xyz")).toBe(false); + }); + it("treats an empty/whitespace query as no match", () => { + expect(nodeNameMatches("anything", "")).toBe(false); + expect(nodeNameMatches("anything", " ")).toBe(false); + }); +}); + describe("labelSize", () => { it("grows with degree and is largest at the max", () => { expect(labelSize(40, 40)).toBeGreaterThan(labelSize(5, 40));