mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-25 23:13:35 +00:00
map: live mode dims nodes, flashes each packet's route
This commit is contained in:
@@ -1,84 +1,48 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import type { ResolvedHop } from "../../types/api";
|
||||
|
||||
// Geometry helpers for the packet-flow animation. No maplibre import, so they stay unit-testable.
|
||||
// Pure helpers for the live packet-flow highlight. No maplibre import, so they stay unit-testable.
|
||||
|
||||
// The [lng, lat] path a packet took, one point per resolved hop. Each hop uses its first located
|
||||
// candidate; hops we can't place are dropped, so the route can come back with fewer than 2 points.
|
||||
export function resolvedPathToRoute(resolvedPath: ResolvedHop[]): [number, number][] {
|
||||
const route: [number, number][] = [];
|
||||
// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. These are
|
||||
// the nodes that light up when the packet is observed.
|
||||
export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] {
|
||||
const seen = new Set<string>();
|
||||
const out: { id: string; lng: number; lat: number }[] = [];
|
||||
for (const hop of resolvedPath) {
|
||||
const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null);
|
||||
if (node) route.push([node.longitude!, node.latitude!]);
|
||||
if (node && !seen.has(node.id)) {
|
||||
seen.add(node.id);
|
||||
out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! });
|
||||
}
|
||||
}
|
||||
return route;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cumulative segment lengths (planar distance in degrees — accurate enough at mesh scale) so a pulse
|
||||
// can be placed by fraction of total path length rather than fraction of hop count.
|
||||
export function routeMetrics(coords: [number, number][]): { cumLengths: number[]; total: number } {
|
||||
const cumLengths: number[] = [0];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
const [x0, y0] = coords[i - 1]!;
|
||||
const [x1, y1] = coords[i]!;
|
||||
cumLengths.push(cumLengths[i - 1]! + Math.hypot(x1 - x0, y1 - y0));
|
||||
}
|
||||
return { cumLengths, total: cumLengths[cumLengths.length - 1] ?? 0 };
|
||||
// A node currently lit because it was on a recently-observed path. litAt is performance.now().
|
||||
export interface LitNode {
|
||||
lng: number;
|
||||
lat: number;
|
||||
litAt: number;
|
||||
}
|
||||
|
||||
// Interpolated [lng, lat] at fraction t in [0,1] along the route, by cumulative length.
|
||||
export function positionAt(
|
||||
coords: [number, number][],
|
||||
cumLengths: number[],
|
||||
total: number,
|
||||
t: number,
|
||||
): [number, number] {
|
||||
if (coords.length === 1 || total === 0) return coords[0]!;
|
||||
const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t;
|
||||
const target = clamped * total;
|
||||
let i = 1;
|
||||
while (i < cumLengths.length - 1 && cumLengths[i]! < target) i++;
|
||||
const segStart = cumLengths[i - 1]!;
|
||||
const segEnd = cumLengths[i]!;
|
||||
const segFrac = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart);
|
||||
const [x0, y0] = coords[i - 1]!;
|
||||
const [x1, y1] = coords[i]!;
|
||||
return [x0 + (x1 - x0) * segFrac, y0 + (y1 - y0) * segFrac];
|
||||
}
|
||||
|
||||
// One in-flight packet animation. cumLengths/total are precomputed (routeMetrics) so each frame is
|
||||
// just an interpolation.
|
||||
export interface Pulse {
|
||||
id: number;
|
||||
coords: [number, number][];
|
||||
cumLengths: number[];
|
||||
total: number;
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface PulseFeatureProps {
|
||||
export interface LitFeatureProps {
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
// Elapsed fraction of a pulse's life; >1 once it has arrived (the caller expires those).
|
||||
export function pulseProgress(pulse: Pulse, nowMs: number): number {
|
||||
return pulse.durationMs <= 0 ? 1 : (nowMs - pulse.startMs) / pulse.durationMs;
|
||||
// Opacity of a lit node: 1 the instant it lights, linearly down to 0 by fadeMs, clamped past that.
|
||||
export function litOpacity(litAt: number, nowMs: number, fadeMs: number): number {
|
||||
const t = (nowMs - litAt) / fadeMs;
|
||||
if (t <= 0) return 1;
|
||||
if (t >= 1) return 0;
|
||||
return 1 - t;
|
||||
}
|
||||
|
||||
// Snapshot the live pulses as point features at their current positions. Opacity holds at 1 then
|
||||
// eases out over the final quarter so a pulse fades as it reaches the last repeater.
|
||||
export function buildPulseFC(pulses: Pulse[], nowMs: number): FeatureCollection<Point, PulseFeatureProps> {
|
||||
const features: Feature<Point, PulseFeatureProps>[] = [];
|
||||
for (const p of pulses) {
|
||||
const t = pulseProgress(p, nowMs);
|
||||
const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t;
|
||||
const opacity = clamped < 0.75 ? 1 : Math.max(0, 1 - (clamped - 0.75) / 0.25);
|
||||
features.push({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, clamped) },
|
||||
properties: { opacity },
|
||||
});
|
||||
}
|
||||
// Snapshot the currently-lit nodes as point features carrying their faded opacity.
|
||||
export function buildLitFC(litNodes: LitNode[], nowMs: number, fadeMs: number): FeatureCollection<Point, LitFeatureProps> {
|
||||
const features: Feature<Point, LitFeatureProps>[] = litNodes.map((n) => ({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [n.lng, n.lat] },
|
||||
properties: { opacity: litOpacity(n.litAt, nowMs, fadeMs) },
|
||||
}));
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
@@ -96,12 +96,11 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben
|
||||
export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines";
|
||||
export type NeighborLinesMode = "on" | "selected" | "off";
|
||||
|
||||
// --- Live packet-flow animation ---
|
||||
// --- Live packet-flow: dim every node, then flash a packet's resolved-path nodes and fade them out ---
|
||||
export const PACKET_FLOW_SOURCE_ID = "packet-flow";
|
||||
export const PACKET_FLOW_LAYER_ID = "packet-flow-pulses"; // circle layer on top (moving pulse)
|
||||
export const PACKET_FLOW_MAX_PULSES = 60; // cap concurrent animations; drop the oldest past this
|
||||
export const PACKET_FLOW_SEGMENT_MS = 700; // pulse travel time per hop segment
|
||||
export const PACKET_FLOW_DEDUP_MS = 3000; // collapse repeat observations of one packetHash within this window
|
||||
export const PACKET_FLOW_LAYER_ID = "packet-flow-lit"; // bright highlight drawn over the route's nodes
|
||||
export const PACKET_FLOW_FADE_MS = 4000; // a lit node fades from full opacity back to nothing over this
|
||||
export const LIVE_DIM_OPACITY = 0.1; // base node + cluster opacity while Live mode is on
|
||||
|
||||
export const CLUSTER_RADIUS = 50; // px
|
||||
// Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, ExpressionSpecification } from "maplibre-gl";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { WsManager } from "../../api/ws-manager";
|
||||
import { resolvedPathToRoute, routeMetrics, buildPulseFC, pulseProgress, type Pulse } from "./packet-flow";
|
||||
import { resolvedPathNodes, buildLitFC, type LitNode } from "./packet-flow";
|
||||
import {
|
||||
PACKET_FLOW_SOURCE_ID,
|
||||
PACKET_FLOW_LAYER_ID,
|
||||
PACKET_FLOW_MAX_PULSES,
|
||||
PACKET_FLOW_SEGMENT_MS,
|
||||
PACKET_FLOW_DEDUP_MS,
|
||||
PACKET_FLOW_FADE_MS,
|
||||
LIVE_DIM_OPACITY,
|
||||
NODES_POINT_LAYER_ID,
|
||||
NODES_CLUSTER_LAYER_ID,
|
||||
NODE_LABEL_MIN_ZOOM,
|
||||
} from "./types";
|
||||
|
||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
|
||||
// node labels normally fade in past NODE_LABEL_MIN_ZOOM — restored when Live turns off
|
||||
const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1];
|
||||
|
||||
function paletteVar(name: string, fallback: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
// Live "packets moving between repeaters" overlay. While enabled it turns on resolvedPath over the
|
||||
// WS (setResolvePath) and animates a pulse along each observed packet's path. Geometry is pure
|
||||
// (packet-flow.ts); here we own the maplibre source, the rAF loop, and the subscription.
|
||||
// Live mode: dim every node/cluster to near-invisible, then flash the nodes on each observed packet's
|
||||
// resolved path to full opacity and fade them back over PACKET_FLOW_FADE_MS. Enabling it also opts the
|
||||
// WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the maplibre
|
||||
// highlight layer, the dimming, the rAF fade loop, and the subscription.
|
||||
export function useMapPacketFlow(
|
||||
mapRef: React.RefObject<MapLibreMap | null>,
|
||||
isReady: boolean,
|
||||
@@ -28,47 +34,46 @@ export function useMapPacketFlow(
|
||||
themeKey: string,
|
||||
resetKey: string,
|
||||
) {
|
||||
const pulsesRef = useRef<Pulse[]>([]);
|
||||
const litRef = useRef<Map<string, LitNode>>(new Map());
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const nextIdRef = useRef(0);
|
||||
const recentRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// start the rAF loop if it's idle. The frame reschedules itself until the last pulse expires, then
|
||||
// leaves rafRef null so we stop instead of spinning on an empty source.
|
||||
// fade loop: recompute each lit node's opacity, drop the fully-faded, stop once none remain
|
||||
const startLoop = useCallback(() => {
|
||||
if (rafRef.current != null) return;
|
||||
function frame() {
|
||||
const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined;
|
||||
const now = performance.now();
|
||||
pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1);
|
||||
if (src) src.setData(buildPulseFC(pulsesRef.current, now));
|
||||
rafRef.current = pulsesRef.current.length > 0 ? requestAnimationFrame(frame) : null;
|
||||
for (const [id, n] of litRef.current) {
|
||||
if (now - n.litAt >= PACKET_FLOW_FADE_MS) litRef.current.delete(id);
|
||||
}
|
||||
const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined;
|
||||
if (src) src.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS));
|
||||
rafRef.current = litRef.current.size > 0 ? requestAnimationFrame(frame) : null;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(frame);
|
||||
}, [mapRef]);
|
||||
|
||||
// build the pulse source + layer; re-adds itself after every style switch, re-tints on theme change
|
||||
// build the highlight source + layer (a bright glow over lit nodes); re-adds after a style switch
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isReady) return;
|
||||
|
||||
const accent = paletteVar("--palette-primary", "#3B82F6");
|
||||
if (!map.getSource(PACKET_FLOW_SOURCE_ID)) {
|
||||
map.addSource(PACKET_FLOW_SOURCE_ID, { type: "geojson", data: EMPTY_FC });
|
||||
}
|
||||
if (!map.getLayer(PACKET_FLOW_LAYER_ID)) {
|
||||
// no beforeId: draw on top of the node markers so the moving pulse stays visible
|
||||
// no beforeId: draw on top so the highlight pops over the dimmed markers
|
||||
map.addLayer({
|
||||
id: PACKET_FLOW_LAYER_ID,
|
||||
type: "circle",
|
||||
source: PACKET_FLOW_SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": 5,
|
||||
"circle-radius": 9,
|
||||
"circle-color": accent,
|
||||
"circle-opacity": ["get", "opacity"],
|
||||
"circle-stroke-width": 1.5,
|
||||
"circle-opacity": ["*", ["get", "opacity"], 0.85],
|
||||
"circle-blur": 0.35,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": accent,
|
||||
"circle-stroke-opacity": ["*", ["get", "opacity"], 0.5],
|
||||
"circle-stroke-opacity": ["get", "opacity"],
|
||||
},
|
||||
} as CircleLayerSpecification);
|
||||
}
|
||||
@@ -76,54 +81,47 @@ export function useMapPacketFlow(
|
||||
map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent);
|
||||
}, [mapRef, isReady, themeKey]);
|
||||
|
||||
// connection-wide resolvePath toggle: on when enabled, off on disable/unmount
|
||||
// connection-wide resolvePath toggle: on while enabled, off otherwise
|
||||
useEffect(() => {
|
||||
wsManager.setResolvePath(enabled);
|
||||
return () => wsManager.setResolvePath(false);
|
||||
}, [enabled, wsManager]);
|
||||
|
||||
// feed observed resolved paths into new pulses; tear the animation down when disabled
|
||||
// dim (or restore) the base node + cluster layers. Keyed on themeKey too so it re-applies after
|
||||
// useMapNodes rebuilds its layers on a theme/style change (that hook runs first, resetting opacity).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isReady) return;
|
||||
const iconOpacity = enabled ? LIVE_DIM_OPACITY : 1;
|
||||
for (const id of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID]) {
|
||||
if (map.getLayer(id)) map.setPaintProperty(id, "icon-opacity", iconOpacity);
|
||||
}
|
||||
if (map.getLayer(NODES_POINT_LAYER_ID)) {
|
||||
map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY);
|
||||
}
|
||||
if (map.getLayer(NODES_CLUSTER_LAYER_ID)) {
|
||||
map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1);
|
||||
}
|
||||
}, [mapRef, isReady, enabled, themeKey]);
|
||||
|
||||
// light up each observed packet's resolved-path nodes; tear down when disabled
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const map = mapRef.current; // stable for the component's life; used to clear the source on cleanup
|
||||
const map = mapRef.current;
|
||||
const lit = litRef.current; // stable Map for the component's life; used in the cleanup too
|
||||
const unsub = wsManager.onPacketObservation((data) => {
|
||||
const resolved = data.observation?.resolvedPath;
|
||||
if (!resolved || resolved.length === 0) return;
|
||||
|
||||
const nodes = resolvedPathNodes(resolved);
|
||||
if (nodes.length === 0) return;
|
||||
const now = performance.now();
|
||||
// many observers report the same packet — animate it once per dedup window
|
||||
const seenAt = recentRef.current.get(data.packetHash);
|
||||
if (seenAt != null && now - seenAt < PACKET_FLOW_DEDUP_MS) return;
|
||||
|
||||
const coords = resolvedPathToRoute(resolved);
|
||||
if (coords.length < 2) return; // nothing to draw between
|
||||
const { cumLengths, total } = routeMetrics(coords);
|
||||
if (total === 0) return;
|
||||
|
||||
// record only after we know this observation produced a pulse, so a partially-resolved report
|
||||
// doesn't suppress a later fully-resolved one for the same packet
|
||||
recentRef.current.set(data.packetHash, now);
|
||||
for (const [hash, ts] of recentRef.current) {
|
||||
if (now - ts > PACKET_FLOW_DEDUP_MS) recentRef.current.delete(hash);
|
||||
}
|
||||
|
||||
pulsesRef.current.push({
|
||||
id: nextIdRef.current++,
|
||||
coords,
|
||||
cumLengths,
|
||||
total,
|
||||
startMs: now,
|
||||
durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS,
|
||||
});
|
||||
if (pulsesRef.current.length > PACKET_FLOW_MAX_PULSES) {
|
||||
pulsesRef.current.splice(0, pulsesRef.current.length - PACKET_FLOW_MAX_PULSES);
|
||||
}
|
||||
for (const n of nodes) lit.set(n.id, { lng: n.lng, lat: n.lat, litAt: now });
|
||||
startLoop();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
pulsesRef.current = [];
|
||||
lit.clear();
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
@@ -133,10 +131,9 @@ export function useMapPacketFlow(
|
||||
};
|
||||
}, [enabled, wsManager, mapRef, startLoop]);
|
||||
|
||||
// clear in-flight pulses when the region changes (their geometry came from the old dataset)
|
||||
// clear highlights when the region changes (those nodes came from the old dataset)
|
||||
useEffect(() => {
|
||||
pulsesRef.current = [];
|
||||
recentRef.current.clear();
|
||||
litRef.current.clear();
|
||||
const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined;
|
||||
src?.setData(EMPTY_FC);
|
||||
}, [resetKey, mapRef]);
|
||||
|
||||
@@ -1,55 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolvedPathToRoute, routeMetrics, positionAt, buildPulseFC } from "../../../src/features/map/packet-flow";
|
||||
import { resolvedPathNodes, litOpacity, buildLitFC } from "../../../src/features/map/packet-flow";
|
||||
import type { ResolvedHop } from "../../../src/types/api";
|
||||
|
||||
// a high-confidence hop resolved to one located node at [lng, lat]
|
||||
function hop(lng: number, lat: number): ResolvedHop {
|
||||
return { confidence: "high", nodes: [{ id: "n", publicKey: "pk", longitude: lng, latitude: lat }] };
|
||||
// a high-confidence hop resolved to one located node
|
||||
function hop(id: string, lng: number, lat: number): ResolvedHop {
|
||||
return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] };
|
||||
}
|
||||
|
||||
describe("resolvedPathToRoute", () => {
|
||||
it("keeps located hops in order as [lng, lat] and drops coordless hops", () => {
|
||||
const path: ResolvedHop[] = [hop(-75, 45), { confidence: "none", nodes: [] }, hop(-76, 46)];
|
||||
expect(resolvedPathToRoute(path)).toEqual([[-75, 45], [-76, 46]]);
|
||||
describe("resolvedPathNodes", () => {
|
||||
it("returns each hop's first located node as { id, lng, lat }, in order", () => {
|
||||
const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("b", -76, 46)];
|
||||
expect(resolvedPathNodes(path)).toEqual([
|
||||
{ id: "a", lng: -75, lat: 45 },
|
||||
{ id: "b", lng: -76, lat: 46 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns fewer than 2 points when the path has no drawable geometry", () => {
|
||||
expect(resolvedPathToRoute([{ confidence: "none", nodes: [] }])).toEqual([]);
|
||||
it("dedupes a node that appears on more than one hop", () => {
|
||||
const path: ResolvedHop[] = [hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)];
|
||||
expect(resolvedPathNodes(path).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("skips hops with no located candidate", () => {
|
||||
const path: ResolvedHop[] = [{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }];
|
||||
expect(resolvedPathNodes(path)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeMetrics + positionAt", () => {
|
||||
it("interpolates endpoints and the midpoint of a straight segment", () => {
|
||||
const coords: [number, number][] = [[0, 0], [10, 0]];
|
||||
const { cumLengths, total } = routeMetrics(coords);
|
||||
expect(total).toBe(10);
|
||||
expect(positionAt(coords, cumLengths, total, 0)).toEqual([0, 0]);
|
||||
expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 0]);
|
||||
expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([5, 0]);
|
||||
});
|
||||
|
||||
it("walks the correct segment on a multi-hop route", () => {
|
||||
const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]];
|
||||
const { cumLengths, total } = routeMetrics(coords);
|
||||
expect(total).toBe(20);
|
||||
expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([10, 0]); // the middle vertex
|
||||
expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]);
|
||||
describe("litOpacity", () => {
|
||||
it("is full at the moment lit and eases to zero by fadeMs", () => {
|
||||
expect(litOpacity(1000, 1000, 4000)).toBe(1); // just lit
|
||||
expect(litOpacity(1000, 3000, 4000)).toBeCloseTo(0.5); // halfway
|
||||
expect(litOpacity(1000, 5000, 4000)).toBe(0); // fully faded
|
||||
expect(litOpacity(1000, 9000, 4000)).toBe(0); // past its life, clamped
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPulseFC", () => {
|
||||
const coords: [number, number][] = [[0, 0], [10, 0]];
|
||||
const { cumLengths, total } = routeMetrics(coords);
|
||||
const pulse = { id: 1, coords, cumLengths, total, startMs: 1000, durationMs: 1000 };
|
||||
|
||||
it("places each pulse at its current position along the route", () => {
|
||||
const fc = buildPulseFC([pulse], 1500); // halfway through
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0]!.geometry.coordinates).toEqual([5, 0]);
|
||||
});
|
||||
|
||||
it("keeps full opacity early and fades to zero at the end", () => {
|
||||
expect(buildPulseFC([pulse], 1500).features[0]!.properties.opacity).toBe(1); // t=0.5
|
||||
expect(buildPulseFC([pulse], 2000).features[0]!.properties.opacity).toBe(0); // t=1
|
||||
describe("buildLitFC", () => {
|
||||
it("emits one point feature per lit node with its current opacity", () => {
|
||||
const lit = [{ lng: -75, lat: 45, litAt: 1000 }, { lng: -76, lat: 46, litAt: 3000 }];
|
||||
const fc = buildLitFC(lit, 3000, 4000);
|
||||
expect(fc.features).toHaveLength(2);
|
||||
expect(fc.features[0]!.geometry.coordinates).toEqual([-75, 45]);
|
||||
expect(fc.features[0]!.properties.opacity).toBeCloseTo(0.5); // lit at 1000, now 3000
|
||||
expect(fc.features[1]!.properties.opacity).toBe(1); // just lit
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user