mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into upstream/traffic-paired-flow-density
This commit is contained in:
499 files changed
+28458
-1374
No files matched your search
@@ -10,11 +10,16 @@
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { gbfsProxy } from '../../../server/providers/gbfs.js';
|
||||
import { localProviderPlugins } from '../../../server/providers/local.js';
|
||||
import { createBikeshareSource } from './source.js';
|
||||
import { createModel } from './model.js';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { makeFixtureRoot } from '../../tooling/fixtureRoot.mjs';
|
||||
|
||||
const nominatimStorageDir = await makeFixtureRoot('gev-gbfs-composition-');
|
||||
test.after(() => rm(nominatimStorageDir, { recursive: true, force: true }));
|
||||
|
||||
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
|
||||
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
|
||||
@@ -339,7 +344,7 @@ test('the mounted route still refuses a host outside the allowlist', async () =>
|
||||
* or from the preview half of it — fails here rather than in a browser.
|
||||
*/
|
||||
function composedGbfsPlugin() {
|
||||
const plugin = localProviderPlugins().find(
|
||||
const plugin = localProviderPlugins({ nominatimStorageDir }).find(
|
||||
(entry) => entry.name === 'gbfs-proxy',
|
||||
);
|
||||
assert.ok(plugin, 'the application must compose a GBFS provider');
|
||||
@@ -446,3 +451,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col
|
||||
assert.equal(model.toNonNegativeInteger('3'), 3);
|
||||
}
|
||||
});
|
||||
|
||||
test('a station without a published capacity re-derives it from every status poll', async () => {
|
||||
const { createRendering } = await import('./rendering.js');
|
||||
const { COLOR_YELLOW } = await import('./policy.js');
|
||||
const model = createModel({});
|
||||
const state = {
|
||||
_cityRuntime: new Map(),
|
||||
_stationRenderMap: new Map(),
|
||||
_pointCollection: { add: (options) => ({ ...options }) },
|
||||
};
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts: { model },
|
||||
});
|
||||
const info = model.parseStationInformation({
|
||||
data: {
|
||||
stations: [
|
||||
{ station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 },
|
||||
{
|
||||
station_id: 'fixed',
|
||||
name: 'Fixed',
|
||||
lat: 30.27,
|
||||
lon: -97.75,
|
||||
capacity: 13,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
rendering.ensureCityPoints('austin', info);
|
||||
const poll = (stations) =>
|
||||
rendering.applyStatusToPoints(
|
||||
'austin',
|
||||
model.parseStationStatus({ data: { stations } }),
|
||||
);
|
||||
const open = state._stationRenderMap.get('austin:open');
|
||||
const fixed = state._stationRenderMap.get('austin:fixed');
|
||||
|
||||
// The first poll has no row for the station: capacity is a placeholder.
|
||||
poll([
|
||||
{
|
||||
station_id: 'fixed',
|
||||
num_bikes_available: 4,
|
||||
num_docks_available: 9,
|
||||
},
|
||||
]);
|
||||
poll([
|
||||
{ station_id: 'open', num_bikes_available: 10, num_docks_available: 20 },
|
||||
{ station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 },
|
||||
]);
|
||||
assert.equal(open.capacity, 30, 'the first poll fixed the capacity');
|
||||
assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty');
|
||||
assert.equal(fixed.capacity, 13, 'a published capacity is kept');
|
||||
|
||||
poll([
|
||||
{ station_id: 'open', num_bikes_available: 4, num_docks_available: 2 },
|
||||
]);
|
||||
assert.equal(open.capacity, 6);
|
||||
});
|
||||
@@ -185,6 +185,7 @@ export function createRendering({
|
||||
stationName: station.name,
|
||||
point,
|
||||
capacity: parts.model.toNonNegativeInteger(station.capacity),
|
||||
publishedCapacity: parts.model.toNonNegativeInteger(station.capacity),
|
||||
bikesAvailable: null,
|
||||
docksAvailable: null,
|
||||
isInstalled: station.isInstalled,
|
||||
@@ -245,7 +246,8 @@ export function createRendering({
|
||||
if (!record) continue;
|
||||
|
||||
const status = statusMap.get(record.stationId) || null;
|
||||
const capacity = resolveCapacity(record.capacity, status);
|
||||
// Resolve from the published capacity, not last poll's derived value.
|
||||
const capacity = resolveCapacity(record.publishedCapacity, status);
|
||||
record.capacity = capacity;
|
||||
|
||||
record.bikesAvailable = parts.model.toNonNegativeInteger(
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
militaryNamesInView,
|
||||
nameMilitaryFragment,
|
||||
} from '../../data/militaryNames.js';
|
||||
import {
|
||||
createOpenFreeMapSource,
|
||||
decodeOpenFreeMapMilitaryTile,
|
||||
} from '../../sources/openFreeMap.js';
|
||||
import { createOpenFreeMapSource } from '../../sources/openFreeMap.js';
|
||||
import {
|
||||
mergeMilitaryFragments,
|
||||
militaryNameInView,
|
||||
@@ -96,7 +93,7 @@ export function createInstallationSource({
|
||||
loadNames = loadMilitaryNames,
|
||||
mapTiles = createOpenFreeMapSource({
|
||||
fetchImpl: tileFetchImpl,
|
||||
decode: decodeOpenFreeMapMilitaryTile,
|
||||
selectDecoded: ({ military }) => ({ military }),
|
||||
}),
|
||||
} = {}) {
|
||||
let overpassUnavailable = false;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MILITARY_TILE_MIN_ZOOM,
|
||||
} from './source.js';
|
||||
import {
|
||||
clearSharedOpenFreeMapTiles,
|
||||
decodeOpenFreeMapMilitaryTile,
|
||||
decodeOpenFreeMapTile,
|
||||
} from '../../sources/openFreeMap.js';
|
||||
@@ -224,6 +225,7 @@ test('the military-only decoder keeps Camp Mabry and skips road geometry', () =>
|
||||
});
|
||||
|
||||
test('installation API transports never receive vector-tile URLs', async (t) => {
|
||||
t.after(clearSharedOpenFreeMapTiles);
|
||||
const calls = [];
|
||||
const tileFetch = async (url) => {
|
||||
calls.push(url);
|
||||
@@ -242,6 +244,7 @@ test('installation API transports never receive vector-tile URLs', async (t) =>
|
||||
};
|
||||
t.mock.method(globalThis, 'fetch', tileFetch);
|
||||
for (const tileFetchImpl of [undefined, tileFetch]) {
|
||||
clearSharedOpenFreeMapTiles();
|
||||
const source = createInstallationSource({
|
||||
tileFetchImpl,
|
||||
fetchImpl: async (url) => {
|
||||
|
||||
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (!parts.interaction.radioPresentationAllowed())
|
||||
return Promise.resolve(false);
|
||||
if (!layerState._selectedId) {
|
||||
if (layerState._selectedId) {
|
||||
// Drop a fallback left by a voice step whose playback never started.
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
} else {
|
||||
const ranked = parts.queries.rankedVisibleStations();
|
||||
if (!ranked.length) return Promise.resolve(false);
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
parts.selection.selectRadioStation(ranked[0].id, {
|
||||
autoplay: false,
|
||||
focus: false,
|
||||
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||
});
|
||||
}
|
||||
return playSelectedRadio({ origin });
|
||||
|
||||
@@ -73,6 +73,16 @@ export function createSelection({
|
||||
return { filter: layerState._filter, volume: layerState._userVolume };
|
||||
}
|
||||
|
||||
// A paused or failed stream of another station would outlive its selection.
|
||||
function releaseStaleRadioStream(stationId) {
|
||||
if (
|
||||
layerState._audioStationId &&
|
||||
layerState._audioStationId !== stationId &&
|
||||
['paused', 'error'].includes(layerState._audioState)
|
||||
)
|
||||
parts.playback.stopRadioPlayback();
|
||||
}
|
||||
|
||||
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
||||
|
||||
function selectRadioStation(
|
||||
@@ -83,11 +93,18 @@ export function createSelection({
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
cameraNavigation = null,
|
||||
playFallback = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const station = layerState._stationById.get(String(id));
|
||||
if (!station) return false;
|
||||
if (!autoplay) releaseStaleRadioStream(station.id);
|
||||
// Set after the release, which clears fallbacks.
|
||||
if (playFallback) {
|
||||
layerState._playFallbackId = playFallback.id;
|
||||
layerState._playFallbackFocus = playFallback.focus;
|
||||
}
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
if (
|
||||
!parts.navigation.radioCameraNavigationOwnsSelection(
|
||||
@@ -104,6 +121,7 @@ export function createSelection({
|
||||
)
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
const changed = layerState._selectedId !== station.id;
|
||||
layerState._selectedId = station.id;
|
||||
const generation = ++layerState._selectionGeneration;
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
@@ -122,6 +140,9 @@ export function createSelection({
|
||||
if (focus) parts.navigation.focusStation(station);
|
||||
parts.presentation.emitState();
|
||||
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
|
||||
// A user step to another station cancels a playback voice prepared.
|
||||
else if (origin === 'user' && changed)
|
||||
parts.presentation.emitPlaybackControl('select', origin);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -133,6 +154,7 @@ export function createSelection({
|
||||
rotate = false,
|
||||
stationIds = null,
|
||||
autoplay = true,
|
||||
fallback = autoplay,
|
||||
origin = 'programmatic',
|
||||
} = {},
|
||||
) {
|
||||
@@ -159,25 +181,29 @@ export function createSelection({
|
||||
current < 0
|
||||
? 0
|
||||
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
||||
layerState._playFallbackId =
|
||||
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
|
||||
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
|
||||
const fallbackId =
|
||||
fallback && ranked.length > 1
|
||||
? ranked[(nextIndex + 1) % ranked.length].id
|
||||
: null;
|
||||
const rotationCameraState = rotate
|
||||
? parts.navigation.radioCameraState()
|
||||
: null;
|
||||
const rotationNavigation = rotate
|
||||
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
|
||||
: null;
|
||||
layerState._playFallbackFocus = rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
const fallbackFocus =
|
||||
fallback && rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
const station = ranked[nextIndex];
|
||||
if (rotate)
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
@@ -191,6 +217,7 @@ export function createSelection({
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation: rotationNavigation,
|
||||
playFallback: { id: fallbackId, focus: fallbackFocus },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,9 +244,12 @@ export function createSelection({
|
||||
},
|
||||
);
|
||||
if (!ranked.length) return null;
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
|
||||
selectRadioStation(ranked[0].id, {
|
||||
autoplay,
|
||||
focus: false,
|
||||
origin,
|
||||
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||
});
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export function createState({ services }) {
|
||||
|
||||
layerState._tuningStartStationId = null;
|
||||
|
||||
layerState._tuningResumesPlayback = false;
|
||||
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._tuningStationById = new Map();
|
||||
|
||||
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._tuningStartStationId = layerState._selectedId;
|
||||
layerState._tuningPreviewId = layerState._selectedId;
|
||||
// Like Previous/Next, the release plays only if a stream was active.
|
||||
layerState._tuningResumesPlayback = [
|
||||
'loading',
|
||||
'buffering',
|
||||
'playing',
|
||||
].includes(layerState._audioState);
|
||||
parts.tuningNoise.installTuningNoise();
|
||||
parts.playback.pauseRadioPlayback({ origin: 'user' });
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._selectionGeneration += 1;
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._tuningUnavailableStationId = stationId;
|
||||
parts.presentation.emitState();
|
||||
// The drag paused the stream; with no selection, Resume would start the
|
||||
// nearest station. Stop also publishes the state and cancels a voice handoff.
|
||||
parts.playback.stopRadioPlayback({ origin });
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'station-unavailable',
|
||||
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
generation,
|
||||
});
|
||||
}
|
||||
const autoplay = layerState._tuningResumesPlayback;
|
||||
layerState._tuningResumesPlayback = false;
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = true;
|
||||
layerState._tuningAwaitingStationId = frozenStation.id;
|
||||
layerState._tuningStatic = autoplay;
|
||||
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
// Muting alone would leave the noise graph running after a silent release.
|
||||
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
|
||||
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
parts.presentation.emitState();
|
||||
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
||||
autoplay: true,
|
||||
autoplay,
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation,
|
||||
|
||||
@@ -50,6 +50,11 @@ export function createTuningNoise({
|
||||
layerState._tuningNoiseGain = null;
|
||||
}
|
||||
|
||||
function suspendTuningNoiseContext() {
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
}
|
||||
|
||||
function syncTuningNoiseGain() {
|
||||
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
|
||||
const audible = radioTuningStaticShouldPlay({
|
||||
@@ -79,8 +84,21 @@ export function createTuningNoise({
|
||||
if (!AudioContextClass) return false;
|
||||
if (!layerState._tuningNoiseContext)
|
||||
layerState._tuningNoiseContext = new AudioContextClass();
|
||||
const resumed = layerState._tuningNoiseContext.resume?.();
|
||||
if (resumed?.catch) void resumed.catch(() => {});
|
||||
const context = layerState._tuningNoiseContext;
|
||||
const resumed = context.resume?.();
|
||||
// A release before the device starts finds the context still suspended,
|
||||
// so its suspend() is a no-op; suspend again once resume() settles.
|
||||
if (resumed?.then)
|
||||
void resumed.then(
|
||||
() => {
|
||||
if (
|
||||
layerState._tuningNoiseContext === context &&
|
||||
!layerState._tuningNoiseSource
|
||||
)
|
||||
suspendTuningNoiseContext();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
if (layerState._tuningNoiseSource) return true;
|
||||
|
||||
const frameCount = Math.max(
|
||||
@@ -120,6 +138,7 @@ export function createTuningNoise({
|
||||
if (layerState._tuningCameraNavigation)
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningResumesPlayback = false;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
@@ -131,8 +150,7 @@ export function createTuningNoise({
|
||||
parts.rendering.updateSelectionEntity();
|
||||
syncTuningNoiseGain();
|
||||
stopTuningNoiseSource();
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
suspendTuningNoiseContext();
|
||||
if (emit) parts.presentation.emitState();
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,21 @@ import {
|
||||
} from './geometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
|
||||
|
||||
function indexedCollection(features) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: features.map((feature, index) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
[SOURCE_FEATURE_INDEX]: index,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createIngestion({ state, parts, source }) {
|
||||
async function load(viewer) {
|
||||
if (state._loading || state._loaded) return;
|
||||
@@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) {
|
||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||
|
||||
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
{ type: 'FeatureCollection', features: cableFeatures },
|
||||
indexedCollection(cableFeatures),
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.cableColor.withAlpha(0.95),
|
||||
@@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) {
|
||||
);
|
||||
if (!owns()) return;
|
||||
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
{ type: 'FeatureCollection', features: landingFeatures },
|
||||
indexedCollection(landingFeatures),
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.landingColor.withAlpha(0.9),
|
||||
@@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) {
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
|
||||
cableEntities.forEach((entity, index) => {
|
||||
// Cesium expands multipart geometry into several entities and suffixes
|
||||
// colliding IDs. Its copied properties retain this source index even
|
||||
// when a provider has no ID, so every segment keeps the right owner.
|
||||
const cableReferences = new Set();
|
||||
cableEntities.forEach((entity) => {
|
||||
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
|
||||
const feature = cableFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
@@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) {
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
if (cableReferences.has(index)) return;
|
||||
cableReferences.add(index);
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
@@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) {
|
||||
});
|
||||
});
|
||||
|
||||
landingEntities.forEach((entity, index) => {
|
||||
const landingReferences = new Set();
|
||||
landingEntities.forEach((entity) => {
|
||||
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
|
||||
const feature = landingFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
@@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) {
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
if (landingReferences.has(index)) return;
|
||||
landingReferences.add(index);
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
|
||||
@@ -154,57 +154,200 @@ for (const targetHeight of [200, 2000]) test(`visible height steps stay pixel-li
|
||||
assert.equal(state._heightPending, true);
|
||||
});
|
||||
|
||||
for (const outcome of ['timeout', 'abort']) test(`a road pass ${outcome} invalidates queued tiles and terrain publication without a newer generation`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const held = [];
|
||||
const setTimer = globalThis.setTimeout;
|
||||
t.mock.method(globalThis, 'setTimeout', (fn, delay, ...args) => delay === 0
|
||||
? (held.push(fn), held.length) : setTimer(fn, delay, ...args));
|
||||
let clock = 0;
|
||||
t.mock.method(performance, 'now', () => clock += 13);
|
||||
function holdFlowTasks(t) {
|
||||
const yields = [];
|
||||
const scheduler = Object.getOwnPropertyDescriptor(globalThis, 'scheduler');
|
||||
Object.defineProperty(globalThis, 'scheduler', {
|
||||
configurable: true,
|
||||
value: { yield: () => new Promise((resolve) => yields.push(resolve)) },
|
||||
});
|
||||
t.after(() => {
|
||||
for (const resolve of yields) resolve();
|
||||
if (scheduler) Object.defineProperty(globalThis, 'scheduler', scheduler);
|
||||
else delete globalThis.scheduler;
|
||||
});
|
||||
return async () => {
|
||||
assert.ok(yields.length, 'pass reached a flow task boundary');
|
||||
yields.shift()();
|
||||
await flush();
|
||||
};
|
||||
}
|
||||
|
||||
function flowFixture(source) {
|
||||
const state = createState({ services: {} });
|
||||
let options, samples = 0;
|
||||
const road = { type: 'primary', oneway: 1, coords: [[0, 0], [0.001, 0]],
|
||||
waypoints: [C.Cartesian3.fromDegrees(0, 0), C.Cartesian3.fromDegrees(0.001, 0)], segmentDist: [111] };
|
||||
const source = { getStatus: async () => ({ hasKey: false }), requestRoads: (_b, opts) => {
|
||||
options = opts;
|
||||
opts.onTile({ roads: [road] });
|
||||
return new Promise(() => {});
|
||||
} };
|
||||
const calls = { samples: 0, selections: 0 };
|
||||
const road = {
|
||||
type: 'primary',
|
||||
oneway: 1,
|
||||
coords: [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
],
|
||||
waypoints: [
|
||||
C.Cartesian3.fromDegrees(0, 0),
|
||||
C.Cartesian3.fromDegrees(0.001, 0),
|
||||
],
|
||||
segmentDist: [111],
|
||||
};
|
||||
const paints = [];
|
||||
Object.assign(state, { _enabled: true, _roadMode: 'osm', _parseRoads: (data) => data.roads,
|
||||
_viewer: { scene: { globe: { show: true, getHeight: () => { samples++; return 100; } } } } });
|
||||
Object.assign(state, {
|
||||
_enabled: true,
|
||||
_roadMode: 'osm',
|
||||
_parseRoads: (data) => data.roads,
|
||||
_viewer: {
|
||||
scene: {
|
||||
globe: {
|
||||
show: true,
|
||||
getHeight: () => {
|
||||
calls.samples++;
|
||||
return 100;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const parts = {
|
||||
viewport: { clampBounds: (b) => b, getBoundsCenter: () => ({ lat: 0, lon: 0 }), onCameraChanged() {} },
|
||||
rendering: { visibleRoadsForAltitude: (roads) => roads, renderRoadsForAltitude: (...args) => paints.push(args) },
|
||||
model: { allocateRoadDotBudgets: (roads) => roads.map(() => 1), recolorDotsInPlace() {} },
|
||||
viewport: {
|
||||
clampBounds: (b) => b,
|
||||
getBoundsCenter: () => ({ lat: 0, lon: 0 }),
|
||||
onCameraChanged() {},
|
||||
},
|
||||
rendering: {
|
||||
visibleRoadsForAltitude: (roads) => {
|
||||
calls.selections++;
|
||||
return roads;
|
||||
},
|
||||
renderRoadsForAltitude: (...args) => paints.push(args),
|
||||
},
|
||||
model: {
|
||||
allocateRoadDotBudgets: (roads) => roads.map(() => 1),
|
||||
recolorDotsInPlace() {},
|
||||
},
|
||||
};
|
||||
const ctx = { state, source, services: { credits: {} }, parts };
|
||||
parts.flow = createFlow(ctx);
|
||||
const ingestion = createIngestion(ctx);
|
||||
const request = ingestion.loadRoadsForBounds(bounds, 450);
|
||||
await flush();
|
||||
assert.equal(held.length, 1);
|
||||
held.shift()(); await flush();
|
||||
assert.equal(held.length, 1, 'held at the final station before the real terrain onReady callback');
|
||||
options.onTile({ roads: [road] });
|
||||
const generation = state._loadGeneration;
|
||||
const revision = state._surfaceRevision;
|
||||
const samplesBefore = samples;
|
||||
if (outcome === 'timeout') t.mock.timers.tick(12001);
|
||||
else ingestion.cancelActiveFetch();
|
||||
await request;
|
||||
assert.equal(options.signal.aborted, true);
|
||||
assert.equal(state._loadGeneration, generation);
|
||||
assert.equal(state._fetching, false);
|
||||
if (outcome === 'timeout') {
|
||||
assert.equal(state._activeFetchAbort.signal.aborted, false);
|
||||
assert.match(state._roadError, /timed out/);
|
||||
assert.equal(state._retryAttempts, 1);
|
||||
}
|
||||
held.shift()(); await flush();
|
||||
assert.equal(paints.length, 0, 'expired terrain cannot publish');
|
||||
assert.equal(samples, samplesBefore, 'queued tiles cannot start surface preparation');
|
||||
assert.equal(state._surfaceRevision, revision, 'queued tiles cannot invalidate another surface pass');
|
||||
ingestion.cancelActiveFetch();
|
||||
});
|
||||
return { state, road, paints, parts, ctx, calls };
|
||||
}
|
||||
|
||||
for (const outcome of ['timeout', 'abort'])
|
||||
test(`a road pass ${outcome} invalidates queued tiles and terrain publication without a newer generation`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const advanceFlowTask = holdFlowTasks(t);
|
||||
const held = [];
|
||||
const setTimer = globalThis.setTimeout;
|
||||
t.mock.method(globalThis, 'setTimeout', (fn, delay, ...args) =>
|
||||
delay === 0 ? (held.push(fn), held.length) : setTimer(fn, delay, ...args),
|
||||
);
|
||||
let clock = 0;
|
||||
t.mock.method(performance, 'now', () => (clock += 13));
|
||||
let options;
|
||||
const source = {
|
||||
getStatus: async () => ({ hasKey: false }),
|
||||
requestRoads: (_b, opts) => {
|
||||
options = opts;
|
||||
opts.onTile({ roads: [road] });
|
||||
return new Promise(() => {});
|
||||
},
|
||||
};
|
||||
const { state, road, paints, ctx, calls } = flowFixture(source);
|
||||
const ingestion = createIngestion(ctx);
|
||||
const request = ingestion.loadRoadsForBounds(bounds, 450);
|
||||
await flush();
|
||||
await advanceFlowTask();
|
||||
const samplesBeforeSurface = calls.samples;
|
||||
await advanceFlowTask();
|
||||
assert.equal(held.length, 1);
|
||||
held.shift()();
|
||||
await flush();
|
||||
assert.equal(
|
||||
held.length,
|
||||
1,
|
||||
'held at the final station before the real terrain onReady callback',
|
||||
);
|
||||
assert.equal(
|
||||
calls.samples - samplesBeforeSurface,
|
||||
road.coords.length,
|
||||
'both terrain stations were sampled before the final hold',
|
||||
);
|
||||
options.onTile({ roads: [road] });
|
||||
const generation = state._loadGeneration;
|
||||
const revision = state._surfaceRevision;
|
||||
const samplesBefore = calls.samples;
|
||||
if (outcome === 'timeout') t.mock.timers.tick(12001);
|
||||
else ingestion.cancelActiveFetch();
|
||||
await request;
|
||||
assert.equal(options.signal.aborted, true);
|
||||
assert.equal(state._loadGeneration, generation);
|
||||
assert.equal(state._fetching, false);
|
||||
if (outcome === 'timeout') {
|
||||
assert.equal(state._activeFetchAbort.signal.aborted, false);
|
||||
assert.match(state._roadError, /timed out/);
|
||||
assert.equal(state._retryAttempts, 1);
|
||||
}
|
||||
held.shift()();
|
||||
await flush();
|
||||
assert.equal(paints.length, 0, 'expired terrain cannot publish');
|
||||
assert.equal(
|
||||
calls.samples,
|
||||
samplesBefore,
|
||||
'queued tiles cannot start surface preparation',
|
||||
);
|
||||
assert.equal(
|
||||
state._surfaceRevision,
|
||||
revision,
|
||||
'queued tiles cannot invalidate another surface pass',
|
||||
);
|
||||
ingestion.cancelActiveFetch();
|
||||
});
|
||||
|
||||
for (const checkpoint of ['none', 'prepare', 'surface'])
|
||||
test(
|
||||
checkpoint === 'none'
|
||||
? 'a current surface pass crosses both task boundaries'
|
||||
: `a newer surface revision stops stale ${checkpoint} work without aborting the pass`,
|
||||
async (t) => {
|
||||
// These cases exercise ownership boundaries, not the surface slice budget.
|
||||
t.mock.method(performance, 'now', () => 0);
|
||||
const advanceFlowTask = holdFlowTasks(t);
|
||||
const { state, road, paints, parts, calls } = flowFixture({});
|
||||
const controller = new AbortController();
|
||||
t.after(() => controller.abort());
|
||||
const generation = state._loadGeneration;
|
||||
const pass = parts.flow.applyFlowThenRender(
|
||||
[road],
|
||||
bounds,
|
||||
generation,
|
||||
450,
|
||||
'Loaded tile',
|
||||
null,
|
||||
false,
|
||||
controller.signal,
|
||||
);
|
||||
await flush();
|
||||
if (checkpoint === 'prepare') state._surfaceRevision++;
|
||||
await advanceFlowTask();
|
||||
if (checkpoint === 'prepare') {
|
||||
assert.equal(
|
||||
calls.selections,
|
||||
0,
|
||||
'stale whole-graph preparation cannot start',
|
||||
);
|
||||
} else {
|
||||
assert.ok(calls.selections > 0, 'the healthy preparation prefix ran');
|
||||
if (checkpoint === 'surface') state._surfaceRevision++;
|
||||
await advanceFlowTask();
|
||||
}
|
||||
const rendered = await pass;
|
||||
assert.equal(state._loadGeneration, generation);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
if (checkpoint === 'none') {
|
||||
assert.equal(rendered, true);
|
||||
assert.ok(calls.samples > 0);
|
||||
assert.ok(paints.length > 0);
|
||||
} else {
|
||||
assert.equal(rendered, false);
|
||||
assert.equal(calls.samples, 0, 'superseded work cannot sample terrain');
|
||||
assert.equal(paints.length, 0, 'superseded work cannot publish');
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
roadDotBudget,
|
||||
} from './policy.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { nextTask } from '../../sources/vectorTiles.js';
|
||||
|
||||
export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
|
||||
@@ -253,6 +254,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
parts.rendering.renderRoadsForAltitude([], altitude, label, trace);
|
||||
return true;
|
||||
}
|
||||
// Separate whole-graph preparation from tile completion and publication.
|
||||
await nextTask();
|
||||
if (!current()) return false;
|
||||
const scene = layerState._viewer.scene;
|
||||
// The enable-time estimate can predate the first local mesh (especially
|
||||
// after an intercontinental jump). Once previews have measured roads, use
|
||||
@@ -476,6 +480,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
if (!lastPaint || performance.now() - lastPaint >= 120) paint();
|
||||
},
|
||||
};
|
||||
// The first bounded surface slice needs its own task, not the prefix's tail.
|
||||
await nextTask();
|
||||
if (!current()) return false;
|
||||
let prepared = await prepareRoadSurfaces(
|
||||
unresolved,
|
||||
layerState._viewer.scene,
|
||||
|
||||
@@ -76,6 +76,10 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
|
||||
const tick = async (ms) => {
|
||||
t.mock.timers.tick(ms);
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
t.mock.timers.tick(0);
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
t.mock.timers.tick(0);
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
return { layer, viewer, move, tick };
|
||||
}
|
||||
|
||||
@@ -84,7 +84,10 @@ export function roadRequestError(status, cause) {
|
||||
export function createTrafficSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
tileFetchImpl = (...args) => globalThis.fetch(...args),
|
||||
mapTiles = createOpenFreeMapSource({ fetchImpl: tileFetchImpl }),
|
||||
mapTiles = createOpenFreeMapSource({
|
||||
fetchImpl: tileFetchImpl,
|
||||
selectDecoded: ({ roads }) => ({ roads }),
|
||||
}),
|
||||
} = {}) {
|
||||
const flow = createFlowTileSource({ fetchImpl });
|
||||
const api = {
|
||||
|
||||
@@ -5,7 +5,10 @@ import { getEventListeners } from 'node:events';
|
||||
import { createFlowTileSource } from './flowSource.js';
|
||||
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
||||
import { decodeFlowTile } from './flowDecode.js';
|
||||
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
|
||||
import {
|
||||
clearSharedOpenFreeMapTiles,
|
||||
decodeOpenFreeMapTile,
|
||||
} from '../../sources/openFreeMap.js';
|
||||
import {
|
||||
RoadRequestError,
|
||||
roadRequestError,
|
||||
@@ -851,6 +854,8 @@ test('failed detail cores retain real coarse roads while successful cores replac
|
||||
});
|
||||
|
||||
test('traffic separates API and public tile transports, including the default tile fetch', async (t) => {
|
||||
clearSharedOpenFreeMapTiles();
|
||||
t.after(clearSharedOpenFreeMapTiles);
|
||||
const publicUrls = [],
|
||||
apiUrls = [];
|
||||
const publicFetch = async (url) => {
|
||||
@@ -886,7 +891,12 @@ test('traffic separates API and public tile transports, including the default ti
|
||||
(await (await source.requestOsmRoads(bounds)).json()).roads.length,
|
||||
);
|
||||
}
|
||||
assert.ok(publicUrls.length >= 4);
|
||||
assert.equal(
|
||||
publicUrls.filter((url) => url.endsWith('.pbf')).length,
|
||||
1,
|
||||
'compatible sources reuse the shared decoded public tile',
|
||||
);
|
||||
assert.equal(publicUrls.filter((url) => url.endsWith('/planet')).length, 2);
|
||||
assert.ok(apiUrls.length >= 4);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user