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
This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-18 14:27:13 -04:00
1 parent 19f604e91e
commit 79f05a2ebd
16 files changed
+810 -68

No files matched your search

+6 -1
View File
@@ -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<string, string | null>;
// 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({
<SegmentedControl
ariaLabel={t("map.clustering")}
options={[on, off]}
value={clustered ? "on" : "off"}
value={clustered && !liveMap ? "on" : "off"}
onChange={(v) => onClusteredChange(v === "on")}
className="w-full"
disabled={liveMap}
/>
{liveMap && <div className="text-[9px] text-text-dim mt-1">{t("map.clusteringLiveNote")}</div>}
</Section>
<Section title={t("map.neighborLines")}>
<SegmentedControl
+13 -2
View File
@@ -12,6 +12,7 @@ import { useMapPacketFlow } from "./useMapPacketFlow";
import { PacketFlowButton } from "./PacketFlowButton";
import { useMapNodesData } from "./useMapNodesData";
import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, buildFocusedNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson";
import { buildLiveNodeIndex, idleDotTypes, withSelectedNode } from "./live-map";
import { MapSettingsPanel } from "./MapSettingsPanel";
import { parseMapView, parseNodeType, buildMapParams, type MapViewSnapshot } from "./map-url";
import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, MAP_BORDERS_STORAGE_KEY, DEFAULT_CENTER, DEFAULT_ZOOM, type NeighborLinesMode } from "./types";
@@ -142,6 +143,15 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
// re-filters the already-built collection instead of re-running the full transform over all nodes
const baseFc = useMemo(() => 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 (
<div className="relative flex flex-1 min-h-0">
@@ -233,6 +243,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
borders={borders}
onBordersChange={handleBordersChange}
buildShareParams={buildShareParams}
liveMap={packetFlow}
/>
<PacketFlowButton active={packetFlow} onToggle={() => setPacketFlow((v) => !v)} />
{/* streams in 50 at a time; the count climbs as pages land, then the pill disappears */}
+7 -3
View File
@@ -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 (
<div role="group" aria-label={ariaLabel} className={containerCls}>
<div role="group" aria-label={ariaLabel} className={`${containerCls} ${stateCls}`}>
{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}
</button>
+92
View File
@@ -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<Point, NodeFeatureProps>): Map<string, LiveNodeInfo> {
const index = new Map<string, LiveNodeInfo>();
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<string, LitEntry>, 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<string, LitEntry>, now: number): FeatureCollection<Point, LitFeatureProps> {
const features: Feature<Point, LitFeatureProps>[] = [];
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<Point, NodeFeatureProps>,
all: FeatureCollection<Point, NodeFeatureProps>,
selectedId: string | null,
): FeatureCollection<Point, NodeFeatureProps> {
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;
}
+46 -1
View File
@@ -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<string, { colorVar: string; fallback: string }> =
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(/<g [^>]*stroke="currentColor"[^>]*>[\s\S]*?<\/g>/)?.[0] ?? "").replaceAll(
"currentColor",
"#ffffff",
);
const glowR = unit(LIT_GLOW_RADIUS);
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -12 88 88" width="88" height="88">` +
`<defs><radialGradient id="glow" cx="32" cy="32" r="${glowR}" gradientUnits="userSpaceOnUse">` +
`<stop offset="0" stop-color="${color}" stop-opacity="${LIT_GLOW_OPACITY}"/><stop offset="1" stop-color="${color}" stop-opacity="0"/>` +
`</radialGradient></defs>` +
`<circle cx="32" cy="32" r="${glowR}" fill="url(#glow)"/>` +
`<circle cx="32" cy="32" r="${unit(LIT_RING_RADIUS)}" fill="none" stroke="${PACKET_FLOW_COLOR}" stroke-width="${unit(LIT_RING_WIDTH)}"/>` +
`<circle cx="32" cy="32" r="29" fill="${color}" stroke="#ffffff" stroke-opacity="0.5" stroke-width="2"/>` +
glyph +
`</svg>`
);
}
async function rasterizeSvg(
svg: string,
scale: number,
@@ -222,6 +257,16 @@ export async function rasterizeNodeIcon(id: string, isDark: boolean): Promise<Ra
return { data: drawSelectionRing(primary, scale), pixelRatio: scale };
}
// Lit icons look the same on every basemap, so they always take the glyph from the dark set.
const LIT_PREFIX = litIconId("");
if (id.startsWith(LIT_PREFIX)) {
const type = id.slice(LIT_PREFIX.length);
const glyph = svgText(type, false, true);
if (!NODE_TYPE_COLOR[type] || !glyph) return null;
const data = await rasterizeSvg(litIconSvg(glyph, nodeTypeColor(type)), scale, MARKER_SIZE);
return { data, pixelRatio: scale };
}
const PREFIX = "node-";
if (!id.startsWith(PREFIX)) return null;
const observer = id.endsWith("-observer");
+19 -4
View File
@@ -115,17 +115,32 @@ export const PACKET_FLOW_DOT_HALO_LAYER_ID = "packet-flow-dot-halo"; // dark hal
export const PACKET_FLOW_DOT_LAYER_ID = "packet-flow-dot"; // the moving packet dot
export const PACKET_FLOW_COLOR = "#ff6b35"; // warm orange, distinct from the node palette so packets pop
export const PACKET_FLOW_HOP_MS = 480; // ms the dot takes to cross one hop segment
export const PACKET_FLOW_FLASH_MS = 900; // a crossed node's flash decays back to dim over this
export const PACKET_FLOW_FLASH_MS = 900; // a crossed node fades out over this, from the moment the dot reaches it
export const PACKET_FLOW_TRAIL_FADE_MS = 1000; // the dashed trail fades once the dot reaches the end
export const PACKET_FLOW_MAX = 120; // cap on concurrent packet animations (busy-feed guard)
// Idle individual-node opacity while Live is on. Kept low because with clustering off, co-located
// markers overlap and their alphas composite toward bright; a crossed node still pops to full via
// the max(dim, feature-state glow) expression.
// Opacity of the nodes outside the selection focus. Kept low because overlapping markers composite
// toward bright.
export const LIVE_DIM_OPACITY = 0.08;
// Clusters dim further: many overlapping semi-transparent hexagons composite toward opaque, so a
// dense cluster field stops being see-through. A lower per-cluster alpha keeps the stack translucent.
export const LIVE_CLUSTER_DIM_OPACITY = 0.07;
// Live Map idle dots and lit nodes. The sizes and colours are shared with the Flutter app, so both
// draw the same map; change them in step.
export const LIVE_DOTS_LAYER_ID = "live-dots"; // idle nodes as small solid dots
export const LIVE_LIT_SOURCE_ID = "live-lit"; // only the nodes a packet just crossed
export const LIVE_LIT_LABEL_LAYER_ID = "live-lit-labels"; // their names, beneath every lit icon
export const LIVE_LIT_ICON_LAYER_ID = "live-lit-icons";
export const LIVE_DOT_OPACITY = 0.75;
export const LIVE_DOT_RADIUS_STOPS: ReadonlyArray<readonly [number, number]> = [[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
+74 -16
View File
@@ -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<Spiderfy | null>(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);
}
+146 -39
View File
@@ -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<MapLibreMap | null>,
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<string, LiveNodeInfo>,
onSelectNode: (id: string) => void,
) {
const flowsRef = useRef<Flow[]>([]);
const litRef = useRef<Set<string>>(new Set()); // node ids currently lit (feature-state glow set)
const litRef = useRef<Map<string, LitEntry>>(new Map());
const seqRef = useRef(0);
const rafRef = useRef<number | null>(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<Point>[] = [];
const lines: Feature<LineString>[] = [];
const glowByNode = new Map<string, number>(); // 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 {
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -570,6 +570,7 @@
"nodeType": "Nodtyp",
"nodeTypeLabel": "Nodtyp",
"clustering": "Klustring",
"clusteringLiveNote": "Av medan livekartan är på",
"neighborLines": "Grannlinjer",
"neighborLinesLabel": "Grannlinjer",
"areaBorders": "Områdesgränser",
+19 -2
View File
@@ -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(
<MapSettingsPanel styleId="dark" onStyleChange={() => {}} 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();
});
});
+96
View File
@@ -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<string, LitEntry>();
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<string, LitEntry>();
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);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { litIconSvg, MAP_ICON_IDS } from "../../../src/features/map/node-icons";
const GLYPH = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" style="color:var(--node-color,#38e8ff)"><circle cx="32" cy="32" r="29" stroke="currentColor"></circle><g fill="none" stroke="currentColor" stroke-width="3"><line x1="32" y1="26" x2="32" y2="45"></line><circle cx="32" cy="23" r="2.6" fill="currentColor" stroke="none"></circle></g></svg>`;
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('<line x1="32" y1="26" x2="32" y2="45"');
expect(svg).not.toContain("currentColor");
expect(svg).toContain('stroke="#ffffff"');
});
it("bakes in the orange ring at 16 px radius, 2.5 px stroke in the 88-unit / 36 px box", () => {
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"]),
);
});
@@ -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");
});
});
@@ -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<string, { setData: ReturnType<typeof vi.fn> }>();
const handlers: { type: string; layer?: string; fn: Handler }[] = [];
const canvas = { style: {} as Record<string, string> };
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<string, unknown> }[] } | 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<number, FrameRequestCallback>();
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<string, LiveNodeInfo>([
["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();
});
});