Add a name search that spotlights matches and dims the rest of the mesh

This commit is contained in:
MrAlders0n committed 2026-07-11 22:19:05 -04:00
1 parent 6b0486f3f4
commit c3979326aa
4 files changed
+72 -12

No files matched your search

+29 -4
View File
@@ -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<EChartsInstance | null>(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 <EChart option={option} onEvents={onEvents} onInit={onInit} className="h-full w-full" />;
}
+22 -7
View File
@@ -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<string | null>(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 <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 className="flex shrink-0 items-center gap-3 border-b border-border bg-bg-surface px-4 py-2">
<span className="text-xs font-mono text-text-muted">
{graph.capped ? `Showing ${CAP} of ${graph.total} nodes — narrow to an IATA for the rest` : `${graph.total} nodes`}
</span>
<div className="ml-auto">
<SearchBar value={search} onChange={setSearch} fields={SEARCH_FIELDS} field={searchField} onFieldChange={setSearchField} />
</div>
)
</div>
)}
<div className="min-h-0 flex-1">
<NeighbourGraph option={option} onSelect={setSelectedId} />
<NeighbourGraph option={option} nodes={(ego ?? graph).nodes} search={ego ? "" : search} onSelect={handleSelect} />
</div>
</div>
);
+6
View File
@@ -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;
+15 -1
View File
@@ -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>): 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));