From d7e5b0ff926498fd2a9cac1663fadc53158f23e7 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:50:14 +0100 Subject: [PATCH] fix(map): stream hop animation along stable arcs --- .../Map/AnimatedPathOverlay.test.ts | 63 +++-- .../components/Map/AnimatedPathOverlay.tsx | 227 ++++++++---------- .../src/components/Map/pathArcStyle.test.ts | 2 - frontend/src/components/Map/pathArcStyle.ts | 6 +- 4 files changed, 152 insertions(+), 146 deletions(-) diff --git a/frontend/src/components/Map/AnimatedPathOverlay.test.ts b/frontend/src/components/Map/AnimatedPathOverlay.test.ts index 71f776a..e6cf17f 100644 --- a/frontend/src/components/Map/AnimatedPathOverlay.test.ts +++ b/frontend/src/components/Map/AnimatedPathOverlay.test.ts @@ -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(); + 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); }); diff --git a/frontend/src/components/Map/AnimatedPathOverlay.tsx b/frontend/src/components/Map/AnimatedPathOverlay.tsx index 58cf262..2f89f50 100644 --- a/frontend/src/components/Map/AnimatedPathOverlay.tsx +++ b/frontend/src/components/Map/AnimatedPathOverlay.tsx @@ -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; 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({ - 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) => ({ diff --git a/frontend/src/components/Map/pathArcStyle.test.ts b/frontend/src/components/Map/pathArcStyle.test.ts index d58866c..5ae4696 100644 --- a/frontend/src/components/Map/pathArcStyle.test.ts +++ b/frontend/src/components/Map/pathArcStyle.test.ts @@ -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'); diff --git a/frontend/src/components/Map/pathArcStyle.ts b/frontend/src/components/Map/pathArcStyle.ts index 30600ce..e942295 100644 --- a/frontend/src/components/Map/pathArcStyle.ts +++ b/frontend/src/components/Map/pathArcStyle.ts @@ -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';