From 79f05a2ebd72e1f8999d24d6fd54696d263cdf79 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:27:13 -0400 Subject: [PATCH] Live Map: keep nodes visible as dots and light crossed nodes on top (#171) * Add live map lit-node and label helpers * Add solid lit icons with a built-in ring * Light crossed nodes as solid icons, newest on top * Show idle repeaters and companions as dots in Live Map * Disable the clustering control during Live Map * Turn clustering off while Live Map is on * Keep the live map helpers lint-clean * Let a click on a lit icon win over the dot beneath it * Tidy live map clicks, comments and filtered selection --- src/features/map/MapSettingsPanel.tsx | 7 +- src/features/map/MapView.tsx | 15 +- src/features/map/SegmentedControl.tsx | 10 +- src/features/map/live-map.ts | 92 +++++++++ src/features/map/node-icons.ts | 47 ++++- src/features/map/types.ts | 23 ++- src/features/map/useMapNodes.ts | 90 +++++++-- src/features/map/useMapPacketFlow.ts | 185 ++++++++++++++---- src/i18n/locales/en.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/sv.json | 1 + tests/features/map/MapSettingsPanel.test.tsx | 21 +- tests/features/map/live-map.test.ts | 96 +++++++++ tests/features/map/node-icons-lit.test.ts | 27 +++ .../map/useMapNodes.layer-order.test.tsx | 77 ++++++++ .../map/useMapPacketFlow.lit.test.tsx | 185 ++++++++++++++++++ 16 files changed, 810 insertions(+), 68 deletions(-) create mode 100644 src/features/map/live-map.ts create mode 100644 tests/features/map/live-map.test.ts create mode 100644 tests/features/map/node-icons-lit.test.ts create mode 100644 tests/features/map/useMapPacketFlow.lit.test.tsx diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 432267b..d8acc94 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -58,6 +58,8 @@ interface MapSettingsPanelProps { onBordersChange: (on: boolean) => void; // builds deep-link params for the current view, evaluated at copy time (reads the live camera) buildShareParams: () => Record; + // Live Map forces clustering off while it runs; the saved choice comes back afterwards + liveMap?: boolean; } export function MapSettingsPanel({ @@ -72,6 +74,7 @@ export function MapSettingsPanel({ borders, onBordersChange, buildShareParams, + liveMap = false, }: MapSettingsPanelProps) { const { t } = useTranslation(); const isMobile = useIsMobile(); @@ -136,10 +139,12 @@ export function MapSettingsPanel({ onClusteredChange(v === "on")} className="w-full" + disabled={liveMap} /> + {liveMap &&
{t("map.clusteringLiveNote")}
}
nodesToFeatureCollection(nodes), [nodes]); const geojson = useMemo(() => filterByNodeType(baseFc, typeFilter), [baseFc, typeFilter]); + // Live Map lights crossed nodes from the unfiltered list and draws idle nodes as dots + const liveNodeIndex = useMemo(() => buildLiveNodeIndex(baseFc), [baseFc]); + const dotTypes = useMemo(() => idleDotTypes(typeFilter), [typeFilter]); + // Live forces clustering off so every crossed node can light; the saved choice is untouched + const effectiveClustered = clustered && !packetFlow; + const mapGeojson = useMemo( + () => (packetFlow ? withSelectedNode(geojson, baseFc, selectedNodeId) : geojson), + [packetFlow, geojson, baseFc, selectedNodeId], + ); // Selected mode colours the one node's edges by observation count + freshness, which only the node // detail endpoint carries (the list's neighborIds are bare uuids). Shares the panel's query cache @@ -210,10 +220,10 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp return { tab: "Map", ...buildMapParams(snapshot) }; }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]); - useMapNodes(mapRef, nodeIconResolverRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); + useMapNodes(mapRef, nodeIconResolverRef, isReady, mapGeojson, isDark, themeKey, effectiveClustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`, dotTypes); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); useMapBorders(mapRef, isReady, borderData, themeKey); - useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); + useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey, isDark, liveNodeIndex, onSelectNode); return (
@@ -233,6 +243,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp borders={borders} onBordersChange={handleBordersChange} buildShareParams={buildShareParams} + liveMap={packetFlow} /> setPacketFlow((v) => !v)} /> {/* streams in 50 at a time; the count climbs as pages land, then the pill disappears */} diff --git a/src/features/map/SegmentedControl.tsx b/src/features/map/SegmentedControl.tsx index 64d0fc0..b06f31b 100644 --- a/src/features/map/SegmentedControl.tsx +++ b/src/features/map/SegmentedControl.tsx @@ -11,14 +11,17 @@ interface SegmentedControlProps { // wrap = individual rounded pills (flex-wrap); default = a connected bar wrap?: boolean; className?: string; + // locks the control (e.g. a setting another mode overrides); the value still shows + disabled?: boolean; } // Single-select segmented control; active state uses aria-pressed, not color alone. -export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = false, className }: SegmentedControlProps) { +export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = false, className, disabled = false }: SegmentedControlProps) { const containerCls = wrap ? `flex flex-wrap gap-1 ${className ?? ""}` : `flex bg-bg-raised border border-border rounded-sm overflow-hidden ${className ?? ""}`; + const stateCls = disabled ? "opacity-50" : ""; const buttonCls = (active: boolean) => wrap @@ -32,7 +35,7 @@ export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = f }`; return ( -
+
{options.map((o) => { const active = o.value === value; return ( @@ -40,8 +43,9 @@ export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = f key={o.value} type="button" aria-pressed={active} + disabled={disabled} onClick={() => onChange(o.value)} - className={buttonCls(active)} + className={`${buttonCls(active)} disabled:cursor-not-allowed`} > {o.label} diff --git a/src/features/map/live-map.ts b/src/features/map/live-map.ts new file mode 100644 index 0000000..8b08f23 --- /dev/null +++ b/src/features/map/live-map.ts @@ -0,0 +1,92 @@ +import type { Feature, FeatureCollection, Point } from "geojson"; +import type { NodeFeatureProps } from "./node-geojson"; +import { PACKET_FLOW_FLASH_MS } from "./types"; + +// Pure helpers for Live Map's lit nodes and idle dots. No maplibre import, so they stay +// unit-testable; useMapPacketFlow owns the layers and the animation loop. + +// The map's glyph font has no emoji, so they'd render as boxes. The ranges match the Flutter app's +// rule so both apps label nodes the same way: pictographs, flags, skin tones, tags, variation +// selectors, joiners and the keycap mark. +const PICTOGRAPHS = /\p{Extended_Pictographic}|\p{Regional_Indicator}|\p{Emoji_Modifier}|[\u{E0020}-\u{E007F}]|\uFE0E|\uFE0F|\u200D|\u20E3/gu; + +export function stripPictographs(name: string): string { + return name.replace(PICTOGRAPHS, "").replace(/\s+/g, " ").trim(); +} + +// Full the moment the packet reaches the node, then a linear fade, independent of the trail. +export function nodeGlow(crossedAt: number, now: number): number { + return Math.min(1, Math.max(0, 1 - (now - crossedAt) / PACKET_FLOW_FLASH_MS)); +} + +// "All" keeps the map to repeaters and companions; a single-type filter shows just that type. +export function idleDotTypes(typeFilter: string): string[] { + return typeFilter === "" ? ["repeater", "companion"] : [typeFilter]; +} + +export interface LiveNodeInfo { + nodeTypeName: string; + name: string | null; +} + +// Built from the unfiltered node list: packet hops carry no node type, and a crossed node should +// light up whatever the type filter hides. +export function buildLiveNodeIndex(fc: FeatureCollection): Map { + const index = new Map(); + for (const f of fc.features) { + index.set(f.properties.id, { nodeTypeName: f.properties.nodeTypeName, name: f.properties.name }); + } + return index; +} + +export interface LitEntry { + id: string; + nodeTypeName: string; + label: string; + lng: number; + lat: number; + crossedAt: number; + seq: number; // lit order; the newest draws on top +} + +export interface LitFeatureProps { + id: string; + nodeTypeName: string; + label: string; + seq: number; + glow: number; +} + +export function lightNode(lit: Map, entry: LitEntry): void { + lit.set(entry.id, entry); +} + +// Snapshot the lit nodes for the lit source, dropping any that have faded out. +export function litFeatureCollection(lit: Map, now: number): FeatureCollection { + const features: Feature[] = []; + for (const [id, e] of lit) { + const glow = nodeGlow(e.crossedAt, now); + if (glow <= 0) { + lit.delete(id); + continue; + } + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [e.lng, e.lat] }, + properties: { id: e.id, nodeTypeName: e.nodeTypeName, label: e.label, seq: e.seq, glow }, + }); + } + return { type: "FeatureCollection", features }; +} + +// In Live the selected node keeps its marker, but a type filter may have dropped it from the map +// data (a lit companion clicked under the Repeater filter), so add it back from the full list. +export function withSelectedNode( + filtered: FeatureCollection, + all: FeatureCollection, + selectedId: string | null, +): FeatureCollection { + if (!selectedId || filtered.features.some((f) => f.properties.id === selectedId)) return filtered; + const selected = all.features.find((f) => f.properties.id === selectedId); + return selected ? { ...filtered, features: [...filtered.features, selected] } : filtered; +} diff --git a/src/features/map/node-icons.ts b/src/features/map/node-icons.ts index 628c9ba..3e7c236 100644 --- a/src/features/map/node-icons.ts +++ b/src/features/map/node-icons.ts @@ -5,6 +5,12 @@ import { CLUSTER_ICON_ID, CLUSTER_ICON_IDS, CLUSTER_BUCKETS, + PACKET_FLOW_COLOR, + LIT_RING_RADIUS, + LIT_RING_WIDTH, + LIT_GLOW_RADIUS, + LIT_GLOW_OPACITY, + litIconId, } from "./types"; // Marker icons: per-type SVG + cluster hexagon, recolored and rasterized to maplibre images (unknown @@ -38,6 +44,12 @@ const NODE_TYPE_COLOR: Record = sensor: { colorVar: "--palette-warn", fallback: "#EAB308" }, }; +export function nodeTypeColor(typeName: string): string { + const c = NODE_TYPE_COLOR[typeName]; + if (!c) return "#73737B"; + return getComputedStyle(document.documentElement).getPropertyValue(c.colorVar).trim() || c.fallback; +} + // Observer is a ROLE pip layered on any type; keep a fixed accent so it reads consistently and // contrasts the (theme-tinted) node color. const OBSERVER_COLOR = "#c79bff"; @@ -56,7 +68,7 @@ export const SELECTION_RING_ICON_ID = "node-selection-ring"; // Every image id the map layers reference, so they can be provided up front (one per cluster density). export const MAP_ICON_IDS: string[] = [ - ...NODE_TYPE_NAMES.flatMap((t) => [nodeIconId(t), nodeObserverIconId(t)]), + ...NODE_TYPE_NAMES.flatMap((t) => [nodeIconId(t), nodeObserverIconId(t), litIconId(t)]), NODE_ICON_UNKNOWN, ...CLUSTER_ICON_IDS, SELECTION_RING_ICON_ID, @@ -97,6 +109,29 @@ function styleSvg( return out; } +// A Live Map lit node: the type's glyph in white on a disc of its colour, with a soft glow and the +// packet-orange ring drawn into the same image so the ring stacks with its own marker. Uses the +// marker's padded 88-unit box, so px values scale by 88 / MARKER_SIZE. +export function litIconSvg(glyphSvg: string, color: string): string { + const unit = (px: number) => (px * 88) / MARKER_SIZE; + const glyph = (glyphSvg.match(/]*stroke="currentColor"[^>]*>[\s\S]*?<\/g>/)?.[0] ?? "").replaceAll( + "currentColor", + "#ffffff", + ); + const glowR = unit(LIT_GLOW_RADIUS); + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + glyph + + `` + ); +} + async function rasterizeSvg( svg: string, scale: number, @@ -222,6 +257,16 @@ export async function rasterizeNodeIcon(id: string, isDark: boolean): Promise = [[7, 2.5], [9, 3], [12, 4.5], [15, 6]]; // [zoom, px] +export const LIT_RING_RADIUS = 16; // px, the packet-orange ring baked into each lit icon +export const LIT_RING_WIDTH = 2.5; // px +export const LIT_GLOW_RADIUS = 18; // px, the type-colour glow fades to nothing here +export const LIT_GLOW_OPACITY = 0.5; +export const LIT_LABEL_SIZE = 12; // px +export const LIT_CLICK_MIN_GLOW = 0.2; // a lit icon fainter than this no longer takes clicks +export const litIconId = (typeName: string): string => `node-lit-${typeName}`; + export const CLUSTER_RADIUS = 50; // px // Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops // clustering above clusterMaxZoom, which would leave co-located nodes as stacked, un-spiderfy-able diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index d8c19c0..25a1f7f 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -3,13 +3,16 @@ import type { Map as MapLibreMap, GeoJSONSource, ExpressionSpecification, + FilterSpecification, SymbolLayerSpecification, + CircleLayerSpecification, MapLayerMouseEvent, } from "maplibre-gl"; import Spiderfy from "@nazka/map-gl-js-spiderfy"; import type { FeatureCollection, Point } from "geojson"; -import { rasterizeNodeIcon, MAP_ICON_IDS, nodeObserverIconId, SELECTION_RING_ICON_ID } from "./node-icons"; +import { rasterizeNodeIcon, MAP_ICON_IDS, nodeObserverIconId, SELECTION_RING_ICON_ID, nodeTypeColor } from "./node-icons"; import type { NodeFeatureProps } from "./node-geojson"; +import { idleDotTypes } from "./live-map"; import { NODES_SOURCE_ID, NODES_CLUSTER_LAYER_ID, @@ -24,6 +27,12 @@ import { NODE_LABEL_MIN_ZOOM, LIVE_DIM_OPACITY, LIVE_CLUSTER_DIM_OPACITY, + LIVE_DOTS_LAYER_ID, + LIVE_LIT_ICON_LAYER_ID, + LIT_CLICK_MIN_GLOW, + LIVE_DOT_OPACITY, + LIVE_DOT_RADIUS_STOPS, + NEIGHBORS_LINE_LAYER_ID, NODE_TYPE_NAMES, NODE_ICON_UNKNOWN, nodeIconId, @@ -86,8 +95,12 @@ const ICON_IMAGE: 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 UNCLUSTERED = ["!", ["has", "point_count"]] as ExpressionSpecification; +const ALL_DOT_TYPES = idleDotTypes(""); +const LIVE_DOT_RADIUS = ["interpolate", ["linear"], ["zoom"], ...LIVE_DOT_RADIUS_STOPS.flat()] as unknown as ExpressionSpecification; +const dotsFilter = (types: string[]) => + ["all", UNCLUSTERED, ["in", ["get", "nodeTypeName"], ["literal", types]]] as FilterSpecification; const SPIDER_LEAVES_LAYOUT: SymbolLayerSpecification["layout"] = { "icon-image": ICON_IMAGE, @@ -107,26 +120,30 @@ 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 packet-flow on: idle nodes become dots and only the selected node keeps its marker 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 = "", + // node types drawn as idle dots while live is on + dotTypes: string[] = ALL_DOT_TYPES, ) { const geojsonRef = useRef(geojson); const spiderRef = useRef(null); const onSelectNodeRef = useRef(onSelectNode); const selectedNodeIdRef = useRef(selectedNodeId); const appliedClusteredRef = useRef(clustered); + const dotTypesRef = useRef(dotTypes); // handlers below capture map at attach time; read live state through these refs useEffect(() => { geojsonRef.current = geojson; onSelectNodeRef.current = onSelectNode; selectedNodeIdRef.current = selectedNodeId; - }, [geojson, onSelectNode, selectedNodeId]); + dotTypesRef.current = dotTypes; + }, [geojson, onSelectNode, selectedNodeId, dotTypes]); // Track device-pixel-ratio so icons re-rasterize at full resolution across a DPR change (e.g. // dragging the window to another monitor). A matchMedia(dppx) query fires once then goes stale, @@ -158,7 +175,7 @@ export function useMapNodes( // maplibre fixes `cluster` at source creation, so toggling clustering means recreating the // source. The spiderfy effect below also keys on `clustered` and re-applies itself around this. if (appliedClusteredRef.current !== clustered && map.getSource(NODES_SOURCE_ID)) { - for (const id of [NODES_SELECTED_LAYER_ID, NODES_CLUSTER_LAYER_ID, NODES_POINT_LAYER_ID]) { + for (const id of [LIVE_DOTS_LAYER_ID, NODES_SELECTED_LAYER_ID, NODES_CLUSTER_LAYER_ID, NODES_POINT_LAYER_ID]) { if (map.getLayer(id)) map.removeLayer(id); } map.removeSource(NODES_SOURCE_ID); @@ -175,8 +192,6 @@ export function useMapNodes( cluster: clustered, clusterRadius: CLUSTER_RADIUS, clusterMaxZoom: CLUSTER_MAX_ZOOM, - // promote the node id so live packet-flow can flash individual nodes via feature-state - promoteId: "id", }); } @@ -277,6 +292,32 @@ export function useMapNodes( } syncLeafSelectionRing(map, selectedNodeIdRef.current); + // Live Map's idle nodes as small solid dots in their type colour, at the bottom of the node stack + // so neighbour lines, the selection and every packet layer draw over them. Hidden until live. + const dotColor = [ + "match", + ["get", "nodeTypeName"], + ...NODE_TYPE_NAMES.flatMap((t) => [t, nodeTypeColor(t)]), + nodeTypeColor(""), + ] as unknown as ExpressionSpecification; + if (!map.getLayer(LIVE_DOTS_LAYER_ID)) { + const lowest = (map.getStyle()?.layers ?? []).find( + (l) => l.id === NODES_SELECTED_LAYER_ID || l.id === NEIGHBORS_LINE_LAYER_ID, + )?.id; + map.addLayer( + { + id: LIVE_DOTS_LAYER_ID, + type: "circle", + source: NODES_SOURCE_ID, + filter: dotsFilter(dotTypesRef.current), + layout: { visibility: "none" }, + paint: { "circle-color": dotColor, "circle-radius": LIVE_DOT_RADIUS, "circle-opacity": LIVE_DOT_OPACITY }, + } as CircleLayerSpecification, + lowest, + ); + } + map.setPaintProperty(LIVE_DOTS_LAYER_ID, "circle-color", dotColor); + // node-label colors track the basemap dark/light flag (cluster count is white on the hexagon) map.setPaintProperty(NODES_POINT_LAYER_ID, "text-color", textColor); map.setPaintProperty(NODES_POINT_LAYER_ID, "text-halo-color", halo); @@ -328,10 +369,10 @@ 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. + // What the node layers show in each mode. Single owner of the markers' filter and icon/text + // opacity so live mode and selection focus never fight over them; re-applies after a + // style/theme/clustering rebuild via the deps. Live wins over focus: idle nodes become dots and + // only the selected node keeps its marker (lit nodes are drawn by useMapPacketFlow). useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; @@ -339,10 +380,19 @@ export function useMapNodes( 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 iconOpacity: ExpressionSpecification | number = live ? 1 : 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(LIVE_DOTS_LAYER_ID)) { + map.setFilter(LIVE_DOTS_LAYER_ID, dotsFilter(dotTypes)); + map.setLayoutProperty(LIVE_DOTS_LAYER_ID, "visibility", live ? "visible" : "none"); + } if (map.getLayer(NODES_POINT_LAYER_ID)) { + // filtered rather than faded, so live mode leaves no invisible click targets behind + map.setFilter( + NODES_POINT_LAYER_ID, + live ? (["all", UNCLUSTERED, ["==", ["get", "id"], selectedNodeId ?? ""]] as FilterSpecification) : UNCLUSTERED, + ); map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", iconOpacity); map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", labelOpacity); } @@ -351,7 +401,7 @@ export function useMapNodes( 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]); + }, [mapRef, isReady, live, focusIds, clustered, themeKey, selectedNodeId, dotTypes]); // Push new node data into the source as it arrives; the source re-clusters automatically. useEffect(() => { @@ -397,6 +447,12 @@ export function useMapNodes( } const onPointClick = (e: MapLayerMouseEvent) => { + // a lit icon draws over the markers and dots, so a click on one belongs to that lit node + if ( + map.getLayer(LIVE_LIT_ICON_LAYER_ID) && + map.queryRenderedFeatures(e.point, { layers: [LIVE_LIT_ICON_LAYER_ID] }).some((f) => Number(f.properties?.["glow"]) >= LIT_CLICK_MIN_GLOW) + ) + return; const id = e.features?.[0]?.properties?.["id"]; if (typeof id === "string") onSelectNodeRef.current(id); }; @@ -407,7 +463,8 @@ export function useMapNodes( map.getCanvas().style.cursor = ""; }; map.on("click", NODES_POINT_LAYER_ID, onPointClick); - for (const layer of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID]) { + map.on("click", LIVE_DOTS_LAYER_ID, onPointClick); + for (const layer of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID, LIVE_DOTS_LAYER_ID]) { map.on("mouseenter", layer, setPointer); map.on("mouseleave", layer, clearPointer); } @@ -425,9 +482,10 @@ export function useMapNodes( return () => { map.off("click", NODES_POINT_LAYER_ID, onPointClick); + map.off("click", LIVE_DOTS_LAYER_ID, onPointClick); map.off("click", onClickResync); map.off("moveend", resyncLeafRing); - for (const layer of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID]) { + for (const layer of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID, LIVE_DOTS_LAYER_ID]) { map.off("mouseenter", layer, setPointer); map.off("mouseleave", layer, clearPointer); } diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 7085f10..7ab849b 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -1,8 +1,17 @@ import { useCallback, useEffect, useRef } from "react"; -import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification } from "maplibre-gl"; +import type { + Map as MapLibreMap, + GeoJSONSource, + CircleLayerSpecification, + LineLayerSpecification, + SymbolLayerSpecification, + ExpressionSpecification, + MapLayerMouseEvent, +} from "maplibre-gl"; import type { Feature, FeatureCollection, Point, LineString } from "geojson"; import type { WsManager } from "../../api/ws-manager"; import { packetChain, resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; +import { lightNode, litFeatureCollection, stripPictographs, type LitEntry, type LiveNodeInfo } from "./live-map"; import { PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_TRAIL_LAYER_ID, @@ -13,10 +22,25 @@ import { PACKET_FLOW_HOP_MS, PACKET_FLOW_TRAIL_FADE_MS, PACKET_FLOW_MAX, - NODES_SOURCE_ID, + LIVE_LIT_SOURCE_ID, + LIVE_LIT_LABEL_LAYER_ID, + LIVE_LIT_ICON_LAYER_ID, + LIT_LABEL_SIZE, + LIT_CLICK_MIN_GLOW, + NODE_TYPE_NAMES, + litIconId, } from "./types"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; +const LIVE_TYPES: readonly string[] = NODE_TYPE_NAMES; +const NAMED_TYPES = new Set(["repeater", "companion"]); + +const LIT_ICON_IMAGE = [ + "match", + ["get", "nodeTypeName"], + ...NODE_TYPE_NAMES.flatMap((t) => [t, litIconId(t)]), + "", +] as unknown as ExpressionSpecification; // one packet riding its hop path once interface Flow { @@ -26,10 +50,11 @@ interface Flow { lastNode: number; } -// Live mode (MeshMapper LiveViz style): dim every node, then per observed packet shoot an orange dot -// along its real hop path with a fading dashed trail, flashing each node to full opacity as the dot -// crosses it. Enabling it opts the WS connection into resolvedPath data. Geometry is pure -// (packet-flow.ts); here we own the maplibre layers, the dimming, the rAF loop, and the subscription. +// Live mode (MeshMapper LiveViz style): per observed packet, shoot an orange dot along its real hop +// path with a fading dashed trail. Each node the dot reaches turns into a solid lit icon (name below +// it) and fades on its own; the most recently lit one always draws on top. Lit nodes live in their +// own small source because draw order can't come from feature-state. Enabling it opts the WS +// connection into resolvedPath data. export function useMapPacketFlow( mapRef: React.RefObject, isReady: boolean, @@ -37,10 +62,22 @@ export function useMapPacketFlow( wsManager: WsManager, themeKey: string, resetKey: string, + isDark: boolean, + // unfiltered node index: packet hops carry no type, and a filtered-out node should still light + nodeIndex: Map, + onSelectNode: (id: string) => void, ) { const flowsRef = useRef([]); - const litRef = useRef>(new Set()); // node ids currently lit (feature-state glow set) + const litRef = useRef>(new Map()); + const seqRef = useRef(0); const rafRef = useRef(null); + const nodeIndexRef = useRef(nodeIndex); + const onSelectNodeRef = useRef(onSelectNode); + + useEffect(() => { + nodeIndexRef.current = nodeIndex; + onSelectNodeRef.current = onSelectNode; + }, [nodeIndex, onSelectNode]); const clearFlows = useCallback((map: MapLibreMap | null) => { if (rafRef.current != null) { @@ -48,35 +85,51 @@ export function useMapPacketFlow( rafRef.current = null; } flowsRef.current = []; - // guard the whole block: on a not-yet-ready or torn-down map, getSource/setFeatureState throw + litRef.current.clear(); + // guard the whole block: on a not-yet-ready or torn-down map, getSource throws try { - for (const id of litRef.current) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); - (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); - (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + for (const id of [PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_DOT_SOURCE_ID, LIVE_LIT_SOURCE_ID]) { + (map?.getSource(id) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + } } catch { // map style not ready / already removed } - litRef.current.clear(); }, []); const startLoop = useCallback(() => { if (rafRef.current != null) return; + // light hop k of a flow at the moment the dot reached it; unknown nodes stay dark + function lightHop(p: Flow, k: number) { + const id = p.ids[k]; + const info = id != null ? nodeIndexRef.current.get(id) : undefined; + if (id == null || !info || !LIVE_TYPES.includes(info.nodeTypeName)) return; + const [lng, lat] = p.coords[k]!; + lightNode(litRef.current, { + id, + nodeTypeName: info.nodeTypeName, + label: NAMED_TYPES.has(info.nodeTypeName) ? stripPictographs(info.name ?? "") : "", + lng, + lat, + crossedAt: p.start + k * PACKET_FLOW_HOP_MS, + seq: ++seqRef.current, + }); + } + function frame() { const map = mapRef.current; const now = performance.now(); const dots: Feature[] = []; const lines: Feature[] = []; - const glowByNode = new Map(); // node id -> glow this frame (max across packets) for (let i = flowsRef.current.length - 1; i >= 0; i--) { const p = flowsRef.current[i]!; const nSeg = p.coords.length - 1; const t = (now - p.start) / PACKET_FLOW_HOP_MS; const node = Math.min(nSeg, Math.floor(t + 1e-6)); - if (node > p.lastNode) p.lastNode = node; + while (p.lastNode < node) lightHop(p, ++p.lastNode); const headT = Math.min(t, nSeg); - // full while the dot is travelling, then eases out with the trail after it reaches the end + // full while the dot is travelling, then eases out after it reaches the end const fade = t > nSeg ? Math.max(0, 1 - (now - (p.start + nSeg * PACKET_FLOW_HOP_MS)) / PACKET_FLOW_TRAIL_FADE_MS) : 1; const coords = trailCoords(p.coords, headT); @@ -86,27 +139,12 @@ export function useMapPacketFlow( if (t <= nSeg) { dots.push({ type: "Feature", properties: { r: 5, a: 1 }, geometry: { type: "Point", coordinates: posAtHop(p.coords, headT) } }); } - // light every node the dot has reached; they hold at full while it travels, then fade with the trail - if (fade > 0) { - for (let k = 0; k <= p.lastNode; k++) { - const id = p.ids[k]; - if (id != null) glowByNode.set(id, Math.max(glowByNode.get(id) ?? 0, fade)); - } - } if (t > nSeg && fade <= 0) flowsRef.current.splice(i, 1); } - // apply node glows via feature-state; drop nodes that are no longer lit by any packet - try { - for (const [id, g] of glowByNode) map?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow: g }); - for (const id of litRef.current) { - if (!glowByNode.has(id)) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); - } - } catch { /* node gone */ } - litRef.current = new Set(glowByNode.keys()); - (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: lines }); (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: dots }); + (map?.getSource(LIVE_LIT_SOURCE_ID) as GeoJSONSource | undefined)?.setData(litFeatureCollection(litRef.current, now)); const busy = flowsRef.current.length > 0 || litRef.current.size > 0; rafRef.current = busy ? requestAnimationFrame(frame) : null; @@ -114,12 +152,16 @@ export function useMapPacketFlow( rafRef.current = requestAnimationFrame(frame); }, [mapRef]); - // build the trail + dot layers (re-add after a style switch); the dot is orange with a white stroke - // and a dark halo behind it, the trail a dashed line whose opacity is data-driven + // Build the layers bottom to top (re-added after a style switch): dashed trail, lit names, lit + // icons, then the dot's dark halo and the orange dot. Names sit under every lit icon and both + // sort by lit order, so the newest node covers older ones and their names. useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; + const textColor = isDark ? "#fafafa" : "#18181b"; + const halo = isDark ? "#09090b" : "#ffffff"; + if (!map.getSource(PACKET_FLOW_TRAIL_SOURCE_ID)) { map.addSource(PACKET_FLOW_TRAIL_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); } @@ -132,6 +174,48 @@ export function useMapPacketFlow( paint: { "line-color": PACKET_FLOW_COLOR, "line-width": 2.5, "line-dasharray": [2, 2], "line-opacity": ["get", "a"] }, } as LineLayerSpecification); } + + if (!map.getSource(LIVE_LIT_SOURCE_ID)) { + map.addSource(LIVE_LIT_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + const beforeHalo = map.getLayer(PACKET_FLOW_DOT_HALO_LAYER_ID) ? PACKET_FLOW_DOT_HALO_LAYER_ID : undefined; + if (!map.getLayer(LIVE_LIT_LABEL_LAYER_ID)) { + map.addLayer({ + id: LIVE_LIT_LABEL_LAYER_ID, + type: "symbol", + source: LIVE_LIT_SOURCE_ID, + layout: { + "text-field": ["get", "label"], + "text-font": ["Noto Sans Regular"], + "text-size": LIT_LABEL_SIZE, + "text-anchor": "top", + "text-offset": [0, 1.5], + "text-allow-overlap": true, + "text-ignore-placement": true, + "symbol-sort-key": ["get", "seq"], + "symbol-z-order": "source", + }, + paint: { "text-color": textColor, "text-halo-color": halo, "text-halo-width": 1.5, "text-opacity": ["get", "glow"] }, + } as SymbolLayerSpecification, beforeHalo); + } + map.setPaintProperty(LIVE_LIT_LABEL_LAYER_ID, "text-color", textColor); + map.setPaintProperty(LIVE_LIT_LABEL_LAYER_ID, "text-halo-color", halo); + if (!map.getLayer(LIVE_LIT_ICON_LAYER_ID)) { + map.addLayer({ + id: LIVE_LIT_ICON_LAYER_ID, + type: "symbol", + source: LIVE_LIT_SOURCE_ID, + layout: { + "icon-image": LIT_ICON_IMAGE, + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "symbol-sort-key": ["get", "seq"], + "symbol-z-order": "source", + }, + paint: { "icon-opacity": ["get", "glow"] }, + } as SymbolLayerSpecification, beforeHalo); + } + if (!map.getSource(PACKET_FLOW_DOT_SOURCE_ID)) { map.addSource(PACKET_FLOW_DOT_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); } @@ -157,7 +241,33 @@ export function useMapPacketFlow( }, } as CircleLayerSpecification); } - }, [mapRef, isReady, themeKey]); + }, [mapRef, isReady, themeKey, isDark]); + + // clicking a lit icon opens that node, like clicking its marker + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const onClick = (e: MapLayerMouseEvent) => { + // an icon that has nearly faded out lets the click through to what's beneath it + const hit = e.features?.find((f) => Number(f.properties?.["glow"]) >= LIT_CLICK_MIN_GLOW); + const id = hit?.properties?.["id"]; + if (typeof id === "string") onSelectNodeRef.current(id); + }; + const setPointer = () => { + map.getCanvas().style.cursor = "pointer"; + }; + const clearPointer = () => { + map.getCanvas().style.cursor = ""; + }; + map.on("click", LIVE_LIT_ICON_LAYER_ID, onClick); + map.on("mouseenter", LIVE_LIT_ICON_LAYER_ID, setPointer); + map.on("mouseleave", LIVE_LIT_ICON_LAYER_ID, clearPointer); + return () => { + map.off("click", LIVE_LIT_ICON_LAYER_ID, onClick); + map.off("mouseenter", LIVE_LIT_ICON_LAYER_ID, setPointer); + map.off("mouseleave", LIVE_LIT_ICON_LAYER_ID, clearPointer); + }; + }, [mapRef, isReady]); // connection-wide resolvePath toggle: on while enabled, off otherwise useEffect(() => { @@ -165,9 +275,6 @@ export function useMapPacketFlow( return () => wsManager.setResolvePath(false); }, [enabled, wsManager]); - // 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(() => { if (!enabled) return; @@ -207,10 +314,10 @@ export function useMapPacketFlow( clearFlows(map); if (!map) return; try { - for (const id of [PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID]) { + for (const id of [PACKET_FLOW_TRAIL_LAYER_ID, LIVE_LIT_LABEL_LAYER_ID, LIVE_LIT_ICON_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID]) { if (map.getLayer(id)) map.removeLayer(id); } - for (const id of [PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_DOT_SOURCE_ID]) { + for (const id of [PACKET_FLOW_TRAIL_SOURCE_ID, LIVE_LIT_SOURCE_ID, PACKET_FLOW_DOT_SOURCE_ID]) { if (map.getSource(id)) map.removeSource(id); } } catch { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2a858a8..546ce2e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -605,6 +605,7 @@ "nodeType": "Node Type", "nodeTypeLabel": "Node type", "clustering": "Clustering", + "clusteringLiveNote": "Off while Live Map is on", "neighborLines": "Neighbor Lines", "neighborLinesLabel": "Neighbor lines", "areaBorders": "Area Borders", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c00b5e4..5af7880 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -585,6 +585,7 @@ "nodeType": "Type de nœud", "nodeTypeLabel": "Type de nœud", "clustering": "Regroupement", + "clusteringLiveNote": "Désactivé tant que la carte en direct est active", "neighborLines": "Liens de voisinage", "neighborLinesLabel": "Liens de voisinage", "areaBorders": "Limites de zone", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index d455120..880694e 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -570,6 +570,7 @@ "nodeType": "Nodtyp", "nodeTypeLabel": "Nodtyp", "clustering": "Klustring", + "clusteringLiveNote": "Av medan livekartan är på", "neighborLines": "Grannlinjer", "neighborLinesLabel": "Grannlinjer", "areaBorders": "Områdesgränser", diff --git a/tests/features/map/MapSettingsPanel.test.tsx b/tests/features/map/MapSettingsPanel.test.tsx index 96610f3..43ffbc1 100644 --- a/tests/features/map/MapSettingsPanel.test.tsx +++ b/tests/features/map/MapSettingsPanel.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, within, fireEvent } from "@testing-library/react"; import { MapSettingsPanel } from "../../../src/features/map/MapSettingsPanel"; import { PacketFlowButton } from "../../../src/features/map/PacketFlowButton"; import i18n from "../../../src/i18n"; @@ -30,4 +30,21 @@ describe("map settings", () => { expect(screen.getByText("Contour de zone")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Lancer le flux de paquets en direct sur la carte" })).toHaveTextContent("Carte en direct"); }); + + it("shows clustering off and disabled with a note while Live Map is on", async () => { + await i18n.changeLanguage("en"); + const onClusteredChange = vi.fn(); + render( + {}} typeFilter="" onTypeChange={() => {}} + clustered onClusteredChange={onClusteredChange} neighborLines="selected" onNeighborLinesChange={() => {}} + borders onBordersChange={() => {}} buildShareParams={() => ({})} liveMap />, + ); + const group = screen.getByRole("group", { name: "Clustering" }); + const off = within(group).getByRole("button", { name: "Off" }); + expect(off).toHaveAttribute("aria-pressed", "true"); + expect(off).toBeDisabled(); + expect(screen.getByText("Off while Live Map is on")).toBeInTheDocument(); + fireEvent.click(within(group).getByRole("button", { name: "On" })); + expect(onClusteredChange).not.toHaveBeenCalled(); + }); }); diff --git a/tests/features/map/live-map.test.ts b/tests/features/map/live-map.test.ts new file mode 100644 index 0000000..f00a68f --- /dev/null +++ b/tests/features/map/live-map.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { + stripPictographs, + nodeGlow, + idleDotTypes, + lightNode, + litFeatureCollection, + buildLiveNodeIndex, + withSelectedNode, + type LitEntry, +} from "../../../src/features/map/live-map"; + +describe("stripPictographs", () => { + it.each([ + ["💽 Back_Talk_Bouncer", "Back_Talk_Bouncer"], + ["Valentine 🐶", "Valentine"], + ["🇨🇦 Ottawa", "Ottawa"], + ["👍🏽", ""], + ["👨‍👩‍👧 Home Base", "Home Base"], + ["Keycap 1️⃣", "Keycap 1"], + ["Plain Name", "Plain Name"], + ])("%s -> %s", (input, out) => expect(stripPictographs(input)).toBe(out)); +}); + +describe("nodeGlow", () => { + it("is full at the crossing and fades linearly over 900 ms", () => { + expect(nodeGlow(1000, 1000)).toBe(1); + expect(nodeGlow(1000, 1450)).toBeCloseTo(0.5); + expect(nodeGlow(1000, 1900)).toBe(0); + expect(nodeGlow(1000, 5000)).toBe(0); + }); +}); + +describe("idleDotTypes", () => { + it("shows repeaters and companions for All, otherwise only the filtered type", () => { + expect(idleDotTypes("")).toEqual(["repeater", "companion"]); + expect(idleDotTypes("repeater")).toEqual(["repeater"]); + expect(idleDotTypes("companion")).toEqual(["companion"]); + }); +}); + +describe("litFeatureCollection", () => { + it("returns lit nodes with their glow and lit order, and drops faded ones", () => { + const lit = new Map(); + lightNode(lit, { id: "a", nodeTypeName: "repeater", label: "A", lng: 1, lat: 2, crossedAt: 0, seq: 1 }); + lightNode(lit, { id: "b", nodeTypeName: "companion", label: "B", lng: 3, lat: 4, crossedAt: 600, seq: 2 }); + const fc = litFeatureCollection(lit, 900); + expect(fc.features.map((f) => f.properties.id)).toEqual(["b"]); + expect(fc.features[0]!.properties).toMatchObject({ seq: 2, label: "B", nodeTypeName: "companion" }); + expect(fc.features[0]!.properties.glow).toBeCloseTo(2 / 3); + expect(fc.features[0]!.geometry.coordinates).toEqual([3, 4]); + expect(lit.has("a")).toBe(false); + }); + + it("re-lighting a node gives it the new lit order", () => { + const lit = new Map(); + lightNode(lit, { id: "a", nodeTypeName: "repeater", label: "A", lng: 1, lat: 2, crossedAt: 0, seq: 1 }); + lightNode(lit, { id: "a", nodeTypeName: "repeater", label: "A", lng: 1, lat: 2, crossedAt: 100, seq: 5 }); + expect(litFeatureCollection(lit, 100).features[0]!.properties).toMatchObject({ seq: 5, glow: 1 }); + }); +}); + +describe("buildLiveNodeIndex", () => { + it("indexes every node by id with type and name", () => { + const fc = { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [1, 2] }, + properties: { id: "x", name: "X", nodeTypeName: "sensor", isObserver: false }, + }, + ], + }; + expect(buildLiveNodeIndex(fc).get("x")).toEqual({ nodeTypeName: "sensor", name: "X" }); + }); +}); + +describe("withSelectedNode", () => { + const feat = (id: string, nodeTypeName: string) => ({ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [1, 2] }, + properties: { id, name: id, nodeTypeName, isObserver: false }, + }); + const base = { type: "FeatureCollection" as const, features: [feat("r", "repeater"), feat("c", "companion")] }; + const filtered = { type: "FeatureCollection" as const, features: [feat("r", "repeater")] }; + + it("adds the selected node back when the type filter hid it", () => { + expect(withSelectedNode(filtered, base, "c").features.map((f) => f.properties.id)).toEqual(["r", "c"]); + }); + it("returns the filtered set unchanged when nothing needs adding", () => { + expect(withSelectedNode(filtered, base, "r")).toBe(filtered); + expect(withSelectedNode(filtered, base, null)).toBe(filtered); + expect(withSelectedNode(filtered, base, "gone")).toBe(filtered); + }); +}); diff --git a/tests/features/map/node-icons-lit.test.ts b/tests/features/map/node-icons-lit.test.ts new file mode 100644 index 0000000..9ce584a --- /dev/null +++ b/tests/features/map/node-icons-lit.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { litIconSvg, MAP_ICON_IDS } from "../../../src/features/map/node-icons"; + +const GLYPH = ``; + +describe("litIconSvg", () => { + const svg = litIconSvg(GLYPH, "#A78BFA"); + + it("fills the disc with the type colour and draws the glyph in white", () => { + expect(svg).toContain('r="29" fill="#A78BFA"'); + expect(svg).toContain(' { + expect(svg).toContain('stroke="#ff6b35"'); + expect(svg).toMatch(/r="39\.1\d*"/); // 16 * 88 / 36 + expect(svg).toMatch(/stroke-width="6\.1\d*"/); // 2.5 * 88 / 36 + }); +}); + +it("registers a lit icon per node type", () => { + expect(MAP_ICON_IDS).toEqual( + expect.arrayContaining(["node-lit-repeater", "node-lit-companion", "node-lit-room_server", "node-lit-sensor"]), + ); +}); diff --git a/tests/features/map/useMapNodes.layer-order.test.tsx b/tests/features/map/useMapNodes.layer-order.test.tsx index 0f9e1b7..b1bdd2d 100644 --- a/tests/features/map/useMapNodes.layer-order.test.tsx +++ b/tests/features/map/useMapNodes.layer-order.test.tsx @@ -4,6 +4,9 @@ import type { Map as MapLibreMap } from "maplibre-gl"; import { NODES_CLUSTER_LAYER_ID, NODES_POINT_LAYER_ID, + NODES_SELECTED_LAYER_ID, + LIVE_DOTS_LAYER_ID, + LIVE_LIT_ICON_LAYER_ID, PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID, @@ -67,4 +70,78 @@ describe("useMapNodes layer order", () => { expect(layers.indexOf(NODES_POINT_LAYER_ID)).toBeLessThan(trail); expect(layers.indexOf(NODES_CLUSTER_LAYER_ID)).toBeLessThan(layers.indexOf(NODES_POINT_LAYER_ID)); }); + + it("puts the idle dots beneath the selected ring, the markers and the packet trail, also after a clustering toggle", () => { + const layers: string[] = []; + const mapRef = { current: stackMap(layers) }; + const geojson = { type: "FeatureCollection" as const, features: [] }; + const { rerender } = renderHook( + ({ clustered }) => + useMapNodes(mapRef, { current: null }, true, geojson, true, "dark", clustered, () => {}, null, true, null), + { initialProps: { clustered: true } }, + ); + layers.push(PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID); + const check = () => { + const dots = layers.indexOf(LIVE_DOTS_LAYER_ID); + expect(dots).toBeGreaterThanOrEqual(0); + expect(dots).toBeLessThan(layers.indexOf(NODES_SELECTED_LAYER_ID)); + expect(layers.indexOf(NODES_SELECTED_LAYER_ID)).toBeLessThan(layers.indexOf(NODES_POINT_LAYER_ID)); + expect(layers.indexOf(NODES_POINT_LAYER_ID)).toBeLessThan(layers.indexOf(PACKET_FLOW_TRAIL_LAYER_ID)); + }; + check(); + rerender({ clustered: false }); + check(); + }); + + it("in Live shows the dots and only the selected node's marker; off again restores the markers", () => { + const map = stackMap([]); + const geojson = { type: "FeatureCollection" as const, features: [] }; + const { rerender } = renderHook( + ({ live }) => useMapNodes({ current: map }, { current: null }, true, geojson, true, "dark", false, () => {}, "n1", live, null), + { initialProps: { live: true } }, + ); + expect(map.setLayoutProperty).toHaveBeenLastCalledWith(LIVE_DOTS_LAYER_ID, "visibility", "visible"); + expect(map.setFilter).toHaveBeenCalledWith(NODES_POINT_LAYER_ID, ["all", ["!", ["has", "point_count"]], ["==", ["get", "id"], "n1"]]); + rerender({ live: false }); + expect(map.setLayoutProperty).toHaveBeenLastCalledWith(LIVE_DOTS_LAYER_ID, "visibility", "none"); + expect(map.setFilter).toHaveBeenLastCalledWith(NODES_POINT_LAYER_ID, ["!", ["has", "point_count"]]); + }); + + it("filters the dots to the given node types", () => { + const map = stackMap([]); + const geojson = { type: "FeatureCollection" as const, features: [] }; + renderHook(() => + useMapNodes({ current: map }, { current: null }, true, geojson, true, "dark", false, () => {}, null, true, null, "", ["companion"]), + ); + expect(map.setFilter).toHaveBeenCalledWith(LIVE_DOTS_LAYER_ID, [ + "all", + ["!", ["has", "point_count"]], + ["in", ["get", "nodeTypeName"], ["literal", ["companion"]]], + ]); + }); + + it("leaves a click to the lit icon drawn over a dot", () => { + const map = stackMap([LIVE_LIT_ICON_LAYER_ID]); + const onSelect = vi.fn(); + const geojson = { type: "FeatureCollection" as const, features: [] }; + renderHook(() => useMapNodes({ current: map }, { current: null }, true, geojson, true, "dark", false, onSelect, null, true, null)); + const dotClick = vi.mocked(map.on).mock.calls.find((c) => c[0] === "click" && c[1] === LIVE_DOTS_LAYER_ID)![2] as unknown as (e: unknown) => void; + vi.mocked(map.queryRenderedFeatures).mockReturnValue([{ properties: { id: "lit", glow: 0.9 } }] as never); + dotClick({ point: { x: 1, y: 2 }, features: [{ properties: { id: "dot" } }] }); + expect(onSelect).not.toHaveBeenCalled(); + vi.mocked(map.queryRenderedFeatures).mockReturnValue([]); + dotClick({ point: { x: 1, y: 2 }, features: [{ properties: { id: "dot" } }] }); + expect(onSelect).toHaveBeenCalledWith("dot"); + }); + + it("lets a click through a lit icon that has almost faded", () => { + const map = stackMap([LIVE_LIT_ICON_LAYER_ID]); + const onSelect = vi.fn(); + const geojson = { type: "FeatureCollection" as const, features: [] }; + renderHook(() => useMapNodes({ current: map }, { current: null }, true, geojson, true, "dark", false, onSelect, null, true, null)); + const dotClick = vi.mocked(map.on).mock.calls.find((c) => c[0] === "click" && c[1] === LIVE_DOTS_LAYER_ID)![2] as unknown as (e: unknown) => void; + vi.mocked(map.queryRenderedFeatures).mockReturnValue([{ properties: { id: "lit", glow: 0.05 } }] as never); + dotClick({ point: { x: 1, y: 2 }, features: [{ properties: { id: "dot" } }] }); + expect(onSelect).toHaveBeenCalledWith("dot"); + }); }); diff --git a/tests/features/map/useMapPacketFlow.lit.test.tsx b/tests/features/map/useMapPacketFlow.lit.test.tsx new file mode 100644 index 0000000..13d9619 --- /dev/null +++ b/tests/features/map/useMapPacketFlow.lit.test.tsx @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { Map as MapLibreMap } from "maplibre-gl"; +import type { WsManager } from "../../../src/api/ws-manager"; +import type { WsPacketObservation } from "../../../src/types/ws"; +import type { ResolvedHop } from "../../../src/types/api"; +import type { LiveNodeInfo } from "../../../src/features/map/live-map"; +import { + PACKET_FLOW_TRAIL_LAYER_ID, + PACKET_FLOW_DOT_HALO_LAYER_ID, + PACKET_FLOW_DOT_LAYER_ID, + PACKET_FLOW_HOP_MS, + LIVE_LIT_SOURCE_ID, + LIVE_LIT_LABEL_LAYER_ID, + LIVE_LIT_ICON_LAYER_ID, +} from "../../../src/features/map/types"; +import { useMapPacketFlow } from "../../../src/features/map/useMapPacketFlow"; + +type Handler = (e: unknown) => void; + +// map stub with a real layer stack, per-source setData spies and recorded event handlers +function stubMap() { + const layers: string[] = []; + const sources = new Map }>(); + const handlers: { type: string; layer?: string; fn: Handler }[] = []; + const canvas = { style: {} as Record }; + const map = { + getCanvas: () => canvas, + getLayer: (id: string) => (layers.includes(id) ? { id } : undefined), + addLayer: (spec: { id: string }, beforeId?: string) => { + const at = beforeId ? layers.indexOf(beforeId) : -1; + if (at >= 0) layers.splice(at, 0, spec.id); + else layers.push(spec.id); + }, + removeLayer: (id: string) => layers.splice(layers.indexOf(id), 1), + getSource: (id: string) => sources.get(id), + addSource: (id: string) => sources.set(id, { setData: vi.fn() }), + removeSource: (id: string) => sources.delete(id), + setPaintProperty: vi.fn(), + on: (type: string, a: string | Handler, b?: Handler) => + handlers.push(typeof a === "string" ? { type, layer: a, fn: b! } : { type, fn: a }), + off: (type: string, a: string | Handler, b?: Handler) => { + const fn = typeof a === "string" ? b : a; + const i = handlers.findIndex((h) => h.type === type && h.fn === fn); + if (i >= 0) handlers.splice(i, 1); + }, + }; + const lastLit = () => { + const calls = sources.get(LIVE_LIT_SOURCE_ID)?.setData.mock.calls ?? []; + return calls.at(-1)?.[0] as { features: { properties: Record }[] } | undefined; + }; + return { map: map as unknown as MapLibreMap, layers, sources, handlers, lastLit }; +} + +function hop(id: string, lng: number, lat: number): ResolvedHop { + return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }; +} + +function fakeWs() { + let handler: ((d: WsPacketObservation["data"]) => void) | null = null; + const ws = { + setResolvePath: vi.fn(), + onPacketObservation: (cb: (d: WsPacketObservation["data"]) => void) => { + handler = cb; + return () => { + handler = null; + }; + }, + } as unknown as WsManager; + const emit = (path: ResolvedHop[]) => + handler?.({ observation: { resolvedPath: path, resolvedSource: null, resolvedDestination: null } } as unknown as WsPacketObservation["data"]); + return { ws, emit }; +} + +let now = 0; +let rafs = new Map(); +let rafId = 0; +function frame(t: number) { + now = t; + const due = [...rafs.values()]; + rafs = new Map(); + due.forEach((cb) => cb(t)); +} + +beforeEach(() => { + now = 0; + rafs = new Map(); + vi.spyOn(performance, "now").mockImplementation(() => now); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafs.set(++rafId, cb); + return rafId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => rafs.delete(id)); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const index = new Map([ + ["r1", { nodeTypeName: "repeater", name: "💽 R1" }], + ["r2", { nodeTypeName: "repeater", name: "R2" }], +]); + +function setup(onSelect = vi.fn()) { + const m = stubMap(); + const { ws, emit } = fakeWs(); + const view = renderHook( + ({ enabled, themeKey, resetKey }) => + useMapPacketFlow({ current: m.map }, true, enabled, ws, themeKey, resetKey, true, index, onSelect), + { initialProps: { enabled: true, themeKey: "dark", resetKey: "r" } }, + ); + return { ...m, emit, view, onSelect }; +} + +const ORDER = [ + PACKET_FLOW_TRAIL_LAYER_ID, + LIVE_LIT_LABEL_LAYER_ID, + LIVE_LIT_ICON_LAYER_ID, + PACKET_FLOW_DOT_HALO_LAYER_ID, + PACKET_FLOW_DOT_LAYER_ID, +]; + +describe("useMapPacketFlow lit nodes", () => { + it("stacks trail < lit labels < lit icons < dot halo < dot, also after a basemap switch", () => { + const { layers, sources, view } = setup(); + expect(layers).toEqual(ORDER); + // a basemap switch drops every custom layer and source; the effect rebuilds them in order + layers.length = 0; + sources.clear(); + view.rerender({ enabled: true, themeKey: "light", resetKey: "r" }); + expect(layers).toEqual(ORDER); + }); + + it("lights crossed nodes in lit order, labels them without emoji, and fades each one", () => { + const { emit, lastLit } = setup(); + emit([hop("r1", -75, 45), hop("r2", -74, 45)]); + frame(0); + expect(lastLit()!.features.map((f) => f.properties)).toEqual([ + { id: "r1", nodeTypeName: "repeater", label: "R1", seq: 1, glow: 1 }, + ]); + frame(PACKET_FLOW_HOP_MS); + const byId = Object.fromEntries(lastLit()!.features.map((f) => [f.properties.id, f.properties])); + expect(byId["r2"]).toMatchObject({ seq: 2, glow: 1, label: "R2" }); + expect(byId["r1"]!.glow).toBeCloseTo(1 - PACKET_FLOW_HOP_MS / 900); + frame(PACKET_FLOW_HOP_MS + 900); + expect(lastLit()!.features).toEqual([]); + }); + + it("skips hops missing from the node index", () => { + const { emit, lastLit } = setup(); + emit([hop("r1", -75, 45), hop("ghost", -74, 45)]); + frame(0); + frame(PACKET_FLOW_HOP_MS); + expect(lastLit()!.features.map((f) => f.properties.id)).toEqual(["r1"]); + }); + + it("empties the lit source when Live turns off or the region changes", () => { + const { emit, lastLit, view } = setup(); + emit([hop("r1", -75, 45), hop("r2", -74, 45)]); + frame(0); + view.rerender({ enabled: true, themeKey: "dark", resetKey: "other" }); + expect(lastLit()!.features).toEqual([]); + view.rerender({ enabled: true, themeKey: "dark", resetKey: "other" }); + emit([hop("r1", -75, 45), hop("r2", -74, 45)]); + frame(10); + expect(lastLit()!.features).toHaveLength(1); + view.rerender({ enabled: false, themeKey: "dark", resetKey: "other" }); + expect(lastLit()!.features).toEqual([]); + }); + + it("selects the node when its lit icon is clicked", () => { + const { handlers, onSelect } = setup(); + const click = handlers.find((h) => h.type === "click" && h.layer === LIVE_LIT_ICON_LAYER_ID); + click!.fn({ features: [{ properties: { id: "r1", glow: 0.9 } }] }); + expect(onSelect).toHaveBeenCalledWith("r1"); + }); + + it("ignores a click on a lit icon that has almost faded", () => { + const { handlers, onSelect } = setup(); + const click = handlers.find((h) => h.type === "click" && h.layer === LIVE_LIT_ICON_LAYER_ID); + click!.fn({ features: [{ properties: { id: "r1", glow: 0.05 } }] }); + expect(onSelect).not.toHaveBeenCalled(); + }); +});