diff --git a/frontend/src/components/Map/LiveOverlayController.test.ts b/frontend/src/components/Map/LiveOverlayController.test.ts new file mode 100644 index 0000000..3ff5f01 --- /dev/null +++ b/frontend/src/components/Map/LiveOverlayController.test.ts @@ -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(); + 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(); + 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); +}); diff --git a/frontend/src/components/Map/LiveOverlayController.tsx b/frontend/src/components/Map/LiveOverlayController.tsx index f8ed4d2..80d887d 100644 --- a/frontend/src/components/Map/LiveOverlayController.tsx +++ b/frontend/src/components/Map/LiveOverlayController.tsx @@ -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, +): 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 = ({ map, filters, @@ -86,6 +108,7 @@ export const LiveOverlayController: React.FC = ({ const { packetPaths, betaPacketPaths, + betaPathPacketHash, betaPathRoutes, betaObserverIds, betaPathConfidence, @@ -105,35 +128,23 @@ export const LiveOverlayController: React.FC = ({ const showPathOnly = filters.betaPaths || pinnedPacketId !== null; const liveAerialPaths = useMemo(() => { 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(() => { diff --git a/frontend/src/hooks/packetPathOverlayUtils.test.ts b/frontend/src/hooks/packetPathOverlayUtils.test.ts index 434e0c1..312f255 100644 --- a/frontend/src/hooks/packetPathOverlayUtils.test.ts +++ b/frontend/src/hooks/packetPathOverlayUtils.test.ts @@ -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, diff --git a/frontend/src/hooks/packetPathOverlayUtils.ts b/frontend/src/hooks/packetPathOverlayUtils.ts index 63fb982..d28d103 100644 --- a/frontend/src/hooks/packetPathOverlayUtils.ts +++ b/frontend/src/hooks/packetPathOverlayUtils.ts @@ -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 | 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), diff --git a/frontend/src/hooks/usePacketPathOverlay.ts b/frontend/src/hooks/usePacketPathOverlay.ts index 0110d99..0a60d3c 100644 --- a/frontend/src/hooks/usePacketPathOverlay.ts +++ b/frontend/src/hooks/usePacketPathOverlay.ts @@ -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(null); const [betaCanonicalPath, setBetaCanonicalPath] = useState([]); const [betaPathRoutes, setBetaPathRoutes] = useState([]); const [betaObserverIds, setBetaObserverIds] = useState([]); @@ -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,