fix(map): bind live paths to resolved packets

This commit is contained in:
gadgethd
2026-08-04 02:12:16 +01:00
parent d7e5b0ff92
commit 262a8a2515
5 changed files with 113 additions and 18 deletions
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildAerialPathSegments,
registerAerialPaths,
type PathRegistryEntry,
} from './AnimatedPathOverlay.js';
import { buildResolvedAerialPaths } from './LiveOverlayController.js';
import {
aggregateCanonicalPath,
type MultiObserverBetaResponse,
} from '../../hooks/packetPathOverlayUtils.js';
function response(packetHash: string, observerLon: number): MultiObserverBetaResponse {
return {
packetHash,
network: 'uk',
canonicalPath: [],
observers: [{ observerId: `rx-${observerLon}` }],
confidence: 0.8,
results: [{
ok: true,
packetHash,
network: 'uk',
mode: 'resolved',
canonicalPath: [],
observers: [{ observerId: `rx-${observerLon}` }],
confidence: 0.8,
purplePath: [[51, -2], [52, -1], [53, observerLon]],
}],
};
}
function pathsFor(dto: MultiObserverBetaResponse) {
const prediction = aggregateCanonicalPath(dto)!;
return buildResolvedAerialPaths(prediction.packetHash, prediction.routes, new Map());
}
test('a new unrelated packet cannot rescope and restart the previous packet mid-animation', () => {
const registry = new Map<string, PathRegistryEntry>();
const packetAPaths = pathsFor(response('packet-a', 0));
const packetAKeys = buildAerialPathSegments(packetAPaths).map((segment) => segment.id);
registerAerialPaths(registry, packetAPaths, 100);
// Packet B is now active, but its response has not arrived. React can still
// render the last committed A prediction once; it must remain scoped to A.
registerAerialPaths(registry, packetAPaths, 250);
const packetBPaths = pathsFor(response('packet-b', 1));
registerAerialPaths(registry, packetBPaths, 250);
assert.equal(registry.get(packetAKeys[0]!)?.startedAt, 100);
assert.equal(registry.get(packetAKeys[1]!)?.startedAt, 500);
assert.equal(registry.size, 4, 'packet A and B own two independent directed segments each');
assert(packetBPaths.every((path) => path.id.includes('PACKET-B')));
});
test('a same-packet observer update keeps the trunk clock and diverts at the split', () => {
const registry = new Map<string, PathRegistryEntry>();
const initialDto = response('packet-a', 0);
const initialPaths = pathsFor(initialDto);
registerAerialPaths(registry, initialPaths, 100);
const updatedDto: MultiObserverBetaResponse = {
...initialDto,
observers: [...initialDto.observers, { observerId: 'rx-1' }],
results: [...initialDto.results!, ...response('packet-a', 1).results!],
};
const updatedPaths = pathsFor(updatedDto);
const [trunkKey, originalBranchKey] = buildAerialPathSegments(initialPaths).map((segment) => segment.id);
const newBranchKey = buildAerialPathSegments([updatedPaths[1]!])[1]!.id;
registerAerialPaths(registry, updatedPaths, 300);
assert.equal(registry.get(trunkKey!)?.startedAt, 100);
assert.equal(registry.get(originalBranchKey!)?.startedAt, 500);
assert.equal(registry.get(newBranchKey)?.startedAt, 500);
});
@@ -5,6 +5,7 @@ import { AnimatedPathOverlay, type AerialPath } from './AnimatedPathOverlay.js';
import { useArcs, useNodeMap } from '../../hooks/useNodes.js';
import { usePacketPathOverlay } from '../../hooks/usePacketPathOverlay.js';
import { packetObserverIds } from '../../hooks/packetPathOverlayUtils.js';
import type { ResolvedPathRoute } from '../../hooks/packetPathOverlayUtils.js';
import type { Filters } from '../FilterPanel/FilterPanel.js';
import { buildHiddenCoordMask, hasCoords, maskNodePoint, maskPoint } from '../../utils/pathing.js';
import { useOverlayStore } from '../../store/overlayStore.js';
@@ -28,6 +29,27 @@ type LiveOverlayControllerProps = {
heatmapEnabled: boolean;
};
export function buildResolvedAerialPaths(
packetHash: string | null,
routes: ResolvedPathRoute[],
hiddenCoordMask: ReturnType<typeof buildHiddenCoordMask>,
): AerialPath[] {
if (!packetHash) return [];
return routes.map((route) => ({
id: `main-live-path:${packetHash}:resolved`,
confidence: route.confidence,
nodes: route.nodes.map((node) => {
const [lat, lon] = maskPoint([node.lat, node.lon], hiddenCoordMask);
return {
position: [lon, lat] as [number, number],
nodeId: node.nodeId ?? undefined,
name: node.name ?? undefined,
confidence: node.confidence,
};
}),
}));
}
export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
map,
filters,
@@ -86,6 +108,7 @@ export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
const {
packetPaths,
betaPacketPaths,
betaPathPacketHash,
betaPathRoutes,
betaObserverIds,
betaPathConfidence,
@@ -105,35 +128,23 @@ export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
const showPathOnly = filters.betaPaths || pinnedPacketId !== null;
const liveAerialPaths = useMemo<AerialPath[]>(() => {
if (!showPathOnly) return [];
const packetKey = activePacketSnapshot?.packetHash ?? activePacketSnapshot?.id ?? 'live';
const nodesFor = (path: [number, number][]) => path.map((point) => {
const [lat, lon] = maskPoint(point, hiddenCoordMask);
return { position: [lon, lat] as [number, number] };
});
if (filters.betaPaths) {
return betaPathRoutes.map((route) => ({
// All routes for one packet deliberately share this scope. The
// animated overlay keys individual edges within it, so a later
// observer reuses the trunk and adds only its new branch.
id: `main-live-path:${packetKey}:resolved`,
confidence: route.confidence,
nodes: route.nodes.map((node) => {
const [lat, lon] = maskPoint([node.lat, node.lon], hiddenCoordMask);
return {
position: [lon, lat] as [number, number],
nodeId: node.nodeId ?? undefined,
name: node.name ?? undefined,
confidence: node.confidence,
};
}),
}));
// The routes and their scope come from the same resolved DTO. During the
// render where packet B becomes active, packet A's still-committed routes
// therefore cannot be registered under B and replayed as new segments.
return buildResolvedAerialPaths(betaPathPacketHash, betaPathRoutes, hiddenCoordMask);
}
const packetKey = activePacketSnapshot?.packetHash ?? activePacketSnapshot?.id ?? 'live';
return renderedPaths.map((path) => ({
id: `main-live-path:${packetKey}:observed`,
confidence: 1,
nodes: nodesFor(path),
})).filter((path) => path.nodes.length > 1);
}, [activePacketSnapshot?.id, activePacketSnapshot?.packetHash, betaPathRoutes, filters.betaPaths,
}, [activePacketSnapshot?.id, activePacketSnapshot?.packetHash, betaPathPacketHash, betaPathRoutes, filters.betaPaths,
hiddenCoordMask, renderedPaths, showPathOnly]);
const observerIdsForOverlay = useMemo(() => {
@@ -39,6 +39,7 @@ test('canonical path aggregation renders one route and exposes observer markers'
};
assert.deepEqual(aggregateCanonicalPath(response), {
packetHash: 'ABC',
canonicalPath: response.canonicalPath,
routes: [{
confidence: 0.9,
@@ -58,6 +58,7 @@ export type ResolvedPathRoute = {
};
export type AggregatedPredictionState = {
packetHash: string;
canonicalPath: CanonicalPathNode[];
routes: ResolvedPathRoute[];
observerIds: string[];
@@ -173,6 +174,7 @@ export function aggregateCanonicalPath(
): Omit<AggregatedPredictionState, 'ts'> | null {
if (!response || response.ok === false) return null;
return {
packetHash: response.packetHash.trim().toUpperCase(),
canonicalPath: Array.isArray(response.canonicalPath) ? response.canonicalPath : [],
routes: multiObserverPathRoutes(response),
observerIds: canonicalPathObserverIds(response),
@@ -32,6 +32,7 @@ type UsePacketPathOverlayParams = {
type UsePacketPathOverlayResult = {
packetPaths: [number, number][][];
betaPacketPaths: [number, number][][];
betaPathPacketHash: string | null;
betaCanonicalPath: CanonicalPathNode[];
betaPathRoutes: ResolvedPathRoute[];
betaObserverIds: string[];
@@ -88,6 +89,7 @@ export function usePacketPathOverlay({
const nodes = useNodeMap();
const [packetPaths, setPacketPaths] = useState<[number, number][][]>([]);
const [betaPacketPaths, setBetaPacketPaths] = useState<[number, number][][]>([]);
const [betaPathPacketHash, setBetaPathPacketHash] = useState<string | null>(null);
const [betaCanonicalPath, setBetaCanonicalPath] = useState<CanonicalPathNode[]>([]);
const [betaPathRoutes, setBetaPathRoutes] = useState<ResolvedPathRoute[]>([]);
const [betaObserverIds, setBetaObserverIds] = useState<string[]>([]);
@@ -130,6 +132,7 @@ export function usePacketPathOverlay({
const clearBetaState = useCallback(() => {
setBetaPacketPaths([]);
setBetaPathPacketHash(null);
setBetaCanonicalPath([]);
setBetaPathRoutes([]);
setBetaObserverIds([]);
@@ -170,6 +173,7 @@ export function usePacketPathOverlay({
setBetaPacketPaths(aggregated.routes.map((route) => (
route.nodes.map((node) => [node.lat, node.lon] as [number, number])
)));
setBetaPathPacketHash(aggregated.packetHash);
setBetaCanonicalPath(aggregated.canonicalPath);
setBetaPathRoutes(aggregated.routes);
setBetaObserverIds(aggregated.observerIds);
@@ -414,6 +418,7 @@ export function usePacketPathOverlay({
return {
packetPaths,
betaPacketPaths,
betaPathPacketHash,
betaCanonicalPath,
betaPathRoutes,
betaObserverIds,