fix(traffic): recover road loading after city navigation

This commit is contained in:
Sameh Khamis committed 2026-09-13 14:47:26 -07:00
1 parent ee03bfb8a8
commit 0732f8f95c
12 files changed
+425 -11

No files matched your search

+1 -1
View File
@@ -154,7 +154,7 @@ export function createControls({ state: layerState, services, parts, source }) {
lastUpdate: layerState._lastUpdate,
loading,
mode: feed.mode,
error: feed.error,
error: layerState._roadError || feed.error,
flowCoveragePct: layerState._flowCoveragePct,
tilesFetched: getFlowSessionStats().tilesFetched,
...(TRAFFIC_TIMING_ENABLED
+2 -1
View File
@@ -131,7 +131,8 @@ export function createFlow({ state: layerState, services, parts, source }) {
);
}
} finally {
layerState._flowPending -= 1;
if (generation === layerState._loadGeneration)
layerState._flowPending -= 1;
}
}
+31 -6
View File
@@ -158,11 +158,22 @@ export function createIngestion({
// Increment generation to invalidate any in-flight responses from prior calls
const generation = ++layerState._loadGeneration;
cancelActiveFetch();
clearTimeout(layerState._retryTimer);
layerState._retryTimer = null;
layerState._flowPending = 0;
layerState._activeFetchAbort = new AbortController();
const requestSignal = layerState._activeFetchAbort.signal;
const clamped = parts.viewport.clampBounds(bounds);
// Cache key: fixed-precision bounding-box string for deterministic lookups
const cacheKey = `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`;
if (cacheKey !== layerState._retryBoundsKey) {
layerState._retryBoundsKey = cacheKey;
layerState._retryDelayMs = 1500;
}
layerState._roadError = null;
// Live mode: warm the flow-tile cache CONCURRENTLY with the Overpass road
// fetch — sequential fetches doubled first-paint latency (field-test
// round 1). Failures are irrelevant; applyFlowToRoads settles the truth.
@@ -172,7 +183,7 @@ export function createIngestion({
layerState._enabled &&
generation === layerState._loadGeneration
) {
fetchFlowForBounds(clamped, {}).catch(() => {
fetchFlowForBounds(clamped, { signal: requestSignal }).catch(() => {
/* warm-up only */
});
}
@@ -233,7 +244,6 @@ export function createIngestion({
renderedSomething = true;
} else {
// Fetch major roads first (smaller payload, faster response)
layerState._activeFetchAbort = new AbortController();
console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`);
const majorData = await fetchRoads(
clamped.south,
@@ -243,7 +253,7 @@ export function createIngestion({
{
majorOnly: true,
timeoutSec: 12,
signal: layerState._activeFetchAbort.signal,
signal: requestSignal,
},
trace,
);
@@ -268,7 +278,6 @@ export function createIngestion({
if (altitude > FAST_FETCH_ALTITUDE) return;
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
layerState._activeFetchAbort = new AbortController();
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
const fullData = await fetchRoads(
clamped.south,
@@ -278,7 +287,7 @@ export function createIngestion({
{
majorOnly: false,
timeoutSec: 20,
signal: layerState._activeFetchAbort.signal,
signal: requestSignal,
},
trace,
);
@@ -299,6 +308,8 @@ export function createIngestion({
renderedSomething = true;
} catch (e) {
if (e?.name === 'AbortError') return;
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadError = 'Road data temporarily unavailable';
console.warn('[Data:Traffic] Fetch error:', e);
} finally {
if (generation === layerState._loadGeneration) {
@@ -310,9 +321,23 @@ export function createIngestion({
if (!renderedSomething) {
layerState._lastBounds = prevBounds;
layerState._lastViewCenter = prevViewCenter;
if (layerState._enabled) {
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
parts.viewport.onCameraChanged();
}, layerState._retryDelayMs);
layerState._retryDelayMs = Math.min(
layerState._retryDelayMs * 2,
30000,
);
}
} else {
layerState._retryDelayMs = 1500;
}
}
layerState._activeFetchAbort = null;
// Keep this generation's controller until superseded or disabled: flow
// can still be running after its paint deadline. An older finally must
// never clear the controller belonging to a newer destination.
}
}
const methods = {
+15 -1
View File
@@ -98,6 +98,11 @@ export function createLifecycle({
// on the camera and leaving it mutated affects every other camera.changed
// listener in the app.
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
// Always inspect the final view, even when the last flight step is below
// camera.changed's movement threshold.
layerState._arrivalRemover = viewer.camera.moveEnd.addEventListener(
parts.viewport.onCameraChanged,
);
layerState._prevPercentageChanged = viewer.camera.percentageChanged;
viewer.camera.percentageChanged = 0.05;
@@ -117,7 +122,8 @@ export function createLifecycle({
layerState._enableKickTimer = null;
return;
}
if (!layerState._fetching) parts.viewport.onCameraChanged();
if (!layerState._fetching && !layerState._retryTimer)
parts.viewport.onCameraChanged();
}, 1500);
},
@@ -133,8 +139,14 @@ export function createLifecycle({
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
clearTimeout(layerState._retryTimer);
layerState._retryTimer = null;
layerState._retryDelayMs = 1500;
parts.ingestion.cancelActiveFetch();
layerState._loadGeneration++;
layerState._fetching = false;
layerState._flowPending = 0;
layerState._roadError = null;
parts.animation.clearDots();
layerState._lastViewCenter = null;
// A stale outage from the last session would misreport a fresh enable —
@@ -155,6 +167,8 @@ export function createLifecycle({
}
viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged);
layerState._arrivalRemover?.();
layerState._arrivalRemover = null;
// Restore the camera's global percentageChanged so other layers/listeners
// keep their expected sensitivity.
if (layerState._prevPercentageChanged != null) {
+218
View File
@@ -0,0 +1,218 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTrafficLayer } from './index.js';
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function setup(t, requestRoads) {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
const camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(
-97.744,
30.267,
3200,
),
get positionWC() {
return Cesium.Cartesian3.fromRadians(
this.positionCartographic.longitude,
this.positionCartographic.latitude,
this.positionCartographic.height,
);
},
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
percentageChanged: 0.5,
computeViewRectangle() {
const { longitude, latitude } = this.positionCartographic;
return new Cesium.Rectangle(
longitude - 0.0002,
latitude - 0.0002,
longitude + 0.0002,
latitude + 0.0002,
);
},
pickEllipsoid: () => null,
};
const viewer = {
camera,
scene: {
canvas: { width: 100, height: 100 },
preRender: new Cesium.Event(),
primitives: { add: (value) => value, remove: () => true },
},
};
const layer = createTrafficLayer({
services: {
credits: {},
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
},
source: {
requestRoads,
getStatus: async () => ({ hasKey: false }),
fetchFlowForBounds: async () => [],
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache() {},
},
});
layer.init(viewer);
t.after(() => layer.destroy(viewer));
const move = (lon, lat, height = 3200) => {
camera.positionCartographic = Cesium.Cartographic.fromDegrees(
lon,
lat,
height,
);
camera.changed.raiseEvent();
camera.moveEnd.raiseEvent();
};
const tick = async (ms) => {
t.mock.timers.tick(ms);
for (let i = 0; i < 30; i++) await Promise.resolve();
};
return { layer, viewer, move, tick };
}
function roads(bounds) {
return {
ok: true,
json: async () => ({
elements: [
{
type: 'way',
tags: { highway: 'primary' },
geometry: [
{ lat: bounds.south, lon: bounds.west },
{ lat: bounds.south + 0.005, lon: bounds.west + 0.005 },
],
},
],
}),
};
}
test('traffic recovers a failed destination request after another city has loaded', async (t) => {
let londonCalls = 0;
const { layer, viewer, move, tick } = setup(t, async (bounds) => {
if (bounds.south > 50 && ++londonCalls === 1)
throw new Error('temporary Overpass outage');
return roads(bounds);
});
layer.enable(viewer);
await tick(400);
await tick(2000);
assert.ok(layer.getStats().count > 0);
move(-40, 40, 1000000);
move(-0.1276, 51.5072);
await tick(400);
assert.equal(londonCalls, 1);
for (let i = 0; i < 20; i++) await tick(1500);
assert.ok(
londonCalls >= 2,
'the stationary destination must retry without a toggle',
);
assert.ok(layer.getStats().count > 0);
assert.equal(layer.getStats().loading, false);
});
test('a superseded road response cannot release the current request controller', async (t) => {
const pending = [];
const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => {
const result = deferred();
pending.push({ ...result, bounds, signal });
return result.promise;
});
layer.enable(viewer);
await tick(400);
move(-0.1276, 51.5072);
await tick(400);
assert.equal(pending.length, 2);
assert.equal(pending[0].signal.aborted, true);
pending[0].resolve(roads(pending[0].bounds));
await tick(0);
layer.disable(viewer);
assert.equal(
pending[1].signal.aborted,
true,
'disable must still cancel the destination request',
);
pending[1].resolve(roads(pending[1].bounds));
await tick(0);
assert.equal(layer.getStats().count, 0);
assert.equal(layer.getStats().loading, false);
});
test('leaving traffic altitude cancels queued and in-flight work', async (t) => {
const pending = [];
const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => {
const result = deferred();
pending.push({ ...result, bounds, signal });
return result.promise;
});
layer.enable(viewer);
move(-40, 40, 1000000);
await tick(400);
assert.equal(
pending.length,
0,
'a departing city debounce must not fetch above traffic altitude',
);
move(-0.1276, 51.5072);
await tick(400);
assert.equal(pending.length, 1);
move(-40, 40, 1000000);
assert.equal(pending[0].signal.aborted, true);
pending[0].resolve(roads(pending[0].bounds));
await tick(0);
assert.equal(layer.getStats().count, 0);
assert.equal(layer.getStats().loading, false);
});
test('arrival below the camera change threshold loads the final city and unsubscribes on disable', async (t) => {
const seen = [];
const { layer, viewer, tick } = setup(t, async (bounds) => {
seen.push(bounds);
return roads(bounds);
});
layer.enable(viewer);
await tick(400);
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1276,
51.5072,
3200,
);
viewer.camera.moveEnd.raiseEvent();
await tick(400);
assert.ok(seen.some((bounds) => bounds.south > 50));
layer.disable(viewer);
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(viewer.camera.changed.numberOfListeners, 0);
});
test('parked failures back off and disabling cancels the scheduled retry', async (t) => {
let calls = 0;
const { layer, viewer, tick } = setup(t, async () => {
calls++;
throw new Error('temporary Overpass outage');
});
layer.enable(viewer);
await tick(400);
assert.equal(calls, 1);
assert.equal(layer.getStats().loading, false);
assert.equal(layer.getStats().error, 'Road data temporarily unavailable');
await tick(1500);
await tick(400);
assert.equal(calls, 2);
await tick(1500);
await tick(400);
assert.equal(calls, 2, 'second failure waits longer than the first');
layer.disable(viewer);
await tick(60000);
assert.equal(calls, 2);
assert.equal(layer.getStats().error, null);
});
+6
View File
@@ -32,6 +32,7 @@ export function createState({ services }) {
/** @type {Function|null} Disposer returned by camera.changed event subscription */
layerState._cameraRemover = null;
layerState._arrivalRemover = null;
/** @type {ReturnType<typeof setTimeout>|null} Debounce timer for camera-change fetch */
@@ -215,6 +216,11 @@ export function createState({ services }) {
/** @type {ReturnType<typeof setInterval>|null} Enable-time retry until the first load commits. */
layerState._enableKickTimer = null;
// Failed destinations retry while parked, with a bounded backoff.
layerState._retryTimer = null;
layerState._retryDelayMs = 1500;
layerState._retryBoundsKey = null;
layerState._roadError = null;
/** @type {number} 0–100 int — matched roads / roads with any flow candidates */
+8
View File
@@ -189,6 +189,14 @@ export function createViewport({ state: layerState, services, parts, source }) {
// viewport hits the overlap/center-shift skip in step 3 and the dots
// (cleared here) never reload (H5). Clearing the gate forces a fresh fetch.
if (alt > ACTIVATION_ALTITUDE) {
clearTimeout(layerState._fetchTimeout);
clearTimeout(layerState._retryTimer);
layerState._retryTimer = null;
parts.ingestion.cancelActiveFetch();
layerState._loadGeneration++;
layerState._fetching = false;
layerState._flowPending = 0;
layerState._roadError = null;
parts.animation.clearDots();
layerState._lastBounds = null;
layerState._lastViewCenter = null;