mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge pull request #428 from bilawalsidhu/fix/traffic-city-navigation
fix(traffic): recover loading after city navigation
This commit is contained in:
12 files changed
+424
-10
No files matched your search
@@ -47,6 +47,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Traffic now retries a failed destination after city navigation without a layer
|
||||||
|
toggle. Camera departure cancels pending work, arrival checks the final view,
|
||||||
|
and superseded requests cannot keep a newer view loading.
|
||||||
|
|
||||||
|
|
||||||
- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services.
|
- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services.
|
||||||
- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place.
|
- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place.
|
||||||
- Cancel pending vessel history during selection and layer teardown.
|
- Cancel pending vessel history during selection and layer teardown.
|
||||||
|
|||||||
@@ -3261,3 +3261,11 @@ nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0;
|
|||||||
image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous
|
image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous
|
||||||
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
|
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
|
||||||
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
|
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
|
||||||
|
|
||||||
|
### Traffic city navigation
|
||||||
|
|
||||||
|
Traffic checks the final camera view on arrival and retries a failed road request
|
||||||
|
while the camera is stationary, backing off from 1.5 to 30 seconds. Failed road
|
||||||
|
requests report an unavailable source. Leaving the traffic altitude range or
|
||||||
|
disabling the layer cancels pending work; superseded road and flow requests cannot
|
||||||
|
release the current request or keep its loading indicator active.
|
||||||
@@ -426,5 +426,7 @@
|
|||||||
"src/data/flowTiles.js",
|
"src/data/flowTiles.js",
|
||||||
"src/layers/traffic/source.test.mjs",
|
"src/layers/traffic/source.test.mjs",
|
||||||
"src/layers/bikeshare/source.test.mjs",
|
"src/layers/bikeshare/source.test.mjs",
|
||||||
"scripts/traffic-fixtures.mjs"
|
"scripts/traffic-fixtures.mjs",
|
||||||
|
"src/layers/traffic/navigation.test.mjs",
|
||||||
|
"scripts/qa-traffic-navigation.mjs"
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Deterministic city-navigation exercise; road/flow responses are fixtures.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const url = args[args.indexOf('--url') + 1] || 'http://localhost:4173';
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
protocolTimeout: 120000,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--use-gl=angle',
|
||||||
|
'--use-angle=swiftshader',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const cctv of [false, true]) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
let londonRequests = 0;
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.text().includes('[Data:Traffic]'))
|
||||||
|
console.log(message.text());
|
||||||
|
});
|
||||||
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on('request', (request) => {
|
||||||
|
let response = trafficFixtureResponse(request);
|
||||||
|
const query = new URLSearchParams(request.postData()).get('data') || '';
|
||||||
|
if (query.includes('highway') && query.includes('(51.')) {
|
||||||
|
londonRequests++;
|
||||||
|
if (londonRequests === 1)
|
||||||
|
response = { status: 504, body: 'Temporary road source timeout' };
|
||||||
|
}
|
||||||
|
return response ? request.respond(response) : request.continue();
|
||||||
|
});
|
||||||
|
await page.goto(`${url}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await sleep(5000);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.evaluate(async (withCctv) => {
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView;
|
||||||
|
const C = await import('/node_modules/.vite/deps/cesium.js');
|
||||||
|
for (const id of ['satellites', 'flights', 'military'])
|
||||||
|
await dataManager.setEnabled(id, false);
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: C.Cartesian3.fromDegrees(-97.744, 30.267, 3200),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
await dataManager.setEnabled('traffic', true);
|
||||||
|
if (withCctv) await dataManager.setEnabled('cctv', true);
|
||||||
|
}, cctv);
|
||||||
|
const ready = () => {
|
||||||
|
const stats = window.__godsEyeView.dataManager.layers
|
||||||
|
.get('traffic')
|
||||||
|
.module.getStats();
|
||||||
|
return stats.count > 0 && !stats.loading;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(ready, { timeout: 45000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
'Traffic timeout state',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
stats: dataManager.layers.get('traffic').module.getStats(),
|
||||||
|
camera: viewer.camera.positionCartographic,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await sleep(2500);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
const C = await import('/node_modules/.vite/deps/cesium.js');
|
||||||
|
viewer.camera.flyTo({
|
||||||
|
destination: C.Cartesian3.fromDegrees(-0.1276, 51.5072, 3200),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
duration: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const deadline = Date.now() + 45000;
|
||||||
|
while (londonRequests < 2 && Date.now() < deadline) await sleep(500);
|
||||||
|
assert.ok(
|
||||||
|
londonRequests >= 2,
|
||||||
|
'London must recover its failed request without a toggle',
|
||||||
|
);
|
||||||
|
await page.waitForFunction(ready, { timeout: 45000 });
|
||||||
|
const stats = await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats(),
|
||||||
|
);
|
||||||
|
assert.equal(stats.error, null);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS: Austin → London, first destination request fails then recovers',
|
||||||
|
{ cctv, londonRequests, count: stats.count },
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('traffic', false),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('traffic')
|
||||||
|
.module.getStats().loading,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
|||||||
});
|
});
|
||||||
trafficLayer.enable(viewer);
|
trafficLayer.enable(viewer);
|
||||||
assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1);
|
assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1);
|
||||||
assert.equal(moveEnd.listenerCount(), 1, 'marksInstalled must represent a live listener');
|
assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check');
|
||||||
assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0);
|
assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0);
|
||||||
assert.equal(performance.getEntriesByName('traffic:stale:measure').length, 0);
|
assert.equal(performance.getEntriesByName('traffic:stale:measure').length, 0);
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -154,7 +154,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
lastUpdate: layerState._lastUpdate,
|
lastUpdate: layerState._lastUpdate,
|
||||||
loading,
|
loading,
|
||||||
mode: feed.mode,
|
mode: feed.mode,
|
||||||
error: feed.error,
|
error: layerState._roadError || feed.error,
|
||||||
flowCoveragePct: layerState._flowCoveragePct,
|
flowCoveragePct: layerState._flowCoveragePct,
|
||||||
tilesFetched: getFlowSessionStats().tilesFetched,
|
tilesFetched: getFlowSessionStats().tilesFetched,
|
||||||
...(TRAFFIC_TIMING_ENABLED
|
...(TRAFFIC_TIMING_ENABLED
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (generation === layerState._loadGeneration)
|
||||||
layerState._flowPending -= 1;
|
layerState._flowPending -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,11 +158,22 @@ export function createIngestion({
|
|||||||
// Increment generation to invalidate any in-flight responses from prior calls
|
// Increment generation to invalidate any in-flight responses from prior calls
|
||||||
const generation = ++layerState._loadGeneration;
|
const generation = ++layerState._loadGeneration;
|
||||||
cancelActiveFetch();
|
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);
|
const clamped = parts.viewport.clampBounds(bounds);
|
||||||
|
|
||||||
// Cache key: fixed-precision bounding-box string for deterministic lookups
|
// 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)}`;
|
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
|
// Live mode: warm the flow-tile cache CONCURRENTLY with the Overpass road
|
||||||
// fetch — sequential fetches doubled first-paint latency (field-test
|
// fetch — sequential fetches doubled first-paint latency (field-test
|
||||||
// round 1). Failures are irrelevant; applyFlowToRoads settles the truth.
|
// round 1). Failures are irrelevant; applyFlowToRoads settles the truth.
|
||||||
@@ -172,7 +183,7 @@ export function createIngestion({
|
|||||||
layerState._enabled &&
|
layerState._enabled &&
|
||||||
generation === layerState._loadGeneration
|
generation === layerState._loadGeneration
|
||||||
) {
|
) {
|
||||||
fetchFlowForBounds(clamped, {}).catch(() => {
|
fetchFlowForBounds(clamped, { signal: requestSignal }).catch(() => {
|
||||||
/* warm-up only */
|
/* warm-up only */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,7 +244,6 @@ export function createIngestion({
|
|||||||
renderedSomething = true;
|
renderedSomething = true;
|
||||||
} else {
|
} else {
|
||||||
// Fetch major roads first (smaller payload, faster response)
|
// Fetch major roads first (smaller payload, faster response)
|
||||||
layerState._activeFetchAbort = new AbortController();
|
|
||||||
console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`);
|
console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`);
|
||||||
const majorData = await fetchRoads(
|
const majorData = await fetchRoads(
|
||||||
clamped.south,
|
clamped.south,
|
||||||
@@ -243,7 +253,7 @@ export function createIngestion({
|
|||||||
{
|
{
|
||||||
majorOnly: true,
|
majorOnly: true,
|
||||||
timeoutSec: 12,
|
timeoutSec: 12,
|
||||||
signal: layerState._activeFetchAbort.signal,
|
signal: requestSignal,
|
||||||
},
|
},
|
||||||
trace,
|
trace,
|
||||||
);
|
);
|
||||||
@@ -268,7 +278,6 @@ export function createIngestion({
|
|||||||
if (altitude > FAST_FETCH_ALTITUDE) return;
|
if (altitude > FAST_FETCH_ALTITUDE) return;
|
||||||
|
|
||||||
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
|
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
|
||||||
layerState._activeFetchAbort = new AbortController();
|
|
||||||
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
|
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
|
||||||
const fullData = await fetchRoads(
|
const fullData = await fetchRoads(
|
||||||
clamped.south,
|
clamped.south,
|
||||||
@@ -278,7 +287,7 @@ export function createIngestion({
|
|||||||
{
|
{
|
||||||
majorOnly: false,
|
majorOnly: false,
|
||||||
timeoutSec: 20,
|
timeoutSec: 20,
|
||||||
signal: layerState._activeFetchAbort.signal,
|
signal: requestSignal,
|
||||||
},
|
},
|
||||||
trace,
|
trace,
|
||||||
);
|
);
|
||||||
@@ -299,6 +308,8 @@ export function createIngestion({
|
|||||||
renderedSomething = true;
|
renderedSomething = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name === 'AbortError') return;
|
if (e?.name === 'AbortError') return;
|
||||||
|
if (generation === layerState._loadGeneration && !renderedSomething)
|
||||||
|
layerState._roadError = 'Road data temporarily unavailable';
|
||||||
console.warn('[Data:Traffic] Fetch error:', e);
|
console.warn('[Data:Traffic] Fetch error:', e);
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === layerState._loadGeneration) {
|
if (generation === layerState._loadGeneration) {
|
||||||
@@ -310,9 +321,23 @@ export function createIngestion({
|
|||||||
if (!renderedSomething) {
|
if (!renderedSomething) {
|
||||||
layerState._lastBounds = prevBounds;
|
layerState._lastBounds = prevBounds;
|
||||||
layerState._lastViewCenter = prevViewCenter;
|
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 = {
|
const methods = {
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export function createLifecycle({
|
|||||||
// on the camera and leaving it mutated affects every other camera.changed
|
// on the camera and leaving it mutated affects every other camera.changed
|
||||||
// listener in the app.
|
// listener in the app.
|
||||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
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;
|
layerState._prevPercentageChanged = viewer.camera.percentageChanged;
|
||||||
viewer.camera.percentageChanged = 0.05;
|
viewer.camera.percentageChanged = 0.05;
|
||||||
|
|
||||||
@@ -117,7 +122,8 @@ export function createLifecycle({
|
|||||||
layerState._enableKickTimer = null;
|
layerState._enableKickTimer = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!layerState._fetching) parts.viewport.onCameraChanged();
|
if (!layerState._fetching && !layerState._retryTimer)
|
||||||
|
parts.viewport.onCameraChanged();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,8 +139,14 @@ export function createLifecycle({
|
|||||||
clearTimeout(layerState._fetchTimeout);
|
clearTimeout(layerState._fetchTimeout);
|
||||||
clearInterval(layerState._enableKickTimer);
|
clearInterval(layerState._enableKickTimer);
|
||||||
layerState._enableKickTimer = null;
|
layerState._enableKickTimer = null;
|
||||||
|
clearTimeout(layerState._retryTimer);
|
||||||
|
layerState._retryTimer = null;
|
||||||
|
layerState._retryDelayMs = 1500;
|
||||||
parts.ingestion.cancelActiveFetch();
|
parts.ingestion.cancelActiveFetch();
|
||||||
layerState._loadGeneration++;
|
layerState._loadGeneration++;
|
||||||
|
layerState._fetching = false;
|
||||||
|
layerState._flowPending = 0;
|
||||||
|
layerState._roadError = null;
|
||||||
parts.animation.clearDots();
|
parts.animation.clearDots();
|
||||||
layerState._lastViewCenter = null;
|
layerState._lastViewCenter = null;
|
||||||
// A stale outage from the last session would misreport a fresh enable —
|
// 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);
|
viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged);
|
||||||
|
layerState._arrivalRemover?.();
|
||||||
|
layerState._arrivalRemover = null;
|
||||||
// Restore the camera's global percentageChanged so other layers/listeners
|
// Restore the camera's global percentageChanged so other layers/listeners
|
||||||
// keep their expected sensitivity.
|
// keep their expected sensitivity.
|
||||||
if (layerState._prevPercentageChanged != null) {
|
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 */
|
/** @type {Function|null} Disposer returned by camera.changed event subscription */
|
||||||
|
|
||||||
layerState._cameraRemover = null;
|
layerState._cameraRemover = null;
|
||||||
|
layerState._arrivalRemover = null;
|
||||||
|
|
||||||
/** @type {ReturnType<typeof setTimeout>|null} Debounce timer for camera-change fetch */
|
/** @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. */
|
/** @type {ReturnType<typeof setInterval>|null} Enable-time retry until the first load commits. */
|
||||||
|
|
||||||
layerState._enableKickTimer = null;
|
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 */
|
/** @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
|
// 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.
|
// (cleared here) never reload (H5). Clearing the gate forces a fresh fetch.
|
||||||
if (alt > ACTIVATION_ALTITUDE) {
|
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();
|
parts.animation.clearDots();
|
||||||
layerState._lastBounds = null;
|
layerState._lastBounds = null;
|
||||||
layerState._lastViewCenter = null;
|
layerState._lastViewCenter = null;
|
||||||
|
|||||||
Reference in new issue
Block a user