map: dim other nodes to spotlight a selected node and its neighbours

This commit is contained in:
MrAlders0n committed 2026-07-08 08:41:00 -04:00
1 parent 26e83e0884
commit 5462c08662
5 files changed
+109 -31

No files matched your search

+9 -2
View File
@@ -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);
+23
View File
@@ -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<string, NodeSummary>();
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<string>([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(
+37 -2
View File
@@ -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;
+3 -26
View File
@@ -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(() => {
+37 -1
View File
@@ -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>): 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" }),