mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(traffic): recover road loading after city navigation
This commit is contained in:
1 parent
ee03bfb8a8
commit
0732f8f95c
12 files changed
+425
-11
No files matched your search
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -131,7 +131,8 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
layerState._flowPending -= 1;
|
||||
if (generation === layerState._loadGeneration)
|
||||
layerState._flowPending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user