mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-24 12:44:11 +00:00
map: dim other nodes to spotlight a selected node and its neighbours
This commit is contained in:
1 parent
26e83e0884
commit
5462c08662
5 files changed
+109
-31
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in new issue
Block a user