mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-12 18:15:38 +00:00
fix(map): bind live paths to resolved packets
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user