fix(map): stream hop animation along stable arcs

This commit is contained in:
gadgethd
2026-08-04 01:50:14 +01:00
parent bcaa35fd27
commit d7e5b0ff92
4 changed files with 152 additions and 146 deletions
@@ -3,8 +3,8 @@ import test from 'node:test';
import {
buildAerialPathSegments,
cachedTerrainElevation,
hopPeakElevation,
interpolateBallistic,
easeArcProgress,
interpolateArcPosition,
registerAerialPaths,
terrainAwarePosition,
type AerialPath,
@@ -72,7 +72,38 @@ test('a later observer reuses the common trunk and registers only its new branch
assert.equal(firstBranchStartedAt, 500, 'the original path still animates hop by hop');
assert.equal(registry.get(firstBranchKey!)?.startedAt, firstBranchStartedAt);
assert.equal(registry.get(secondBranchKey)?.startedAt, 500, 'only the new branch starts later');
assert.equal(registry.get(trunkKey!)?.segments[0]?.confidence, 0.9, 'shared edges retain strongest evidence');
assert.equal(registry.get(trunkKey!)?.segment.confidence, 0.9, 'shared edges retain strongest evidence');
});
test('a branch arriving mid-trunk waits at the split without replaying stable segments', () => {
const registry = new Map<string, PathRegistryEntry>();
const initial: AerialPath = {
id: 'packet-a',
confidence: 0.6,
nodes: [
{ position: [-2, 51] },
{ position: [-1, 52] },
{ position: [0, 53] },
],
};
const diverted: AerialPath = {
id: 'packet-a',
confidence: 0.9,
nodes: [
{ position: [-2, 51] },
{ position: [-1, 52] },
{ position: [1, 53] },
],
};
const [trunkKey, originalBranchKey] = buildAerialPathSegments([initial]).map((segment) => segment.id);
const divertedBranchKey = buildAerialPathSegments([diverted])[1]!.id;
registerAerialPaths(registry, [initial], 100);
registerAerialPaths(registry, [initial, diverted], 300);
assert.equal(registry.get(trunkKey!)?.startedAt, 100, 'active trunk progress is retained');
assert.equal(registry.get(originalBranchKey!)?.startedAt, 500, 'existing stream timing is retained');
assert.equal(registry.get(divertedBranchKey)?.startedAt, 500, 'new stream starts when the split is reached');
});
test('terrain elevation queries are cached per coordinate and preserve null fallbacks', () => {
@@ -100,20 +131,24 @@ test('terrain-aware endpoint positions apply clearance and fall back to ground l
assert.deepEqual(terrainAwarePosition([1, 2], 120, false, 2), [1, 2, 0]);
});
test('hop interpolation is horizontal-linear but vertically ballistic', () => {
test('hop interpolation samples the same eased ArcLayer paraboloid', () => {
const source: [number, number, number] = [-1, 51, 100];
const target: [number, number, number] = [0, 52, 200];
const peak = hopPeakElevation(source, target, 1);
const midpoint = interpolateBallistic(source, target, 0.5, peak);
const midpoint = interpolateArcPosition(source, target, easeArcProgress(0.5));
assert.deepEqual(interpolateBallistic(source, target, 0, peak), source);
assert.deepEqual(interpolateBallistic(source, target, 1, peak), target);
assert.deepEqual(midpoint.slice(0, 2), [-0.5, 51.5]);
assert.equal(midpoint[2], peak, 'mid-hop reaches the planned peak elevation');
assert(midpoint[2] > target[2], 'the active marker clears both endpoints');
assert.deepEqual(interpolateArcPosition(source, target, 0), source);
assert.deepEqual(interpolateArcPosition(source, target, 1), target);
assert(Math.abs(midpoint[0] - -0.5) < 1e-12);
assert(midpoint[1] > 51.5, 'the marker follows Web Mercator arc projection, not linear latitude');
assert(midpoint[2] > target[2], 'the ArcLayer height profile clears both endpoints');
});
test('long hops scale their airborne peak above the minimum', () => {
const peak = hopPeakElevation([0, 0, 0], [1, 0, 0]);
assert(peak > 300);
test('arc progress eases smoothly while preserving segment endpoints', () => {
assert.equal(easeArcProgress(-1), 0);
assert.equal(easeArcProgress(0), 0);
assert.equal(easeArcProgress(0.25), 0.15625);
assert.equal(easeArcProgress(0.5), 0.5);
assert.equal(easeArcProgress(0.75), 0.84375);
assert.equal(easeArcProgress(1), 1);
assert.equal(easeArcProgress(2), 1);
});
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef } from 'react';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { WebMercatorViewport, type Layer, type PickingInfo } from '@deck.gl/core';
import { ArcLayer, ScatterplotLayer } from '@deck.gl/layers';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type maplibregl from 'maplibre-gl';
@@ -9,8 +9,6 @@ import {
PATH_ARC_CORE_WIDTH,
PATH_ARC_HEIGHT,
PATH_HOP_ANIMATION_MS,
PATH_HOP_PEAK_DISTANCE_SCALE,
PATH_HOP_PEAK_HEIGHT_M,
PATH_LINE_FADE_MS,
PATH_LINE_TTL_MS,
PATH_TERRAIN_CLEARANCE_M,
@@ -66,7 +64,7 @@ type TerrainElevationMap = Pick<maplibregl.Map, 'queryTerrainElevation'>;
export type PathRegistryEntry = {
signature: string;
segments: AerialPathSegment[];
segment: AerialPathSegment;
startedAt: number;
};
@@ -76,12 +74,20 @@ function aerialNodeKey(node: AerialPathNode): string {
return `${node.position[0].toFixed(6)},${node.position[1].toFixed(6)}`;
}
export function aerialSegmentKey(
pathId: string,
source: AerialPathNode,
target: AerialPathNode,
): string {
return `${pathId}:stable-segment:${aerialNodeKey(source)}>${aerialNodeKey(target)}`;
}
export function buildAerialPathSegments(paths: AerialPath[]): AerialPathSegment[] {
return paths.flatMap((path) => path.nodes.slice(0, -1).flatMap((source, index) => {
const target = path.nodes[index + 1];
if (!target) return [];
return [{
id: `${path.id}:${aerialNodeKey(source)}>${aerialNodeKey(target)}`,
id: aerialSegmentKey(path.id, source, target),
source,
target,
confidence: target.confidence ?? path.confidence,
@@ -109,7 +115,7 @@ export function registerAerialPaths(
let nextSegmentStart = now;
for (const segment of buildAerialPathSegments([path])) {
const existing = registry.get(segment.id);
const previous = existing?.segments[0];
const previous = existing?.segment;
const merged = previous
? { ...segment, confidence: strongestConfidence(previous.confidence, segment.confidence) }
: segment;
@@ -120,7 +126,7 @@ export function registerAerialPaths(
].join(':');
registry.set(segment.id, {
signature,
segments: [merged],
segment: merged,
startedAt: existing?.startedAt ?? nextSegmentStart,
});
const completionAt = (existing?.startedAt ?? nextSegmentStart) + PATH_HOP_ANIMATION_MS;
@@ -171,46 +177,54 @@ export function terrainAwarePosition(
];
}
function horizontalDistanceMeters(source: DeckPosition, target: DeckPosition): number {
const earthRadiusM = 6_371_000;
const sourceLat = source[1] * Math.PI / 180;
const targetLat = target[1] * Math.PI / 180;
const deltaLat = targetLat - sourceLat;
const deltaLon = (target[0] - source[0]) * Math.PI / 180;
const haversine = Math.sin(deltaLat / 2) ** 2
+ Math.cos(sourceLat) * Math.cos(targetLat) * Math.sin(deltaLon / 2) ** 2;
return 2 * earthRadiusM * Math.asin(Math.sqrt(Math.min(1, haversine)));
const ARC_PROJECTION = new WebMercatorViewport({
width: 1,
height: 1,
longitude: 0,
latitude: 0,
zoom: 0,
});
export function easeArcProgress(progress: number): number {
const clamped = Math.max(0, Math.min(1, progress));
return clamped * clamped * (3 - 2 * clamped);
}
export function hopPeakElevation(
source: DeckPosition,
target: DeckPosition,
terrainExaggeration = 1,
): number {
const peakHeightM = Math.max(
PATH_HOP_PEAK_HEIGHT_M,
horizontalDistanceMeters(source, target) * PATH_HOP_PEAK_DISTANCE_SCALE,
);
return Math.max(source[2], target[2]) + peakHeightM * terrainExaggeration;
}
/** Interpolate horizontally while following a parabolic, midpoint-peaking hop. */
export function interpolateBallistic(
/** Sample the same projected paraboloid used by Deck.gl's ArcLayer shader. */
export function interpolateArcPosition(
source: DeckPosition,
target: DeckPosition,
progress: number,
peakElevation: number,
height = PATH_ARC_HEIGHT,
): DeckPosition {
const clampedProgress = Math.max(0, Math.min(1, progress));
const linearElevation = source[2] + (target[2] - source[2]) * clampedProgress;
const midpointElevation = (source[2] + target[2]) / 2;
const arcElevation = 4 * clampedProgress * (1 - clampedProgress)
* Math.max(0, peakElevation - midpointElevation);
return [
source[0] + (target[0] - source[0]) * clampedProgress,
source[1] + (target[1] - source[1]) * clampedProgress,
linearElevation + arcElevation,
];
const ratio = Math.max(0, Math.min(1, progress));
if (ratio === 0) return source;
if (ratio === 1) return target;
const sourceWorld = ARC_PROJECTION.projectPosition(source);
const targetWorld = ARC_PROJECTION.projectPosition(target);
const distance = Math.hypot(
targetWorld[0] - sourceWorld[0],
targetWorld[1] - sourceWorld[1],
);
const heightDistance = distance * height;
const deltaZ = targetWorld[2] - sourceWorld[2];
let z: number;
if (heightDistance === 0) {
z = sourceWorld[2] + deltaZ * ratio;
} else {
const unitZ = deltaZ / heightDistance;
const paraboloidWidth = unitZ * unitZ + 1;
const reversed = deltaZ <= 0;
const arcRatio = reversed ? 1 - ratio : ratio;
const baseZ = reversed ? targetWorld[2] : sourceWorld[2];
z = Math.sqrt(Math.max(0, arcRatio * (paraboloidWidth - arcRatio)))
* heightDistance + baseZ;
}
return ARC_PROJECTION.unprojectPosition([
sourceWorld[0] + (targetWorld[0] - sourceWorld[0]) * ratio,
sourceWorld[1] + (targetWorld[1] - sourceWorld[1]) * ratio,
z,
]) as DeckPosition;
}
function renderedPosition(
@@ -318,7 +332,7 @@ function layersForFrame(
if (pulses.length > 0) {
layers.push(new ScatterplotLayer<LeadingPulse>({
id: 'resolved-path-leading-pulse',
id: 'resolved-path-arc-rider',
data: pulses,
getPosition: (item) => item.position,
getFillColor: (item) => pathArcColors(item.confidence).coreTarget,
@@ -415,8 +429,8 @@ export const AnimatedPathOverlay: React.FC<{
let needsAnimationFrame = false;
let nextFadeAt = Number.POSITIVE_INFINITY;
for (const [pathId, entry] of registryRef.current) {
const animationDuration = entry.segments.length * PATH_HOP_ANIMATION_MS;
for (const [segmentId, entry] of registryRef.current) {
const animationDuration = PATH_HOP_ANIMATION_MS;
if (now < entry.startedAt) {
needsAnimationFrame = true;
continue;
@@ -424,71 +438,35 @@ export const AnimatedPathOverlay: React.FC<{
const elapsed = now - entry.startedAt;
if (elapsed < animationDuration) {
needsAnimationFrame = true;
const completedCount = Math.floor(elapsed / PATH_HOP_ANIMATION_MS);
const activeSegment = entry.segments[completedCount];
for (const segment of entry.segments.slice(0, completedCount)) {
const sourcePosition = renderedPosition(
segment.source,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const targetPosition = renderedPosition(
segment.target,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
rendered.push({
...segment,
sourcePosition,
targetPosition,
renderedTarget: targetPosition,
opacity: 1,
});
}
if (activeSegment) {
const hopProgress = Math.min(
1,
(elapsed - completedCount * PATH_HOP_ANIMATION_MS) / PATH_HOP_ANIMATION_MS,
);
const sourcePosition = renderedPosition(
activeSegment.source,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const targetPosition = renderedPosition(
activeSegment.target,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const position = interpolateBallistic(
sourcePosition,
targetPosition,
hopProgress,
hopPeakElevation(
sourcePosition,
targetPosition,
terrainEnabledNow ? TERRAIN_CONFIG.exaggeration : 1,
),
);
rendered.push({
...activeSegment,
sourcePosition,
targetPosition,
renderedTarget: position,
opacity: 1,
});
pulses.push({ position, confidence: activeSegment.confidence });
}
const activeSegment = entry.segment;
const hopProgress = easeArcProgress(elapsed / PATH_HOP_ANIMATION_MS);
const sourcePosition = renderedPosition(
activeSegment.source,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const targetPosition = renderedPosition(
activeSegment.target,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const position = interpolateArcPosition(sourcePosition, targetPosition, hopProgress);
rendered.push({
...activeSegment,
sourcePosition,
targetPosition,
renderedTarget: targetPosition,
opacity: 1,
});
pulses.push({ position, confidence: activeSegment.confidence });
continue;
}
const ageSinceCompletion = elapsed - animationDuration;
if (ageSinceCompletion >= PATH_LINE_TTL_MS + PATH_LINE_FADE_MS) {
registryRef.current.delete(pathId);
registryRef.current.delete(segmentId);
continue;
}
const opacity = ageSinceCompletion <= PATH_LINE_TTL_MS
@@ -496,27 +474,26 @@ export const AnimatedPathOverlay: React.FC<{
: Math.max(0, 1 - (ageSinceCompletion - PATH_LINE_TTL_MS) / PATH_LINE_FADE_MS);
if (ageSinceCompletion > PATH_LINE_TTL_MS) needsAnimationFrame = true;
else nextFadeAt = Math.min(nextFadeAt, entry.startedAt + animationDuration + PATH_LINE_TTL_MS);
for (const segment of entry.segments) {
const sourcePosition = renderedPosition(
segment.source,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const targetPosition = renderedPosition(
segment.target,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
rendered.push({
...segment,
sourcePosition,
targetPosition,
renderedTarget: targetPosition,
opacity,
});
}
const segment = entry.segment;
const sourcePosition = renderedPosition(
segment.source,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
const targetPosition = renderedPosition(
segment.target,
terrainMap,
terrainEnabledNow,
elevationCacheRef.current,
);
rendered.push({
...segment,
sourcePosition,
targetPosition,
renderedTarget: targetPosition,
opacity,
});
}
const renderedObserverNodes: RenderedObserverNode[] = observerNodesRef.current.map((node) => ({
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
PATH_HOP_ANIMATION_MS,
PATH_HOP_PEAK_HEIGHT_M,
PATH_LINE_TTL_MS,
PATH_TERRAIN_CLEARANCE_M,
pathArcColors,
@@ -13,7 +12,6 @@ test('path confidence uses low/mid/high traffic-light bands', () => {
assert.equal(PATH_LINE_TTL_MS, 5_000);
assert.equal(PATH_HOP_ANIMATION_MS, 400);
assert.equal(PATH_TERRAIN_CLEARANCE_M, 32);
assert.equal(PATH_HOP_PEAK_HEIGHT_M, 300);
assert.equal(pathConfidenceBand(null), 'low');
assert.equal(pathConfidenceBand(0.39), 'low');
assert.equal(pathConfidenceBand(0.4), 'mid');
+1 -5
View File
@@ -12,12 +12,8 @@ export const PATH_ARC_HEIGHT = 0.15;
export const PATH_ARC_BLOOM_WIDTH = 10;
export const PATH_ARC_CORE_WIDTH = 2;
// Live-path endpoints sit just above the sampled DEM when MapLibre terrain is
// active. The peak is deliberately much higher so the animated hop reads as
// an airborne transmission rather than a line being dragged over the ground.
// Live-path endpoints sit just above the sampled DEM when MapLibre terrain is active.
export const PATH_TERRAIN_CLEARANCE_M = 32;
export const PATH_HOP_PEAK_HEIGHT_M = 300;
export const PATH_HOP_PEAK_DISTANCE_SCALE = 0.1;
export type PathConfidenceBand = 'low' | 'mid' | 'high';