From 5462c0866248e65d285ae16fc4442d398c4aaa1e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 8 Jul 2026 08:41:00 -0400 Subject: [PATCH] map: dim other nodes to spotlight a selected node and its neighbours --- src/features/map/MapView.tsx | 11 +++++-- src/features/map/node-geojson.ts | 23 +++++++++++++++ src/features/map/useMapNodes.ts | 39 +++++++++++++++++++++++-- src/features/map/useMapPacketFlow.ts | 29 ++---------------- tests/features/map/node-geojson.test.ts | 38 +++++++++++++++++++++++- 5 files changed, 109 insertions(+), 31 deletions(-) diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 50c32dd..058d432 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -7,7 +7,7 @@ import { useMapNeighbors } from "./useMapNeighbors"; import { useMapPacketFlow } from "./useMapPacketFlow"; import { PacketFlowButton } from "./PacketFlowButton"; import { useMapNodesData } from "./useMapNodesData"; -import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, type NeighborEdgeProps } from "./node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson"; import { MapSettingsPanel } from "./MapSettingsPanel"; import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, type NeighborLinesMode } from "./types"; import type { FeatureCollection, LineString } from "geojson"; @@ -117,6 +117,13 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp [nodes, neighborLines, selectedNodeId], ); + // With neighbors shown and a node selected, fade every other node (like live mode) to spotlight + // the selection and its neighbors. null when there's nothing to focus, so the map stays full-bright. + const focusIds = useMemo( + () => (neighborLines === "off" ? null : neighborFocusIds(nodes, selectedNodeId)), + [nodes, neighborLines, selectedNodeId], + ); + // IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no // bounds from the API, so their member IATAs stand in for the extent. See CLAUDE.md (map framing). const fitPoints = useMemo<[number, number][] | null>(() => { @@ -130,7 +137,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome - useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, `${regionKey}:${typeFilter}`); + useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index a0a69a7..6929b16 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -73,6 +73,29 @@ export function buildNeighborEdges( return { type: "FeatureCollection", features }; } +// The located nodes to keep lit when a node is selected: the selection plus its neighbors (links are +// undirected — a node listing the selection counts). Returns null when there's nothing to focus on: +// no selection, the selected node isn't on the map, or it has no located neighbors. Mirrors the +// undirected logic in buildNeighborEdges so the bright set matches the drawn edges. +export function neighborFocusIds(nodes: NodeSummary[], selectedId: string | null): string[] | null { + if (!selectedId) return null; + const located = new Map(); + for (const n of nodes) { + if (n.lat != null && n.lng != null) located.set(n.id, n); + } + const selected = located.get(selectedId); + if (!selected) return null; + + const focus = new Set([selectedId]); + for (const otherId of selected.neighborIds ?? []) { + if (otherId !== selectedId && located.has(otherId)) focus.add(otherId); + } + for (const n of located.values()) { + if (n.id !== selectedId && n.neighborIds?.includes(selectedId)) focus.add(n.id); + } + return focus.size > 1 ? [...focus] : null; +} + // Filter to a single device type ("" = All). Filtering the data (not a layer filter) lets the // clustered source re-count only the visible type. export function filterByNodeType( diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index 951479c..c4a7807 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -21,6 +21,8 @@ import { NODES_SOURCE_MAXZOOM, SPIDERFY_MIN_ZOOM, NODE_LABEL_MIN_ZOOM, + LIVE_DIM_OPACITY, + LIVE_CLUSTER_DIM_OPACITY, NODE_TYPE_NAMES, NODE_ICON_UNKNOWN, nodeIconId, @@ -81,6 +83,11 @@ const ICON_IMAGE: ExpressionSpecification = [ NODE_ICON_UNKNOWN, ] as unknown as ExpressionSpecification; +// Node labels fade in only past NODE_LABEL_MIN_ZOOM. +const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; +// Live mode: dim to the idle floor, but lift a currently-flashing node to full (feature-state glow 0..1). +const LIVE_ICON_OPACITY: ExpressionSpecification = ["max", LIVE_DIM_OPACITY, ["coalesce", ["feature-state", "glow"], 0]]; + const SPIDER_LEAVES_LAYOUT: SymbolLayerSpecification["layout"] = { "icon-image": ICON_IMAGE, "icon-size": 1, @@ -98,6 +105,10 @@ export function useMapNodes( clustered: boolean, onSelectNode: (id: string) => void, selectedNodeId: string | null, + // live packet-flow on: fade every node (a crossed one lifts via feature-state glow) + live: boolean, + // selection focus: keep only these node ids lit and fade the rest; null = off + focusIds: string[] | null, // identity of the dataset (region + type filter); an open spiderfy fan closes when it changes, // since its leaves were drawn from the previous dataset resetKey = "", @@ -210,8 +221,7 @@ export function useMapNodes( "text-color": textColor, "text-halo-color": halo, "text-halo-width": 1.3, - // labels fade in only at high zoom - "text-opacity": ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1], + "text-opacity": LABEL_OPACITY, // labels fade in only at high zoom }, } as SymbolLayerSpecification); } @@ -313,6 +323,31 @@ export function useMapNodes( syncLeafSelectionRing(map, selectedNodeId); }, [mapRef, isReady, selectedNodeId]); + // Base-layer opacity for the two "fade all but a subset" dim modes. Single owner of icon/text + // opacity so live mode and selection focus never fight over the paint property; re-applies after a + // style/theme/clustering rebuild via the deps. Live wins over focus. The live-mode packet glow + // rides feature-state (set by useMapPacketFlow's loop), so it needs no re-run here. + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + // lit for the focus set, dimmed otherwise + const focusCase = (lit: ExpressionSpecification | number, dim: ExpressionSpecification | number) => + ["case", ["in", ["get", "id"], ["literal", focusIds ?? []]], lit, dim] as ExpressionSpecification; + + const iconOpacity: ExpressionSpecification | number = live ? LIVE_ICON_OPACITY : focusIds ? focusCase(1, LIVE_DIM_OPACITY) : 1; + const labelOpacity: ExpressionSpecification | number = live ? 0 : focusIds ? focusCase(LABEL_OPACITY, 0) : LABEL_OPACITY; + const dimActive = live || Boolean(focusIds); + if (map.getLayer(NODES_POINT_LAYER_ID)) { + map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", iconOpacity); + map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", labelOpacity); + } + // a cluster can't tell which nodes it holds, so both modes just dim it flat (matches live mode) + if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", dimActive ? LIVE_CLUSTER_DIM_OPACITY : 1); + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", dimActive ? 0 : 1); + } + }, [mapRef, isReady, live, focusIds, clustered, themeKey]); + // Push new node data into the source as it arrives; the source re-clusters automatically. useEffect(() => { const map = mapRef.current; diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 498babf..b4e815f 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl"; +import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification } from "maplibre-gl"; import type { Feature, FeatureCollection, Point, LineString } from "geojson"; import type { WsManager } from "../../api/ws-manager"; import { resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; @@ -13,21 +13,11 @@ import { PACKET_FLOW_HOP_MS, PACKET_FLOW_TRAIL_FADE_MS, PACKET_FLOW_MAX, - LIVE_DIM_OPACITY, - LIVE_CLUSTER_DIM_OPACITY, NODES_SOURCE_ID, - NODES_POINT_LAYER_ID, - NODES_CLUSTER_LAYER_ID, - NODE_LABEL_MIN_ZOOM, } from "./types"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; -// node labels normally fade in past NODE_LABEL_MIN_ZOOM — restored when Live turns off -const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; -// dimmed to the idle baseline, but lifted to full for a node currently flashing (feature-state glow 0..1) -const LIVE_ICON_OPACITY: ExpressionSpecification = ["max", LIVE_DIM_OPACITY, ["coalesce", ["feature-state", "glow"], 0]]; - // one packet riding its hop path once interface Flow { coords: [number, number][]; @@ -175,21 +165,8 @@ export function useMapPacketFlow( return () => wsManager.setResolvePath(false); }, [enabled, wsManager]); - // dim the base nodes. The point layer lifts back to full per-node via feature-state glow; clusters - // dim flat (a clustered node can't be individually flashed). Keyed on themeKey so it re-applies - // after useMapNodes rebuilds its layers on a theme/style change. - useEffect(() => { - const map = mapRef.current; - if (!map || !isReady) return; - if (map.getLayer(NODES_POINT_LAYER_ID)) { - map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", enabled ? LIVE_ICON_OPACITY : 1); - map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY); - } - if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { - map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", enabled ? LIVE_CLUSTER_DIM_OPACITY : 1); - map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1); - } - }, [mapRef, isReady, enabled, themeKey]); + // Base-node dimming (fade all, lift the flashing node) is owned by useMapNodes so live mode and + // selection focus share one opacity owner; here we only feed it the per-node glow feature-state. // launch a flow per observed packet; tear the animation down when disabled useEffect(() => { diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 27eee62..10e356f 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges } from "../../../src/features/map/node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds } from "../../../src/features/map/node-geojson"; import type { NodeSummary } from "../../../src/features/nodes/types"; function node(overrides: Partial): NodeSummary { @@ -116,6 +116,42 @@ describe("buildNeighborEdges", () => { }); }); +describe("neighborFocusIds", () => { + const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); + const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); + const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); + + const sorted = (ids: string[] | null) => (ids ? [...ids].sort() : ids); + + it("returns null when nothing is selected", () => { + expect(neighborFocusIds([a, b, c], null)).toBeNull(); + }); + + it("includes the selected node and its located neighbors", () => { + expect(sorted(neighborFocusIds([a, b, c], "a"))).toEqual(["a", "b", "c"]); + }); + + it("treats neighbor links as undirected (a node listing the selection counts)", () => { + // b lists a; c does not list b, so only a<->b makes c irrelevant to b's focus set + expect(sorted(neighborFocusIds([a, b, c], "b"))).toEqual(["a", "b"]); + }); + + it("skips neighbor ids that are absent or unlocated", () => { + const noCoord = node({ id: "c", lat: null, lng: null, neighborIds: ["a"] }); + expect(sorted(neighborFocusIds([a, b, noCoord], "a"))).toEqual(["a", "b"]); + }); + + it("returns null when the selected node is unlocated (no marker to keep lit)", () => { + const unlocated = node({ id: "a", lat: null, lng: null, neighborIds: ["b"] }); + expect(neighborFocusIds([unlocated, b], "a")).toBeNull(); + }); + + it("returns null when the selected node has no located neighbors", () => { + const lonely = node({ id: "x", lat: 45, lng: -75, neighborIds: ["ghost"] }); + expect(neighborFocusIds([lonely, b], "x")).toBeNull(); + }); +}); + describe("filterByNodeType", () => { const fc = nodesToFeatureCollection([ node({ id: "r1", nodeTypeName: "repeater" }),