mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 14:11:24 +00:00
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:
1 parent
19f604e91e
commit
79f05a2ebd
16 files changed
+810
-68
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -570,6 +570,7 @@
|
||||
"nodeType": "Nodtyp",
|
||||
"nodeTypeLabel": "Nodtyp",
|
||||
"clustering": "Klustring",
|
||||
"clusteringLiveNote": "Av medan livekartan är på",
|
||||
"neighborLines": "Grannlinjer",
|
||||
"neighborLinesLabel": "Grannlinjer",
|
||||
"areaBorders": "Områdesgränser",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user