mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 06:22:51 +00:00
Add a name search that spotlights matches and dims the rest of the mesh
This commit is contained in:
1 parent
6b0486f3f4
commit
c3979326aa
4 files changed
+72
-12
No files matched your search
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user