mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-02 18:53:47 +00:00
fix(map): stream hop animation along stable arcs
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user