fix(osm): stop using public Overpass by default (#648) (#742)

Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a
key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled
worldwide name index; wide views show named points. ALPR reads an hourly
OpenStreetMap extract. Cockpit place context and country, state and US county
outlines use bundled Natural Earth and US Census data. Overpass is used only
when an operator configures their own endpoint.

Also: camera arrivals no longer land below ground, keyless terrain retries on
429, bounded relation geometry for configured Overpass, one shared
OpenStreetMap credit, and separate tile and API transports for embedders.

Co-authored-by: theGaryLarson <[email protected]>
Co-authored-by: Milan Khanal <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-28 19:07:57 -05:00
1 parent 81eb44340d
commit f4fcf267c6
200 files changed
+26665 -1977

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
/** One cancellable consumer of the shared terrain service, coalesced by floor cell. */
export function createAlprFloorResolver(ground) {
const pending = new Map();
const queued = new Map();
let owner = null;
let running = false;
async function pump(controller) {
try {
while (queued.size && !controller.signal.aborted) {
const batch = [...queued.values()].slice(0, 64);
for (const cell of batch) queued.delete(cell.key);
try {
await ground.resolveGroundFloorCells(
batch.map(({ lat, lon }) => ({ lat, lon })),
{ signal: controller.signal },
);
} catch {
// Missing floors remain clamped; cancellation must not publish later.
}
for (const cell of batch) {
if (pending.get(cell.key) === cell) pending.delete(cell.key);
cell.finish();
}
}
} finally {
if (owner === controller) running = false;
}
}
function resolve(points) {
if (typeof ground.resolveGroundFloorCells !== 'function')
return Promise.resolve();
owner ??= new AbortController();
const waits = [];
for (const point of points) {
const lat = Number(point.lat.toFixed(3));
const lon = Number(point.lon.toFixed(3));
const key = `${lat},${lon}`;
let cell = pending.get(key);
if (!cell) {
let finish;
const promise = new Promise((done) => {
finish = done;
});
cell = { key, lat, lon, promise, finish };
pending.set(key, cell);
queued.set(key, cell);
}
waits.push(cell.promise);
}
if (!running && queued.size) {
running = true;
void pump(owner);
}
return Promise.all(waits);
}
async function prepare(points) {
let timer;
try {
await Promise.race([
resolve(points),
new Promise((done) => {
timer = setTimeout(done, ground.FLOOR_RESOLVE_DEADLINE_MS ?? 1200);
}),
]);
} finally {
clearTimeout(timer);
}
}
function cancel() {
owner?.abort();
owner = null;
running = false;
for (const cell of pending.values()) cell.finish();
pending.clear();
queued.clear();
}
return { resolve, prepare, cancel };
}
+107 -33
View File
@@ -1,3 +1,4 @@
import { isUnavailableCapability } from '../../sources/capability.js';
import * as Cesium from 'cesium';
import {
LAYER_ID,
@@ -21,7 +22,9 @@ import { createAlprPresentation } from './presentation.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
* A source fetches a bounded box with an AbortSignal and resolves
* { records, stale, saturated }. Records use stable string ids and latitude /
* { records, stale, saturated }, optionally with `coverage` (the bounds its
* records fully cover, reused for later views inside it) and `zoom` (their
* tile precision; `source.detailZoom(box)` names the zoom a view needs). Records use stable string ids and latitude /
* longitude in degrees; optional directionDeg is a compass bearing [0, 360).
* Manufacturer, operator and cameraType are optional display fields.
* Source label and attribution { name, description, text, href } identify the
@@ -67,6 +70,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
debounceTimer: null,
/** Snapped box of the last successful query; a view still inside it reuses its records. */
lastQueryBox: null,
/** Extract zoom of the accepted records (null for sources without tiles). */
lastQueryZoom: null,
/** Increments whenever the displayed camera set changes (QA stability probe). */
renderRevision: 0,
/** Cameras in view beyond the render cap. */
renderSaturated: false,
};
const {
initOverlay,
@@ -81,6 +90,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
} = createAlprPresentation({ state, services, source });
function setAlprStatus(status, error = null) {
@@ -109,10 +119,34 @@ export function createAlprCamerasLayer({ source, services } = {}) {
if (resetBackoff) state.retryDelayMs = 0;
}
/** Whether accepted records already answer `box` without a request. */
function canReuse(box) {
const detailZoom = source.detailZoom?.(box) ?? null;
return Boolean(
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
(state.lastQueryZoom == null ||
detailZoom == null ||
state.lastQueryZoom >= detailZoom - 1) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable',
);
}
function scheduleLoad() {
if (!state.enabled) return;
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.debounceTimer);
// A view the accepted tiles already cover is a local filter: show it now.
// Only a view that needs a request waits out the debounce.
const box = viewportBox(state.viewer);
if (box && canReuse(box)) {
state.debounceTimer = null;
loadCameras();
return;
}
state.debounceTimer = setTimeout(() => {
state.debounceTimer = null;
loadCameras();
@@ -142,14 +176,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
// request still in flight was for a viewport the user has since left, so it
// is aborted rather than allowed to replace the records that already cover
// this view. Failed queries never replace the last successful box.
if (
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable'
) {
// Reuse also needs the accepted records to be precise enough for this view:
// one zoom coarser than the view would pick is a few metres, never more.
if (canReuse(box)) {
if (state.abort) {
state.abort.abort();
state.abort = null;
@@ -165,7 +194,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
}
// A move inside an in-flight query must not abort and restart that query.
if (state.abort && boxContains(state.pendingQueryBox, box)) {
renderRecords();
// The accepted records may cover only part of the new view. Re-filtering
// them during floor preparation creates an intermediate marker set.
return;
}
const queryBox = snapAlprBox(box);
@@ -176,7 +206,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.loading = true;
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
setAlprStatus('loading');
renderRecords();
// Keep the current markers until the new view's records land, so a still
// camera sees one update instead of old records re-filtered, then new ones.
try {
const snapshot = await source.fetch(queryBox, requestAbort.signal);
if (
@@ -185,16 +216,43 @@ export function createAlprCamerasLayer({ source, services } = {}) {
!state.enabled
)
return;
if (snapshot.zoomIn) {
state.records = [];
state.recordById.clear();
state.noCoverage = false;
state.lastQueryBox = null;
state.lastQueryZoom = null;
clearUnavailableRetry();
renderRecords();
setAlprStatus('zoom-in');
return;
}
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
// Resolve marker floors (bounded wait, warm cells answer at once) so new
// markers are placed on the floor instead of clamped then moved.
await prepareFloors(records);
if (
requestAbort.signal.aborted ||
state.abort !== requestAbort ||
!state.enabled
)
return;
state.noCoverage = snapshot.noCoverage === true;
state.records = records;
state.recordById = new Map(records.map((r) => [r.id, r]));
state.lastUpdate = Date.now();
state.stale = stale;
// Saturated when the QUERY truncated OR the render cap would hide cameras:
// either way the view holds more than the screen shows, and the user must
// be told rather than left with a count that disagrees with the map.
state.saturated = saturated || records.length > MAX_RENDERED;
state.lastQueryBox = queryBox;
// Saturated when the QUERY truncated; the render cap is judged against
// the cameras in view (renderSaturated). Either way the row says so.
state.saturated = saturated;
// The fetched tiles cover more than the view: pans inside them are free.
state.lastQueryBox =
snapshot.coverage && boxContains(snapshot.coverage, queryBox)
? snapshot.coverage
: queryBox;
state.lastQueryZoom = Number.isInteger(snapshot.zoom)
? snapshot.zoom
: null;
clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness.
@@ -219,7 +277,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
'unavailable',
error?.message || 'Camera source temporarily unavailable',
);
scheduleUnavailableRetry();
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
} finally {
if (state.abort === requestAbort) {
state.abort = null;
@@ -282,6 +340,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
return loadCameras();
},
destroy(viewer = state.viewer) {
source.destroy?.();
this.disable();
destroyOverlay();
state.moveEndRemove?.();
@@ -299,6 +358,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.records = [];
state.recordById = new Map();
state.lastQueryBox = null;
state.lastQueryZoom = null;
state.lastUpdate = null;
state.error = null;
state.status = 'idle';
@@ -335,12 +395,21 @@ export function createAlprCamerasLayer({ source, services } = {}) {
getStats() {
return {
count: state.dataSource?.entities.values.length || 0,
countLabel: state.enabled
? `${state.dataSource?.entities.values.length || 0} nearby`
: '',
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? `${state.dataSource?.entities.values.length || 0} nearby${
Number.isInteger(state.onScreen)
? ` · ${state.onScreen} on screen`
: ''
}`
: '',
noCoverage: Boolean(state.noCoverage),
lastUpdate: state.lastUpdate,
stale: state.stale,
saturated: state.saturated,
saturated: state.saturated || state.renderSaturated,
renderRevision: state.renderRevision,
onScreen: state.onScreen ?? null,
shown: state.dataSource?.entities.values.length || 0,
error: state.error,
status: state.status,
loading: state.loading,
@@ -353,17 +422,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
: [
state.stale ? 'Showing cached locations' : '',
state.saturated ? 'Coverage limited — zoom in' : '',
state.status === 'empty'
? 'No mapped cameras returned — coverage is incomplete'
: '',
]
.filter(Boolean)
.join(' · '),
: state.noCoverage
? 'No ALPR data for this area — US and Canada only'
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
: [
state.stale ? 'Showing cached locations' : '',
state.saturated || state.renderSaturated
? 'Coverage limited — zoom in'
: '',
state.status === 'empty' ? 'No ALPR data for this area' : '',
state.status === 'ready' && state.onScreen === 0
? 'None on screen — nearby cameras are outside the view'
: '',
]
.filter(Boolean)
.join(' · '),
};
},
};
@@ -384,4 +458,4 @@ export {
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
} from './policy.js';
export { createOverpassAlprSource } from './source.js';
export { createOverpassAlprSource, createAlprTileSource } from './source.js';
+178 -32
View File
@@ -3,18 +3,28 @@ import {
MAX_CANVAS_FRUSTUMS,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
ANCHOR_SAMPLES_PER_PAINT,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
} from './policy.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
BRACKETS_IMAGE,
directionWedgePositions,
markerScale,
paintDirectionWedge,
validAlprGroundHeight,
} from './visuals.js';
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({ state, services }) {
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
}) {
let lane,
removeMapListener,
retryTimer,
@@ -22,14 +32,21 @@ export function createAlprOverlay({ state, services }) {
let records = [],
hits = [],
images = [],
destroyed = false;
let surfaceRegime;
destroyed = false,
lastCamera = null;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
function nativeVisible(entity, visible) {
if (!entity) return;
canvasOwned(entity, !visible);
setShown(entity, visible);
}
/** Swap a marker between its native look and a faint canvas pick target. */
function canvasOwned(entity, owned) {
const visible = !owned;
const billboard = entity.billboard;
// Keep a faint native pick target under the canvas badge so sibling layer
// handlers recognize ALPR ownership instead of treating the click as empty.
@@ -55,7 +72,15 @@ export function createAlprOverlay({ state, services }) {
entity.gevAlprNativeAppearance = null;
entity.gevAlprPickPosition = null;
}
billboard.show = visible;
}
// Assign only on change: each Entity property write mints a new property
// and raises definitionChanged, which paint used to do for every overlay
// camera on every frame.
function setShown(entity, visible) {
if (entity.gevAlprShown === visible) return;
entity.gevAlprShown = visible;
entity.billboard.show = visible;
if (entity.polyline) entity.polyline.show = visible;
if (entity.polygon) entity.polygon.show = visible;
}
@@ -72,15 +97,25 @@ export function createAlprOverlay({ state, services }) {
requestPaint();
}
function anchorFor(record, entity) {
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
let height;
if (scene.sampleHeightSupported) {
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
@@ -89,19 +124,107 @@ export function createAlprOverlay({ state, services }) {
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height))
height = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
}
function floorHeightAt(latitude, longitude) {
const floor = services.groundFloor.cachedGroundFloor(latitude, longitude);
return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null;
}
function setAnchor(record, entity, height) {
entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
height,
);
entity.gevAlprWedge = directionWedgePositions(record, height);
return entity.gevAlprCanvasPosition;
entity.gevAlprWedge = directionWedgePositions(
record,
height,
floorHeightAt,
);
}
/** Whether the visible photoreal tiles (or globe) have finished streaming. */
function surfaceSettled(scene) {
if (scene.globe?.show) return scene.globe.tilesLoaded !== false;
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
const primitive = scene.primitives.get(i);
if (primitive?.show && primitive.tilesLoaded === false) return false;
}
return true;
}
/**
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
*/
function refineAnchors(budget) {
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
lastCamera &&
camera.positionWC &&
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
lastCamera.position,
0,
0.05,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
lastCamera.direction,
0,
1e-6,
);
if (camera.positionWC && camera.directionWC)
lastCamera = {
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
if (!scene.sampleHeightSupported) return false;
let pending = false;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
pending = true;
continue;
}
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
const accepted =
validAlprGroundHeight(sample) &&
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
return pending;
}
function paint({ ctx, width, height, keyhole, occluder }) {
@@ -109,6 +232,7 @@ export function createAlprOverlay({ state, services }) {
if (!state.enabled || destroyed) return;
let unresolved = false;
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -122,23 +246,32 @@ export function createAlprOverlay({ state, services }) {
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity);
const anchor = anchorFor(record, entity, budget);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
continue;
}
nativeVisible(entity, false);
if (occluder && !occluder.isPointVisible(anchor)) continue;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
continue;
}
const scene = state.viewer.scene;
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
anchor,
);
if (!origin) continue;
if (!origin) {
setShown(entity, false);
continue;
}
const alpha =
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
if (!(alpha > 0)) continue;
if (!(alpha > 0)) {
setShown(entity, false);
continue;
}
const wedge = entity.gevAlprWedge;
if (wedge) {
const left = Cesium.SceneTransforms.worldToWindowCoordinates(
@@ -161,15 +294,27 @@ export function createAlprOverlay({ state, services }) {
origin.x > width + 60 ||
origin.y < -60 ||
origin.y > height + 60
)
) {
setShown(entity, false);
continue;
entity.billboard.show = true;
painted.push({ record, origin, selected, image, alpha });
}
setShown(entity, true);
painted.push({
record,
origin,
selected,
image,
alpha,
scale: markerScale(
Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor),
),
});
if (selected) entity.gevAlprDisplayPosition = anchor;
}
// Paint all glyphs after the wedges so one camera's cone cannot wash out another.
for (const { record, origin, selected, image, alpha } of painted) {
const size = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE;
for (const { record, origin, selected, image, alpha, scale } of painted) {
const size =
(selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.drawImage(
@@ -190,7 +335,10 @@ export function createAlprOverlay({ state, services }) {
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
if (unresolved && !retryTimer && Date.now() < retryUntil) {
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
@@ -201,6 +349,12 @@ export function createAlprOverlay({ state, services }) {
return {
init() {
destroyed = false;
removeMapListener = services.overlays?.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready' || !state.enabled) return;
// Same-regime provider changes also invalidate absolute placements.
onSurfaceChange();
resetAnchors();
});
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
return;
lane = services.overlays.registerPaintLane('selected', paint, {
@@ -214,14 +368,6 @@ export function createAlprOverlay({ state, services }) {
image.src = src;
return image;
});
surfaceRegime = state.viewer.scene.globe.show;
removeMapListener = services.overlays.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready') return;
const next = state.viewer.scene.globe.show;
if (next === surfaceRegime) return;
surfaceRegime = next;
resetAnchors();
});
},
sync(visible) {
if (!lane) return;
+67 -3
View File
@@ -2,9 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { directionWedgePositions, alprLabelDetails } from './visuals.js';
import {
directionWedgePositions,
alprLabelDetails,
markerScale,
} from './visuals.js';
function harness(t, count = 2) {
function harness(t, count = 2, { floor = null } = {}) {
const previousImage = globalThis.Image;
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.Image = class {
@@ -43,7 +47,7 @@ function harness(t, count = 2) {
const overlay = createAlprOverlay({
state,
services: {
groundFloor: { cachedGroundFloor: () => null },
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
registerPaintLane(_lane, painter) {
paint = painter;
@@ -190,6 +194,20 @@ test('an unresolved surface preserves the native marker; the horizon removes ove
assert.equal(h.draw.length, 0);
});
test('repeated paints of a still view never rewrite marker properties', (t) => {
// Each Entity write raises definitionChanged; paint used to hide then show
// every overlay camera's native marker on every frame.
const h = harness(t, 3);
h.overlay.sync(h.records);
h.paint();
let changes = 0;
for (const entity of h.entities.values)
entity.definitionChanged.addEventListener(() => changes++);
for (let i = 0; i < 5; i++) h.paint();
assert.equal(changes, 0);
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
});
test('adapter labels never claim a custom source is public OSM data', () => {
assert.deepEqual(
alprLabelDetails(
@@ -199,3 +217,49 @@ test('adapter labels never claim a custom source is public OSM data', () => {
['Source: Custom directory', 'VENDOR'],
);
});
test('overlay glyphs and native badges shrink alike with distance', () => {
assert.equal(markerScale(100), 1.15);
assert.equal(markerScale(40_000), 0.45);
assert.equal(markerScale(90_000), 0.45);
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
const h = harness(t, 2, { floor: 150 });
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
};
h.overlay.sync(h.records);
h.paint();
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.equal(
samples,
0,
'no depth samples while the camera may still be moving',
);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
assert.ok(
Math.abs(height('camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(height('camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
const taken = samples;
h.paint();
assert.equal(samples, taken, 'each anchor is checked once');
});
+42 -21
View File
@@ -1,33 +1,37 @@
/**
* @file Community-mapped automatic license plate reader (ALPR) camera layer.
*
* Data source: OpenStreetMap contributors, including the DeFlock community
* (`man_made=surveillance` + `surveillance:type=ALPR`) — see
* https://wiki.openstreetmap.org/wiki/Tag:surveillance:type=ALPR and
* https://deflock.org. OSM data remains ODbL 1.0, separate from the MIT code.
* Fetched viewport-bounded through the existing generic
* `/api/overpass` proxy (same one `traffic.js` and `militaryInstallations.js`
* use) — no new server route needed for a single narrow tag pair.
*
* This is mapped surveillance infrastructure, not a live camera feed: no
* plate records, no vendor accounts, nothing beyond what a contributor chose
* to publish to OSM. Coverage and tag accuracy are not guaranteed.
*
* @module data/alprCameras
* Community-mapped OpenStreetMap ALPR locations, via hourly US/Canada detail
* tiles. OSM data remains ODbL, separate from the application code. These are
* mapped locations, not footage, plate records or evidence of current activity.
*/
export const LAYER_ID = 'alpr-cameras';
export const OVERPASS_URL = '/api/overpass';
export const REQUEST_DEBOUNCE_MS = 500;
export const REQUEST_DEBOUNCE_MS = 300;
/** Keep public Overpass queries city-scale, never globe-wide. */
/** Keep camera acquisition city-scale, never globe-wide. */
export const MAX_VIEWPORT_DEGREES = 3;
/** Overpass `out body N;` cap — also detects possibly truncated coverage. */
/** Camera record cap; reaching it reports limited coverage. */
export const QUERY_LIMIT = 1500;
/**
* The extract carries full camera attributes from z9 (z0-8 are geometry-only
* heat tiles). Each view uses the finest zoom from z12 down to z9 that fits
* DETAIL_MAX_TILES, so a whole city loads from a few z9/z10 tiles and a
* neighbourhood from z12 (about 2 m position precision).
*/
export const DETAIL_MIN_ZOOM = 9;
export const DETAIL_MAX_ZOOM = 12;
export const DETAIL_MAX_TILES = 16;
/** Decoded records kept per accepted tile coverage (memory bound, not a render cap). */
export const SOURCE_RECORD_LIMIT = 6000;
/** Best-precision coordinates remembered per camera across zoom changes. */
export const PRECISION_CACHE_LIMIT = 20000;
/** Render cap. Kept at or above QUERY_LIMIT on purpose: if it ever sat below
* it, cameras between the two would be silently dropped while `saturated`
* stayed false and `count` still reported them — a lie about coverage. */
@@ -40,20 +44,37 @@ export const DIRECTION_CONE_HALF_ANGLE_DEG = 20;
export const EARTH_MEAN_RADIUS_M = 6371008.8;
/** Query boxes are snapped outward to this grid so nearby camera moves reuse
* one Overpass request body (the shared proxy keys its cache and in-flight
* dedupe by the exact body) instead of each becoming a distinct upstream fetch. */
* one accepted viewport snapshot rather than rebuilding records on every pan. */
export const QUERY_SNAP_DEGREES = 0.05;
/** A view still fully inside the last snapped query box reuses those records
* for this long before asking Overpass again. */
* for this long before selecting tiles again. */
export const QUERY_REUSE_MS = 10 * 60 * 1000;
/** Vendor-neutral camera badge palette, with coral selection. */
export const ALPR_COLOR = '#52d4ff';
export const ALPR_SELECTED_COLOR = '#ff6474';
export const MARKER_ICON_SIZE = 38;
/** Badge scale by camera distance (near m, scale, far m, scale), shared by
* native badges and overlay glyphs so both shrink alike in city-wide views. */
export const MARKER_SCALE_BY_DISTANCE = Object.freeze([
500, 1.15, 40_000, 0.45,
]);
export const SELECTED_MARKER_ICON_SIZE = 60;
export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
export const CREDIT_DISPLAY_MS = 5000;
+244 -51
View File
@@ -9,15 +9,17 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
CREDIT_DISPLAY_MS,
MARKER_FLOOR_LIFT_M,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
alprDisplayId,
alprLabelDetails,
directionWedgePositions,
validAlprGroundHeight,
} from './visuals.js';
@@ -32,9 +34,198 @@ export function createAlprPresentation({ state, services, source }) {
} = services.context;
const { cachedGroundFloor } = services.groundFloor;
const overlay = createAlprOverlay({ state, services });
const overlay = createAlprOverlay({
state,
services,
onSurfaceChange: refreshMarkerPlacements,
});
let visibleRecords = [];
let selectionStartedAt = 0;
/** Markers still clamped while their floor cell resolves (id -> entity). */
const clampedMarkers = new Map();
let floorResolve = null;
const floors = createAlprFloorResolver(services.groundFloor);
/**
* Marker placement from the cached ground floor (validated mesh or DEM, the
* ellipsoidal datum every layer shares). Ground clamping on photoreal tiles
* re-samples every clamped marker whenever a tile streams in, which made a
* whole-city view stutter while zooming, so it is only the placeholder for
* a cell that has not resolved yet.
* @param {{latitude:number, longitude:number}} record Camera.
* @returns {{position: Cesium.Cartesian3, clamped: boolean}} Placement.
*/
function markerPlacement(record) {
const floor = cachedGroundFloor(record.latitude, record.longitude);
const clamped = !validAlprGroundHeight(floor);
return {
clamped,
position: Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
clamped ? 0 : floor + MARKER_FLOOR_LIFT_M,
),
};
}
/** Records the current view would display (viewport box, nearest first when capped). */
function viewRecords(records) {
const box = viewportBox(state.viewer);
if (!box) return { box, visible: [], capped: false };
let visible = records.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
);
const capped = visible.length > MAX_RENDERED;
if (capped) {
const lat = (box.south + box.north) / 2;
const lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
const distance = (record) =>
(record.latitude - lat) ** 2 + ((record.longitude - lon) * scale) ** 2;
visible = visible
.map((record) => ({ record, d: distance(record) }))
.sort((a, b) => a.d - b.d)
.slice(0, MAX_RENDERED)
.map((item) => item.record);
}
return { box, visible, capped };
}
/**
* Resolve the floor cells of the records this view will display, waiting at
* most the ground service's bounded deadline. Never throws.
* @param {Array<object>} records Candidate records.
* @returns {Promise<void>}
*/
async function prepareFloors(records) {
const resolve = services.groundFloor.resolveGroundFloorCells;
if (typeof resolve !== 'function') return;
const cold = viewRecords(records)
.visible.filter(
(record) =>
!validAlprGroundHeight(
cachedGroundFloor(record.latitude, record.longitude),
),
)
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
if (!cold.length) return;
try {
await floors.prepare(cold);
} catch {
/* markers fall back to clamping until their floors resolve */
}
}
/**
* Loaded cameras that project inside the canvas and in front of the globe.
* "Nearby" spans twice the camera-to-ground range, so a view can hold
* cameras while none is on screen; the row then says so rather than
* leaving the user to wait for markers. Runs once per render pass.
* @returns {number|null} Count, or null without a scene to project into.
*/
function countOnScreen() {
const scene = state.viewer?.scene;
const canvas = scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
const camera = state.viewer?.camera;
if (!scene || !width || !height || !camera?.positionWC) return null;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.positionWC,
);
let count = 0;
try {
for (const entity of state.dataSource.entities.values) {
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
if (!position || !occluder.isPointVisible(position)) continue;
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
);
if (
point &&
point.x >= 0 &&
point.x <= width &&
point.y >= 0 &&
point.y <= height
)
count += 1;
}
} catch {
// A scene without a usable frame (startup, teardown) cannot answer.
return null;
}
return count;
}
/** Write a marker position, respecting the overlay's pick-target swap. */
function setMarkerPosition(entity, position, clamped) {
const heightReference = clamped
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const saved = entity.gevAlprNativeAppearance;
if (saved) {
saved.position = position;
saved.heightReference = heightReference;
} else {
entity.position = position;
entity.billboard.heightReference = heightReference;
}
entity.gevAlprClamped = clamped;
}
/** Refresh all native placements, including the appearances held by the canvas. */
function refreshMarkerPlacements() {
if (!state.enabled || !state.dataSource) return;
clampedMarkers.clear();
for (const entity of state.dataSource.entities.values) {
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
setMarkerPosition(entity, position, clamped);
if (clamped) clampedMarkers.set(entity.id, entity);
}
resolveClampedFloors();
governorRequestRender('alpr-surface');
}
/** Resolve floors for clamped markers once, then place them in place. */
function resolveClampedFloors() {
const resolve = services.groundFloor.resolveGroundFloorCells;
if (floorResolve || !clampedMarkers.size || typeof resolve !== 'function')
return;
const batch = [...clampedMarkers.values()];
const request = Promise.resolve(
floors.resolve(
batch.map((entity) => ({
lat: entity.gevAlprRecord.latitude,
lon: entity.gevAlprRecord.longitude,
})),
),
)
.catch(() => {})
.then(() => {
if (floorResolve !== request) return;
floorResolve = null;
if (!state.enabled || !state.dataSource) return;
let moved = false;
for (const entity of batch) {
if (clampedMarkers.get(entity.id) !== entity) continue;
clampedMarkers.delete(entity.id);
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
if (clamped) continue;
setMarkerPosition(entity, position, false);
moved = true;
}
if (moved) governorRequestRender('alpr-floor');
// Cells that never resolved stay clamped; later renders retry them.
});
floorResolve = request;
}
function updateAppearance(entity, selected) {
const color = Cesium.Color.fromCssColorString(
@@ -44,12 +235,6 @@ export function createAlprPresentation({ state, services, source }) {
entity.billboard.width = entity.billboard.height = selected
? SELECTED_MARKER_ICON_SIZE
: MARKER_ICON_SIZE;
if (entity.polyline) {
entity.polyline.width = selected ? 3 : 1.75;
entity.polyline.material = color.withAlpha(selected ? 0.98 : 0.82);
}
if (entity.polygon)
entity.polygon.material = color.withAlpha(selected ? 0.8 : 0.2);
if (entity.gevLabelModel) {
entity.gevLabelModel.accent = color.toCssColorString();
entity.gevLabelModel.leaderAnimationStartedAt = selected
@@ -118,13 +303,18 @@ export function createAlprPresentation({ state, services, source }) {
}
function clearRendered() {
hideOnMapCredit();
floors.cancel();
floorResolve = null;
overlay.clear();
visibleRecords = [];
clampedMarkers.clear();
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID);
}
function hideOnMapCredit() {
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
clearTimeout(state.creditTimer);
state.creditTimer = null;
if (state.credit)
@@ -133,6 +323,14 @@ export function createAlprPresentation({ state, services, source }) {
}
function presentOnMapCredit() {
if (
state.enabled &&
source.attribution?.name === 'OpenStreetMap' &&
services.credits?.showOsmCredit
) {
services.credits.showOsmCredit(state.viewer, LAYER_ID);
return;
}
if (!state.enabled || state.creditPresented || !state.credit) return;
state.creditPresented = true;
state.viewer.creditDisplay?.addStaticCredit(state.credit);
@@ -142,19 +340,10 @@ export function createAlprPresentation({ state, services, source }) {
function renderRecords() {
const selectedContext = getSelectedEntityContext();
const box = viewportBox(state.viewer);
const visible = box
? state.records
.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
)
.slice(0, MAX_RENDERED)
: [];
// Over the render cap, keep the cameras nearest the view centre and say
// coverage is limited rather than dropping an arbitrary subset.
const { visible, capped } = viewRecords(state.records);
state.renderSaturated = capped;
visibleRecords = visible;
// A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action.
@@ -168,8 +357,14 @@ export function createAlprPresentation({ state, services, source }) {
}
governorRequestRender('alpr-render');
const visibleIds = new Set(visible.map((record) => record.id));
let changed = false;
state.dataSource.entities.suspendEvents();
for (const entity of [...state.dataSource.entities.values]) {
if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
if (!visibleIds.has(entity.id)) {
state.dataSource.entities.remove(entity);
clampedMarkers.delete(entity.id);
changed = true;
}
}
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
for (const record of visible) {
@@ -180,10 +375,11 @@ export function createAlprPresentation({ state, services, source }) {
}
const color = markerColor();
const selected = record.id === state.selectedId;
const position = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
);
const { position, clamped } = markerPlacement(record);
// Direction wedges are painted by the overlay for the nearest cameras.
// Native ground-clamped wedges (a ground polyline plus a classification
// polygon per camera) cost ~40 ms per frame for 200 downtown cameras on
// photoreal tiles, so markers stay billboards only.
const entityDef = {
id: record.id,
position,
@@ -191,38 +387,30 @@ export function createAlprPresentation({ state, services, source }) {
image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
heightReference: clamped
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48),
// City-wide views hold hundreds of badges; let them shrink.
scaleByDistance: new Cesium.NearFarScalar(
...MARKER_SCALE_BY_DISTANCE,
),
},
};
const wedge = directionWedgePositions(record);
if (wedge) {
entityDef.polyline = {
positions: [wedge[1], position, wedge[2]],
width: selected ? 3 : 1.75,
material: color.withAlpha(0.82),
clampToGround: true,
};
entityDef.polygon = {
hierarchy: wedge,
material: color.withAlpha(0.2),
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
classificationType: Cesium.ClassificationType.BOTH,
};
}
let entity = existing;
// Keep the Cesium entity and its ground-clamping subscription while its
// geometry is unchanged. Updating metadata must not rebuild the marker.
const previous = entity?.gevAlprRecord;
if (!entity) entity = state.dataSource.entities.add(entityDef);
else {
if (!entity) {
entity = state.dataSource.entities.add(entityDef);
entity.gevAlprClamped = clamped;
changed = true;
} else {
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude
) {
entity.position = position;
setMarkerPosition(entity, position, clamped);
entity.gevAlprCanvasPosition = null;
entity.gevAlprDisplayPosition = null;
}
@@ -231,8 +419,6 @@ export function createAlprPresentation({ state, services, source }) {
previous.longitude !== record.longitude ||
previous.directionDeg !== record.directionDeg
) {
entity.polyline = entityDef.polyline;
entity.polygon = entityDef.polygon;
entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
}
@@ -281,6 +467,12 @@ export function createAlprPresentation({ state, services, source }) {
},
});
}
state.dataSource.entities.resumeEvents();
if (changed) state.renderRevision += 1;
state.onScreen = countOnScreen();
for (const entity of state.dataSource.entities.values)
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
resolveClampedFloors();
const selectedEntity = state.selectedId
? state.dataSource.entities.getById(state.selectedId)
: null;
@@ -288,9 +480,9 @@ export function createAlprPresentation({ state, services, source }) {
updateSelectedAnchor();
overlay.sync(visible);
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
// Start only when mapped data is actually displayed, not while an upstream
// request or a zoom-in prompt could consume the five-second introduction.
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
if (visible.length) presentOnMapCredit();
else hideOnMapCredit();
}
function focusNearest() {
@@ -458,5 +650,6 @@ export function createAlprPresentation({ state, services, source }) {
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
};
}
+191 -2
View File
@@ -1,10 +1,199 @@
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import {
OVERPASS_URL,
QUERY_LIMIT,
MAX_VIEWPORT_DEGREES,
QUERY_SNAP_DEGREES,
QUERY_LIMIT,
DETAIL_MIN_ZOOM,
DETAIL_MAX_ZOOM,
DETAIL_MAX_TILES,
SOURCE_RECORD_LIMIT,
PRECISION_CACHE_LIMIT,
} from './policy.js';
import {
createVectorTileSource,
validTileBounds,
} from '../../sources/vectorTiles.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
import { decodeAlprTile } from './tileRecords.js';
/**
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
* or null when even z9 would not (the layer asks the user to zoom in).
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @returns {number|null} Tile zoom.
*/
export function alprDetailZoom(box) {
for (let zoom = DETAIL_MAX_ZOOM; zoom >= DETAIL_MIN_ZOOM; zoom--)
if (
tilesForBounds(box, zoom, { maxTiles: DETAIL_MAX_TILES + 1 }).length <=
DETAIL_MAX_TILES
)
return zoom;
return null;
}
/**
* Tile coordinates are quantized per zoom (about 19 m at z9, 2 m at z12).
* Remember each camera's finest position so zooming out never nudges a marker
* that a closer view already placed precisely. Bounded, oldest first.
* @param {number} [limit] Cameras remembered.
* @returns {{apply: (record: object, zoom: number) => object, clear: () => void,
* size: () => number}} Cache.
*/
export function createAlprPrecisionCache(limit = PRECISION_CACHE_LIMIT) {
const known = new Map();
return {
apply(record, zoom) {
const finer = known.get(record.id);
if (finer && finer.zoom > zoom)
return finer.latitude === record.latitude &&
finer.longitude === record.longitude
? record
: { ...record, latitude: finer.latitude, longitude: finer.longitude };
known.delete(record.id);
known.set(record.id, {
zoom,
latitude: record.latitude,
longitude: record.longitude,
});
while (known.size > limit) known.delete(known.keys().next().value);
return record;
},
clear: () => known.clear(),
size: () => known.size,
};
}
/** Construct the viewport-bounded OpenStreetMap hourly extract adapter. */
export function createAlprTileSource({
tileFetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const sources = ['us', 'ca'].map((country) =>
createVectorTileSource({
tileJsonUrl: `https://tiles.dontgetflocked.com/cameras-${country}-hourly.json`,
allowedOrigin: 'https://tiles.dontgetflocked.com',
decode: decodeAlprTile,
fetchImpl: tileFetchImpl,
maxTiles: DETAIL_MAX_TILES,
ttlMs: 60 * 60 * 1000,
}),
);
const precision = createAlprPrecisionCache();
const precise = precision.apply;
return {
/**
* Read the camera records for every extract tile covering `box`.
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @param {AbortSignal} [signal] Cancellation.
* @returns {Promise<{records: Array<object>, stale: boolean, saturated: boolean,
* noCoverage?: boolean, zoomIn?: boolean, coverage?: object, zoom?: number}>}
* Records inside the fetched tiles; `coverage` is those tiles' bounds.
*/
async fetch(box, signal) {
if (
!validTileBounds(box) ||
box.north - box.south >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 ||
box.east - box.west >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9
)
throw new TypeError('ALPR requires a bounded city viewport');
signal?.throwIfAborted();
// Fast geographic rejection; the exact extract extents below refine coverage.
if (box.east < -180 || box.west > -50 || box.north < 17 || box.south > 84)
return {
records: [],
stale: false,
saturated: false,
noCoverage: true,
};
const zoom = alprDetailZoom(box);
if (zoom === null)
return { records: [], stale: false, saturated: false, zoomIn: true };
const tiles = tilesForBounds(box, zoom, {
maxTiles: DETAIL_MAX_TILES + 1,
});
const tileBounds = tiles.map((tile) => tileToBBox(zoom, tile.x, tile.y));
const coverage = {
south: Math.min(...tileBounds.map((b) => b.south)),
west: Math.min(...tileBounds.map((b) => b.west)),
north: Math.max(...tileBounds.map((b) => b.north)),
east: Math.max(...tileBounds.map((b) => b.east)),
};
const records = new Map();
let covered = false,
partial = false;
for (const [index, source] of sources.entries()) {
if (
index === 1 &&
(box.north < 41 ||
box.south > 84 ||
box.east < -142 ||
box.west > -52)
)
continue;
const metadata = await source.getMetadata(signal);
const [west, south, east, north] = metadata.bounds || [];
if (![west, south, east, north].every(Number.isFinite))
throw new Error('Camera coverage unavailable');
if (
box.east < west ||
box.west > east ||
box.north < south ||
box.south > north
)
continue;
covered = true;
const result = await source.fetchBounds(box, { zoom, signal });
partial ||= result.partial;
for (const record of result.tiles.flat())
records.set(record.id, precise(record, zoom));
}
signal?.throwIfAborted();
let list = [...records.values()];
const truncated = list.length > SOURCE_RECORD_LIMIT;
if (truncated) {
// Keep the cameras nearest the view centre when a dense metro overflows.
const lat = (box.south + box.north) / 2;
const lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
list = list
.map((record) => ({
record,
d:
(record.latitude - lat) ** 2 +
((record.longitude - lon) * scale) ** 2,
}))
.sort((a, b) => a.d - b.d)
.slice(0, SOURCE_RECORD_LIMIT)
.map((item) => item.record);
}
return {
records: list,
stale: false,
saturated: partial || truncated,
noCoverage: !covered,
// A partial or truncated read does not cover its tiles; reuse only the box.
coverage: partial || truncated ? { ...box } : coverage,
zoom,
};
},
detailZoom: alprDetailZoom,
destroy() {
for (const source of sources) source.clear();
precision.clear();
},
label: 'OpenStreetMap · community mapped',
attribution: {
name: 'OpenStreetMap',
description: '© OpenStreetMap contributors, ODbL',
text: '© OpenStreetMap contributors, ODbL',
href: 'https://www.openstreetmap.org/copyright',
},
};
}
/** Construct the bounded OSM request adapter without starting a request. */
export function createOverpassAlprSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -75,7 +264,7 @@ export function createOverpassAlprSource({
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
text: '© OpenStreetMap',
text: '© OpenStreetMap contributors',
href: 'https://www.openstreetmap.org/copyright',
},
};
+211 -38
View File
@@ -1,12 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOverpassAlprSource } from './source.js';
import { readFileSync } from 'node:fs';
import {
alprDetailZoom,
createAlprPrecisionCache,
createAlprTileSource,
createOverpassAlprSource,
buildOverpassQuery,
} from './source.js';
import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
test('the source rejects invalid and unbounded queries before fetching', async () => {
let calls = 0;
const source = createOverpassAlprSource({
fetchImpl() {
const source = createAlprTileSource({
tileFetchImpl() {
calls++;
},
});
@@ -21,55 +28,77 @@ test('the source rejects invalid and unbounded queries before fetching', async (
}
assert.equal(calls, 0);
});
test('cancellation during body parsing rejects even when the transport ignores it', async () => {
const fixture = readFileSync(
new URL(
'../../data/fixtures/osm-alpr-austin-11-467-843.pbf',
import.meta.url,
),
);
const metadata = (country) => ({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
});
const austin = { south: 30.2, north: 30.35, west: -97.85, east: -97.65 };
test('cancellation while reading TileJSON is preserved even if fetch ignores it', async () => {
const abort = new AbortController();
const source = createOverpassAlprSource({
fetchImpl: async () => ({
const source = createAlprTileSource({
tileFetchImpl: async () => ({
ok: true,
headers: new Headers(),
json: async () => {
async text() {
abort.abort();
return { elements: [] };
return JSON.stringify(metadata('us'));
},
}),
});
await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' });
await assert.rejects(source.fetch(austin, abort.signal), {
name: 'AbortError',
});
});
test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => {
const source = createOverpassAlprSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'x-overpass-cache': 'STALE' }),
json: async () => ({
elements: [
{
type: 'node',
id: 12,
lat: 30,
lon: -98,
tags: {
'surveillance:type': 'camera;ALPR',
'camera:direction': '90',
},
},
],
}),
}),
test('extract detail tiles map OSM records and repeated pans reuse decoded tiles', async () => {
const calls = [];
const source = createAlprTileSource({
tileFetchImpl: async (url) => {
calls.push(url);
return url.endsWith('.json')
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
: new Response(fixture);
},
});
const snapshot = await source.fetch(box);
assert.equal(snapshot.records[0].id, 'alpr:12');
assert.equal(snapshot.records[0].latitude, 30);
assert.equal(snapshot.records[0].directionDeg, 90);
assert.equal(snapshot.stale, true);
assert.equal(snapshot.saturated, false);
const snapshot = await source.fetch(austin);
assert.ok(snapshot.records.length > 0);
const record = snapshot.records[0];
assert.match(record.id, /^alpr:/);
assert.equal(record.manufacturer, 'Flock Safety');
assert.equal(record.lastVerified, null);
assert.ok(record.osmTimestamp);
assert.equal(snapshot.stale, false);
assert.equal('elements' in snapshot, false);
const fetched = calls.length;
await source.fetch(austin);
assert.equal(calls.length, fetched);
assert.ok(calls.every((url) => !url.includes('overpass')));
assert.doesNotMatch(JSON.stringify(source.attribution), /flock/i);
});
test('outside extract coverage is an explicit no-data state without Overpass', async () => {
const source = createAlprTileSource({
tileFetchImpl: () => assert.fail('no network expected in Europe'),
});
assert.deepEqual(
await source.fetch({ south: 51, north: 51.1, west: 0, east: 0.1 }),
{ records: [], stale: false, saturated: false, noCoverage: true },
);
});
test('an unsuccessful source response releases its body before reporting an error', async () => {
let cancelled = 0;
const source = createOverpassAlprSource({
fetchImpl: async () => ({
const source = createAlprTileSource({
tileFetchImpl: async () => ({
ok: false,
status: 429,
body: {
@@ -79,7 +108,7 @@ test('an unsuccessful source response releases its body before reporting an erro
},
}),
});
await assert.rejects(source.fetch(box), /rate-limited/);
await assert.rejects(source.fetch(box), /unavailable/);
assert.equal(cancelled, 1);
});
@@ -115,3 +144,147 @@ test('provider attribution escapes markup and rejects executable links', () => {
assert.ok(html.includes('?a=1&amp;b=2'));
assert.ok(!html.includes('<img'));
});
test('wide detail view returns zoom guidance before any fetch and can retry a smaller view', async () => {
let calls = 0;
const source = createAlprTileSource({
tileFetchImpl: async () => {
calls++;
return Response.json({
tiles: ['https://tiles.dontgetflocked.com/{z}/{x}/{y}.pbf'],
bounds: [-180, 17, -50, 84],
});
},
});
const wide = await source.fetch({
south: 29,
north: 32,
west: -99,
east: -96,
});
assert.equal(wide.zoomIn, true);
assert.equal(calls, 0);
await source
.fetch({ south: 30.267, north: 30.268, west: -97.744, east: -97.743 })
.catch(() => {});
assert.ok(calls > 0);
});
test('each view reads the finest attributed extract zoom that fits 16 tiles', () => {
// Field report: a z11-only read refused whole-city views. Attributed detail
// exists from z9, so a city reads a few z9/z10 tiles and a street z12.
assert.equal(
alprDetailZoom({ south: 30.29, north: 30.31, west: -97.78, east: -97.75 }),
12,
);
assert.equal(
alprDetailZoom({ south: 30.17, north: 30.36, west: -97.85, east: -97.63 }),
12,
);
assert.equal(
alprDetailZoom({ south: 29.7, north: 30.7, west: -98.25, east: -97.25 }),
10,
);
assert.equal(
alprDetailZoom({ south: 29.2, north: 31.2, west: -98.75, east: -96.75 }),
9,
);
assert.equal(
alprDetailZoom({ south: 29, north: 32, west: -99, east: -96 }),
null,
);
});
test('a whole-city view loads cameras and reports the tiles it covers', async () => {
const urls = [];
const source = createAlprTileSource({
tileFetchImpl: async (url) => {
urls.push(url);
return url.endsWith('.json')
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
: new Response(fixture);
},
});
const city = { south: 29.95, north: 30.55, west: -98.05, east: -97.45 };
const snapshot = await source.fetch(city);
assert.equal(snapshot.zoomIn, undefined);
assert.equal(snapshot.zoom, 10);
assert.ok(snapshot.records.length > 0);
assert.ok(urls.some((url) => url.includes('/10/')));
for (const key of ['south', 'west'])
assert.ok(snapshot.coverage[key] <= city[key]);
for (const key of ['north', 'east'])
assert.ok(snapshot.coverage[key] >= city[key]);
});
test('coarser tiles never move a camera a finer tile already placed', () => {
const cache = createAlprPrecisionCache(2);
const fine = {
id: 'alpr:1',
latitude: 30.30001,
longitude: -97.76001,
operator: 'A',
};
assert.equal(cache.apply(fine, 12), fine);
const coarse = {
...fine,
latitude: 30.3001,
longitude: -97.7602,
operator: 'B',
};
const kept = cache.apply(coarse, 9);
assert.equal(kept.latitude, fine.latitude);
assert.equal(kept.longitude, fine.longitude);
assert.equal(kept.operator, 'B', 'attributes still refresh');
const finer = { ...fine, latitude: 30.300012 };
assert.equal(cache.apply(finer, 13), finer);
cache.apply({ id: 'alpr:2', latitude: 1, longitude: 1 }, 12);
cache.apply({ id: 'alpr:3', latitude: 1, longitude: 1 }, 12);
assert.equal(cache.size(), 2, 'bounded');
});
test('the explicit Overpass factory posts the unchanged query to its API transport', async () => {
const calls = [];
const source = createOverpassAlprSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({
elements: [
{
type: 'node',
id: 42,
lat: 30.05,
lon: -97.95,
tags: { 'surveillance:type': 'ALPR', operator: 'Test' },
},
],
});
},
});
const result = await source.fetch(box);
assert.equal(result.records.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/overpass');
assert.equal(calls[0].options.method, 'POST');
assert.equal(
new URLSearchParams(calls[0].options.body).get('data'),
buildOverpassQuery(box.south, box.west, box.north, box.east),
);
});
test('the tile adapter never passes public URLs through an injected API transport', async (t) => {
const urls = [];
t.mock.method(globalThis, 'fetch', async (url) => {
urls.push(url);
return url.endsWith('.json')
? Response.json(metadata('us'))
: new Response(fixture);
});
const source = createAlprTileSource({
fetchImpl: () => assert.fail('API transport must not receive tile URLs'),
});
const result = await source.fetch(austin);
assert.ok(result.records.length);
assert.ok(urls.length > 1);
assert.ok(urls.every((url) => url.startsWith('https://tiles.')));
});
+43
View File
@@ -0,0 +1,43 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { normalizeAlprNode } from './records.js';
/** Map extract attributes without treating an OSM edit date as a field verification. */
export function normalizeAlprTileFeature(feature) {
const p = feature?.properties || {};
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
const [lon, lat] = feature.geometry.coordinates;
const record = normalizeAlprNode({
type: 'node',
id: Number(p.osmId),
lat,
lon,
tags: {
'surveillance:type': 'ALPR',
operator: p.operator,
manufacturer: p.brand,
'camera:type': p.cameraType,
'camera:direction': p.direction,
'surveillance:zone': p.surveillanceZone,
ref: p.ref,
check_date: p.check_date,
},
});
return record ? { ...record, osmTimestamp: p.osmTimestamp || null } : null;
}
/** Decode detail-level camera points; geometry-only heatmap tiles are not records. */
export function decodeAlprTile(bytes, z, x, y) {
const layer = new VectorTile(new PbfReader(bytes)).layers.cameras;
if (!layer) return [];
if (layer.length > 40_000)
throw new Error('Camera tile feature limit exceeded');
const records = [];
for (let i = 0; i < layer.length; i++) {
const record = normalizeAlprTileFeature(
layer.feature(i).toGeoJSON(x, y, z),
);
if (record) records.push(record);
}
return records;
}
+40 -5
View File
@@ -1,6 +1,25 @@
import * as Cesium from 'cesium';
import { destinationPointDeg } from './model.js';
import { DIRECTION_CONE_M, DIRECTION_CONE_HALF_ANGLE_DEG } from './policy.js';
import {
DIRECTION_CONE_M,
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
* native badges (linear between near and far, clamped outside).
* @param {number} distanceM Camera-to-marker distance.
* @returns {number} Scale factor.
*/
export function markerScale(distanceM) {
const [near, nearScale, far, farScale] = MARKER_SCALE_BY_DISTANCE;
if (!Number.isFinite(distanceM) || distanceM <= near) return nearScale;
if (distanceM >= far) return farScale;
return (
nearScale + ((distanceM - near) / (far - near)) * (farScale - nearScale)
);
}
// Camera artwork and visual treatment contributed by Manjunath (@manjunath22466).
// Module-relative assets also resolve when the layer is consumed by another app.
@@ -48,8 +67,16 @@ export function alprLabelDetails(record, source) {
return details;
}
/** Illustrative bearing wedge, not measured field of view or operating range. */
export function directionWedgePositions(record, heightM = 0) {
/**
* Illustrative bearing wedge, not measured field of view or operating range.
* @param {object} record Camera record.
* @param {number} [heightM] Apex height.
* @param {function(number, number): (number|null)} [heightAt] Optional height
* for the two far corners (degrees lat, lon), so the wedge follows a slope
* instead of floating level from the apex; falls back to the apex height.
* @returns {Array<Cesium.Cartesian3>|null} Apex, left and right corners.
*/
export function directionWedgePositions(record, heightM = 0, heightAt = null) {
if (!Number.isFinite(record.directionDeg)) return null;
const left = destinationPointDeg(
record.latitude,
@@ -63,10 +90,18 @@ export function directionWedgePositions(record, heightM = 0) {
record.directionDeg + DIRECTION_CONE_HALF_ANGLE_DEG,
DIRECTION_CONE_M,
);
const corner = (point) => {
const height = heightAt?.(point.latitude, point.longitude);
return Number.isFinite(height) ? height : heightM;
};
return [
Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, heightM),
Cesium.Cartesian3.fromDegrees(right.longitude, right.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, corner(left)),
Cesium.Cartesian3.fromDegrees(
right.longitude,
right.latitude,
corner(right),
),
];
}
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createControls } from './controls.js';
import { createSubject } from './subject.js';
test('passive Contacts releases its installation anchor and subject refresh cannot reclaim it', (t) => {
const prior = globalThis.document;
globalThis.document = { body: { classList: { contains: () => false } } };
t.after(() => {
globalThis.document = prior;
});
const anchors = [];
const position = Cesium.Cartesian3.fromDegrees(-117.14, 32.87);
const state = {
enabled: true,
passive: false,
subject: {
id: 'site',
layerId: 'military-installations',
label: 'Site',
position,
},
sourceRevision: 'current',
lastEvaluatedPosition: position,
results: {},
};
const services = {
installations: { setContextAnchor: (value) => anchors.push(value) },
};
let activations = 0;
const parts = {
dependencies: { activateOperationalContext: () => activations++ },
queries: {
collectSourceStates: () => ({}),
sourceRevision: () => 'current',
},
navigation: { isFlightLayer: () => false },
model: { awarenessRefreshRequired: () => false },
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
};
const controls = createControls({ state, services, parts }).methods;
const subject = createSubject({ state, services, parts });
subject.refreshSelectedSubject();
assert.equal(anchors.length, 1);
assert.ok(anchors[0] instanceof Cesium.Cartesian3);
controls.setParams({ passive: true });
assert.equal(anchors.at(-1), null);
const released = anchors.length;
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(
anchors.length,
released,
'passive refresh must not reclaim the viewport',
);
controls.setParams({ passive: false });
assert.equal(activations, 1);
subject.refreshSelectedSubject();
assert.ok(anchors.at(-1) instanceof Cesium.Cartesian3);
});
+1
View File
@@ -29,6 +29,7 @@ export function createControls({ state: layerState, services, parts, source }) {
if (typeof params.passive !== 'boolean') return;
const wasPassive = layerState.passive;
layerState.passive = params.passive;
if (layerState.passive) services.installations?.setContextAnchor?.(null);
if (layerState.enabled && wasPassive && !layerState.passive)
parts.dependencies.activateOperationalContext();
},
+1
View File
@@ -206,6 +206,7 @@ export function createLifecycle({
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
layerState.subject = null;
services.installations?.setContextAnchor?.(null);
layerState.results = null;
layerState.lastSubjectRefreshMs = 0;
layerState.lastEvaluatedPosition = null;
+8 -1
View File
@@ -72,7 +72,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -164,11 +164,18 @@ export function createPanel({ state: layerState, services, parts, source }) {
);
}
function namedAreasHtml(names) {
return names?.length
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
+20 -1
View File
@@ -94,14 +94,33 @@ export function createQueries({ state: layerState, services, parts, source }) {
*/
function summarizeInstallationViewport(items, source) {
const coverage = source.stats?.coverage;
// getNearby measures these distances from the current subject, independent
// of the retained square used to fetch installation tiles.
const withinRadius =
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
? items.filter(
(item) => (item.distanceM ?? item.distance) <= coverage.radiusM,
)
: items;
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
items,
withinRadius,
source,
);
if (summary.count === null)
return source.stats?.statusMessage
? { ...summary, reason: source.stats.statusMessage }
: summary;
// A subject window is its own bounded area: name it instead of the viewport.
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
const km = Math.round(coverage.radiusM / 1000);
return {
...summary,
reason: summary.count
? `mapped matches within ${km} km of the subject`
: `none mapped within ${km} km; not a complete 250 km survey`,
};
}
return {
...summary,
reason: summary.count
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createControls } from '../installations/controls.js';
import { createModel } from '../installations/model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
test('Contacts excludes square corners and measures from the current moving subject', () => {
const records = [
{ id: 'corner', kind: 'installation', latitude: 90 / 111.195, longitude: 90 / 111.195 },
{ id: 'east', kind: 'installation', latitude: 0, longitude: 90 / 111.195 },
{ id: 'west', kind: 'installation', latitude: 0, longitude: -90 / 111.195 },
];
const installationState = { records, contextAnchor: { latitude: 0, longitude: 0 },
lastUpdate: 1, status: 'ready', coverage: { kind: 'subject', radiusM: 100000 },
distanceEndpointScratch: new Cesium.Cartographic(),
distanceGeodesicScratch: new Cesium.EllipsoidGeodesic(),
};
const installationParts = { rendering: { installationSurfaceHeightM: () => 0 } };
installationParts.model = createModel({ state: installationState, parts: installationParts });
const installations = createControls({ state: installationState, parts: installationParts }).methods;
const services = { installations, flights: { getNearby: () => [] },
military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = Object.fromEntries(['flights', 'military', 'ais-live-vessels', 'military-installations']
.map((id) => [id, { available: true, stats: {} }]));
states['military-installations'].stats.coverage = { kind: 'subject', radiusM: 100000 };
const evaluate = (lon) => subjectOwner.evaluateSubject({
id: 'moving', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(lon, 0, 10000),
}, states).cohorts[3].summary;
installations.setContextAnchor({ latitude: 0, longitude: 0 });
const first = evaluate(0);
assert.equal(first.count, 2);
assert.ok(!first.nearest.some((item) => item.id === 'corner'), '90 km N + 90 km E is outside the disk');
assert.equal(installations.getStats().count, 2);
assert.equal(installations.getStats().statusMessage, '2 mapped sites within 100 km of the contact');
installations.setContextAnchor({ latitude: 0, longitude: 19 / 111.195 });
const moved = evaluate(19 / 111.195);
assert.equal(installations.getStats().count, 1);
assert.equal(installations.getStats().statusMessage, '1 mapped site within 100 km of the contact');
assert.equal(moved.count, 1, 'west site leaves the radius before the fetch anchor refreshes');
assert.equal(moved.nearest[0].id, 'east');
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
+15 -2
View File
@@ -48,8 +48,13 @@ export function createSubject({ state: layerState, services, parts, source }) {
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
);
const coverage = installationsState.stats?.coverage;
const installationRadius =
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
? coverage.radiusM
: AWARENESS_RADIUS_M;
const installations = militaryInstallationsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.getNearby(position, installationRadius, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
@@ -92,7 +97,8 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
coverage: 'CURRENT VIEWPORT ONLY',
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
@@ -326,6 +332,11 @@ export function createSubject({ state: layerState, services, parts, source }) {
});
if (!resolved) return;
const { position, presence } = resolved;
// Mapped installations load around the subject, not the (follow/Cockpit)
// camera view. The installation layer moves its window only after the
// subject travels far enough, so this per-refresh call is cheap.
if (!layerState.passive)
militaryInstallationsLayer.setContextAnchor?.(position);
// UNCHECKED leaves the verdict alone: this tick simply did not look.
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
else if (presence === SUBJECT_PRESENCE.MISSING)
@@ -392,6 +403,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
label: record.label || record.id,
memberNames: record.properties?.memberNames || [],
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
};
}
@@ -411,6 +423,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
}
function clearAwarenessSubject() {
militaryInstallationsLayer.setContextAnchor?.(null);
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.subjectMissing = false;
+8
View File
@@ -339,6 +339,12 @@ export function createLifecycle({
*/
enable() {
layerState._enabled = true;
if (
layerState._records.some(
(record) => record.camera.cityId === 'warendorf',
)
)
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
@@ -385,6 +391,7 @@ export function createLifecycle({
/** Disables the layer: hides entities, stops the projection loop and load queue. */
disable() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
layerState._enabled = false;
unregisterPickOwner('cctv');
// ADJUST mode does not survive a layer toggle — predictable re-entry.
@@ -408,6 +415,7 @@ export function createLifecycle({
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
*/
destroy(viewer) {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
layerState._sourceAbort?.abort();
if (typeof document !== 'undefined')
document.removeEventListener(
+2
View File
@@ -695,6 +695,7 @@ export function createDirectionsLayer({ services }) {
}
function clearRouteGraphics() {
services.credits?.hideOsmCredit?.(_viewer, 'directions');
_clearStepSelection();
stopStepAnchoring();
removeEntity(_routeEntity);
@@ -800,6 +801,7 @@ export function createDirectionsLayer({ services }) {
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
services.credits?.showOsmCredit?.(_viewer, 'directions');
services.ground.warmGroundFloor(cells);
_anchorAttempts = 0;
_anchorStartedAt = Date.now();
+93 -2
View File
@@ -1,6 +1,10 @@
import * as Cesium from 'cesium';
import { installationFeedback } from '../../data/installationFeedback.js';
import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
import {
LAYER_ID,
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -16,12 +20,71 @@ export function createControls({ state: layerState, services, parts, source }) {
statsRefreshInterval: 1000,
visitNamedMarkers(visitor) {
parts.namedMarkers.visit(visitor);
},
/** Request a one-shot Google Maps Places search around the current map view. */
searchNearby() {
layerState.googleSearchRequested = true;
return parts.ingestion.loadInstallations();
},
/**
* Load sites around a Contacts subject instead of the camera viewport.
*
* A follow or Cockpit camera moves every frame, so `moveEnd` never fires and
* its view rectangle often reaches the horizon; a subject window is the
* only area that answers "what is near this contact". The window moves
* only after the subject travels ANCHOR_REFRESH_M, and cached tiles make
* that cheap. Passing null returns the layer to viewport loading.
* @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position
* Subject position.
* @returns {boolean} Whether a new load was scheduled.
*/
setContextAnchor(position) {
let next = null;
if (position) {
if (
Number.isFinite(position.latitude) &&
Number.isFinite(position.longitude)
)
next = { latitude: position.latitude, longitude: position.longitude };
else {
const carto = Cesium.Cartographic.fromCartesian(position);
if (carto)
next = {
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
};
}
}
// Keep the live centre even when the coarse fetch anchor has not moved.
layerState.contextPosition = next
? Cesium.Cartesian3.fromDegrees(next.longitude, next.latitude)
: null;
const previous = layerState.contextAnchor;
if (!next) {
if (!previous) return false;
layerState.contextAnchor = null;
parts.viewport.scheduleLoad();
return true;
}
if (
previous &&
parts.model.approximateSurfaceDistanceM(
Cesium.Math.toRadians(previous.latitude),
Cesium.Math.toRadians(previous.longitude),
next.latitude,
next.longitude,
) < ANCHOR_REFRESH_M
)
return false;
layerState.contextAnchor = next;
parts.viewport.scheduleLoad();
return true;
},
getNearby(center, rangeM, maxCount = 50) {
if (!center) return [];
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
@@ -100,8 +163,30 @@ export function createControls({ state: layerState, services, parts, source }) {
},
getStats() {
const namedMarkers = parts.namedMarkers?.stats();
const subjectWindow =
layerState.coverage?.kind === 'subject' &&
layerState.contextPosition &&
Number.isFinite(layerState.coverage.radiusM);
const mappedCount = subjectWindow
? methods.getNearby(
layerState.contextPosition,
layerState.coverage.radiusM,
layerState.records.length || 1,
).length
: layerState.wide && namedMarkers
? namedMarkers.pointsOnScreen
: layerState.records.filter(
(record) => record.kind === 'installation',
).length;
return {
count: layerState.records.length,
count:
subjectWindow || layerState.wide
? mappedCount
: layerState.records.length,
namedInView: layerState.namedInView || 0,
wide: Boolean(layerState.wide),
...namedMarkers,
lastUpdate: layerState.lastUpdate,
stale: layerState.stale,
saturated: layerState.saturated,
@@ -113,11 +198,17 @@ export function createControls({ state: layerState, services, parts, source }) {
failureReason: layerState.failureReason,
statusMessage: installationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
}),
loadingLabel: layerState.loading
? 'loading mapped installation context'
: '',
coverage: layerState.coverage,
coverageLabel:
layerState.coverage?.kind === 'subject'
? `WITHIN ${Math.round(layerState.coverage.radiusM / 1000)} KM`
: 'CURRENT VIEWPORT ONLY',
};
},
};
+4 -2
View File
@@ -1,3 +1,4 @@
import { createNamedMarkers } from './namedMarkers.js';
import { createModel } from './model.js';
import { createRendering } from './rendering.js';
import { createIngestion } from './ingestion.js';
@@ -8,7 +9,7 @@ import { createLifecycle } from './lifecycle.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createInstallationsLayer({ services, source }) {
export function createInstallationsLayer({ services, source, overlayHost }) {
if (
typeof source?.getMappedSites !== 'function' ||
typeof source?.searchNearby !== 'function'
@@ -16,9 +17,10 @@ export function createInstallationsLayer({ services, source }) {
throw new TypeError('An installation source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
const context = { state, services, parts, source, overlayHost };
parts.model = createModel(context);
parts.rendering = createRendering(context);
parts.namedMarkers = createNamedMarkers(context);
parts.ingestion = createIngestion(context);
parts.viewport = createViewport(context);
parts.selection = createSelection(context);
+147 -37
View File
@@ -1,3 +1,50 @@
import { militaryOsmKey } from '../../data/militaryNames.js';
import { isUnavailableCapability } from '../../sources/capability.js';
/** Keep named point coverage until its matching polygon takes over. */
export function retainNamedHandoff(previous, records, box) {
const named = new Map(
previous.filter((r) => r.namedArea).map((r) => [r.id, r]),
);
const represented = new Set();
const next = records.map((record) => {
represented.add(record.id);
for (const id of record.aliasIds || []) represented.add(id);
for (const member of record.namedMembers || []) represented.add(member.id);
for (const source of record.sources || []) {
const key = militaryOsmKey(source.id);
if (key) represented.add(`osm:military:${key}`);
}
const prior =
named.get(record.id) ||
(record.aliasIds || [])
.map((id) => named.get(id))
.filter(Boolean)
.sort((a, b) => b.areaM2 - a.areaM2)[0];
return !record.namedArea && prior
? {
...record,
name: prior.name,
namedArea: true,
areaM2: prior.areaM2,
class: prior.class,
}
: record;
});
for (const record of previous) {
if (
record.pointOnly &&
record.namedArea &&
!represented.has(record.id) &&
record.longitude >= box.west &&
record.longitude <= box.east &&
record.latitude >= box.south &&
record.latitude <= box.north
)
next.push(record);
}
return next;
}
export function createIngestion({
state: layerState,
services,
@@ -26,7 +73,7 @@ export function createIngestion({
async function loadInstallations() {
if (!layerState.enabled || !layerState.viewer) return;
const box = parts.viewport.viewportBox(layerState.viewer);
const { box, coverage } = parts.viewport.loadArea(layerState.viewer);
// Guidance, not a fault: the layer chose not to query because the view is
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
// of `error` — the manager derives refresh failures and the global status
@@ -43,6 +90,7 @@ export function createIngestion({
}
layerState.abort?.abort();
const requestAbort = new AbortController();
const keyOwner = (layerState.cameraLoadOwner ??= {});
layerState.abort = requestAbort;
layerState.loading = true;
parts.viewport.clearUnavailableRetry({ resetBackoff: false });
@@ -53,19 +101,34 @@ export function createIngestion({
source.getMappedSites(box, { exact, signal: requestAbort.signal });
let payload = await fetchInstallations(false);
// Map tiles carry military areas only at z9 and finer: a view too wide
// for that is guidance, exactly like an unbounded view, never an empty
// all-clear. Previously loaded sites stay on the map.
if (payload.zoomIn) {
if (
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
parts.viewport.clearUnavailableRetry();
setInstallationStatus('zoom-in');
return;
}
// A SATURATED snapped tile was truncated upstream, so features from the
// snap's extra ring may have crowded out sites actually on screen. Re-ask
// for the exact viewport (separately keyed and cached) before rendering.
let saturated = payload.saturated === true;
if (saturated) {
if (saturated && !payload.tileSource) {
payload = await fetchInstallations(true);
saturated = payload.saturated === true;
}
// The proxy answers a bbox at least as large as the viewport; keep only what
// was actually asked for so nothing off-screen reaches the map or the
// "current viewport only" context claim.
const records = payload.records.filter((record) =>
parts.model.installationWithinViewport(record, box),
const records = payload.records.filter(
(record) =>
payload.wide || parts.model.installationWithinViewport(record, box),
);
let placesError = null;
if (layerState.googleSearchRequested) {
@@ -139,43 +202,84 @@ export function createIngestion({
'Google Places search unavailable; showing mapped sites';
}
}
await resolveGroundFloorCellsBounded(
records.map((record) => ({
lat: record.latitude,
lon: record.longitude,
})),
);
let floorTimer;
await Promise.race([
resolveGroundFloorCellsBounded(
records
.filter((record) => !record.pointOnly)
.map((record) => ({
lat: record.latitude,
lon: record.longitude,
})),
),
new Promise((resolve) => {
floorTimer = setTimeout(resolve, 120);
}),
]).finally(() => clearTimeout(floorTimer));
if (
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
layerState.records = records;
layerState.recordById = new Map(
layerState.records.map((record) => [record.id, record]),
);
layerState.lastUpdate = Date.now();
layerState.stale = payload.status === 'stale';
// Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed.
layerState.saturated = saturated;
layerState.failureReason = null;
parts.viewport.clearUnavailableRetry();
setInstallationStatus(
layerState.records.length
? layerState.stale
? 'stale'
: 'ready'
: 'empty',
payload.status === 'stale'
? 'Serving cached mapped context'
: saturated
? 'Too many mapped sites in view to list them all'
: placesError,
);
parts.rendering.renderRecords();
parts.rendering.warmInstallationFloors(layerState.records);
const publish = (payload, records) => {
if (
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
if (!payload.wide)
records = retainNamedHandoff(layerState.records, records, box);
layerState.namedInView =
payload.namedInView ?? records.filter((r) => r.namedArea).length;
layerState.wide = Boolean(payload.wide);
layerState.records = records;
layerState.recordById = new Map(
layerState.records.map((record) => [record.id, record]),
);
layerState.lastUpdate = Date.now();
layerState.coverage = coverage;
layerState.stale = payload.status === 'stale';
// Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed.
layerState.saturated = saturated;
layerState.failureReason = null;
parts.viewport.clearUnavailableRetry();
setInstallationStatus(
layerState.records.length
? layerState.stale
? 'stale'
: 'ready'
: 'empty',
payload.status === 'stale'
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
: saturated
? 'Too many mapped sites in view to list them all'
: placesError,
);
parts.rendering.renderRecords();
parts.rendering.warmInstallationFloors(
layerState.records.filter((r) => !r.pointOnly),
);
};
publish(payload, records);
payload.enrichment?.then((enriched) => {
if (
!enriched ||
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
const named = enriched.records.filter((record) =>
parts.model.installationWithinViewport(record, box),
);
publish(enriched, [
...named,
...records.filter((record) => record.id.startsWith('google:')),
]);
});
} catch (error) {
if (
requestAbort.signal.aborted ||
@@ -189,11 +293,17 @@ export function createIngestion({
'unavailable',
error?.message || 'Installation context unavailable',
);
parts.viewport.scheduleUnavailableRetry();
if (!isUnavailableCapability(error))
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
} finally {
// An older aborted request must not clear a newer request's busy state.
if (layerState.abort === requestAbort) {
layerState.abort = null;
if (
requestAbort.signal.aborted &&
layerState.cameraLoadOwner === keyOwner
)
layerState.cameraLoadKey = null;
// Keep ownership until superseded so late names cannot enrich an old view.
layerState.loading = false;
}
}
+48 -3
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium';
import { LAYER_ID } from './policy.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js';
export function createLifecycle({
state: layerState,
@@ -17,14 +21,37 @@ export function createLifecycle({
'military-installations',
);
viewer.dataSources.add(layerState.dataSource);
layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
parts.viewport.scheduleLoad,
);
const cameraChanged = () => {
if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
};
layerState.moveEndRemove =
viewer.camera.moveEnd.addEventListener(cameraChanged);
layerState.changedRemove =
viewer.camera.changed?.addEventListener(cameraChanged);
parts.selection.installInteraction(viewer);
},
enable() {
layerState.enabled = true;
layerState.surfaceChangedRemove ||= services.maps?.subscribeMapStack(
parts.rendering.syncSelectionFill,
);
// Cached markers and geometry become visible before the next fetch.
if (
layerState.records?.some((r) =>
r.sources?.some((s) => s.name === 'OpenStreetMap'),
)
)
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
openMapTiles: layerState.records.some((r) =>
r.sources?.some(
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
),
),
});
parts.namedMarkers?.enable();
parts.namedMarkers?.sync(layerState.records || []);
claimCameraSensitivity(layerState.viewer.camera, LAYER_ID, 0.01);
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
layerState.dataSource.show = true;
// DataLayerManager invokes update() immediately after enable(), which owns
@@ -33,6 +60,14 @@ export function createLifecycle({
disable() {
layerState.enabled = false;
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
parts.namedMarkers?.hide();
releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID);
layerState.contextAnchor = null;
layerState.contextPosition = null;
layerState.coverage = { kind: 'viewport' };
layerState.cameraLoadKey = null;
layerState.cameraLoadOwner = null;
unregisterPickOwner(LAYER_ID);
parts.viewport.clearUnavailableRetry();
clearTimeout(layerState.timer);
@@ -42,15 +77,25 @@ export function createLifecycle({
if (layerState.dataSource) layerState.dataSource.show = false;
clearSelectedEntityContextForLayer(LAYER_ID);
layerState.selectedId = null;
parts.rendering.clearSelectionFill?.();
layerState.surfaceChangedRemove?.();
layerState.surfaceChangedRemove = null;
layerState.failureReason = null;
parts.ingestion.setInstallationStatus('idle');
},
destroy(viewer) {
source.destroy?.();
this.disable();
layerState.contextAnchor = null;
layerState.contextPosition = null;
layerState.coverage = { kind: 'viewport' };
layerState.moveEndRemove?.();
layerState.changedRemove?.();
parts.selection.destroy?.();
layerState.clickHandler?.destroy();
layerState.clickHandler = null;
parts.namedMarkers?.destroy();
parts.rendering.clearRendered();
if (layerState.dataSource && viewer)
viewer.dataSources.remove(layerState.dataSource, true);
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as credits from '../../data/dataCredits.js';
import { createLifecycle } from './lifecycle.js';
import { LAYER_ID } from './policy.js';
for (const source of [
{ name: 'OpenStreetMap', id: 'tile:base' },
{ name: 'OpenStreetMap', id: 'way:123' },
{ name: 'Google Places', id: 'place:base' },
null,
]) {
test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => {
const visibleCredits = new Set();
const noop = () => {};
const viewer = {
camera: {},
creditDisplay: {
addStaticCredit: (credit) => visibleCredits.add(credit),
removeStaticCredit: (credit) => visibleCredits.delete(credit),
},
};
const state = {
viewer,
records: source ? [{ id: 'base', sources: [source] }] : [],
recordById: new Map(),
dataSource: { show: false },
};
let markersVisible = false;
const parts = {
namedMarkers: {
enable: () => {
markersVisible = true;
},
sync: noop,
hide: () => {
markersVisible = false;
},
},
rendering: {},
viewport: { clearUnavailableRetry: noop },
ingestion: { setInstallationStatus: noop },
};
const lifecycle = createLifecycle({
state,
services: {
credits,
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
context: { clearSelectedEntityContextForLayer: noop },
},
parts,
source: {},
}).methods;
const expected =
source?.name === 'OpenStreetMap'
? source.id.startsWith('tile:')
? 2
: 1
: 0;
try {
for (let cycle = 0; cycle < 2; cycle++) {
lifecycle.enable();
assert.equal(state.dataSource.show, true);
assert.equal(markersVisible, true);
// No renderRecords or successful fetch occurs between toggles.
assert.equal(visibleCredits.size, expected);
lifecycle.disable();
assert.equal(state.dataSource.show, false);
assert.equal(markersVisible, false);
assert.equal(visibleCredits.size, 0);
}
credits.showOsmCredit(viewer, 'another-layer');
lifecycle.enable();
lifecycle.disable();
assert.equal(visibleCredits.size, 1, 'another owner retains its credit');
assert.equal(credits.hideOsmCredit(viewer, LAYER_ID), false);
} finally {
lifecycle.disable();
credits.hideOsmCredit(viewer, 'another-layer');
}
});
}
+22 -18
View File
@@ -116,24 +116,28 @@ export function createModel({ state: layerState, services, parts, source }) {
longitude >= box.west &&
longitude <= box.east;
if (centreInside) return true;
if (Array.isArray(footprint) && footprint.length) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const [lon, lat] of footprint) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
return (
maxLat >= box.south &&
minLat <= box.north &&
maxLon >= box.west &&
minLon <= box.east
);
}
const footprints =
record.footprints?.map((rings) => rings[0]) ||
(footprint?.length ? [footprint] : []);
if (footprints.length)
return footprints.some((footprint) => {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const [lon, lat] of footprint) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
return (
maxLat >= box.south &&
minLat <= box.north &&
maxLon >= box.west &&
minLon <= box.east
);
});
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
return record.osmType !== 'node';
}
+224
View File
@@ -0,0 +1,224 @@
import * as Cesium from 'cesium';
import {
MILITARY_LABEL_CAP,
MILITARY_POINT_CAP,
} from '../../data/militaryNames.js';
import { LAYER_ID, COLOR_BY_CLASS } from './policy.js';
import { createInfrastructureOverlayEntry } from '../../data/infrastructureOverlayEntry.js';
/** Bounded point markers; the shared overlay owns text projection and decluttering. */
export function createNamedMarkers({ state, services, parts, overlayHost }) {
let points = null;
let entries = [];
const markers = new Map();
const screen = new Cesium.Cartesian2();
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
let shownPoints = 0;
let ordered = [];
const viewMatrix = new Cesium.Matrix4();
let width = 0,
height = 0;
let removeChanged = null,
removeEnd = null,
removeFrame = null;
function refresh() {
const viewer = state.viewer;
if (!state.enabled || !points) return;
width = viewer.scene.canvas.clientWidth;
height = viewer.scene.canvas.clientHeight;
if (viewer.camera.viewMatrix)
Cesium.Matrix4.clone(viewer.camera.viewMatrix, viewMatrix);
occluder.cameraPosition = viewer.camera.positionWC;
shownPoints = 0;
for (let index = 0; index < ordered.length; index++) {
const marker = ordered[index];
const point = marker.point;
point.show = marker.named && occluder.isPointVisible(point.position);
if (!point.show) continue;
const projected = Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
point.position,
screen,
);
if (
projected &&
screen.x >= 0 &&
screen.y >= 0 &&
screen.x < width &&
screen.y < height
)
shownPoints++;
}
}
function publishLabels() {
overlayHost.setEntries(LAYER_ID, entries, {
// Keep the bounded point cohort available while Contacts owns the window.
// The arbiter spends at most 24 slots on currently visible, separated titles.
cohortLimit: MILITARY_POINT_CAP,
collisionCapacity: MILITARY_LABEL_CAP,
maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0),
moving: false,
visible: state.enabled,
});
}
function cameraChanged() {
refresh();
services.render.governorRequestRender('installation-names');
}
function frame() {
const viewer = state.viewer;
if (
viewer?.camera.viewMatrix &&
(!Cesium.Matrix4.equals(viewMatrix, viewer.camera.viewMatrix) ||
width !== viewer.scene.canvas.clientWidth ||
height !== viewer.scene.canvas.clientHeight)
)
refresh();
}
function enable() {
const viewer = state.viewer;
if (!viewer || removeChanged || removeEnd || removeFrame) return;
removeChanged = viewer.camera.changed?.addEventListener(cameraChanged);
removeEnd = viewer.camera.moveEnd?.addEventListener(cameraChanged);
removeFrame = viewer.scene.preRender?.addEventListener(frame);
if (points) points.show = true;
publishLabels();
overlayHost.setVisible(LAYER_ID, true);
cameraChanged();
}
function hide() {
removeChanged?.();
removeEnd?.();
removeFrame?.();
removeChanged = removeEnd = removeFrame = null;
if (points) points.show = false;
overlayHost.clearSource(LAYER_ID);
overlayHost.setVisible(LAYER_ID, false);
shownPoints = 0;
}
function sync(records) {
const viewer = state.viewer;
if (!viewer?.scene?.primitives) return;
if (!points) {
points = viewer.scene.primitives.add(
new Cesium.PointPrimitiveCollection(),
);
}
points.show = state.enabled;
const named = records
.filter((r) => r.namedArea || r.id === state.selectedId)
.sort(
(a, b) =>
Number(b.id === state.selectedId) -
Number(a.id === state.selectedId) || b.areaM2 - a.areaM2,
)
.slice(0, MILITARY_POINT_CAP);
const keep = new Set(named.map((r) => r.id));
for (const record of named) {
if (markers.has(record.id)) continue;
const alias = record.aliasIds?.find(
(id) => !keep.has(id) && markers.has(id),
);
if (!alias) continue;
const marker = markers.get(alias);
markers.delete(alias);
marker.point.id = { installationId: record.id };
marker.entry.id = record.id;
markers.set(record.id, marker);
}
for (const [id, marker] of markers)
if (!keep.has(id)) {
points.remove(marker.point);
markers.delete(id);
}
ordered = [];
entries = [];
for (const record of named) {
const position = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
record.pointOnly
? 0
: parts.rendering.installationSurfaceHeightM(record),
);
let marker = markers.get(record.id);
if (!marker) {
const id = { installationId: record.id };
marker = {
point: points.add({
id,
position,
pixelSize: 7,
color: parts.model.colorFor(record),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
disableDepthTestDistance: Infinity,
}),
entry: createInfrastructureOverlayEntry({
id: record.id,
source: LAYER_ID,
position,
title: record.name,
priority: record.areaM2 || 0,
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
}),
};
markers.set(record.id, marker);
} else {
marker.point.position = position;
}
marker.named = record.namedArea;
marker.entry.variant =
record.id === state.selectedId ? 'selected' : 'card';
marker.entry.selected = record.id === state.selectedId;
marker.entry.details = marker.entry.selected
? [
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
...(record.memberNames || []).slice(0, 3),
]
: [];
marker.entry.position = position;
marker.entry.title = record.name;
marker.entry.priority = record.areaM2 || 0;
entries.push(marker.entry);
ordered.push(marker);
}
if (state.enabled) publishLabels();
refresh();
services.render.governorRequestRender('installation-names');
}
return {
sync,
visit(visitor) {
for (let i = 0; i < ordered.length; i++) {
const marker = ordered[i];
visitor(marker.point.id.installationId, marker.point);
}
},
refresh,
enable,
hide,
stats: () => ({
namedMarkers: markers.size,
pointsOnScreen: shownPoints,
labelsOnScreen: state.enabled
? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0
: 0,
labelCap: MILITARY_LABEL_CAP,
}),
destroy() {
hide();
ordered = [];
if (points) state.viewer?.scene.primitives.remove(points);
points = null;
entries = [];
markers.clear();
},
};
}
@@ -0,0 +1,185 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createNamedMarkers } from './namedMarkers.js';
import { createLifecycle } from './lifecycle.js';
import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js';
function setup(t) {
let labels = [], options, publications = 0, visible = false;
const overlayHost = {
setEntries(id, entries, policy) {
assert.equal(id, 'military-installations');
labels = entries; options = policy; publications++;
},
clearSource() { labels = []; },
setVisible(_id, value) { visible = value; },
};
let x = -100;
const projections = new Set();
t.mock.method(C.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; });
const camera = { changed: new C.Event(), moveEnd: new C.Event(), positionWC: C.Cartesian3.fromDegrees(0, 0, 1e6), viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) };
const state = { viewer: { camera, dataSources: { add() {}, remove() {} }, scene: { canvas: { clientWidth: 1280, clientHeight: 800 }, preRender: new C.Event(), primitives: { add: (p) => p, remove() {} } } }, enabled: true, contextAnchor: { latitude: 0, longitude: 0 } };
const services = { render: { governorRequestRender() {} }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, context: { clearSelectedEntityContextForLayer() {} } };
let loads = 0;
const parts = { model: { colorFor: () => C.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0, clearRendered() {} }, viewport: { scheduleLoad: () => loads++, clearUnavailableRetry() {} }, selection: { installInteraction() {} }, ingestion: { setInstallationStatus() {} } };
const ctx = { state, services, parts, source: {}, overlayHost };
parts.namedMarkers = createNamedMarkers(ctx);
const lifecycle = createLifecycle(ctx).methods;
lifecycle.init(state.viewer); lifecycle.enable();
const records = [1, 2].map((n) => ({ id: `osm:military:w${n}`, name: `Site ${n}`, namedArea: true, areaM2: 100 / n, latitude: 0, longitude: 0, pointOnly: true }));
return { state, camera, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, projections, records, markers: parts.namedMarkers, lifecycle, setX: (value) => { x = value; }, publications: () => publications, loads: () => loads };
}
test('Contacts camera motion reprojects and declutters existing labels without source loads or primitive replacement', (t) => {
const h = setup(t);
h.markers.sync(h.records);
assert.equal(h.markers.stats().pointsOnScreen, 0);
h.setX(600); h.camera.changed.raiseEvent(); h.camera.moveEnd.raiseEvent();
assert.equal(h.markers.stats().pointsOnScreen, 2);
assert.equal(h.labels.length, 2);
assert.equal(h.options.collisionCapacity, 24);
assert.equal(h.options.cohortLimit, 512);
assert.equal(h.visible, true);
assert.equal(h.loads(), 0);
assert.equal(h.publications(), 3);
assert.equal(h.projections.size, 1);
h.setX(1400); h.camera.viewMatrix[12] = 1; h.state.viewer.scene.preRender.raiseEvent();
assert.equal(h.markers.stats().pointsOnScreen, 0, 'small pose changes refresh before render');
h.lifecycle.disable();
assert.equal(h.camera.changed.numberOfListeners, 1, 'only the viewport listener remains');
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
assert.equal(h.labels.length, 0);
assert.equal(h.visible, false);
h.state.records = h.records;
h.setX(600); h.lifecycle.enable();
assert.equal(h.labels.length, 2);
assert.equal(h.publications(), 5);
h.lifecycle.destroy(h.state.viewer);
assert.equal(h.camera.changed.numberOfListeners, 0);
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
});
test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
const { retainNamedHandoff } = await import('./ingestion.js');
const h = setup(t);
h.setX(600); h.markers.sync(h.records);
const first = h.labels[0];
const box = { west: -1, east: 1, south: -1, north: 1 };
h.markers.sync(retainNamedHandoff(h.records, [], box));
assert.equal(h.labels[0], first);
assert.equal(first.title, 'Site 1');
const polygon = { ...h.records[0], namedArea: false, name: 'Military area', pointOnly: false, footprint: [[0, 0], [0.1, 0], [0, 0.1], [0, 0]] };
const handed = retainNamedHandoff(h.records, [polygon], box);
assert.equal(handed.length, 2);
assert.equal(handed[0].name, 'Site 1');
assert.equal(handed[0].pointOnly, false);
h.markers.sync(handed);
assert.equal(h.labels[0], first);
assert.equal(first.title, 'Site 1');
assert.equal(h.labels.length, 2, 'no duplicate title at handoff');
assert.equal(retainNamedHandoff(h.records, [], { west: 1, east: 2, south: 1, north: 2 }).length, 0);
h.lifecycle.destroy(h.state.viewer);
});
test('canonical aliases transfer named marker ownership without replacing primitives', (t) => {
const h = setup(t);
h.setX(600);
const parent = h.records[1];
h.markers.sync([parent]);
let originalPoint;
h.markers.visit((_id, point) => { originalPoint = point; });
const label = h.labels[0];
for (const record of [
{ ...parent, id: h.records[0].id, aliasIds: [parent.id], pointOnly: false },
{ ...parent, aliasIds: [h.records[0].id], pointOnly: false },
parent,
]) {
h.markers.sync([record]);
h.markers.visit((id, point) => {
const currentLabel = h.labels[0];
assert.equal(id, record.id);
assert.equal(point, originalPoint);
assert.equal(currentLabel, label);
assert.equal(point.id.installationId, record.id);
assert.equal(currentLabel.id, record.id);
});
}
assert.equal(h.labels.length, 1);
h.lifecycle.destroy(h.state.viewer);
});
test('the bounded host cohort ranks wide points and polygon titles by area', (t) => {
const h = setup(t);
h.markers.sync(Array.from({ length: 600 }, (_, n) => ({
...h.records[0], id: `site:${n}`, name: `Site ${n}`, areaM2: n,
pointOnly: n % 2 === 0,
})));
assert.equal(h.labels.length, 512);
assert.equal(h.labels[0].id, 'site:599');
assert.equal(h.labels.at(-1).id, 'site:88');
assert.ok(h.labels.every((entry) => !entry.stateless && entry.horizonCull && entry.collisionGroup === 'ambient-card'));
const first = h.labels[0];
const publications = h.publications();
for (let i = 0; i < 100; i++) {
h.camera.viewMatrix[12] += 0.01;
h.state.viewer.scene.preRender.raiseEvent();
}
assert.equal(h.labels[0], first);
assert.equal(h.publications(), publications, 'camera frames never rebuild host entries');
assert.equal(h.projections.size, 1, 'point projection reuses scratch storage');
h.lifecycle.destroy(h.state.viewer);
assert.equal(h.labels.length, 0);
});
test('wide and polygon titles use the Data Centers entry contract and persistent shared domain', (t) => {
const h = setup(t);
h.markers.sync([h.records[0], { ...h.records[1], pointOnly: false }]);
assert.equal(h.options.moving, false);
assert.equal(h.options.solveIntervalMs, undefined);
assert.equal(h.options.maxVisible, 24);
for (const entry of h.labels) {
const reference = createLocalInfrastructureOverlayEntry({ id: entry.id, layerId: 'military-installations', position: entry.position, properties: { name: entry.title }, priority: entry.priority, accent: entry.accent });
assert.deepEqual(entry, { ...reference, selected: false });
}
h.lifecycle.destroy(h.state.viewer);
});
test('selection promotes exactly one entry and deselection restores that same label', (t) => {
const h = setup(t);
h.markers.sync(h.records);
const original = h.labels[0];
h.state.selectedId = h.records[0].id;
h.markers.sync(h.records);
assert.equal(h.labels.length, 2);
assert.equal(h.labels[0], original);
assert.equal(original.variant, 'selected');
assert.equal(h.labels.filter(e => e.selected).length, 1);
assert.equal(h.options.maxVisible, 23);
h.state.selectedId = null;
h.markers.sync(h.records);
assert.equal(h.labels[0], original);
assert.equal(original.variant, 'card');
assert.equal(original.selected, false);
assert.deepEqual(original.details, []);
h.lifecycle.destroy(h.state.viewer);
});
test('Contacts can select a point outside the normal title cap or an unnamed polygon', (t) => {
const h = setup(t);
const records = Array.from({ length: 520 }, (_, i) => ({ ...h.records[0], id: `site:${i}`, areaM2: 1000 - i }));
records.push({ ...h.records[0], id: 'unnamed', name: 'Military area', namedArea: false, pointOnly: false });
for (const id of ['site:519', 'unnamed']) {
h.state.selectedId = id;
h.markers.sync(records);
assert.equal(h.labels[0].id, id);
assert.equal(h.labels[0].variant, 'selected');
assert.equal(h.labels.filter(e => e.selected).length, 1);
assert.equal(h.labels.length, 512);
}
h.lifecycle.destroy(h.state.viewer);
});
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createInstallationSource } from './source.js';
import { createIngestion } from './ingestion.js';
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
import { loadBundledJson } from '../../data/bundledJson.js';
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
const wide = { south: 20, west: -130, north: 60, east: -60 };
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
function sourceWith(job) { return createInstallationSource({
loadNames: () => job.promise,
fetchImpl: async () => Response.json({ unavailable: true }),
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
}); }
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
const job = deferred(), source = sourceWith(job), abort = new AbortController();
const waiting = source.getMappedSites(wide, { signal: abort.signal });
const rejection = assert.rejects(waiting, { name: 'AbortError' });
const payload = await source.getMappedSites(box);
assert.ok(payload.records.length);
assert.ok(payload.records.every((r) => r.name === 'Military area'));
abort.abort();
await rejection;
job.resolve(await loadMilitaryNames());
const enriched = await payload.enrichment;
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
});
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
const job = deferred(), source = sourceWith(job), publications = [];
let currentBox = box;
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
const ingestion = createIngestion({ state, source,
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
parts: {
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
model: { installationWithinViewport: () => true },
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
},
});
await ingestion.loadInstallations();
assert.equal(state.loading, false);
assert.ok(publications[0].length);
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
job.resolve(await loadMilitaryNames());
await flush();
assert.equal(publications.length, stale ? 1 : 2);
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
state.abort.abort();
});
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
let calls = 0, signal;
t.mock.method(globalThis, 'fetch', async (_url, options) => {
calls++; signal = options.signal;
if (calls > 1) return Response.json({ records: [], classes: [] });
if (stage === 'response') return new Promise(() => {});
return { ok: true, json: () => new Promise(() => {}) };
});
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
const first = load();
assert.equal(load(), first, 'shared acquisition');
const rejected = assert.rejects(first, { name: 'TimeoutError' });
await flush(); t.mock.timers.tick(51); await rejected;
assert.equal(signal.aborted, true);
await assert.rejects(load(), { name: 'TimeoutError' });
assert.equal(calls, 1);
t.mock.timers.tick(101);
assert.deepEqual((await load()).records, []);
assert.equal(calls, 2);
});
@@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createViewport } from './viewport.js';
import { createLifecycle } from './lifecycle.js';
test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
const camera = {
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
percentageChanged: 0.5,
computeViewRectangle: () => rectangle,
};
const viewer = {
camera,
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
dataSources: { add() {}, remove() {} },
};
const state = { enabled: false, renderedKeys: new Map() };
const loads = [];
const parts = {
selection: { installInteraction() {} },
rendering: { clearRendered() {} },
ingestion: {
loadInstallations() {
state.abort = new AbortController();
loads.push({
...parts.viewport.loadArea(viewer),
signal: state.abort.signal,
});
},
setInstallationStatus() {},
},
};
const services = {
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
context: { clearSelectedEntityContextForLayer() {} },
};
const ctx = { state, parts, services, source: {} };
parts.viewport = createViewport(ctx);
const life = createLifecycle(ctx).methods;
life.init(viewer);
life.enable();
for (let i = 0; i < 8; i++) {
camera.changed.raiseEvent();
t.mock.timers.tick(15);
}
t.mock.timers.tick(200);
assert.equal(
loads.length,
1,
'changed alone must load, even without moveEnd',
);
camera.moveEnd.raiseEvent();
t.mock.timers.tick(200);
assert.equal(loads.length, 1);
assert.equal(
loads[0].signal.aborted,
false,
'duplicate settle must not abort its own load',
);
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
camera.changed.raiseEvent();
assert.equal(loads[0].signal.aborted, true);
t.mock.timers.tick(200);
assert.equal(loads.length, 2);
rectangle = Cesium.Rectangle.fromDegrees(-150, 10, -80, 60);
camera.moveEnd.raiseEvent();
t.mock.timers.tick(200);
assert.equal(
loads.at(-1).box.west,
-150,
'wide views reach the named-point source',
);
rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
camera.changed.raiseEvent();
t.mock.timers.tick(200);
assert.ok(loads.at(-1).box);
state.contextAnchor = { latitude: 32.8, longitude: -117.1 };
const count = loads.length;
camera.moveEnd.raiseEvent();
t.mock.timers.tick(200);
assert.equal(loads.length, count, 'Contacts owns the anchor while active');
life.disable();
assert.equal(state.contextAnchor, null);
life.enable();
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
camera.changed.raiseEvent();
t.mock.timers.tick(200);
assert.equal(loads.at(-1).coverage.kind, 'viewport');
life.destroy(viewer);
assert.equal(camera.changed.numberOfListeners, 0);
assert.equal(camera.moveEnd.numberOfListeners, 0);
assert.equal(camera.percentageChanged, 0.5);
});
test('an aborting real ingestion cannot clear the next scheduled view key', async (t) => {
const { createIngestion } = await import('./ingestion.js');
t.mock.timers.enable({ apis: ['setTimeout'] });
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
const viewer = {
camera: { computeViewRectangle: () => rectangle },
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
};
const state = { enabled: true, viewer };
const pending = [];
const parts = {
rendering: { renderRecords() {}, warmInstallationFloors() {} },
model: { installationWithinViewport: () => true },
};
const ctx = {
state,
parts,
services: {
render: { governorRequestRender() {} },
ground: { resolveGroundFloorCellsBounded: async () => {} },
},
source: {
getMappedSites(box, { signal }) {
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
pending.push({ box, signal, resolve });
});
},
},
};
parts.viewport = createViewport(ctx);
parts.ingestion = createIngestion(ctx);
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
parts.viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(pending.length, 1);
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
parts.viewport.scheduleLoad();
const nextKey = state.cameraLoadKey;
assert.equal(pending[0].signal.aborted, true);
await flush();
assert.equal(
state.cameraLoadKey,
nextKey,
'A finally must not clear B schedule ownership',
);
t.mock.timers.tick(200);
assert.equal(pending.length, 2);
parts.viewport.scheduleLoad();
t.mock.timers.tick(600);
assert.equal(pending.length, 2);
assert.equal(pending[1].signal.aborted, false);
pending[1].resolve({ records: [], status: 'ready' });
await flush();
assert.equal(state.loading, false);
assert.equal(state.cameraLoadKey, nextKey);
});
test('wide views refresh screen decluttering when the globe rectangle stays unchanged', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const camera = {
positionWC: new Cesium.Cartesian3(10000000, 0, 0),
directionWC: new Cesium.Cartesian3(-1, 0, 0),
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
};
const state = {
enabled: true,
viewer: { camera, scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } } },
};
let loads = 0;
const parts = {
ingestion: {
loadInstallations() {
loads++;
},
},
};
const viewport = createViewport({ state, parts, services: {}, source: {} });
viewport.scheduleLoad();
t.mock.timers.tick(200);
viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(loads, 1, 'the same settled camera remains deduplicated');
camera.positionWC = new Cesium.Cartesian3(0, 10000000, 0);
camera.directionWC = new Cesium.Cartesian3(0, -1, 0);
viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(loads, 2, 'a different continent needs fresh projected labels');
});
+4 -1
View File
@@ -1,6 +1,6 @@
export const LAYER_ID = 'military-installations';
export const REQUEST_DEBOUNCE_MS = 500;
export const REQUEST_DEBOUNCE_MS = 180;
export const MAX_VIEWPORT_DEGREES = 10;
@@ -19,3 +19,6 @@ export const COLOR_BY_CLASS = {
export const EARTH_MEAN_RADIUS_M = 6371008.8;
export const DISTANCE_PREFILTER_MARGIN_M = 5000;
/** A Contacts subject must move this far before its installation window moves. */
export const ANCHOR_REFRESH_M = 20_000;
+188 -20
View File
@@ -32,12 +32,104 @@ export function createRendering({
);
}
let selectedFillKey = null;
const selectedFillIds = [];
function clearSelectionFill() {
for (const id of selectedFillIds)
layerState.dataSource?.entities.removeById(id);
selectedFillIds.length = 0;
selectedFillKey = null;
}
/** Ground geometry classifies the active surface; no height/extrusion plane. */
function syncSelectionFill() {
const record =
layerState.enabled && layerState.recordById.get(layerState.selectedId);
if (!record) {
clearSelectionFill();
return;
}
const classificationType =
layerState.viewer.scene.globe?.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: Cesium.ClassificationType.BOTH;
const key = `${record.id}|${renderKey(record)}|${classificationType}`;
if (key === selectedFillKey) return;
clearSelectionFill();
selectedFillKey = key;
const footprints =
record.footprints || (record.footprint ? [[record.footprint]] : []);
const material = new Cesium.ColorMaterialProperty(
parts.model.colorFor(record).withAlpha(0.22),
);
for (let i = 0; i < footprints.length; i++) {
const rings = footprints[i];
const positions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
const fill = layerState.dataSource.entities.add({
id: `${record.id}:selected-fill:${i}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
positions(rings[0]),
rings
.slice(1)
.map((ring) => new Cesium.PolygonHierarchy(positions(ring))),
),
material,
classificationType,
},
});
fill.installationId = record.id;
selectedFillIds.push(fill.id);
}
governorRequestRender('installation-selection-fill');
}
function clearRendered() {
clearSelectionFill();
if (layerState.dataSource?.entities)
layerState.dataSource.entities.removeAll();
layerState.renderedKeys.clear();
removeEntityContextsForLayer(LAYER_ID);
}
/**
* Geometry identity of one rendered record. Records whose key is unchanged
* keep their Cesium entities across refreshes: ground-clamped outlines and
* classification fills rebuild asynchronously, so recreating them on every
* reload made unchanged sites blink.
* @param {object} record Installation record.
* @returns {string} Stable key for the record's drawn geometry.
*/
function renderKey(record) {
const footprints =
record.footprints || (record.footprint ? [[record.footprint]] : []);
const outlines =
record.outlineLines || (record.footprint ? [record.footprint] : []);
let points = 0;
for (const rings of footprints)
for (const ring of rings) points += ring.length;
for (const line of outlines) points += line.length;
return [
record.latitude.toFixed(6),
record.longitude.toFixed(6),
record.class,
record.kind,
record.name,
footprints.length,
outlines.length,
points,
].join('|');
}
function removeRecordEntities(id) {
const entities = layerState.dataSource.entities;
for (const entityId of layerState.renderedKeys.get(id)?.entityIds || [])
entities.removeById(entityId);
layerState.renderedKeys.delete(id);
}
/**
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
* the selected one when it falls outside that window.
@@ -52,7 +144,9 @@ export function createRendering({
*/
function renderableRecords() {
const rendered = layerState.records.slice(0, MAX_RENDERED);
const rendered = layerState.records
.filter((r) => !r.pointOnly)
.slice(0, MAX_RENDERED);
if (!layerState.selectedId) return rendered;
if (rendered.some((record) => record.id === layerState.selectedId))
return rendered;
@@ -67,16 +161,48 @@ export function createRendering({
if (
!claimSelection &&
layerState.selectedId &&
selectedContext &&
selectedContext.id !== layerState.selectedId
(!selectedContext || selectedContext.id !== layerState.selectedId)
) {
layerState.selectedId = null;
}
let migratedSelection = false;
if (
layerState.selectedId &&
!layerState.recordById.has(layerState.selectedId)
) {
const replacement = layerState.records.find((record) =>
record.aliasIds?.includes(layerState.selectedId),
);
if (replacement) {
layerState.selectedId = replacement.id;
migratedSelection = true;
}
}
// Post-moveEnd debounced fetches commit after the camera settles; the
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
governorRequestRender('installations-render');
clearRendered();
for (const record of renderableRecords()) {
if (
layerState.records.some((r) =>
r.sources?.some((s) => s.name === 'OpenStreetMap'),
)
)
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
openMapTiles: layerState.records.some((r) =>
r.sources?.some(
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
),
),
});
else services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
const records = renderableRecords();
const keys = new Map(
records.map((record) => [record.id, renderKey(record)]),
);
const entities = layerState.dataSource.entities;
entities.suspendEvents();
for (const [id, rendered] of [...layerState.renderedKeys])
if (keys.get(id) !== rendered.key) removeRecordEntities(id);
for (const record of records) {
const color = parts.model.colorFor(record);
const surfaceHeightM = installationSurfaceHeightM(record);
const displayPosition = Cesium.Cartesian3.fromDegrees(
@@ -84,31 +210,63 @@ export function createRendering({
record.latitude,
surfaceHeightM,
);
const selectedRecord = record.id === layerState.selectedId;
const retained = layerState.renderedKeys.has(record.id)
? entities.getById(record.id)
: null;
if (retained) {
// Same geometry: update the marker in place (selection, floor height).
if (retained.gevInstallationHeightM !== surfaceHeightM) {
retained.gevInstallationHeightM = surfaceHeightM;
retained.position = displayPosition;
retained.gevDisplayPosition = () => displayPosition;
}
if (retained.gevInstallationSelected !== selectedRecord) {
retained.gevInstallationSelected = selectedRecord;
retained.point.pixelSize = selectedRecord ? 13 : 9;
retained.point.color = selectedRecord ? Cesium.Color.WHITE : color;
}
continue;
}
const entityIds = [record.id];
layerState.renderedKeys.set(record.id, {
key: keys.get(record.id),
entityIds,
});
const entity = layerState.dataSource.entities.add({
id: record.id,
position: displayPosition,
point: {
show: !record.namedArea,
pixelSize: record.id === layerState.selectedId ? 13 : 9,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
color:
record.id === layerState.selectedId ? Cesium.Color.WHITE : color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
polygon: record.footprint
? {
hierarchy: new Cesium.PolygonHierarchy(
record.footprint.map(([longitude, latitude]) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude),
),
),
material: color.withAlpha(0.12),
outline: true,
outlineColor: color.withAlpha(0.65),
height: surfaceHeightM,
}
: undefined,
});
const outlines =
record.outlineLines || (record.footprint ? [record.footprint] : []);
for (let i = 0; i < outlines.length; i++) {
const outline = layerState.dataSource.entities.add({
id: `${record.id}:outline:${i}`,
polyline: {
positions: outlines[i].map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat),
),
width: 2,
material: color.withAlpha(0.85),
clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH,
},
});
outline.installationId = record.id;
entityIds.push(outline.id);
}
entity.gevInstallationHeightM = surfaceHeightM;
entity.gevInstallationSelected = selectedRecord;
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
@@ -117,6 +275,7 @@ export function createRendering({
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
...(record.memberNames || []).slice(0, 3),
],
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
};
@@ -133,6 +292,7 @@ export function createRendering({
longitude: record.longitude,
properties: {
class: record.class,
memberNames: record.memberNames || [],
primaryType: record.primaryType || null,
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
validation: record.validation,
@@ -140,11 +300,17 @@ export function createRendering({
},
});
}
entities.resumeEvents();
const selectedEntity = layerState.selectedId
? layerState.dataSource.entities.getById(layerState.selectedId)
: null;
if (selectedEntity) selectEntityContext(selectedEntity);
else layerState.selectedId = null;
if (selectedEntity && (claimSelection || migratedSelection))
selectEntityContext(selectedEntity);
else if (!selectedEntity) layerState.selectedId = null;
// Re-register rebuilt geometry and migrate aliases before pruning old context.
removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) });
parts.namedMarkers?.sync(layerState.records);
syncSelectionFill();
}
/**
@@ -187,6 +353,8 @@ export function createRendering({
}
return {
installationSurfaceHeightM,
clearSelectionFill,
syncSelectionFill,
clearRendered,
renderableRecords,
renderRecords,
+40 -2
View File
@@ -10,6 +10,24 @@ export function createSelection({
}) {
const { clearSelectedEntityContextForLayer } = services.context;
let removeContextListeners = null;
function syncContextSelection(event) {
if (!layerState.enabled) return;
// Tracking layers publish this event before replacing the shared context.
const selected =
event?.type === 'gev:awareness-subject-selected'
? event.detail
: services.context.getSelectedEntityContext();
const next =
selected?.layerId === LAYER_ID && layerState.recordById.has(selected.id)
? selected.id
: null;
if (next === layerState.selectedId) return;
layerState.selectedId = next;
parts.rendering.renderRecords();
}
function selectRecord(id) {
const record = layerState.recordById.get(id);
if (!record || !layerState.dataSource) return false;
@@ -21,6 +39,17 @@ export function createSelection({
function installInteraction(viewer) {
if (layerState.clickHandler) return;
const events = [
'gev:entity-selected',
'gev:entity-selection-cleared',
'gev:awareness-subject-selected',
];
for (const event of events)
globalThis.window?.addEventListener?.(event, syncContextSelection);
removeContextListeners = () => {
for (const event of events)
globalThis.window?.removeEventListener?.(event, syncContextSelection);
};
layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
@@ -29,7 +58,9 @@ export function createSelection({
if (!isPointerFree()) return;
if (!layerState.enabled) return;
const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
const id =
picked?.id?.installationId ||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
selectRecord(id);
} else if (layerState.selectedId) {
@@ -42,5 +73,12 @@ export function createSelection({
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
return { selectRecord, installInteraction };
return {
selectRecord,
installInteraction,
destroy() {
removeContextListeners?.();
removeContextListeners = null;
},
};
}
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as context from '../../data/contextStore.js';
import { createSelection } from './selection.js';
import { createRendering } from './rendering.js';
import { createNamedMarkers } from './namedMarkers.js';
function harness(t) {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = new EventTarget();
globalThis.document = new EventTarget();
const record = {
id: 'base',
name: 'Base',
class: 'base',
namedArea: true,
latitude: 0,
longitude: 0,
footprints: [
[
[
[0, 0],
[0.01, 0],
[0.01, 0.01],
[0, 0],
],
],
],
};
const state = {
enabled: true,
selectedId: null,
records: [record],
recordById: new Map([[record.id, record]]),
renderedKeys: new Map(),
dataSource: new Cesium.CustomDataSource(),
viewer: {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e6) },
scene: {
globe: { show: false },
canvas: Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
}),
primitives: { add: (p) => p, remove() {} },
},
},
};
t.mock.method(
Cesium.SceneTransforms,
'worldToWindowCoordinates',
(_scene, _point, scratch) => {
scratch.x = 640;
scratch.y = 400;
return scratch;
},
);
let labels = [];
const services = {
context,
anchors: {},
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
render: { governorRequestRender() {} },
};
const parts = {
model: {
colorFor: () => Cesium.Color.ORANGE,
installationSourceLabel: () => 'OpenStreetMap',
},
};
const ctx = {
state,
services,
parts,
overlayHost: {
setEntries(_id, entries) {
labels = entries;
},
clearSource() {},
setVisible() {},
},
};
parts.rendering = createRendering(ctx);
parts.namedMarkers = createNamedMarkers(ctx);
const selection = createSelection(ctx);
selection.installInteraction(state.viewer);
t.after(() => {
selection.destroy();
state.clickHandler.destroy();
parts.namedMarkers.destroy();
parts.rendering.clearRendered();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
});
const assertSelected = (selected) => {
assert.equal(state.selectedId, selected ? record.id : null);
assert.equal(labels[0].selected, selected);
assert.equal(labels[0].variant, selected ? 'selected' : 'card');
assert.equal(
state.dataSource.entities.values.filter((e) => e.polygon).length,
selected ? 1 : 0,
);
};
assert.equal(selection.selectRecord(record.id), true);
assertSelected(true);
return { state, selection, assertSelected };
}
for (const layerId of ['flights', 'military', 'satellites']) {
for (const publishBeforeEvent of [false, true]) {
test(`${layerId} programmatic selection clears the installation card and fill (context first: ${publishBeforeEvent})`, (t) => {
const h = harness(t);
const subject = { id: 'other', layerId, origin: 'programmatic' };
if (publishBeforeEvent) context.selectTrackedSubjectContext(subject);
window.dispatchEvent(
new CustomEvent('gev:awareness-subject-selected', { detail: subject }),
);
h.assertSelected(false);
if (!publishBeforeEvent) context.selectTrackedSubjectContext(subject);
assert.equal(context.getSelectedEntityContext().id, 'other');
assert.equal(context.getSelectedEntityContext().layerId, layerId);
});
}
}
test('entity selection and clear still release the installation card and fill', (t) => {
const h = harness(t);
const other = {};
context.registerEntityContext(other, { id: 'other', layerId: 'vessels' });
context.selectEntityContext(other);
h.assertSelected(false);
h.selection.selectRecord('base');
context.clearSelectedEntityContextForLayer('military-installations');
h.assertSelected(false);
});
test('destroy removes all installation context listeners', (t) => {
const h = harness(t);
h.selection.destroy();
context.selectTrackedSubjectContext({ id: 'other', layerId: 'flights' });
for (const type of [
'gev:entity-selected',
'gev:entity-selection-cleared',
'gev:awareness-subject-selected',
]) {
window.dispatchEvent(
new CustomEvent(type, { detail: { id: 'other', layerId: 'flights' } }),
);
h.assertSelected(true);
}
});
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
function harness() {
const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]];
const record = { id: 'base', latitude: 30, longitude: -97, name: 'Base', class: 'base', footprints: [[ring, ring.map(([x,y]) => [x+0.001,y+0.001])], [ring]] };
const state = { enabled: true, selectedId: 'base', records: [record], recordById: new Map([['base', record]]), renderedKeys: new Map(), dataSource: new C.CustomDataSource(), viewer: { scene: { globe: { show: false } }, camera: {} } };
let listener, removed = 0;
const noop = () => {};
const services = { ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {}, render: { governorRequestRender: noop }, context: { removeEntityContextsForLayer: noop, clearSelectedEntityContextForLayer: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, maps: { subscribeMapStack(cb) { listener = cb; return () => { listener = null; removed++; }; } } };
const parts = { model: { colorFor: () => C.Color.ORANGE, installationSourceLabel: () => 'OpenStreetMap' }, viewport: { clearUnavailableRetry: noop }, ingestion: { setInstallationStatus: noop } };
parts.rendering = createRendering({ state, services, parts });
const lifecycle = createLifecycle({ state, services, parts, source: {} }).methods;
return { state, record, parts, lifecycle, changeSurface: () => listener(), removed: () => removed };
}
test('selected multipart footprints classify Google tiles with one constant translucent material and holes', () => {
const h = harness(), render = h.parts.rendering;
render.syncSelectionFill();
const fills = h.state.dataSource.entities.values.slice();
assert.equal(fills.length, 2);
for (const fill of fills) {
assert.equal(fill.installationId, 'base');
assert.equal(fill.polygon.classificationType.getValue(), C.ClassificationType.CESIUM_3D_TILE);
assert.equal(fill.polygon.height, undefined);
assert.equal(fill.polygon.extrudedHeight, undefined);
assert.equal(fill.polygon.material.isConstant, true);
assert.equal(fill.polygon.material.color.getValue().alpha, 0.22);
}
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
assert.equal(fills[0].polygon.material, fills[1].polygon.material);
for (let i = 0; i < 100; i++) render.syncSelectionFill();
assert.deepEqual(h.state.dataSource.entities.values, fills, 'repaints retain ground geometry');
h.state.selectedId = null; render.syncSelectionFill();
assert.equal(h.state.dataSource.entities.values.length, 0);
});
test('surface changes reclassify selected geometry and disable tears down fills and subscription', () => {
const h = harness();
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
h.state.viewer.scene.globe.show = true; h.changeSurface();
assert.ok(h.state.dataSource.entities.values.every(e => e.polygon.classificationType.getValue() === C.ClassificationType.BOTH));
h.lifecycle.disable();
assert.equal(h.state.dataSource.entities.values.length, 0);
assert.equal(h.removed(), 1);
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
assert.equal(h.state.dataSource.entities.values.length, 0, 'reenabling cannot revive a stale selection');
h.lifecycle.disable();
});
test('geometry rebuild and canonical alias migration preserve shared selection before pruning', async () => {
const context = await import('../../data/contextStore.js');
const previousWindow = globalThis.window;
globalThis.window = new EventTarget();
try {
const h = harness();
const services = { context, render: { governorRequestRender() {} }, ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {} };
const rendering = createRendering({ state: h.state, services, parts: h.parts });
const cleared = [];
window.addEventListener('gev:entity-selection-cleared', e => { cleared.push(e.detail); h.state.selectedId = null; });
rendering.renderRecords({ claimSelection: true });
const oldEntity = context.getSelectedEntityContext().entity;
h.record.latitude += 0.001;
rendering.renderRecords();
assert.equal(h.state.selectedId, 'base');
assert.equal(context.getSelectedEntityContext().id, 'base');
assert.notEqual(context.getSelectedEntityContext().entity, oldEntity);
const replacement = { ...h.record, id: 'canonical', aliasIds: ['base'] };
h.state.records = [replacement]; h.state.recordById = new Map([['canonical', replacement]]);
rendering.renderRecords();
assert.equal(h.state.selectedId, 'canonical');
assert.equal(context.getSelectedEntityContext().id, 'canonical');
assert.deepEqual(cleared, [], 'no transient eviction can tear down the selected card or fill');
rendering.clearRendered();
} finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; }
});
+183 -4
View File
@@ -1,3 +1,22 @@
import { waitForSignal } from '../../sources/requestDeadline.js';
import {
loadMilitaryNames,
militaryNamesInView,
nameMilitaryFragment,
} from '../../data/militaryNames.js';
import {
createOpenFreeMapSource,
decodeOpenFreeMapMilitaryTile,
} from '../../sources/openFreeMap.js';
import {
mergeMilitaryFragments,
militaryNameInView,
} from '../../sources/militaryTileGeometry.js';
import { tilesForBounds } from '../../data/tomtomTiles.js';
import {
isUnavailableCapability,
sourceResponseError,
} from '../../sources/capability.js';
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
/** Preserve legacy cache admission even when the explicit saturation flag is absent. */
@@ -8,11 +27,160 @@ export function installationResponseSaturated(payload) {
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
}
/**
* OpenMapTiles carries `landuse=military` only from z9; coarser tiles omit it,
* so a view that needs them would read as an empty (falsely all-clear) area.
*/
export const MILITARY_TILE_MIN_ZOOM = 9;
/** Per-view tile cap for installation tiles. */
export const MILITARY_TILE_MAX = 16;
/**
* Pick the finest installation tile zoom that fits the view, or null when even
* z9 would exceed the tile cap (the caller uses bundled named points).
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @returns {number|null} Tile zoom.
*/
export function installationTileZoom(box) {
let zoom =
Math.max(box.north - box.south, box.east - box.west) > 0.2 ? 10 : 12;
const fits = (z) =>
tilesForBounds(box, z, { maxTiles: MILITARY_TILE_MAX + 1 }).length <=
MILITARY_TILE_MAX;
while (zoom > MILITARY_TILE_MIN_ZOOM && !fits(zoom)) zoom -= 1;
return fits(zoom) ? zoom : null;
}
/**
* Square window around a context subject that fits the installation tile cap
* at the military minimum zoom: 100 km half-width at mid latitudes, shrinking
* toward the poles where z9 tiles are shorter.
* @param {{latitude:number, longitude:number}} anchor Subject position.
* @returns {{south:number, west:number, north:number, east:number,
* radiusM:number}|null} Bounded box, or null for an invalid anchor.
*/
export function installationAnchorBox(anchor) {
const { latitude, longitude } = anchor || {};
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 85 ||
Math.abs(longitude) > 180
)
return null;
for (let radiusM = 100_000; radiusM >= 10_000; radiusM *= 0.8) {
const latSpan = radiusM / 111_320;
const lonSpan =
latSpan / Math.max(0.05, Math.cos((latitude * Math.PI) / 180));
const box = {
south: Math.max(-85, latitude - latSpan),
north: Math.min(85, latitude + latSpan),
west: Math.max(-180, longitude - lonSpan),
east: Math.min(180, longitude + lonSpan),
};
if (
tilesForBounds(box, MILITARY_TILE_MIN_ZOOM, {
maxTiles: MILITARY_TILE_MAX + 1,
}).length <= MILITARY_TILE_MAX
)
return { ...box, radiusM: Math.round(radiusM) };
}
return null;
}
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
export function createInstallationSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
loadNames = loadMilitaryNames,
mapTiles = createOpenFreeMapSource({
fetchImpl: tileFetchImpl,
decode: decodeOpenFreeMapMilitaryTile,
}),
} = {}) {
let overpassUnavailable = false;
const installationIds = new Map();
async function getNamedSites(box, signal) {
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
signal?.throwIfAborted();
throw Object.assign(
new Error('Mapped names temporarily unavailable', { cause }),
{
failureReason: 'names_unavailable',
},
);
});
signal?.throwIfAborted();
const { records, count } = militaryNamesInView(names, box);
return {
records,
namedInView: count,
wide: true,
status: 'ready',
tileSource: true,
saturated: false,
droppedCount: 0,
source: 'OpenStreetMap',
};
}
async function getTileSites(box, signal) {
const zoom = installationTileZoom(box);
if (zoom === null) return getNamedSites(box, signal);
let names = null;
const namesJob = waitForSignal(loadNames(), signal)
.then((loaded) => {
names = loaded;
return loaded;
})
.catch(() => null);
let result;
try {
result = await mapTiles.fetchBounds(box, { zoom, signal });
} catch (error) {
if (error?.name === 'AbortError' || signal?.aborted) throw error;
// Name the tile source: an Overpass reason here would be untrue.
throw Object.assign(
error instanceof Error ? error : new Error(String(error)),
{
failureReason: 'tiles_unavailable',
},
);
}
signal?.throwIfAborted();
const snapshot = (names) => {
const fragments = result.tiles
.flatMap((tile) => tile.military)
.map((record) => nameMilitaryFragment(record, names));
const retrievedAt = new Date().toISOString();
return {
records: mergeMilitaryFragments(
fragments.slice(0, 2048),
installationIds,
).map((record) => ({
...militaryNameInView(record, box),
retrievedAt,
sources: record.sources.map((source) => ({ ...source, retrievedAt })),
})),
status: 'ready',
droppedCount: 0,
saturated: result.partial || fragments.length > 2048,
source: 'OpenStreetMap tiles',
tileSource: true,
};
};
const payload = snapshot(names);
if (!names)
payload.enrichment = namesJob.then((loaded) =>
loaded && !signal?.aborted ? snapshot(loaded) : null,
);
return payload;
}
return {
destroy() {
mapTiles.clear();
installationIds.clear();
},
async getMappedSites(box, { exact = false, signal } = {}) {
const { south, west, north, east } = box || {};
if (
@@ -20,14 +188,17 @@ export function createInstallationSource({
south < -90 ||
north > 90 ||
west < -180 ||
west > 180 ||
east < -180 ||
east > 180 ||
north <= south ||
east <= west ||
north - south > 10 ||
east - west > 10
east === west
)
throw new TypeError('A bounded installation viewport is required');
signal?.throwIfAborted();
if (east < west || north - south > 10 || east - west > 10)
return getNamedSites(box, signal);
if (overpassUnavailable) return getTileSites(box, signal);
const query = new URLSearchParams(
Object.entries({ south, west, north, east }).map(([key, value]) => [
key,
@@ -40,9 +211,17 @@ export function createInstallationSource({
});
const body = await response.json();
signal?.throwIfAborted();
if (isUnavailableCapability(body)) {
overpassUnavailable = true;
return getTileSites(box, signal);
}
if (!response.ok)
throw Object.assign(
new Error(body?.error || `Installation feed HTTP ${response.status}`),
sourceResponseError(
body,
response,
'Installation context unavailable',
),
{
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(
body?.reason,
+180 -2
View File
@@ -1,8 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInstallationSource } from './source.js';
import { readFileSync } from 'node:fs';
import { tilesForBounds } from '../../data/tomtomTiles.js';
import { installationFeedback } from '../../data/installationFeedback.js';
import {
createInstallationSource,
installationAnchorBox,
installationTileZoom,
MILITARY_TILE_MAX,
MILITARY_TILE_MIN_ZOOM,
} from './source.js';
import {
decodeOpenFreeMapMilitaryTile,
decodeOpenFreeMapTile,
} from '../../sources/openFreeMap.js';
const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 };
test('a wide-view pack failure names the unavailable data without an API fallback', async () => {
const source = createInstallationSource({
fetchImpl: async () => assert.fail('no API request for bundled names'),
loadNames: async () => {
throw new Error('asset unavailable');
},
});
await assert.rejects(
source.getMappedSites({ south: 20, west: -130, north: 60, east: -60 }),
(error) => {
assert.equal(error.failureReason, 'names_unavailable');
assert.equal(
installationFeedback({
status: 'unavailable',
failureReason: error.failureReason,
}),
'Mapped names temporarily unavailable',
);
return true;
},
);
});
test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => {
const calls = [];
const source = createInstallationSource({
@@ -14,7 +49,7 @@ test('mapped-site sources validate viewport bounds and preserve the exact retry
for (const invalid of [
null,
{ ...box, east: Infinity },
{ ...box, north: 90 },
{ ...box, north: 91 },
{ ...box, south: 31 },
])
await assert.rejects(
@@ -84,3 +119,146 @@ test('mapped-site sources normalize records and derive saturation from legacy ca
assert.equal(payload.status, 'stale');
assert.equal('elements' in payload, false);
});
test('installation tiles never drop below z9, where military areas exist', () => {
// OpenMapTiles omits landuse=military below z9; a z6-z8 view used to read as
// an empty, "Mapped sites loaded" area in Cockpit and wide follow views.
assert.equal(
installationTileZoom({
south: 30.3,
west: -97.8,
north: 30.33,
east: -97.74,
}),
12,
);
assert.equal(
installationTileZoom({ south: 30, west: -98, north: 31, east: -97 }),
10,
);
assert.equal(
installationTileZoom({ south: 30, west: -98.2, north: 31.4, east: -96.8 }),
9,
);
assert.equal(
installationTileZoom({ south: 28, west: -100, north: 32, east: -96 }),
null,
);
});
test('a Contacts window fits the tile cap at z9 from the tropics to the Arctic', () => {
for (const latitude of [0, 30.3125, 51.5, 60, 70]) {
const box = installationAnchorBox({ latitude, longitude: -97.765 });
assert.ok(box, `window at ${latitude}`);
assert.ok(
tilesForBounds(box, MILITARY_TILE_MIN_ZOOM, { maxTiles: 64 }).length <=
MILITARY_TILE_MAX,
);
assert.ok(box.south < latitude && box.north > latitude);
}
assert.equal(
installationAnchorBox({ latitude: 30.3125, longitude: -97.765 }).radiusM,
100_000,
);
assert.ok(
installationAnchorBox({ latitude: 60, longitude: 10 }).radiusM < 100_000,
);
assert.equal(
installationAnchorBox({ latitude: Number.NaN, longitude: 0 }),
null,
);
});
test('too-wide tile views use bundled named points without requesting tiles', async () => {
const fetched = [];
const source = createInstallationSource({
fetchImpl: async () =>
Response.json({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }),
mapTiles: {
clear() {},
async fetchBounds(box, { zoom }) {
fetched.push(zoom);
return { tiles: [], partial: false };
},
},
});
const wide = await source.getMappedSites({
south: 28,
west: -100,
north: 32,
east: -96,
});
assert.equal(wide.wide, true);
assert.equal(wide.status, 'ready');
assert.ok(wide.records.some((r) => r.name === 'Fort Hood'));
assert.ok(wide.records.every((r) => r.namedArea));
assert.deepEqual(
fetched,
[],
'no tile request for a view the tiles cannot answer',
);
await source.getMappedSites({
south: 30,
west: -98.2,
north: 31.4,
east: -96.8,
});
assert.deepEqual(fetched, [9]);
});
test('the military-only decoder keeps Camp Mabry and skips road geometry', () => {
const bytes = readFileSync(
new URL(
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
import.meta.url,
),
);
const full = decodeOpenFreeMapTile(bytes, 12, 935, 1685);
const military = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
assert.ok(full.roads.length > 0);
assert.equal(military.roads.length, 0);
assert.deepEqual(
military.military.map((record) => record.id),
full.military.map((record) => record.id),
);
});
test('installation API transports never receive vector-tile URLs', async (t) => {
const calls = [];
const tileFetch = async (url) => {
calls.push(url);
return url.endsWith('/planet')
? Response.json({
tiles: ['https://tiles.openfreemap.org/test/{z}/{x}/{y}.pbf'],
})
: new Response(
readFileSync(
new URL(
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
import.meta.url,
),
),
);
};
t.mock.method(globalThis, 'fetch', tileFetch);
for (const tileFetchImpl of [undefined, tileFetch]) {
const source = createInstallationSource({
tileFetchImpl,
fetchImpl: async (url) => {
assert.ok(url.startsWith('/api/'));
return Response.json({
code: 'OVERPASS_NOT_CONFIGURED',
retryable: false,
});
},
});
const data = await source.getMappedSites({
south: 30.3,
west: -97.77,
north: 30.32,
east: -97.75,
});
assert.ok(data.records.length);
}
assert.ok(calls.length >= 4);
});
+8
View File
@@ -32,6 +32,14 @@ export function createState({ services }) {
clickHandler: null,
timer: null,
googleSearchRequested: false,
/** Contacts subject position; when set, it replaces the camera viewport. */
contextAnchor: null,
/** Latest subject centre, independent of the retained tile-fetch anchor. */
contextPosition: null,
/** Coverage of the last successful load: viewport, or radius around a subject. */
coverage: { kind: 'viewport' },
/** Per-record geometry keys of the rendered entities, for in-place updates. */
renderedKeys: new Map(),
});
return state;
}
+50 -7
View File
@@ -1,7 +1,27 @@
import * as Cesium from 'cesium';
import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js';
import { REQUEST_DEBOUNCE_MS } from './policy.js';
import { installationAnchorBox } from './source.js';
export function createViewport({ state: layerState, services, parts, source }) {
/**
* The area this load describes: the window around a Contacts subject while
* one is set (a follow or Cockpit camera never settles and often looks at the
* horizon), otherwise the settled camera viewport.
* @param {object} viewer Cesium viewer.
* @returns {{box: object|null, coverage: object}} Query box and its coverage.
*/
function loadArea(viewer) {
const anchor = layerState.contextAnchor;
if (anchor) {
const box = installationAnchorBox(anchor);
if (box) {
const { radiusM, ...bounds } = box;
return { box: bounds, coverage: { kind: 'subject', radiusM } };
}
}
return { box: viewportBox(viewer), coverage: { kind: 'viewport' } };
}
function viewportBox(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle(
viewer.scene.globe.ellipsoid,
@@ -11,12 +31,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
const north = Cesium.Math.toDegrees(rectangle.north);
const west = Cesium.Math.toDegrees(rectangle.west);
const east = Cesium.Math.toDegrees(rectangle.east);
// Cross-dateline/global views require a zoom before a bounded request.
// Wide and dateline views use the bundled point index.
if (
!Number.isFinite(south + north + west + east) ||
east <= west ||
north - south > MAX_VIEWPORT_DEGREES ||
east - west > MAX_VIEWPORT_DEGREES
east === west ||
north <= south
)
return null;
return { south, west, north, east };
@@ -44,10 +63,13 @@ export function createViewport({ state: layerState, services, parts, source }) {
* zoom-out, or disable cancels it.
*/
function scheduleUnavailableRetry() {
function scheduleUnavailableRetry(retryAfterMs = 0) {
if (!layerState.enabled) return;
clearTimeout(layerState.retryTimer);
layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs);
layerState.retryDelayMs = Math.max(
retryAfterMs,
installationRetryDelayMs(layerState.retryDelayMs),
);
layerState.retryAt = Date.now() + layerState.retryDelayMs;
layerState.retryTimer = setTimeout(() => {
layerState.retryTimer = null;
@@ -66,15 +88,36 @@ export function createViewport({ state: layerState, services, parts, source }) {
function scheduleLoad() {
if (!layerState.enabled) return;
const { box, coverage } = loadArea(layerState.viewer);
const camera = layerState.viewer.camera;
const widePose =
coverage.kind === 'viewport' &&
box &&
(box.east < box.west ||
box.east - box.west > 10 ||
box.north - box.south > 10)
? [camera.positionWC, camera.directionWC]
.map((v) =>
v ? [v.x, v.y, v.z].map((n) => n.toFixed(4)).join(',') : '',
)
.join(':')
: '';
const key = `${coverage.kind}:${box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(6)).join(',') : 'wide'}:${widePose}`;
if (layerState.cameraLoadKey === key && !layerState.error) return;
layerState.cameraLoadKey = key;
layerState.cameraLoadOwner = {};
layerState.abort?.abort();
// A user-driven load supersedes any pending retry; the load reschedules on
// failure, so the backoff step is kept rather than reset.
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(layerState.timer);
layerState.timer = setTimeout(() => {
layerState.timer = null;
parts.ingestion.loadInstallations();
}, REQUEST_DEBOUNCE_MS);
}
return {
loadArea,
viewportBox,
installationRetryDelayMs,
scheduleUnavailableRetry,
+71 -41
View File
@@ -1,3 +1,4 @@
import { DOT_FADE_MS } from './retention.js';
import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js';
import { presetDotOutline } from '../../data/trafficPresetStyle.js';
import * as Cesium from 'cesium';
@@ -6,7 +7,6 @@ import {
SPEED_MPS,
MAX_DOTS,
JAM_DOT_FAR_SCALE,
JAM_DOT_DEPTH_PUNCH,
CREEP_MOVE_MS,
CREEP_STOP_MS,
CREEP_BURST,
@@ -129,11 +129,10 @@ export function createAnimation({
layerState._scratchLerp,
);
// Jam-viz density prototype: jam dots stay visible at city scale — a
// longer depth-test punch-through (single-sample road heights sit under
// the mesh at oblique views) and a higher far-scale floor. Sim dots and
// other buckets keep the shipped values.
// Grounded traffic respects building/terrain occlusion. The former
// depth override made distant roads look like vehicles crossing roofs.
const jamProminent = bucket === 'jam' && parts.style.jamDensityOn();
const id = layerState._nextDotId++;
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.clone(layerState._scratchLerp),
pixelSize,
@@ -151,8 +150,8 @@ export function createAnimation({
layerState._fadeTransFar,
0.0,
),
// visible through tiles only when very close (jam: city-scale punch)
disableDepthTestDistance: jamProminent ? JAM_DOT_DEPTH_PUNCH : 2000,
// Respect the sampled mesh at every viewing distance.
disableDepthTestDistance: 0,
// Preset dark halo (spread only when present — the keyless/normal
// path passes the exact shipped option set).
...(outlineSpec
@@ -161,7 +160,14 @@ export function createAnimation({
});
layerState._bucketCounts[bucket || 'sim'] += 1;
layerState._motion.added++;
layerState._dots.push({
id,
index: layerState._dots.length,
born: now,
retiring: 0,
nominalSize: pixelSize,
recycle: false,
point,
road,
bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse
@@ -188,11 +194,11 @@ export function createAnimation({
* Per-frame animation callback registered on `scene.preRender`.
*
* For every active dot:
* 1. Skip if currently paused by a simulated stop-light.
* 1. Ease heights and fade arrivals/departures, including paused dots.
* 2. Convert speed (m/s) to a parametric t-delta relative to the current
* segment's Cartesian distance.
* 3. Advance t in the dot's travel direction, handling segment boundary
* crossings and end-of-road reversals.
* crossings and faded end-of-road departures.
* 4. Linearly interpolate between the two bounding waypoints and update the
* point primitive's position.
*
@@ -207,55 +213,76 @@ export function createAnimation({
: 0.016;
layerState._lastAnimTime = now;
parts.retention.easeHeights(dt);
for (let i = 0; i < layerState._dots.length; i++) {
const dot = layerState._dots[i];
// Simulated stop-light pause — skip movement while timer is active
if (now < dot.stoppedUntil) continue;
if (dot.recycle) {
// A road-end departure is a new simulated vehicle at the entry, never
// a teleport attributed to the departed vehicle's stable identity.
dot.id = layerState._nextDotId++;
dot.segIdx = dot.direction > 0 ? 0 : dot.numSegments - 1;
dot.t = dot.direction > 0 ? 0 : 1;
dot.born = now;
dot.recycle = false;
layerState._motion.recycled++;
}
const remaining =
dot.direction > 0
? dot.segIdx === dot.numSegments - 1
? (1 - dot.t) * dot.segmentDist[dot.segIdx]
: Infinity
: dot.segIdx === 0
? dot.t * dot.segmentDist[0]
: Infinity;
const endpointFade = Math.min(1, remaining / Math.max(1, dot.mps * 0.25));
dot.point.pixelSize =
dot.nominalSize *
Math.max(
0,
Math.min(
1,
(now - dot.born) / DOT_FADE_MS,
dot.retiring ? 1 - (now - dot.retiring) / DOT_FADE_MS : 1,
endpointFade,
),
);
// Stop-and-go creep (jam-viz density prototype, live jam dots only):
// alternate short forward bursts with stops. The burst multiplier keeps
// the long-run average near the honest TomTom crawl speed.
let burst = 1;
let burst = now < dot.stoppedUntil ? 0 : 1;
if (dot.creep) {
if (now >= dot.creep.until) {
dot.creep.moving = !dot.creep.moving;
const [lo, hi] = dot.creep.moving ? CREEP_MOVE_MS : CREEP_STOP_MS;
dot.creep.until = now + lo + Math.random() * (hi - lo);
}
if (!dot.creep.moving) continue;
burst = CREEP_BURST;
if (!dot.creep.moving) burst = 0;
else burst *= CREEP_BURST;
}
// Convert m/s speed to parametric t-delta for the current segment length
const segLen = dot.segmentDist[dot.segIdx] || 1;
const tDelta = (dot.mps * burst * dt) / segLen;
// Advance parametric position along the road in the current direction
dot.t += tDelta * dot.direction;
// Handle forward segment boundary crossing (t >= 1.0)
if (dot.t >= 1.0) {
dot.t -= 1.0;
dot.segIdx++;
if (dot.segIdx >= dot.numSegments) {
// End of road: recycle to the road's entry with a small stagger —
// cars don't reverse at the end of a street (field-test round 1).
// Direction is preserved, so one-way flow stays legal.
dot.segIdx = 0;
dot.t = Math.random() * 0.3;
// Carry metres across bends, not a t fraction from a differently sized
// segment. Short segments cannot turn a slow car into a large jump.
let travel = dot.mps * burst * dt;
while (travel > 0) {
const length = Math.max(0.001, dot.segmentDist[dot.segIdx]);
const available = (dot.direction > 0 ? 1 - dot.t : dot.t) * length;
if (travel < available) {
dot.t += (travel / length) * dot.direction;
break;
}
maybeStopLight(dot, now);
} else if (dot.t <= 0.0) {
// Handle backward segment boundary crossing (t <= 0.0)
dot.t += 1.0;
dot.segIdx--;
if (dot.segIdx < 0) {
// Start of road (traveling backward): recycle to the far end.
dot.segIdx = dot.numSegments - 1;
dot.t = 1.0 - Math.random() * 0.3;
travel -= available;
const next = dot.segIdx + dot.direction;
if (next < 0 || next >= dot.numSegments) {
dot.t = dot.direction > 0 ? 1 : 0;
dot.point.pixelSize = 0;
dot.recycle = true;
break;
}
dot.segIdx = next;
dot.t = dot.direction > 0 ? 0 : 1;
maybeStopLight(dot, now);
if (now < dot.stoppedUntil) break;
}
// Lerp between pre-computed Cartesian3 waypoints (no trig needed).
@@ -302,6 +329,9 @@ export function createAnimation({
/** Remove all point primitives and reset dot/road arrays and counters. */
function clearDots() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
parts.retention.clear();
if (layerState._dots.length) layerState._motion.rebuilds++;
if (layerState._pointCollection) layerState._pointCollection.removeAll();
parts.rendering.removeHeatLines();
layerState._dots = [];
+172 -11
View File
@@ -1,3 +1,8 @@
import {
normalizeRoadMode,
resolveRoadMode,
TRAFFIC_ROAD_MODES,
} from './roadModes.js';
import {
trafficBucketTier,
trafficStyleProfile,
@@ -5,6 +10,33 @@ import {
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
/** One status line: feed, drawn road source, then degraded-coverage notes. */
function roadStatusLabel(feed) {
const source = layerState._roadSource;
if (layerState._roadError)
return `UNAVAILABLE · ${source} · Roads unavailable`;
const notes = [feed.loadingLabel];
if (!layerState._liveMode || feed.error) notes.push(`Roads: ${source}`);
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
if (
!layerState._liveMode &&
['tomtom', 'hybrid'].includes(layerState._roadMode)
)
notes.push(
layerState._flowStatusUnavailable
? `${layerState._roadMode === 'tomtom' ? 'TomTom roads' : 'Hybrid'} unavailable while the traffic service is unreachable`
: layerState._roadMode === 'tomtom'
? 'TomTom roads need a TomTom key'
: 'Hybrid needs a TomTom key',
);
if (layerState._surfacePending) notes.push('Local surface still loading');
if (layerState._roadWarning) notes.push(layerState._roadWarning);
else if (layerState._roadPartial) notes.push('Partial coverage');
if (layerState._detailError) notes.push('Detailed roads unavailable');
else if (layerState._detailLimited) notes.push('Reduced detail coverage');
return notes.join(' · ');
}
const { getFlowSessionStats } = source;
const methods = {
@@ -14,19 +46,48 @@ export function createControls({ state: layerState, services, parts, source }) {
icon: '🚗',
source: 'OpenStreetMap',
source: 'OpenStreetMap / TomTom',
/** @type {number} Zero — layer is self-managed via camera listener + preRender */
updateInterval: 0,
/**
* Update user-adjustable parameters (density and speed scaling).
* Update user-adjustable parameters (road source, density and speed scaling).
*
* @param {Object} [params]
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default.
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
* @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`.
*/
setParams(params = {}) {
setParams(params = {}, { origin } = {}) {
const previousDensity = layerState._densityScale;
const previousSpeed = layerState._speedScale;
if (
Object.hasOwn(params, 'roadMode') &&
(params.roadMode === null || normalizeRoadMode(params.roadMode))
) {
const explicit = ['user', 'voice', 'tool'].includes(origin);
const mode =
!explicit && layerState._roadModeUrlOverride
? layerState._roadModeUrlOverride
: params.roadMode;
if (explicit) layerState._roadModeUrlOverride = null;
if (mode !== layerState._roadMode) {
layerState._roadMode = mode;
// Redraw from scratch: a different source means different roads.
parts.ingestion.cancelActiveFetch();
clearTimeout(layerState._retryTimer);
layerState._retryTimer = null;
layerState._loadGeneration++;
layerState._fetching = false;
layerState._lastBounds = null;
layerState._lastViewCenter = null;
parts.animation.clearDots();
if (layerState._enabled)
parts.viewport.onCameraChanged({ immediate: true });
}
}
if (typeof params.densityScale === 'number') {
layerState._densityScale = Math.max(
0.2,
@@ -39,6 +100,23 @@ export function createControls({ state: layerState, services, parts, source }) {
Math.min(3.0, params.speedScale),
);
}
if (layerState._speedScale !== previousSpeed) {
const ratio = layerState._speedScale / previousSpeed;
for (const dot of layerState._dots) {
dot.baseMps *= ratio;
dot.mps *= ratio;
}
}
if (
layerState._densityScale !== previousDensity &&
layerState._enabled &&
layerState._roads.length
)
parts.rendering.renderRoadsForAltitude(
layerState._roads,
layerState._lastRenderAltitude,
'Density',
);
// Live-mode treatment of roads TomTom has no flow data for:
// 'sim' (default) keeps them as today's white ambient dots — colored =
// real data, white = simulation; 'hide' spawns nothing on them (strict
@@ -70,6 +148,7 @@ export function createControls({ state: layerState, services, parts, source }) {
*/
getParams() {
return {
roadMode: layerState._roadMode,
densityScale: layerState._densityScale,
speedScale: layerState._speedScale,
uncoveredRoads: layerState._uncoveredMode,
@@ -78,6 +157,28 @@ export function createControls({ state: layerState, services, parts, source }) {
};
},
/** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */
getRowControls() {
const selected =
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
const needsKey = layerState._liveMode ? '' : ' (needs a TomTom key)';
const titles = {
tomtom: `TomTom roads only, each with its live flow${needsKey}`,
osm: 'OpenStreetMap roads, with TomTom flow matched when available',
hybrid: `TomTom roads first; OpenStreetMap fills the rest, simulated${needsKey}`,
};
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: 'Hybrid' };
return {
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
})),
};
},
/**
* Return a sub-sampled list of active dot positions for detection overlays
* (e.g. CCTV bounding-box rendering).
@@ -106,7 +207,7 @@ export function createControls({ state: layerState, services, parts, source }) {
if (!pos) continue;
const entry = {
position: pos,
id: `VEH-${String(i).padStart(4, '0')}`,
id: `VEH-${String(layerState._dots[i].id).padStart(4, '0')}`,
type: 'VEH',
};
// Live mode: the detection bracket carries the congestion signal —
@@ -124,38 +225,94 @@ export function createControls({ state: layerState, services, parts, source }) {
return result;
},
/** Visit stable dot identities and their owning geometry without allocating snapshots. */
visitMotionDots(visitor) {
for (let i = 0; i < layerState._dots.length; i++) {
const dot = layerState._dots[i];
visitor(dot.id, dot.road.key, dot.point, dot.road);
}
},
/**
* Return current layer statistics for UI status chips.
* `mode` is the CONFIGURED source — 'live' (a TomTom key is present) or
* 'sim' (keyless simulation, which the manager renders as a FALLBACK chip);
* `error` carries this instant's health, so a live-configured layer whose
* flow feed went down reads DEGRADED with the reason instead of a stale
* LIVE coverage number. `flowCoveragePct` is matched roads / roads with any
* flow candidates (0–100 int); `tilesFetched` counts flow-tile requests
* LIVE coverage number. `flowCoveragePct` is shown dots on matched roads /
* all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests
* issued to the proxy this session (decode-cache hits excluded).
* @returns {{count:number, lastUpdate:number|null, loading:boolean,
* mode:'live'|'sim', error:string|null, flowCoveragePct:number,
* tilesFetched:number}}
*/
getLoadingDiagnostics() {
let movingWaypoints = 0,
maxResidualM = 0;
for (const road of layerState._roads) {
if (!road.heightMoving) continue;
for (let i = 0; i < road.waypoints.length; i++) {
const a = road.waypoints[i],
b = road.targetWaypoints[i];
const residual = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
if (residual >= 0.01) movingWaypoints++;
maxResidualM = Math.max(maxResidualM, residual);
}
}
return {
fetching: Boolean(layerState._fetching),
flowPending: layerState._flowPending,
surfaceRefining: Boolean(layerState._surfaceRefining),
populationPending: Boolean(layerState._populationPending),
heightPending: Boolean(layerState._heightPending),
generation: layerState._loadGeneration,
surfaceRevision: layerState._surfaceRevision,
lastPaintStage: layerState._lastPaintStage || null,
movingWaypoints,
maxResidualM,
};
},
getStats() {
// Outstanding flow work counts as loading: the paint race can leave a
// TomTom request in flight after the roads have settled, and the shared
// loading batch has to stay open long enough to announce its failure.
const loading = layerState._fetching || layerState._flowPending > 0;
const loading =
layerState._fetching ||
layerState._flowPending > 0 ||
Boolean(layerState._surfaceRefining) ||
Boolean(layerState._populationPending) ||
Boolean(layerState._heightPending);
const { free, slow, jam, sim } = layerState._bucketCounts;
const matched = free + slow + jam;
const flowCoveragePct =
matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0;
const feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
flowError: layerState._flowError,
coveragePct: layerState._flowCoveragePct,
coveragePct: flowCoveragePct,
statusUnavailable: layerState._flowStatusUnavailable,
roadSource: layerState._roadSource,
});
return {
count: layerState._count,
motion: { ...layerState._motion },
surfaceBudgetOmitted: layerState._surfaceBudgetOmitted || 0,
lastUpdate: layerState._lastUpdate,
loading,
mode: feed.mode,
error: layerState._roadError || feed.error,
flowCoveragePct: layerState._flowCoveragePct,
error:
layerState._roadError ||
layerState._roadWarning ||
layerState._detailError ||
feed.error,
roadBounds: layerState._lastBounds,
surfacePending: layerState._surfacePending || 0,
roadWarning: layerState._roadWarning || null,
detailError: layerState._detailError || null,
detailLimited: Boolean(layerState._detailLimited),
flowCoveragePct,
tilesFetched: getFlowSessionStats().tilesFetched,
...(TRAFFIC_TIMING_ENABLED
? { trafficTiming: parts.timing.getTrafficTimingDiagnostics() }
@@ -180,7 +337,11 @@ export function createControls({ state: layerState, services, parts, source }) {
// in one had better be the honest one. This is also where LIVE vs
// SIMULATED mode is surfaced, and it must never imply a live feed the
// layer does not have.
loadingLabel: feed.loadingLabel,
roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode),
roadModeRequested: layerState._roadMode,
roadSource: layerState._roadSource,
source: layerState._roadSource,
loadingLabel: roadStatusLabel(feed),
};
},
};
+210
View File
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createIngestion } from './ingestion.js';
import { createFlow } from './flow.js';
import { createRetention } from './retention.js';
import { createControls } from './controls.js';
import { createState } from './state.js';
const bounds = { west: 0, east: 0.01, south: 0, north: 0.01 };
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
function setup(source) {
const state = { _enabled: true, _roadMode: 'osm', _loadGeneration: 0,
_tileCache: new Map(), _parseRoads: (data) => data.roads };
const paints = [];
const ingestion = createIngestion({ state, source, services: {}, parts: {
viewport: { clampBounds: (b) => b, getBoundsCenter: () => ({ lat: 0, lon: 0 }), onCameraChanged() {} },
flow: { warmFlow: async () => [], applyFlowThenRender: async (roads) => { paints.push(roads); return true; } },
} });
return { state, ingestion, paints };
}
for (const stage of ['request', 'body']) test(`road ${stage} deadline releases an uncooperative transport and ignores late callbacks`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let options, resolveLate;
const stalled = new Promise((resolve) => { resolveLate = resolve; });
const h = setup({ requestRoads: (_bounds, opts) => {
options = opts;
return stage === 'request' ? stalled : Promise.resolve({ ok: true, json: () => stalled });
} });
const request = h.ingestion.loadRoadsForBounds(bounds, 450);
await flush();
t.mock.timers.tick(12001);
await request;
assert.equal(options.signal.aborted, true);
assert.equal(h.state._fetching, false);
assert.equal(h.state._retryAttempts, 1);
assert.ok(h.state._retryTimer);
const source = h.state._roadSource;
h.state._loadGeneration++;
h.state._fetching = true;
options.onTile({ roads: [{ coords: [] }], roadSource: 'late' });
resolveLate(stage === 'request' ? { ok: true, json: async () => ({ roads: [], roadSource: 'late' }) } : { roads: [], roadSource: 'late' });
await flush();
assert.equal(h.state._roadSource, source);
assert.equal(h.state._fetching, true, 'old completion cannot clear a new generation');
assert.equal(h.paints.length, 0);
h.ingestion.cancelActiveFetch();
});
test('detail deadline retains painted coarse roads and schedules bounded retry', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const coarse = [{ coords: [[0, 0], [0.01, 0.01]] }];
const h = setup({ requestRoads: async (_b, options) => options.majorOnly
? { ok: true, json: async () => ({ roads: coarse }) } : new Promise(() => {}) });
const request = h.ingestion.loadRoadsForBounds(bounds, 450);
await flush();
t.mock.timers.tick(20001);
await request;
assert.deepEqual(h.paints.at(-1), coarse);
assert.equal(h.state._fetching, false);
assert.equal(h.state._roadPartial, true);
assert.equal(h.state._retryAttempts, 1);
h.ingestion.cancelActiveFetch();
});
test('superseding a stalled pass aborts promptly without clearing newer loading', async () => {
const h = setup({ requestRoads: () => new Promise(() => {}) });
const old = h.ingestion.loadRoadsForBounds(bounds, 450);
const next = h.ingestion.loadRoadsForBounds(bounds, 450);
await old;
assert.equal(h.state._fetching, true);
h.state._enabled = false;
h.ingestion.cancelActiveFetch();
await next;
clearTimeout(h.state._retryTimer);
});
for (const y of [-175, -200, -300]) test(`off-screen camera-plane height correction settles without holding public loading (${y})`, () => {
const angle = 35 * Math.PI / 180;
const scene = {
canvas: { clientWidth: 1280, clientHeight: 800 },
frameState: { mode: C.SceneMode.SCENE3D },
camera: { viewMatrix: C.Matrix4.computeView(new C.Cartesian3(0, 0, 450),
new C.Cartesian3(0, Math.cos(angle), -Math.sin(angle)),
new C.Cartesian3(0, Math.sin(angle), Math.cos(angle)), C.Cartesian3.UNIT_X, new C.Matrix4()),
frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) },
};
const point = new C.Cartesian3(0, y, 100), target = new C.Cartesian3(0, y, 197);
const state = createState({ services: {} });
Object.assign(state, { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] });
const retention = createRetention({ state, parts: {} });
retention.easeHeights(1 / 60);
const controls = createControls({ state, services: {}, source: { getFlowSessionStats: () => ({}) }, parts: { model: { trafficFeedPresentation: () => ({}) } } });
assert.equal(controls.methods.getStats().loading, false);
for (let frame = 0; frame < 180; frame++) retention.easeHeights(1 / 60);
assert.equal(state._heightPending, false);
assert.equal(state._roads[0].heightMoving, false);
assert.ok(C.Cartesian3.distance(point, target) < 0.01);
assert.equal(controls.methods.getLoadingDiagnostics().movingWaypoints, 0);
});
test('visible corrections use the pixel limit without the off-screen world-speed cap', () => {
const angle = 35 * Math.PI / 180;
const scene = {
canvas: { clientWidth: 1280, clientHeight: 800 },
frameState: { mode: C.SceneMode.SCENE3D },
camera: { viewMatrix: C.Matrix4.computeView(new C.Cartesian3(0, 0, 450),
new C.Cartesian3(0, Math.cos(angle), -Math.sin(angle)),
new C.Cartesian3(0, Math.sin(angle), Math.cos(angle)), C.Cartesian3.UNIT_X, new C.Matrix4()),
frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) },
};
const point = new C.Cartesian3(0, 1000, 0), target = new C.Cartesian3(0, 1000, 300);
const state = { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] };
const retention = createRetention({ state, parts: {} });
let loadingFrames = 0, maxPixels = 0;
for (let frame = 0; frame < 180; frame++) {
const before = C.SceneTransforms.worldToWindowCoordinates(scene, point);
retention.easeHeights(1 / 60);
const after = C.SceneTransforms.worldToWindowCoordinates(scene, point);
if (before.y >= 0 && after.y >= 0) maxPixels = Math.max(maxPixels, C.Cartesian2.distance(before, after));
if (state._heightPending) loadingFrames++;
}
assert.ok(maxPixels <= 12.01, `visible correction: ${maxPixels} px`);
assert.ok(loadingFrames <= 66, `visible loading: ${loadingFrames / 60} s`);
assert.equal(state._roads[0].heightMoving, false);
});
for (const targetHeight of [200, 2000]) test(`visible height steps stay pixel-limited outside the margin or across the near plane (${targetHeight})`, () => {
const angle = 35 * Math.PI / 180;
const scene = {
canvas: { clientWidth: 1280, clientHeight: 800 },
frameState: { mode: C.SceneMode.SCENE3D },
camera: { viewMatrix: C.Matrix4.computeView(new C.Cartesian3(0, 0, 450),
new C.Cartesian3(0, Math.cos(angle), -Math.sin(angle)),
new C.Cartesian3(0, Math.sin(angle), Math.cos(angle)), C.Cartesian3.UNIT_X, new C.Matrix4()),
frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) },
};
const point = new C.Cartesian3(0, 50, 400), target = new C.Cartesian3(0, 50, targetHeight);
const state = { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] };
const before = C.SceneTransforms.worldToWindowCoordinates(scene, point);
assert.ok(before.y > 0 && before.y < 800);
const trial = C.Cartesian3.lerp(point, target, 0.2, new C.Cartesian3());
const trialEye = C.Matrix4.multiplyByPoint(scene.camera.viewMatrix, trial, new C.Cartesian3());
if (targetHeight === 200) {
assert.ok(trialEye.z < -1);
assert.ok(C.SceneTransforms.worldToWindowCoordinates(scene, trial).y > 900);
} else assert.ok(trialEye.z >= -1);
createRetention({ state, parts: {} }).easeHeights(1 / 60);
const after = C.SceneTransforms.worldToWindowCoordinates(scene, point);
const pixels = C.Cartesian2.distance(before, after);
assert.ok(pixels > 0 && pixels <= 12, `visible correction: ${pixels} px`);
assert.ok(C.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, new C.Cartesian3()).z < -1);
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);
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 paints = [];
Object.assign(state, { _enabled: true, _roadMode: 'osm', _parseRoads: (data) => data.roads,
_viewer: { scene: { globe: { show: true, getHeight: () => { 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() {} },
};
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();
});
+426 -82
View File
@@ -1,5 +1,16 @@
import { roadIdentity } from './retention.js';
import * as Cesium from 'cesium';
import {
prepareRoadSurfaces,
roadHeightStations,
trafficSurfaceReady,
} from './surface.js';
import { matchFlowToRoads } from '../../data/flowMatch.js';
import { TRAFFIC_TIMING_ENABLED, FLOW_RENDER_RACE_MS } from './policy.js';
import {
DOT_HEIGHT_OFFSET,
FAST_FETCH_ALTITUDE,
roadDotBudget,
} from './policy.js';
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
@@ -22,7 +33,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
function deriveTrafficFlowError(error) {
if (!error || error.name === 'AbortError') return null;
const message = String(error.message || error);
const status = Number(message.match(/HTTP (\d{3})/)?.[1]);
const status = Number.isFinite(error.status)
? error.status
: Number(message.match(/HTTP (\d{3})/)?.[1]);
if (status === 503) return 'TomTom key unavailable';
if (status === 429) return 'TomTom daily budget reached';
if (status === 502 || status === 504) return 'TomTom upstream unreachable';
@@ -39,23 +52,33 @@ export function createFlow({ state: layerState, services, parts, source }) {
* @returns {Promise<void>} Resolves when `_liveMode` is settled.
*/
function ensureFlowStatus() {
function ensureFlowStatus(signal) {
if (layerState._flowStatusSignal?.aborted)
layerState._flowStatusPromise = null;
if (!layerState._flowStatusPromise) {
layerState._flowStatusSignal = signal;
layerState._flowStatusPromise = source
.getStatus()
.getStatus({ signal })
.then((status) => {
if (layerState._flowStatusSignal === signal)
layerState._flowStatusSignal = null;
layerState._liveMode = Boolean(status?.hasKey);
layerState._flowStatusUnavailable = false;
layerState._flowStatusKnown = true;
if (layerState._liveMode) {
console.log('[Data:Traffic] TomTom key present — live flow mode');
registerDynamicCredit(layerState._viewer, TOMTOM_CREDIT);
}
})
.catch((e) => {
if (e?.name === 'AbortError') throw e;
if (layerState._flowStatusSignal === signal)
layerState._flowStatusSignal = null;
// Simulating because we could not ask, which is NOT the same as
// "server says no key" — getStats() distinguishes the two.
layerState._liveMode = false;
layerState._flowStatusUnavailable = true;
layerState._flowStatusKnown = true;
console.warn(
'[Data:Traffic] TomTom status unreachable — simulated traffic:',
e?.message || e,
@@ -82,49 +105,67 @@ export function createFlow({ state: layerState, services, parts, source }) {
* @returns {Promise<void>}
*/
async function applyFlowToRoads(roads, clamped, generation) {
async function applyFlowToRoads(roads, clamped, generation, signal) {
// Claim the work synchronously, before the first await, so `stats.loading`
// covers this request from the same tick the caller started it — the
// loading batch must not be able to close underneath an in-flight fetch.
layerState._flowPending += 1;
layerState._flowRoads = roads;
// Geometry survives in the session cache; congestion does not. Never paint
// an old match as current while a refresh is pending or has failed.
for (const road of roads) {
road.key ||= roadIdentity(road);
if (!road.directFlow) road.flow = null;
}
try {
if (!layerState._flowStatusPromise) return; // status check not started — sim mode
await layerState._flowStatusPromise;
if (!layerState._liveMode || !layerState._enabled) return;
if (generation !== layerState._loadGeneration) return;
if (generation !== layerState._loadGeneration || signal?.aborted) return;
if (!Array.isArray(roads) || roads.length === 0) return;
try {
// Cached paths reach here without a live controller; the fetch paths
// reuse theirs so one cancel covers both roads and flow.
if (!layerState._activeFetchAbort)
layerState._activeFetchAbort = new AbortController();
const segments = await fetchFlowForBounds(clamped, {
signal: layerState._activeFetchAbort.signal,
});
if (generation !== layerState._loadGeneration) return;
const { matches, matchedCount, candidateCount } = matchFlowToRoads(
roads,
segments,
);
for (let i = 0; i < roads.length; i++) {
roads[i].flow = matches[i];
const segments = await warmFlow(clamped, generation);
if (
generation !== layerState._loadGeneration ||
!layerState._enabled ||
signal?.aborted
)
return;
if (layerState._matchedFlowGeneration !== generation) {
layerState._matchedFlowGeneration = generation;
layerState._matchedFlow = new Map();
}
layerState._flowCoveragePct =
candidateCount > 0
? Math.round((matchedCount / candidateCount) * 100)
: 0;
layerState._flowError = null;
const memo = layerState._matchedFlow;
const matchable = roads.filter(
(road) =>
!road.directFlow && !road.simulatedOnly && !memo.has(road.key),
);
const { matches } = matchFlowToRoads(matchable, segments);
for (let i = 0; i < matchable.length; i++)
memo.set(matchable[i].key, matches[i]);
for (const road of roads)
if (!road.directFlow && !road.simulatedOnly)
road.flow = memo.get(road.key) || null;
if (layerState._flowRoads === roads) layerState._flowError = null;
} catch (e) {
if (e?.name === 'AbortError') return;
// Same guard the success path gets: a superseded request rejecting late
// (or after disable() cleared the state) must not restore a stale
// outage over newer good data.
if (generation !== layerState._loadGeneration || !layerState._enabled)
if (
generation !== layerState._loadGeneration ||
!layerState._enabled ||
signal?.aborted
)
return;
// Every covering tile failed: there is no live flow on screen. Drop the
// now-false coverage number and surface the reason through getStats().
layerState._flowError = deriveTrafficFlowError(e);
layerState._flowCoveragePct = 0;
if (layerState._flowRoads === roads)
layerState._flowError = deriveTrafficFlowError(e);
console.warn(
'[Data:Traffic] Flow fetch failed (sim colors remain):',
e?.message || e,
@@ -136,18 +177,22 @@ export function createFlow({ state: layerState, services, parts, source }) {
}
}
/**
* Race flow application against the paint deadline, render, and schedule an
* in-place recolor if flow lost the race.
* @param {Array} roads - Parsed road objects.
* @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds.
* @param {number} generation - `_loadGeneration` at call time.
* @param {number} altitude - Camera altitude in meters.
* @param {string} label - Render log label.
* @param {Object|null} [trace=null] - Development-only correlated load trace.
* @returns {Promise<boolean>} True if this generation rendered.
*/
// One flow acquisition per generation, started alongside road acquisition.
function warmFlow(clamped, generation) {
if (layerState._warmFlowGeneration === generation)
return layerState._warmFlow;
layerState._warmFlowGeneration = generation;
const signal = layerState._activeFetchAbort?.signal;
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
if (!layerState._liveMode || generation !== layerState._loadGeneration)
return [];
return fetchFlowForBounds(clamped, { signal });
});
layerState._warmFlow.catch(() => {});
return layerState._warmFlow;
}
/** Paint locally resolved roads incrementally; flow never delays surface work. */
async function applyFlowThenRender(
roads,
clamped,
@@ -155,61 +200,360 @@ export function createFlow({ state: layerState, services, parts, source }) {
altitude,
label,
trace = null,
majorPreview = false,
signal = layerState._activeFetchAbort?.signal,
) {
const state =
TRAFFIC_TIMING_ENABLED && trace
? parts.timing.trafficTimingRenderState(trace, label)
: null;
const flowRaceStart = state
? parts.timing.trafficTimingMark(state, 'flow-render-race-start', {
deadlineMs: FLOW_RENDER_RACE_MS,
})
: null;
const flowJob = applyFlowToRoads(roads, clamped, generation);
const outcome = await Promise.race([
flowJob.then(() => 'flow'),
new Promise((resolve) =>
setTimeout(() => resolve('timeout'), FLOW_RENDER_RACE_MS),
),
]);
if (state) {
const flowRaceEnd = parts.timing.trafficTimingMark(
state,
'flow-render-race-end',
{
deadlineMs: FLOW_RENDER_RACE_MS,
outcome,
},
);
parts.timing.trafficTimingMeasure(
'flow-render-race',
state,
flowRaceStart,
flowRaceEnd,
{
deadlineMs: FLOW_RENDER_RACE_MS,
outcome,
},
);
if (generation !== layerState._loadGeneration || signal?.aborted)
return false;
const revision = (layerState._surfaceRevision || 0) + 1;
layerState._surfaceRevision = revision;
const current = () =>
generation === layerState._loadGeneration &&
revision === layerState._surfaceRevision &&
!signal?.aborted;
if (!current()) return false;
// Once traffic exists, retain it while the concurrently acquired detail
// snapshot finishes. Repeated coarse/tile previews sample roads that are
// immediately replaced, delaying the final population after every move.
if (
altitude <= FAST_FETCH_ALTITUDE &&
layerState._roadSource !== 'TomTom' &&
(layerState._dots.length ||
(layerState._detailRoadsReady && layerState._motion?.added > 0)) &&
(majorPreview || label === 'Loaded tile' || label === 'Loaded major')
) {
layerState._retainedPreview = true;
return true;
}
if (generation !== layerState._loadGeneration) return false;
parts.rendering.renderRoadsForAltitude(roads, altitude, label, trace);
if (outcome === 'timeout') {
flowJob
.then(() => {
if (generation !== layerState._loadGeneration) return;
parts.model.recolorDotsInPlace(label);
})
.catch(() => {
/* applyFlowToRoads settles its own failures */
});
const preparedRoads =
layerState._preparedRoads || (layerState._preparedRoads = new Map());
const refining = label.includes('refined');
if (!refining) {
layerState._surfaceRefining = false;
layerState._surfaceRefineRemove?.();
layerState._surfaceRefineRemove = null;
}
return true;
if (!roads.length) {
// A streamed tile with no roads changes nothing on screen. A completed
// pass with none (TomTom roads only, where TomTom has no flow) is valid
// empty coverage: clear the old view and do not retry.
if (label === 'Loaded tile') return false;
parts.rendering.renderRoadsForAltitude([], altitude, label, trace);
return true;
}
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
// that local evidence to select the detail view at its real elevation.
const localHeights = [];
for (const prepared of preparedRoads.values()) {
const height =
Cesium.Cartographic.fromCartesian(prepared.waypoints[0]).height -
DOT_HEIGHT_OFFSET;
if (Number.isFinite(height) && Math.abs(height) <= 9000)
localHeights.push(height);
if (localHeights.length === 9) break;
}
if (localHeights.length) {
localHeights.sort((a, b) => a - b);
layerState._viewHeightEstimate =
localHeights[Math.floor(localHeights.length / 2)];
}
// A rendered center-depth pick follows the current city's elevation even
// while older coarse samples are being replaced. It is selection-only.
let depthHeight;
if (scene.pickPositionSupported && scene.canvas?.clientWidth) {
const probe = new Cesium.Cartesian2(
scene.canvas.clientWidth / 2,
scene.canvas.clientHeight * 0.6,
);
try {
const hit = scene.pickPosition(probe);
const height = hit && Cesium.Cartographic.fromCartesian(hit).height;
if (Number.isFinite(height) && Math.abs(height) <= 9000)
depthHeight = height;
} catch {
/* no rendered depth yet */
}
}
if (depthHeight !== undefined) layerState._viewHeightEstimate = depthHeight;
for (const road of roads) {
road.key ||= roadIdentity(road);
const prepared = !refining && preparedRoads.get(road.key);
if (prepared) {
for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(prepared.waypoints[i], road.waypoints[i]);
} else if (
(!road.surfaceReady || refining) &&
(localHeights.length || depthHeight !== undefined)
) {
road.surfaceReady = false;
// Opposite source directions may share waypoint arrays. Provisional
// selection must not overwrite an already measured sibling.
road.waypoints = road.waypoints.map((point) =>
Cesium.Cartesian3.clone(point),
);
for (let i = 0; i < road.coords.length; i++) {
const [lon, lat] = road.coords[i];
const floor = services.ground?.cachedGroundFloor?.(lat, lon);
const height =
Number.isFinite(floor) && Math.abs(floor) <= 9000
? Math.max(floor, layerState._viewHeightEstimate)
: layerState._viewHeightEstimate;
Cesium.Cartesian3.fromDegrees(
lon,
lat,
height + DOT_HEIGHT_OFFSET,
undefined,
road.waypoints[i],
);
}
}
}
const selected = parts.rendering
.visibleRoadsForAltitude(roads, altitude)
.filter((road) => {
const camera = layerState._viewer.camera?.positionWC;
const localHeight = Math.max(
0,
altitude - (layerState._viewHeightEstimate || 0),
);
const radius = clamped.coverage
? clamped.coverage.rangeKm * 1000 + 1500
: Math.max(1200, localHeight * 2);
if (
camera &&
road.waypoints.every(
(point) =>
Cesium.Cartesian3.distanceSquared(camera, point) >
radius * radius,
)
)
return false;
// Spend surface work on roads crossing the canvas, with a small pan margin.
if (!scene.canvas?.clientWidth) return true;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const point of road.waypoints) {
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
point,
);
if (!screen) continue;
minX = Math.min(minX, screen.x);
maxX = Math.max(maxX, screen.x);
minY = Math.min(minY, screen.y);
maxY = Math.max(maxY, screen.y);
}
road.coverageBand = maxY < scene.canvas.clientHeight / 2 ? 1 : 0;
if (clamped.coverage && camera) {
road.baseDensityWeight ??= road.densityWeight || 1;
road.densityWeight =
road.baseDensityWeight /
Math.max(
1,
Cesium.Cartesian3.distance(camera, road.waypoints[0]) / 1500,
);
}
return (
maxX >= -100 &&
minX <= scene.canvas.clientWidth + 100 &&
maxY >= -100 &&
minY <= scene.canvas.clientHeight + 100
);
});
const budgets = parts.model.allocateRoadDotBudgets(
selected,
altitude,
roadDotBudget(altitude),
);
const admitted = selected.filter((r, i) => budgets[i] > 0);
const camera = layerState._viewer.camera?.positionWC;
if (camera)
admitted.sort(
(a, b) =>
Cesium.Cartesian3.distanceSquared(camera, a.waypoints[0]) -
Cesium.Cartesian3.distanceSquared(camera, b.waypoints[0]),
);
// The wide pass is a fast preview when detailed tiles follow. Preparing
// the entire coarse graph only to replace it doubles surface work.
const preview =
label.includes('tile') ||
(altitude <= FAST_FETCH_ALTITUDE &&
layerState._roadSource !== 'TomTom' &&
(majorPreview || label.includes('major')));
if (preview) admitted.length = Math.min(admitted.length, 32);
// Mesh picks are synchronous GPU work. Bound the unique height stations
// as well as the dot count: a one-dot long road can otherwise cost dozens
// of picks. Nearest roads win, and the fixed coordinate budget gives cold
// and warm views the same deterministic coverage.
const stations = new Set();
const bounded = [];
const upper = clamped.coverage
? admitted.filter((r) => r.coverageBand === 1)
: [];
const lower = clamped.coverage
? admitted.filter((r) => r.coverageBand !== 1)
: admitted;
const ordered = [];
for (let i = 0; i < Math.max(upper.length, lower.length); i++) {
if (lower[i]) ordered.push(lower[i]);
if (upper[i]) ordered.push(upper[i]);
}
for (const road of ordered) {
const keys = roadHeightStations(road.coords).indices.map(
(i) =>
`${road.coords[i][0].toFixed(6)},${road.coords[i][1].toFixed(6)}`,
);
const fresh = [...new Set(keys)].filter((key) => !stations.has(key));
if (stations.size + fresh.length > 1000) continue;
for (const key of fresh) stations.add(key);
bounded.push(road);
}
layerState._surfaceBudgetOmitted = admitted.length - bounded.length;
admitted.length = 0;
admitted.push(...bounded);
const ready = [];
const unresolved = [];
for (const road of admitted) {
road.key ||= roadIdentity(road);
const prepared = !refining && preparedRoads.get(road.key);
if (prepared) {
for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(prepared.waypoints[i], road.waypoints[i]);
for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = prepared.segmentDist[i];
road.surfaceReady = true;
road.surfaceSettled = prepared.surfaceSettled;
ready.push(road);
} else unresolved.push(road);
}
let lastPaint = 0;
let painted = 0;
const paint = (complete = false) => {
if (!current() || !ready.length) return;
if (!complete && painted === ready.length) return;
parts.rendering.renderRoadsForAltitude(
complete ? ready : ready.slice(painted),
altitude,
label,
trace,
complete && !preview,
);
painted = ready.length;
lastPaint = performance.now();
};
paint();
const flowJob = applyFlowToRoads(admitted, clamped, generation, signal);
flowJob
.then(() => {
if (current()) parts.model.recolorDotsInPlace(label);
})
.catch(() => {});
const options = {
// A tileset can briefly report loaded before finer destination tiles
// start streaming. The one completed-mesh pass must replace those early
// cache entries even when camera distance stayed in the same band.
revalidate: refining,
onReady: (road) => {
if (!current()) return;
ready.push(road);
preparedRoads.set(road.key, road);
if (!lastPaint || performance.now() - lastPaint >= 120) paint();
},
};
let prepared = await prepareRoadSurfaces(
unresolved,
layerState._viewer.scene,
services.ground,
[layerState._pointCollection],
signal,
options,
);
paint();
// Missing mesh is a surface state, never an OpenFreeMap provider failure.
// Only completed snapshots retry locally. A provisional tile/major pass
// must never hold the detail fetch behind unresolved preview stations.
const deadline = performance.now() + 1500;
while (
!preview &&
!ready.length &&
prepared.pending.length &&
current() &&
performance.now() < deadline
) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!current()) return false;
prepared = await prepareRoadSurfaces(
prepared.pending,
layerState._viewer.scene,
services.ground,
[layerState._pointCollection],
signal,
options,
);
paint();
}
paint(true);
if (current()) layerState._surfacePending = prepared.pending.length;
if (
current() &&
!preview &&
!label.includes('refined') &&
scene.postRender &&
(!trafficSurfaceReady(scene) ||
prepared.pending.length ||
ready.some((road) => road.surfaceSettled === false))
) {
layerState._surfaceRefineRemove?.();
const stop = () => {
remove();
clearTimeout(timer);
if (layerState._surfaceRefineRemove === stop)
layerState._surfaceRefineRemove = null;
};
const remove = scene.postRender.addEventListener(() => {
if (!current()) {
stop();
return;
}
if (!trafficSurfaceReady(scene)) return;
stop();
// Revalidate only local samples taken at a coarser rendered LOD. This
// background upgrade never holds road acquisition or first paint.
layerState._surfaceRefining = true;
applyFlowThenRender(
roads,
clamped,
generation,
altitude,
`${label} refined`,
trace,
false,
signal,
)
.catch(() => {})
.finally(() => {
if (
generation === layerState._loadGeneration &&
layerState._surfaceRevision === revision + 1
)
layerState._surfaceRefining = false;
});
});
const timer = setTimeout(stop, 10000);
layerState._surfaceRefineRemove = stop;
}
return current() && ready.length > 0;
}
return {
deriveTrafficFlowError,
ensureFlowStatus,
applyFlowToRoads,
warmFlow,
applyFlowThenRender,
};
}
+16 -3
View File
@@ -8,7 +8,7 @@ import { VectorTile } from '@mapbox/vector-tile';
* (the TomTom key never reaches the browser) and decodes the Mapbox Vector
* Tile layer "Traffic flow" into plain lon/lat polylines with congestion
* attributes. Consumed by the traffic layer's live mode
* (`src/data/traffic.js` → `src/data/flowMatch.js`).
* for matching congestion onto OpenStreetMap roads.
*
* Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string,
* closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed
@@ -23,13 +23,15 @@ import { VectorTile } from '@mapbox/vector-tile';
*/
const FLOW_LAYER_NAME = 'Traffic flow';
export function decodeFlowTile(data, z, x, y) {
/** Decode flow geometry; strict mode distinguishes damaged tiles from valid empty coverage. */
export function decodeFlowTile(data, z, x, y, { strict = false } = {}) {
let layer;
try {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const tile = new VectorTile(new PbfReader(bytes));
layer = tile.layers[FLOW_LAYER_NAME];
} catch {
if (strict) throw new Error('Malformed TomTom flow tile');
return [];
}
if (!layer) return [];
@@ -63,7 +65,18 @@ export function decodeFlowTile(data, z, x, y) {
: [];
for (const coords of lines) {
if (!Array.isArray(coords) || coords.length < 2) continue;
segments.push({ coords, trafficLevel, roadType, closure });
segments.push({
coords,
trafficLevel,
roadType,
roadCategory: props.road_category ?? null,
roadSubcategory: props.road_subcategory ?? null,
closure,
coverage: props.traffic_road_coverage ?? null,
leftHandTraffic:
props.left_hand_traffic === true ||
props.left_hand_traffic === 'true',
});
}
}
return segments;
+42 -92
View File
@@ -1,97 +1,47 @@
import { tilesForBounds } from '../../data/tomtomTiles.js';
import { isDrivableFlowSegment } from './roadModes.js';
import { createVectorTileSource } from '../../sources/vectorTiles.js';
import { clipTileLine } from '../../sources/openFreeMap.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
/** Own one decoded flow cache and its session counters. */
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
export function createFlowTileSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const DECODE_CACHE_TTL_MS = 120_000;
/** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */
const DECODE_CACHE_MAX_ENTRIES = 64;
/**
* Decoded-tile cache keyed by "z/x/y".
* @type {Map<string, {at:number, segments:Array}>}
*/
const _decodeCache = new Map();
/** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */
let _tilesFetched = 0;
/** Insert into the decode cache with oldest-entry eviction. */
function cacheSet(key, entry) {
if (
!_decodeCache.has(key) &&
_decodeCache.size >= DECODE_CACHE_MAX_ENTRIES
) {
const oldest = _decodeCache.keys().next().value;
_decodeCache.delete(oldest);
}
_decodeCache.set(key, entry);
}
/**
* Fetch + decode all flow tiles covering the given bounds.
*
* Tiles are fetched from the local proxy in parallel; each decoded tile is
* cached in memory for 120 s (keyed z/x/y), so repeat calls for the same
* viewport are free. Partial tile failures return the segments that DID
* decode (last-good philosophy); the promise rejects only when every tile
* failed (e.g. keyless 503, aborted signal, proxy down).
*
* @param {{south:number, west:number, north:number, east:number}} bounds - Degrees.
* @param {Object} [opts]
* @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled).
* @param {number} [opts.zoom=12] - Flow tile zoom level.
* @returns {Promise<Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>>}
* Flat array of flow segments across all covering tiles.
*/
async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
signal?.throwIfAborted();
const tiles = tilesForBounds(bounds, zoom);
if (tiles.length === 0) return [];
const now = Date.now();
const results = await Promise.allSettled(
tiles.map(async ({ z, x, y }) => {
const key = `${z}/${x}/${y}`;
const cached = _decodeCache.get(key);
if (cached && now - cached.at < DECODE_CACHE_TTL_MS)
return cached.segments;
_tilesFetched += 1;
const res = await fetchImpl(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, {
signal,
});
if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`);
const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y);
signal?.throwIfAborted();
cacheSet(key, { at: Date.now(), segments });
return segments;
}),
);
signal?.throwIfAborted();
const fulfilled = results.filter((r) => r.status === 'fulfilled');
if (fulfilled.length === 0) {
throw results[0].reason instanceof Error
? results[0].reason
: new Error('flow fetch failed');
}
return fulfilled.flatMap((r) => r.value);
}
/**
* Session diagnostics for `getStats()` surfaces.
* @returns {{tilesFetched:number}} Count of tile requests issued to the proxy
* this session (decode-cache hits excluded).
*/
function getFlowSessionStats() {
return { tilesFetched: _tilesFetched };
}
/** Clear the decode cache (tests + layer teardown). Session stats persist. */
function resetFlowTileCache() {
_decodeCache.clear();
}
return { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache };
const tiles = createVectorTileSource({
template: '/api/tomtom/flow/{z}/{x}/{y}.pbf',
allowedOrigin: 'http://localhost',
decode: (bytes, z, x, y) =>
decodeFlowTile(bytes, z, x, y, { strict: true })
.filter(isDrivableFlowSegment)
.flatMap((segment) =>
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
...segment,
coords,
})),
),
fetchImpl,
ttlMs: 120_000,
maxTiles: 16,
});
let partial = false;
return {
async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
zoom,
signal,
tiles: bounds.coverage?.coarse,
});
partial = result.partial;
if (bounds.coverage) return result.tiles.flat();
return result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
};
}
+166
View File
@@ -0,0 +1,166 @@
import * as Cesium from 'cesium';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
export const RETICLE_RANGE_KM = 10;
export const RETICLE_TILE_CAP = 16;
export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`;
const cross = (a, b, c) =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
/** Convex ground envelope of the sampled screen square. */
export function convexFootprint(points) {
const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const half = (list) => {
const out = [];
for (const p of list) {
while (out.length > 1 && cross(out.at(-2), out.at(-1), p) <= 0) out.pop();
out.push(p);
}
return out;
};
return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)];
}
/** Select only tiles intersecting the convex footprint, ordered from the camera nadir. */
export function footprintTiles(polygon, zoom, near) {
if (polygon.length < 3) return [];
const bounds = {
west: Math.min(...polygon.map((p) => p[0])),
east: Math.max(...polygon.map((p) => p[0])),
south: Math.min(...polygon.map((p) => p[1])),
north: Math.max(...polygon.map((p) => p[1])),
};
const cos = Math.cos((near.lat * Math.PI) / 180);
return tilesForBounds(bounds, zoom, { maxTiles: 4096 })
.filter((t) => {
const b = tileToBBox(t.z, t.x, t.y);
const corners = [
[b.west, b.south],
[b.east, b.south],
[b.east, b.north],
[b.west, b.north],
];
for (let i = 0; i < polygon.length; i++)
if (
corners.every(
(p) =>
cross(polygon[i], polygon[(i + 1) % polygon.length], p) < -1e-12,
)
)
return false;
return true;
})
.sort((a, b) => {
const distance = (t) => {
const box = tileToBBox(t.z, t.x, t.y);
return (
((box.west + box.east) / 2 - near.lon) ** 2 * cos * cos +
((box.south + box.north) / 2 - near.lat) ** 2
);
};
return distance(a) - distance(b) || a.y - b.y || a.x - b.x;
});
}
/** Reusable camera scratch; sampled only on camera events, never in the animation loop. */
export function createReticleFootprint() {
const pixels = Array.from({ length: 25 }, () => new Cesium.Cartesian2());
const hits = Array.from({ length: 25 }, () => new Cesium.Cartesian3());
const carto = new Cesium.Cartographic();
const ray = new Cesium.Ray();
return (viewer, altitude) => {
const { camera, scene } = viewer;
if (!camera.getPickRay) return null;
const nadir = camera.positionCartographic;
const near = {
lat: Cesium.Math.toDegrees(nadir.latitude),
lon: Cesium.Math.toDegrees(nadir.longitude),
};
const width = scene.canvas.clientWidth || scene.canvas.width,
height = scene.canvas.clientHeight || scene.canvas.height;
const side = Math.min(width, height),
left = (width - side) / 2,
top = (height - side) / 2;
const points = [];
for (let row = 0; row < 5; row++)
for (let col = 0; col < 5; col++) {
const i = row * 5 + col,
p = pixels[i];
p.x = left + (side * col) / 4;
p.y = top + (side * row) / 4;
const hit = camera.pickEllipsoid(p, Cesium.Ellipsoid.WGS84, hits[i]);
let dx, dy;
if (hit) {
Cesium.Cartographic.fromCartesian(hit, Cesium.Ellipsoid.WGS84, carto);
dy = (Cesium.Math.toDegrees(carto.latitude) - near.lat) * 111.195;
let dlon = Cesium.Math.toDegrees(carto.longitude) - near.lon;
dlon -= 360 * Math.round(dlon / 360);
dx = dlon * 111.195 * Math.cos(nadir.latitude);
} else {
camera.getPickRay(p, ray);
const d = ray.direction;
dx =
-Math.sin(nadir.longitude) * d.x + Math.cos(nadir.longitude) * d.y;
dy =
-Math.sin(nadir.latitude) * Math.cos(nadir.longitude) * d.x -
Math.sin(nadir.latitude) * Math.sin(nadir.longitude) * d.y +
Math.cos(nadir.latitude) * d.z;
const length = Math.hypot(dx, dy) || 1;
dx = (dx / length) * RETICLE_RANGE_KM;
dy = (dy / length) * RETICLE_RANGE_KM;
}
points.push([dx, dy]);
}
let range = RETICLE_RANGE_KM,
polygon,
coarse;
do {
polygon = convexFootprint(
points.map(([dx, dy]) => {
const scale = Math.min(1, range / Math.max(1e-9, Math.hypot(dx, dy)));
return [
near.lon +
(dx * scale) /
(111.195 * Math.max(0.05, Math.cos(nadir.latitude))),
near.lat + (dy * scale) / 111.195,
];
}),
);
if (polygon.some((p) => Math.abs(p[0]) > 180 || Math.abs(p[1]) > 85))
return null;
coarse = footprintTiles(polygon, 12, near);
if (coarse.length <= RETICLE_TILE_CAP) break;
range *= 0.8;
} while (range > 0.5);
const detail =
altitude <= 4500
? footprintTiles(polygon, 14, near).slice(0, RETICLE_TILE_CAP)
: [];
const bounds = {
west: Math.min(...polygon.map((p) => p[0])),
east: Math.max(...polygon.map((p) => p[0])),
south: Math.min(...polygon.map((p) => p[1])),
north: Math.max(...polygon.map((p) => p[1])),
};
return {
...bounds,
coverage: {
polygon,
near,
rangeKm: range,
coarse,
detail,
viewKey: [
nadir.latitude,
nadir.longitude,
nadir.height / 10000,
camera.heading,
camera.pitch,
]
.map((v) => Number(v || 0).toFixed(7))
.join(','),
key: [...coarse, ...detail].map(tileKey).sort().join('|'),
},
};
};
}
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
convexFootprint,
footprintTiles,
createReticleFootprint,
} from './footprint.js';
import { tileToBBox, tilesForBounds } from '../../data/tomtomTiles.js';
import { outsideDetailTiles } from './source.js';
test('footprint selection excludes bbox corners and orders tiles nearest first', () => {
const polygon = convexFootprint([
[0, 0],
[0.15, 0],
[0, 0.15],
[0.01, 0.01],
]);
const tiles = footprintTiles(polygon, 12, { lat: 0, lon: 0 });
const bbox = tilesForBounds(
{ west: 0, east: 0.15, south: 0, north: 0.15 },
12,
);
assert.ok(tiles.length < bbox.length);
const b = tileToBBox(tiles[0].z, tiles[0].x, tiles[0].y);
assert.ok(b.west <= 0 && b.east >= 0 && b.south <= 0 && b.north >= 0);
});
test('oblique horizon bearings are bounded and tile caps hold at city latitudes', () => {
for (const [lat, lon] of [
[24.964, 55.037],
[30.2672, -97.7431],
[51.507, -0.128],
[70, 10],
]) {
const camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, 300),
heading: 0,
pitch: -Math.PI / 12,
pickEllipsoid(p, e, result) {
if (p.y < 300) return undefined;
return Cesium.Cartesian3.fromDegrees(
lon + (p.x - 640) / 100000,
lat + (800 - p.y) / 50000,
0,
e,
result,
);
},
getPickRay(p, ray) {
ray.direction.x =
-Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180);
ray.direction.y =
-Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180);
ray.direction.z = Math.cos((lat * Math.PI) / 180);
return ray;
},
};
const result = createReticleFootprint()(
{ camera, scene: { canvas: { width: 1280, height: 800 } } },
300,
);
assert.ok(result.coverage.coarse.length <= 16);
assert.ok(result.coverage.detail.length <= 16);
assert.ok(
result.north > lat + 0.04,
'upper footprint extends past old fixed box',
);
assert.ok(result.coverage.rangeKm <= 10);
}
});
test('coarse roads retain far segments but never duplicate a detailed tile core', () => {
const tile = { z: 14, x: 8192, y: 8192 },
b = tileToBBox(tile.z, tile.x, tile.y);
const y = (b.south + b.north) / 2;
const roads = outsideDetailTiles(
[
{
coordinates: [
[b.west - 0.01, y],
[b.east + 0.01, y],
],
type: 'primary',
},
],
[tile],
);
assert.equal(roads.length, 2);
assert.equal(roads[0].coordinates.at(-1)[0], b.west);
assert.equal(roads[1].coordinates[0][0], b.east);
});
+2
View File
@@ -9,6 +9,7 @@ import { createFlow } from './flow.js';
import { createRendering } from './rendering.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createRetention } from './retention.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
@@ -33,6 +34,7 @@ export function createTrafficLayer({ services, source }) {
parts.animation = createAnimation(context);
parts.viewport = createViewport(context);
parts.flow = createFlow(context);
parts.retention = createRetention(context);
parts.rendering = createRendering(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
+351 -67
View File
@@ -1,10 +1,47 @@
import * as Cesium from 'cesium';
import { requestWithDeadline } from '../../sources/requestDeadline.js';
import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
import { RoadRequestError, roadRequestError } from './source.js';
import {
isUnavailableCapability,
sourceResponseError,
} from '../../sources/capability.js';
import {
TRAFFIC_TIMING_ENABLED,
OVERPASS_URL,
TILE_CACHE_MAX_ENTRIES,
FAST_FETCH_ALTITUDE,
} from './policy.js';
import { RoadRequestError, roadRequestError } from './source.js';
/** Retain complete parsed snapshots under both an LRU entry cap and a byte budget. */
export function cacheRoadSnapshot(
entries,
key,
entry,
{ retain = true, maxBytes = 24 * 1024 * 1024 } = {},
) {
entries.delete(key);
if (!retain) return;
entry.cacheBytes = [entry.major, entry.full].reduce(
(sum, roads) =>
sum +
(roads || []).reduce(
(n, road) =>
n + 192 + (road.coords || road.coordinates || []).length * 96,
0,
),
0,
);
if (entry.cacheBytes > maxBytes) return;
entries.set(key, entry);
let bytes = 0;
for (const value of entries.values()) bytes += value.cacheBytes || 0;
while (entries.size > TILE_CACHE_MAX_ENTRIES || bytes > maxBytes) {
const oldest = entries.keys().next().value;
bytes -= entries.get(oldest).cacheBytes || 0;
entries.delete(oldest);
}
}
export function createIngestion({
state: layerState,
@@ -12,13 +49,10 @@ export function createIngestion({
parts,
source,
}) {
const { fetchFlowForBounds } = source;
/**
* Fetch road geometries from the Overpass API via the local proxy.
* Fetch road geometries from the selected vector tile source.
*
* Sends a POST with the query as form-encoded `data`. Supports
* AbortController signals so in-flight requests can be cancelled
* Supports AbortController signals so in-flight requests can be cancelled
* when the camera moves before the response arrives.
*
* @param {number} south - Southern latitude bound (degrees).
@@ -27,19 +61,55 @@ export function createIngestion({
* @param {number} east - Eastern longitude bound (degrees).
* @param {Object} [opts]
* @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes.
* @param {number} [opts.timeoutSec=25] - Server-side Overpass timeout.
* @param {number} [opts.timeoutSec=25] - Compatibility deadline for injected sources.
* @param {AbortSignal} [opts.signal] - Abort signal for cancellation.
* @param {Object|null} [trace=null] - Development-only correlated load trace.
* @returns {Promise<Object>} Parsed JSON response from Overpass.
* @returns {Promise<Object>} Parsed JSON response from vector tiles.
* @throws {Error} If the HTTP response status is not OK.
*/
async function fetchRoads(
function fetchRoads(south, west, north, east, options = {}, trace = null) {
const generation = layerState._loadGeneration;
return requestWithDeadline(
async (signal) => {
const invalidate = () => options.passAbort?.abort(signal.reason);
signal.addEventListener('abort', invalidate, { once: true });
try {
return await readRoads(
south,
west,
north,
east,
{
...options,
signal,
onTile: (data) => {
if (
!signal.aborted &&
generation === layerState._loadGeneration
)
options.onTile?.(data);
},
},
trace,
);
} catch (error) {
options.passAbort?.abort(error);
throw error;
} finally {
signal.removeEventListener('abort', invalidate);
}
},
{ signal: options.signal, timeoutMs: (options.timeoutSec ?? 25) * 1000 },
);
}
async function readRoads(
south,
west,
north,
east,
{ majorOnly = false, timeoutSec = 25, signal } = {},
{ majorOnly = false, timeoutSec = 25, signal, onTile } = {},
trace = null,
) {
const state =
@@ -64,11 +134,27 @@ export function createIngestion({
}
const response = await source.requestRoads(
{ south, west, north, east },
{ majorOnly, timeoutSec, signal },
{
coverage: layerState._lastBounds?.coverage,
majorOnly,
timeoutSec,
signal,
onTile,
roadMode: layerState._roadMode,
flowSnapshot: layerState._roadFlowSnapshot,
liveModeHint: () => layerState._liveMode,
},
);
if (!response.ok) {
throw roadRequestError(response.status);
throw Object.assign(
roadRequestError(response.status),
sourceResponseError(
(await response.json?.().catch(() => ({}))) ?? {},
response,
roadRequestError(response.status).message,
),
);
}
if (!state) {
@@ -76,6 +162,9 @@ export function createIngestion({
signal?.throwIfAborted();
if (!Array.isArray(data?.roads))
throw new Error('Malformed road snapshot');
layerState._roadSource = data.roadSource || 'OpenStreetMap';
layerState._roadWarning = data.roadWarning || null;
layerState._roadPartial = Boolean(data.partial);
return data;
}
@@ -120,12 +209,22 @@ export function createIngestion({
},
);
}
layerState._roadSource = data.roadSource || 'OpenStreetMap';
layerState._roadWarning = data.roadWarning || null;
layerState._roadPartial = Boolean(data.partial);
return data;
}
/** Abort any in-flight Overpass fetch and clear the controller reference. */
/** Abort any in-flight road fetch and clear the controller reference. */
function cancelActiveFetch() {
layerState._preparedRoads?.clear();
layerState._preparedRoads = null;
layerState._matchedFlow?.clear();
layerState._matchedFlowGeneration = null;
layerState._surfaceRefineRemove?.();
layerState._surfaceRefineRemove = null;
layerState._surfaceRefining = false;
if (layerState._activeFetchAbort) {
layerState._activeFetchAbort.abort();
layerState._activeFetchAbort = null;
@@ -140,7 +239,7 @@ export function createIngestion({
* 1. Check the tile cache (keyed by clamped bounding-box coordinates).
* - If a full road set is cached, render immediately and return.
* - If only major roads are cached, render those first.
* 2. Fetch major roads from Overpass (fast, small payload). Render.
* 2. Fetch major roads from vector tiles (fast, small payload). Render.
* 3. If altitude is low enough (< FAST_FETCH_ALTITUDE), fetch the full
* road graph (includes tertiary/residential). Render again to upgrade.
*
@@ -158,39 +257,69 @@ export function createIngestion({
// Increment generation to invalidate any in-flight responses from prior calls
const generation = ++layerState._loadGeneration;
cancelActiveFetch();
layerState._preparedRoads = new Map();
clearTimeout(layerState._retryTimer);
layerState._retryTimer = null;
layerState._flowPending = 0;
layerState._activeFetchAbort = new AbortController();
const requestSignal = layerState._activeFetchAbort.signal;
const clamped = parts.viewport.clampBounds(bounds);
// This single local estimate is only for visibility selection. Every road
// still needs measured height stations before any dots can be admitted.
// Zero-height provisional roads misproject badly in elevated cities.
layerState._viewHeightEstimate = 0;
const centerLat = (clamped.south + clamped.north) / 2;
const centerLon = (clamped.west + clamped.east) / 2;
const scene = layerState._viewer?.scene;
const location = Cesium.Cartographic.fromDegrees(centerLon, centerLat);
let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon);
if (!Number.isFinite(estimated)) {
try {
estimated = scene?.globe?.show
? scene.globe.getHeight?.(location)
: scene?.sampleHeightSupported
? scene.sampleHeight(location, [layerState._pointCollection])
: undefined;
} catch {
/* the local mesh can still be loading */
}
}
if (Number.isFinite(estimated) && Math.abs(estimated) <= 9000)
layerState._viewHeightEstimate = estimated;
// Only plain OpenStreetMap snapshots are cached here: TomTom and Hybrid
// roads carry flow, which expires, and recompose from the tile caches.
// Reads need the mode that will actually be drawn. It is known up front
// for an explicit OSM choice, or once the TomTom status probe has
// settled; before that (a session's first load) the cache is skipped
// rather than waiting on the probe. Writes are decided by the snapshot.
const cacheMode =
layerState._roadMode === 'osm'
? 'osm'
: layerState._flowStatusKnown
? resolveRoadMode(layerState._roadMode, layerState._liveMode)
: null;
// 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.coverage?.key ||
`${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`;
const retryKey = `${layerState._roadMode || 'auto'}:${cacheKey}`;
const retainSnapshot = (data, roads) =>
data?.roadMode === 'osm' &&
!layerState._roadPartial &&
roads.every((road) => !road.simulatedOnly && !road.directFlow);
if (cacheKey !== layerState._retryBoundsKey) {
layerState._retryBoundsKey = cacheKey;
if (retryKey !== layerState._retryBoundsKey) {
layerState._retryBoundsKey = retryKey;
layerState._retryDelayMs = 1500;
layerState._retryAttempts = 0;
layerState._roadRetryStopped = false;
}
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.
parts.flow.ensureFlowStatus().then(() => {
if (
layerState._liveMode &&
layerState._enabled &&
generation === layerState._loadGeneration
) {
fetchFlowForBounds(clamped, { signal: requestSignal }).catch(() => {
/* warm-up only */
});
}
});
layerState._roadWarning = null;
layerState._fetching = true;
// Only COMMIT these on success. Committing up-front means a failed Overpass
// Only COMMIT these on success. Committing up-front means a failed road
// fetch (rate-limited / feed down) still trips the overlap gate in
// onCameraChanged, so a stationary user never retries (H3/H5). Stage the
// prospective values and roll back if nothing rendered.
@@ -199,24 +328,106 @@ export function createIngestion({
layerState._lastBounds = clamped;
layerState._lastViewCenter = parts.viewport.getBoundsCenter(clamped);
let renderedSomething = false;
layerState._detailError = null;
layerState._detailLimited = false;
let retryable = true;
let tilePaint = Promise.resolve();
let streamed = [];
let majorPass = true;
let majorFallback = null;
layerState._retainedPreview = false;
layerState._detailRoadsReady = false;
let paintRevision = 0;
const onTile = (data, passSignal, preview) => {
if (generation !== layerState._loadGeneration || passSignal.aborted)
return;
const start = performance.now();
const parsed = layerState._parseRoads(data, trace);
phaseTiming('parse', start, { roads: parsed.length, incremental: true });
if (data.replace) streamed = parsed;
else streamed.push(...parsed);
const revision = ++paintRevision;
layerState._roadSource = data.roadSource || 'OpenStreetMap';
const snapshot = streamed.slice();
tilePaint = tilePaint.then(async () => {
if (
generation !== layerState._loadGeneration ||
revision !== paintRevision ||
passSignal.aborted
)
return;
renderedSomething =
(await parts.flow.applyFlowThenRender(
snapshot,
clamped,
generation,
altitude,
'Loaded tile',
trace,
preview,
passSignal,
)) || renderedSomething;
});
tilePaint.catch(() => {});
};
try {
let cache = layerState._tileCache.get(cacheKey);
if (!cache) {
// LRU eviction: drop the oldest entry when cache exceeds the cap
if (layerState._tileCache.size >= TILE_CACHE_MAX_ENTRIES) {
const oldest = layerState._tileCache.keys().next().value;
layerState._tileCache.delete(oldest);
}
cache = { major: null, full: null };
layerState._tileCache.set(cacheKey, cache);
layerState._roadFlowSnapshot = parts.flow
.warmFlow(clamped, generation)
.then(
(segments) => ({
segments,
hasKey: layerState._liveMode,
partial: source.getFlowSessionStats?.().partial,
}),
(error) => {
if (error?.name === 'AbortError') throw error;
const reason = parts.flow.deriveTrafficFlowError(error);
if (generation === layerState._loadGeneration)
layerState._flowError = reason;
return {
segments: [],
hasKey: layerState._liveMode,
error: reason,
};
},
);
layerState._roadFlowSnapshot.catch(() => {});
// Warm the exact detail footprint while the major preview is painted.
// This uses the same shared XYZ cache and cap, with no extra ring.
if (
altitude <= FAST_FETCH_ALTITUDE &&
layerState._roadMode !== 'tomtom'
) {
source
.requestOsmRoads?.(clamped, {
signal: requestSignal,
coverage: clamped.coverage,
})
.then(() => {
if (generation === layerState._loadGeneration)
layerState._detailRoadsReady = true;
})
.catch(() => {});
}
requestSignal.throwIfAborted();
// Until the probe settles only OpenStreetMap roads can be drawn.
layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm'];
let cache =
cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null;
if (cache) {
// LRU touch; cacheRoadSnapshot() inserts and evicts on write.
layerState._tileCache.delete(cacheKey);
layerState._tileCache.set(cacheKey, cache);
} else cache = { major: null, full: null };
// Fast path: full road set already cached — render and return.
// Flow is (re)applied even on cache hits: roads cache for the session,
// but congestion data has a 120s shelf life. The race renders within
// FLOW_RENDER_RACE_MS either way; late flow recolors in place.
// but congestion data has a 120s shelf life. Locally grounded roads
// paint independently; late flow recolors in place.
if (cache.full) {
layerState._roadPartial = false;
layerState._detailLimited = Boolean(cache.detailLimited);
renderedSomething = await parts.flow.applyFlowThenRender(
cache.full,
clamped,
@@ -230,21 +441,24 @@ export function createIngestion({
// Intermediate path: render cached major roads while fetching the rest
if (cache.major) {
if (
!(await parts.flow.applyFlowThenRender(
layerState._roadPartial = false;
renderedSomething =
(await parts.flow.applyFlowThenRender(
cache.major,
clamped,
generation,
altitude,
'Cache major',
trace,
))
)
)) || renderedSomething;
// An unresolved major preview must not starve locally usable detail.
if (generation !== layerState._loadGeneration || requestSignal.aborted)
return;
renderedSomething = true;
} else {
// Fetch major roads first (smaller payload, faster response)
console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`);
const passAbort = new AbortController();
const passSignal = AbortSignal.any([requestSignal, passAbort.signal]);
const majorData = await fetchRoads(
clamped.south,
clamped.west,
@@ -252,33 +466,52 @@ export function createIngestion({
clamped.east,
{
majorOnly: true,
onTile: (data) => onTile(data, passSignal, true),
timeoutSec: 12,
signal: requestSignal,
signal: passSignal,
passAbort,
},
trace,
);
// Discard stale response if a newer load was triggered while waiting
if (generation !== layerState._loadGeneration) return;
cache.major = layerState._parseRoads(majorData, trace);
if (
!(await parts.flow.applyFlowThenRender(
await tilePaint;
const parseStart = performance.now();
cache.major = streamed.length
? streamed
: layerState._parseRoads(majorData, trace);
phaseTiming('parse', parseStart, { roads: cache.major.length });
cacheRoadSnapshot(layerState._tileCache, cacheKey, cache, {
retain: retainSnapshot(majorData, cache.major),
});
renderedSomething =
(await parts.flow.applyFlowThenRender(
cache.major,
clamped,
generation,
altitude,
'Loaded major',
trace,
))
)
false,
passSignal,
)) || renderedSomething;
// An unresolved major preview must not starve locally usable detail.
if (generation !== layerState._loadGeneration || requestSignal.aborted)
return;
renderedSomething = true;
}
majorFallback = cache.major;
// TomTom roads come only from the z12 flow tiles already drawn.
if (layerState._roadSource === ROAD_SOURCE_LABELS.tomtom) return;
// At higher altitude, major roads provide sufficient motion density
if (altitude > FAST_FETCH_ALTITUDE) return;
streamed = [];
majorPass = false;
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
const passAbort = new AbortController();
const passSignal = AbortSignal.any([requestSignal, passAbort.signal]);
const fullData = await fetchRoads(
clamped.south,
clamped.west,
@@ -286,14 +519,26 @@ export function createIngestion({
clamped.east,
{
majorOnly: false,
onTile: (data) => onTile(data, passSignal, false),
timeoutSec: 20,
signal: requestSignal,
signal: passSignal,
passAbort,
},
trace,
);
if (generation !== layerState._loadGeneration) return;
cache.full = layerState._parseRoads(fullData, trace);
layerState._detailLimited = Boolean(fullData.detailLimited);
cache.detailLimited = layerState._detailLimited;
await tilePaint;
const parseStart = performance.now();
cache.full = streamed.length
? streamed
: layerState._parseRoads(fullData, trace);
phaseTiming('parse', parseStart, { roads: cache.full.length });
cacheRoadSnapshot(layerState._tileCache, cacheKey, cache, {
retain: retainSnapshot(fullData, cache.full),
});
if (
!(await parts.flow.applyFlowThenRender(
cache.full,
@@ -302,34 +547,72 @@ export function createIngestion({
altitude,
'Loaded full',
trace,
false,
passSignal,
))
)
return;
renderedSomething = true;
} catch (e) {
if (e?.name === 'AbortError') return;
if (generation === layerState._loadGeneration && !renderedSomething) {
// A refusal we classified carries its own line; anything else — a
// dropped connection, a malformed snapshot — keeps the general one,
// because `e.message` from the platform is not a sentence anyone
// should have to read off a panel.
// Retained dots let successful detail loads skip coarse surface work.
// If detail fails, ground the current destination's major roads now;
// never leave an old city's population masquerading as the fallback.
if (
!majorPass &&
layerState._retainedPreview &&
majorFallback &&
generation === layerState._loadGeneration
) {
try {
renderedSomething =
(await parts.flow.applyFlowThenRender(
majorFallback,
clamped,
generation,
altitude,
'Detail fallback',
trace,
)) || renderedSomething;
} catch (surfaceError) {
if (surfaceError?.name === 'AbortError') return;
}
}
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadSource =
ROAD_SOURCE_LABELS[
resolveRoadMode(layerState._roadMode, layerState._liveMode)
];
retryable = !isUnavailableCapability(e);
layerState._roadRetryStopped = !retryable;
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadError =
e instanceof RoadRequestError
? e.message
: 'Road data temporarily unavailable';
: roadRequestError(null, e).message;
if (generation === layerState._loadGeneration && renderedSomething) {
layerState._detailError = `Detailed roads unavailable — ${e instanceof RoadRequestError ? e.message : roadRequestError(null, e).message}`;
layerState._roadPartial = true;
}
console.warn('[Data:Traffic] Fetch error:', e);
} finally {
if (generation === layerState._loadGeneration) {
layerState._fetching = false;
// Roll back the bounds commit if this load rendered nothing (e.g. the
// Overpass fetch failed). Leaving them committed would make the overlap
// road fetch failed). Leaving them committed would make the overlap
// gate skip the retry while the user sits still. Guarded on generation so
// a superseding load's commit is not clobbered.
if (!renderedSomething) {
layerState._lastBounds = prevBounds;
layerState._lastViewCenter = prevViewCenter;
if (layerState._enabled) {
}
if (!renderedSomething || layerState._roadPartial) {
if (
layerState._enabled &&
retryable &&
layerState._retryAttempts < 3
) {
layerState._retryAttempts += 1;
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
parts.viewport.onCameraChanged();
@@ -341,6 +624,7 @@ export function createIngestion({
}
} else {
layerState._retryDelayMs = 1500;
layerState._retryAttempts = 0;
}
}
// Keep this generation's controller until superseded or disabled: flow
+19 -9
View File
@@ -1,3 +1,4 @@
import { observeTrafficSurface } from './surface.js';
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import {
@@ -40,7 +41,7 @@ export function createLifecycle({
layerState._densityScale = 1.0;
layerState._speedScale = 1.0;
layerState._lastViewCenter = null;
layerState._flowCoveragePct = 0;
layerState._flowRoads = null;
layerState._flowError = null;
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingCurrentAnchor = null;
@@ -76,14 +77,14 @@ export function createLifecycle({
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
enable(viewer) {
if (layerState._enabled) return;
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
holdContinuousRender('traffic'); // per-frame animator (perf wave 2)
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
// One status check per session decides sim vs live-TomTom mode.
parts.flow.ensureFlowStatus();
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
parts.animation.animate,
);
@@ -101,13 +102,13 @@ export function createLifecycle({
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._arrivalRemover = viewer.camera.moveEnd.addEventListener(() =>
parts.viewport.onCameraChanged({ immediate: true }),
);
claimCameraSensitivity(viewer.camera, 'traffic', 0.05);
// Kick off initial viewport check
parts.viewport.onCameraChanged();
// Enabling is explicit intent, not a camera gesture that needs settling.
parts.viewport.onCameraChanged({ immediate: true });
// Boot-order guard (field-test round 1: layer sat empty until the user
// moved): when the persisted layer state re-enables traffic during the
@@ -117,7 +118,12 @@ export function createLifecycle({
// failed first fetch left the viewport unloaded while parked.
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = setInterval(() => {
if (!layerState._enabled || layerState._lastUpdate) {
if (
!layerState._enabled ||
layerState._lastUpdate ||
layerState._roadRetryStopped ||
layerState._retryAttempts >= 3
) {
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
return;
@@ -135,6 +141,7 @@ export function createLifecycle({
*/
disable(viewer) {
layerState._enabled = false;
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
releaseContinuousRender('traffic');
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
@@ -148,11 +155,14 @@ export function createLifecycle({
layerState._flowPending = 0;
layerState._roadError = null;
parts.animation.clearDots();
layerState._lastBounds = null;
layerState._lastViewCenter = null;
// A stale outage from the last session would misreport a fresh enable —
// the next load re-derives feed health from real evidence.
layerState._flowError = null;
layerState._surfaceFrameRemover?.();
layerState._surfaceFrameRemover = null;
if (layerState._preRenderRemover) {
layerState._preRenderRemover();
layerState._preRenderRemover = null;
+85 -73
View File
@@ -1,15 +1,14 @@
import * as Cesium from 'cesium';
import { roadSurfaceChunks } from './surface.js';
import {
flowDensityMult,
flowBucket,
flowSpeedScale,
} from '../../data/trafficFlowStyle.js';
import {
MAX_WAYPOINTS_PER_ROAD,
DOT_HEIGHT_OFFSET,
DENSITY_MULT,
JAM_DOT_FAR_SCALE,
JAM_DOT_DEPTH_PUNCH,
} from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
@@ -23,57 +22,43 @@ export function createModel({ state: layerState, services, parts, source }) {
for (const road of roadData.roads) {
if (!road.coordinates || road.coordinates.length < 2) continue;
const rawCoords = road.coordinates;
for (const coords of roadSurfaceChunks(road.coordinates)) {
const type = road.type;
const oneway = road.oneway;
// Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation
const waypoints = coords.map(([lng, lat]) => {
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
const h =
(Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor
: layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h);
});
// Sub-sample long polylines: keep every Nth vertex to stay within budget
const simplifyStep =
rawCoords.length > MAX_WAYPOINTS_PER_ROAD
? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD)
: 1;
const coords = [];
for (let i = 0; i < rawCoords.length; i += simplifyStep) {
coords.push(rawCoords[i]);
// Pre-compute segment distances in meters for speed-to-t conversion
const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]),
);
}
for (const direction of oneway ? [oneway] : [1, -1])
roads.push({
densityWeight: road.densityWeight ?? (oneway ? 1 : 0.5),
drivable: road.drivable,
roadClass: road.roadClass,
roadProperties: road.roadProperties,
directFlow: road.directFlow,
simulatedOnly: road.simulatedOnly,
coords,
type,
oneway: direction,
waypoints,
segmentDist,
flow: road.flow || null,
});
}
// Ensure the original endpoint is always preserved
const last = rawCoords[rawCoords.length - 1];
const tail = coords[coords.length - 1];
if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) {
coords.push(last);
}
if (coords.length < 2) continue;
const type = road.type;
const oneway = road.oneway;
// Sample terrain height once at the road start to avoid per-vertex cost
let baseHeight = 0;
const firstCoord = coords[0];
if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) {
const carto = Cesium.Cartographic.fromDegrees(
firstCoord[0],
firstCoord[1],
);
const sampled = layerState._viewer.scene.sampleHeight(carto);
if (Number.isFinite(sampled)) baseHeight = sampled;
}
// Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation
const waypoints = coords.map(([lng, lat]) => {
const h = baseHeight + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h);
});
// Pre-compute segment distances in meters for speed-to-t conversion
const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]),
);
}
roads.push({ coords, type, oneway, waypoints, segmentDist });
}
return roads;
@@ -130,10 +115,14 @@ export function createModel({ state: layerState, services, parts, source }) {
const lengthM = estimateRoadLengthDeg(road.coords);
// Altitude-adaptive spacing: closer camera = denser dots
const clearance = Math.max(
0,
altitude - (layerState._viewHeightEstimate || 0),
);
let spacing;
if (altitude < 1000) spacing = 30;
else if (altitude < 3000) spacing = 80;
else if (altitude < 5000) spacing = 150;
if (clearance < 1000) spacing = 30;
else if (clearance < 3000) spacing = 80;
else if (clearance < 5000) spacing = 150;
else spacing = 250;
const mult =
@@ -142,7 +131,10 @@ export function createModel({ state: layerState, services, parts, source }) {
(flow
? flowDensityMult(flow.level, { jamBoost: parts.style.jamDensityOn() })
: 1);
return Math.max(1, Math.floor((lengthM / spacing) * mult));
return Math.max(
1,
Math.floor((lengthM / spacing) * mult * (road.densityWeight || 1)),
);
}
/**
@@ -236,8 +228,9 @@ export function createModel({ state: layerState, services, parts, source }) {
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
* @param {boolean} [input.fetching] - A viewport load is in flight.
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
* @param {number} [input.coveragePct] - Matched-road coverage, 0–100.
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
* @param {string} [input.roadSource] - Name of the geometry being drawn.
* @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}}
*/
@@ -247,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
flowError = null,
coveragePct = 0,
statusUnavailable = false,
roadSource = 'OpenStreetMap',
} = {}) {
// `mode` is the CONFIGURED source (live key present vs keyless), not this
// instant's health — health rides on `error`. The qa-traffic harness pins
@@ -260,13 +254,36 @@ export function createModel({ state: layerState, services, parts, source }) {
const degraded = `SIMULATED — ${flowError}`;
return { mode, error: degraded, loadingLabel: degraded };
}
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
if (liveMode && roadSource === 'TomTom') {
return {
mode,
error: null,
loadingLabel: fetching
? 'Syncing flow · Roads: TomTom'
: coveragePct > 0
? 'LIVE · Roads: TomTom · Roads without flow hidden'
: 'LIVE · Roads: TomTom · No flow roads in view',
};
}
if (liveMode && roadSource === 'TomTom + OpenStreetMap') {
return {
mode,
error: null,
loadingLabel: fetching
? `Syncing flow · Roads: ${roadSource}`
: `${coveragePct > 0 ? 'LIVE' : 'SIMULATED'} · Roads: ${roadSource} · Flow ${coveragePct}%`,
};
}
if (liveMode) {
return {
mode,
error: null,
loadingLabel: fetching
? 'syncing LIVE traffic flow'
: `LIVE · TomTom flow · ${coveragePct}% cov`,
? 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated'
: coveragePct > 0
? `LIVE · Roads: OpenStreetMap · Flow: TomTom · ${coveragePct}% cov${coveragePct < 100 ? ' · Unmatched: simulated' : ''}`
: 'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)',
};
}
// Keyless simulation — one terse line that names the mode and the remedy
@@ -295,29 +312,24 @@ export function createModel({ state: layerState, services, parts, source }) {
let closedDots = 0;
const now = Date.now();
for (const dot of layerState._dots) {
const flow = dot.road ? dot.road.flow : null;
if (flow?.closure) {
const flow = dot.road?.source ? dot.road.source.flow : dot.road?.flow;
dot.road.flow = flow;
if (flow?.closure || (!flow && layerState._uncoveredMode === 'hide')) {
dot.point.show = false;
dot.mps = 0;
closedDots += 1;
continue;
}
dot.point.show = true;
const bucket = flow ? flowBucket(flow.level) : null;
dot.bucket = bucket;
dot.point.color = bucket
? layerState._activeBucketColors[bucket]
: Cesium.Color.WHITE.withAlpha(0.85);
if (bucket === 'jam') {
dot.point.pixelSize =
parts.style.baseDotSize(dot.road?.type, bucket) +
1 +
parts.style.activeSizeDelta('jam');
} else if (bucket && parts.style.presetProfileActive()) {
// Preset profiles size-floor every bucket; the shipped normal path
// keeps its jam-only size touch (byte-identical behavior).
dot.point.pixelSize =
parts.style.baseDotSize(dot.road?.type, bucket) +
parts.style.activeSizeDelta(bucket);
}
dot.nominalSize =
parts.style.baseDotSize(dot.road?.type, bucket) +
(bucket === 'jam' ? 1 : 0) +
parts.style.activeSizeDelta(bucket);
// Late flow can move a dot between buckets — keep the preset halo in
// step (no-op writes under the normal profile, whose dots have none).
if (parts.style.presetProfileActive())
@@ -338,12 +350,12 @@ export function createModel({ state: layerState, services, parts, source }) {
layerState._fadeScaleFar,
JAM_DOT_FAR_SCALE,
);
dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH;
} else {
dot.creep = null;
}
layerState._bucketCounts[bucket || 'sim'] += 1;
}
layerState._count = layerState._dots.length - closedDots;
layerState._closedRoads = layerState._roads.reduce(
(n, r) => n + (r.flow?.closure ? 1 : 0),
0,
+635
View File
@@ -0,0 +1,635 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createState } from './state.js';
import { createStyle } from './style.js';
import { createModel } from './model.js';
import { createAnimation } from './animation.js';
import {
createRetention,
DOT_CHANGE_BUDGET,
roadIdentity,
} from './retention.js';
function setup(t) {
const frames = [];
let now = 10000;
t.mock.method(Date, 'now', () => now);
const beforeRAF = globalThis.requestAnimationFrame;
const beforeCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (fn) => (frames.push(fn), frames.length);
globalThis.cancelAnimationFrame = () => {
frames.length = 0;
};
t.after(() => {
globalThis.requestAnimationFrame = beforeRAF;
globalThis.cancelAnimationFrame = beforeCancel;
});
const state = createState({ services: {} });
state._enabled = true;
state._liveMode = true;
state._pointCollection = new C.PointPrimitiveCollection();
state._viewer = { scene: {} };
const parts = {
rendering: {
removeHeatLines() {},
rebuildHeatLines() {},
visibleRoadsForAltitude: (roads) => roads,
},
};
const ctx = { state, services: {}, parts, source: {} };
parts.style = createStyle(ctx);
parts.model = createModel(ctx);
parts.animation = createAnimation(ctx);
parts.retention = createRetention(ctx);
const roads = (offset = 0, height = 100) =>
parts.model
.parseRoads({
roads: [
{
type: 'primary',
oneway: 1,
coordinates: [
[offset, 0],
[offset + 0.003, 0],
[offset + 0.004, 0.001],
],
},
],
})
.map((road) => {
road.waypoints = road.coords.map(([lon, lat]) =>
C.Cartesian3.fromDegrees(lon, lat, height),
);
road.segmentDist = road.waypoints
.slice(1)
.map((p, i) => C.Cartesian3.distance(road.waypoints[i], p));
return road;
});
const frame = (ms = 16) => {
now += ms;
const fn = frames.shift();
fn?.();
parts.animation.animate();
};
const settle = () => {
for (let i = 0; i < 100 && frames.length; i++) frame();
};
t.after(() => {
parts.animation.clearDots();
state._pointCollection.destroy();
});
return { state, parts, roads, frame, settle, frames };
}
test('incremental tiles, reordered snapshots and flow changes retain existing dot identities and progress', (t) => {
const h = setup(t),
a = h.roads(),
b = h.roads(0.01);
h.parts.retention.reconcile(a, 450, true);
h.settle();
const before = h.state._dots.map((d) => ({
dot: d,
id: d.id,
point: d.point,
t: d.t,
segIdx: d.segIdx,
road: d.road,
}));
h.parts.retention.reconcile([...b, ...h.roads()], 450, true);
for (const p of before) {
assert.equal(p.dot.id, p.id);
assert.equal(p.dot.point, p.point);
assert.equal(p.dot.t, p.t);
assert.equal(p.dot.segIdx, p.segIdx);
assert.equal(p.dot.road, p.road);
}
const target = h.state._dots[0];
const oldSpeed = target.mps;
target.road.source.flow = { level: 0.2, closure: false };
h.parts.model.recolorDotsInPlace('late flow');
assert.equal(target.id, before[0].id);
assert.ok(target.mps < oldSpeed);
target.road.source.flow = { level: 0, closure: true };
h.parts.model.recolorDotsInPlace('closure');
assert.equal(target.mps, 0);
assert.equal(target.point.show, false);
target.road.source.flow = { level: 1, closure: false };
h.parts.model.recolorDotsInPlace('reopened');
assert.equal(target.point.show, true);
assert.equal(target.id, before[0].id);
assert.equal(h.state._motion.rebuilds, 0);
});
test('height refinement eases retained waypoints vertically without changing road or dot progress', (t) => {
const h = setup(t);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
const d = h.state._dots[0],
p = d.waypoints[0];
const start = C.Cartographic.fromCartesian(p);
const id = d.id,
progress = d.t;
h.parts.retention.reconcile(h.roads(0, 120), 450, true);
assert.equal(Math.round(C.Cartographic.fromCartesian(p).height), 100);
h.parts.retention.easeHeights(0.016);
const mid = C.Cartographic.fromCartesian(p);
assert.ok(mid.height > 100 && mid.height < 120);
assert.ok(Math.abs(mid.latitude - start.latitude) < 1e-12);
assert.ok(Math.abs(mid.longitude - start.longitude) < 1e-12);
assert.equal(d.id, id);
assert.equal(d.t, progress);
for (let i = 0; i < 100; i++) h.parts.retention.easeHeights(0.016);
assert.ok(Math.abs(C.Cartographic.fromCartesian(p).height - 120) < 0.01);
});
test('density and replacement changes are bounded; removed roads fade out and teardown cancels pending work', (t) => {
const h = setup(t);
h.parts.model.allocateRoadDotBudgets = (roads) => roads.map(() => 3000);
h.parts.retention.reconcile(h.roads(), 450, true);
assert.equal(h.state._dots.length, DOT_CHANGE_BUDGET);
let previous = h.state._dots.length;
for (let i = 0; i < 6; i++) {
h.frame();
assert.ok(h.state._dots.length - previous <= DOT_CHANGE_BUDGET);
previous = h.state._dots.length;
}
assert.equal(h.state._dots.length, 3000);
const oldIds = new Set(h.state._dots.map((d) => d.id));
h.parts.retention.reconcile(h.roads(0.01), 450, true);
h.frame();
assert.ok(h.state._dots.some((d) => d.retiring));
h.settle();
assert.equal(h.state._dots.length, 3000);
assert.ok(h.state._dots.every((d) => !oldIds.has(d.id)));
assert.equal(new Set(h.state._dots.map((d) => d.id)).size, 3000);
h.parts.animation.clearDots();
h.frame();
assert.equal(h.state._pointCollection.length, 0);
assert.equal(h.state._dots.length, 0);
assert.equal(h.frames.length, 0);
});
test('identity distinguishes sources and opposing directions but ignores flow and height', () => {
const road = {
type: 'primary',
oneway: 1,
coords: [
[1, 2],
[1.001, 2.001],
],
};
assert.equal(
roadIdentity(road),
roadIdentity({ ...road, flow: { level: 0 }, waypoints: [1] }),
);
assert.notEqual(roadIdentity(road), roadIdentity({ ...road, oneway: -1 }));
assert.notEqual(
roadIdentity(road),
roadIdentity({ ...road, directFlow: true }),
);
});
test('one-way departures fade before a new identity enters; segment crossings preserve metre speed', (t) => {
const h = setup(t);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
const d = h.state._dots[0];
d.mps = 10;
d.stoppedUntil = 0;
d.creep = null;
d.segIdx = d.numSegments - 1;
d.t = 0.99999;
const old = d.id;
h.frame(100);
assert.equal(d.id, old);
assert.equal(d.point.pixelSize, 0);
assert.equal(d.recycle, true);
h.frame(16);
assert.notEqual(d.id, old);
assert.equal(d.direction, 1);
assert.equal(d.segIdx, 0);
assert.equal(d.point.pixelSize, 0);
});
test('stopped vehicles still follow smooth height refinement without moving along the road', (t) => {
const h = setup(t);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
const d = h.state._dots[0];
d.stoppedUntil = Date.now() + 100000;
const before = C.Cartographic.fromCartesian(d.point.position).height;
const progress = d.t;
h.parts.retention.reconcile(h.roads(0, 120), 450, true);
h.frame();
assert.equal(d.t, progress);
const after = C.Cartographic.fromCartesian(d.point.position).height;
assert.ok(after > before && after - before <= 2.01);
});
test('flow closing a queued road cannot leave a population reconciliation loop', (t) => {
const h = setup(t);
h.parts.model.allocateRoadDotBudgets = (roads) => roads.map(() => 3000);
h.parts.retention.reconcile(h.roads(), 450, true);
h.state._roads[0].source.flow = { level: 0, closure: true };
h.settle();
assert.equal(h.state._dots.length, 0);
assert.equal(h.frames.length, 0);
assert.equal(h.state._populationPending, false);
});
test('street and city budgets are bounded and zoom changes do not increase the city population', async () => {
const { roadDotBudget } = await import('./policy.js');
assert.equal(roadDotBudget(450), 6000);
assert.equal(roadDotBudget(1000), 6000);
assert.equal(roadDotBudget(3000), 667);
assert.ok(roadDotBudget(1500) > roadDotBudget(3000));
assert.ok(roadDotBudget(8000) >= 400);
});
test('small previews merge without evicting other retained roads until a completed snapshot', (t) => {
const h = setup(t);
const a = h.roads(),
b = h.roads(0.01);
h.parts.retention.reconcile(a, 450, true);
h.settle();
const first = h.state._dots[0];
h.parts.retention.reconcile(b, 450, false);
h.settle();
assert.ok(h.state._dots.includes(first));
assert.equal(first.retiring, 0);
h.parts.retention.reconcile(b, 450, true);
h.settle();
assert.ok(!h.state._dots.includes(first));
assert.equal(h.state._motion.rebuilds, 0);
});
test('major previews merge and never retry missing mesh ahead of the detail pass', async () => {
const { createFlow } = await import('./flow.js');
for (const measured of [true, false]) {
let samples = 0;
const paints = [];
const scene = {
globe: {
show: true,
tilesLoaded: true,
getHeight: () => {
samples++;
return measured ? 100 : undefined;
},
},
};
const coords = [
[0, 0],
[0.001, 0],
];
const road = {
type: 'primary',
oneway: 1,
coords,
waypoints: coords.map(([lon, lat]) => C.Cartesian3.fromDegrees(lon, lat)),
segmentDist: [111],
};
const state = {
_dots: [],
_detailRoadsReady: true,
_motion: { added: 0 },
_viewer: { scene },
_enabled: true,
_loadGeneration: 1,
_roadSource: 'OpenStreetMap',
_flowPending: 0,
_activeFetchAbort: new AbortController(),
};
const flow = createFlow({
state,
services: { credits: { registerDynamicCredit() {} } },
source: {},
parts: {
model: {
allocateRoadDotBudgets: (roads) => roads.map(() => 1),
recolorDotsInPlace() {},
},
rendering: {
visibleRoadsForAltitude: (roads) => roads,
renderRoadsForAltitude: (...args) => paints.push(args),
},
},
});
await flow.applyFlowThenRender([road], {}, 1, 450, 'Loaded major');
assert.equal(
samples,
2,
'preview tries each station once, then lets detailed roads proceed',
);
assert.ok(
paints.every((args) => args[4] === false),
'provisional roads cannot retire a surviving road',
);
assert.equal(paints.length > 0, measured);
assert.equal(state._flowPending, 0);
state._dots = [{}];
const before = paints.length;
await flow.applyFlowThenRender([road], {}, 1, 450, 'Loaded major');
await flow.applyFlowThenRender([road], {}, 1, 450, 'Loaded tile');
assert.equal(
samples,
2,
'retained traffic skips redundant preview sampling',
);
assert.equal(
paints.length,
before,
'previews leave the retained population intact',
);
state._dots = [];
state._detailRoadsReady = true;
state._motion.added = 1;
await flow.applyFlowThenRender([road], {}, 1, 450, 'Loaded major');
assert.equal(
samples,
2,
'decoded detail can supersede a cold coarse preview',
);
assert.equal(paints.length, before);
}
});
test('unresolved major roads cannot prevent the detailed request', async () => {
const { createIngestion } = await import('./ingestion.js');
const requests = [],
paints = [];
const state = {
_enabled: true,
_roadMode: 'osm',
_loadGeneration: 0,
_tileCache: new Map(),
_parseRoads: (data) => data.roads,
};
const ingestion = createIngestion({
state,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 0, lon: 0 }),
},
flow: {
warmFlow: async () => [],
applyFlowThenRender: async (
_roads,
_bounds,
_generation,
_altitude,
label,
) => {
paints.push(label);
return label === 'Loaded full';
},
},
},
source: {
requestRoads: async (_bounds, options) => {
requests.push(options.majorOnly);
return {
ok: true,
json: async () => ({ roads: [], roadSource: 'OpenStreetMap' }),
};
},
},
});
await ingestion.loadRoadsForBounds(
{ west: 0, east: 0.01, south: 0, north: 0.01 },
450,
);
assert.deepEqual(requests, [true, false]);
assert.deepEqual(paints, ['Loaded major', 'Loaded full']);
assert.equal(state._fetching, false);
assert.equal(state._roadError, null);
});
test('measured preview heights select elevated detail roads before surface admission', async () => {
const { createFlow } = await import('./flow.js');
const selections = [];
const road = (lon) => ({
type: 'primary',
oneway: 1,
coords: [
[lon, 0],
[lon + 0.001, 0],
],
waypoints: [
C.Cartesian3.fromDegrees(lon, 0),
C.Cartesian3.fromDegrees(lon + 0.001, 0),
],
segmentDist: [111],
});
const scene = {
globe: { show: true, tilesLoaded: true, getHeight: () => 750 },
};
const state = {
_dots: [],
_viewer: { scene },
_enabled: true,
_loadGeneration: 1,
_roadSource: 'OpenStreetMap',
_flowPending: 0,
_viewHeightEstimate: 0,
_activeFetchAbort: new AbortController(),
};
const flow = createFlow({
state,
services: {
credits: { registerDynamicCredit() {} },
ground: { cachedGroundFloor: () => 50 },
},
source: {},
parts: {
model: {
allocateRoadDotBudgets: (roads) => roads.map(() => 1),
recolorDotsInPlace() {},
},
rendering: {
visibleRoadsForAltitude: (roads) => {
selections.push(
roads.map(
(r) => C.Cartographic.fromCartesian(r.waypoints[0]).height,
),
);
return roads;
},
renderRoadsForAltitude() {},
},
},
});
await flow.applyFlowThenRender([road(0)], {}, 1, 1250, 'Loaded major');
await flow.applyFlowThenRender(
[road(0), road(0.002)],
{},
1,
1250,
'Loaded full',
);
assert.ok(Math.abs(state._viewHeightEstimate - 750) < 0.01);
assert.ok(
selections.at(-1).every((height) => Math.abs(height - 753) < 0.01),
'both retained and new roads project at the measured local elevation',
);
});
test('street spacing follows clearance above local ground in elevated cities', (t) => {
const h = setup(t);
const road = h.roads()[0];
const high = h.parts.model.computeDotCount(road, 1250);
h.state._viewHeightEstimate = 750;
const street = h.parts.model.computeDotCount(road, 1250);
assert.equal(street, h.parts.model.computeDotCount(road, 500));
assert.ok(street > high);
});
test('provisional opposite-direction heights cannot overwrite a measured sibling during selection', async () => {
const { createFlow } = await import('./flow.js');
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 selections = [];
const scene = {
globe: {
show: true,
tilesLoaded: true,
getHeight: (c) => 750 + C.Math.toDegrees(c.longitude) * 10000,
},
};
const state = {
_dots: [],
_viewer: { scene },
_enabled: true,
_loadGeneration: 1,
_roadSource: 'OpenStreetMap',
_flowPending: 0,
_activeFetchAbort: new AbortController(),
};
const flow = createFlow({
state,
services: { credits: { registerDynamicCredit() {} } },
source: {},
parts: {
model: {
allocateRoadDotBudgets: (roads) => roads.map(() => 1),
recolorDotsInPlace() {},
},
rendering: {
visibleRoadsForAltitude: (roads) => {
selections.push(
roads.map((r) =>
r.waypoints.map((p) => C.Cartographic.fromCartesian(p).height),
),
);
return roads;
},
renderRoadsForAltitude() {},
},
},
});
await flow.applyFlowThenRender([road()], {}, 1, 1250, 'Loaded major');
const forward = road(),
reverse = { ...forward, oneway: -1 };
await flow.applyFlowThenRender(
[forward, reverse],
{},
1,
1250,
'Loaded full',
);
assert.ok(
Math.abs(selections.at(-1)[0][1] - 763) < 0.01,
'measured slope survives provisional sibling selection',
);
assert.notEqual(forward.waypoints, reverse.waypoints);
});
test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => {
const h = setup(t);
h.state._viewer.scene = {
canvas: { clientWidth: 1280, clientHeight: 2000 },
camera: {},
frameState: {},
};
const scratch = new Set();
t.mock.method(
C.SceneTransforms,
'worldToWindowCoordinates',
(_scene, point, result) => {
scratch.add(result);
result.x = 0;
result.y = C.Cartographic.fromCartesian(point).height * 10;
return result;
},
);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
const dot = h.state._dots[0],
id = dot.id;
h.parts.retention.reconcile(h.roads(0, 160), 450, true);
for (let i = 0; i < 150; i++) {
const before = C.Cartographic.fromCartesian(dot.waypoints[0]).height;
h.parts.retention.easeHeights(1 / 60);
const after = C.Cartographic.fromCartesian(dot.waypoints[0]).height;
assert.ok((after - before) * 10 <= 12.01);
}
assert.ok(
Math.abs(C.Cartographic.fromCartesian(dot.waypoints[0]).height - 160) <
0.01,
);
assert.equal(dot.id, id);
assert.equal(h.state._heightPending, false);
assert.equal(scratch.size, 2);
});
test('grounded vehicles respect mesh occlusion even after live congestion changes', (t) => {
const h = setup(t);
const roads = h.roads();
h.parts.retention.reconcile(roads, 450, true);
h.settle();
assert.ok(h.state._dots.length > 0);
assert.ok(h.state._dots.every((d) => d.point.disableDepthTestDistance === 0));
for (const road of roads) road.flow = { level: 0.01, closure: false };
h.parts.model.recolorDotsInPlace('late jam');
assert.ok(h.state._dots.every((d) => d.point.disableDepthTestDistance === 0));
});
test('retained road density follows the settled view without requiring a toggle', (t) => {
const h = setup(t),
initial = h.roads();
initial[0].densityWeight = 0.2;
h.parts.retention.reconcile(initial, 450, true);
h.settle();
const ids = new Set(h.state._dots.map((d) => d.id)),
before = h.state._dots.length;
const settled = h.roads();
settled[0].densityWeight = 1;
h.parts.retention.reconcile(settled, 450, true);
h.settle();
assert.ok(h.state._dots.length > before);
assert.equal(h.state._roads[0].densityWeight, 1);
assert.ok([...ids].every((id) => h.state._dots.some((d) => d.id === id)));
const count = h.state._dots.length;
h.parts.animation.clearDots();
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
assert.equal(
h.state._dots.length,
count,
'fresh toggle and retained view have the same budget',
);
});
+188 -14
View File
@@ -11,7 +11,7 @@ function deferred() {
return { promise, resolve };
}
function setup(t, requestRoads) {
function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
const camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(
@@ -44,6 +44,7 @@ function setup(t, requestRoads) {
camera,
scene: {
canvas: { width: 100, height: 100 },
globe: { show: true, tilesLoaded: true, getHeight: () => 0 },
preRender: new Cesium.Event(),
primitives: { add: (value) => value, remove: () => true },
},
@@ -55,7 +56,7 @@ function setup(t, requestRoads) {
},
source: {
requestRoads,
getStatus: async () => ({ hasKey: false }),
getStatus,
fetchFlowForBounds: async () => [],
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache() {},
@@ -204,7 +205,7 @@ test('parked failures back off and disabling cancels the scheduled retry', async
await tick(400);
assert.equal(calls, 1);
assert.equal(layer.getStats().loading, false);
assert.equal(layer.getStats().error, 'Road data temporarily unavailable');
assert.equal(layer.getStats().error, 'OpenFreeMap tiles unavailable');
await tick(1500);
await tick(400);
assert.equal(calls, 2);
@@ -217,38 +218,211 @@ test('parked failures back off and disabling cancels the scheduled retry', async
assert.equal(layer.getStats().error, null);
});
test('not-configured roads stop both parked retries and the enable-time kick', async (t) => {
let requests = 0;
const h = setup(t, async () => {
requests++;
return {
ok: false,
status: 503,
headers: new Headers(),
json: async () => ({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }),
};
});
h.layer.enable(h.viewer);
await h.tick(400);
await h.tick(60_000);
assert.equal(requests, 1);
assert.equal(h.layer.getStats().loading, false);
assert.match(h.layer.getStats().loadingLabel, /UNAVAILABLE/);
});
test('a declined road request reaches the row as the reason, not as a shrug', async (t) => {
// Issue #661: with every public Overpass mirror refusing a network, the row
// read "Road data temporarily unavailable" while the console already held
// "Overpass API returned 406". The reporter went looking for a bad TomTom
// key — the other half of this layer, and never the problem. The status the
// code already has must survive the trip to the panel.
let status = 406;
const { layer, viewer, tick } = setup(t, async () => ({ ok: false, status }));
layer.enable(viewer);
await tick(400);
assert.equal(
layer.getStats().error,
'Overpass refused the road query (HTTP 406)',
'OpenFreeMap tiles unavailable (HTTP 406)',
);
status = 429;
for (let i = 0; i < 20; i++) await tick(1500);
assert.equal(
layer.getStats().error,
'Overpass rate-limited',
'OpenFreeMap tiles rate-limited',
'a rate limit is a different instruction to the reader than a refusal',
);
});
test('an unclassified road failure keeps the general line', async (t) => {
// The complement of the test above, and the reason this is not just
// `e.message`: a dropped socket throws whatever the platform felt like
// saying, and "Failed to fetch" on a panel row helps nobody.
const { layer, viewer, tick } = setup(t, async () => {
throw new Error('socket hang up');
});
layer.enable(viewer);
await tick(400);
assert.equal(layer.getStats().error, 'Road data temporarily unavailable');
assert.equal(layer.getStats().error, 'OpenFreeMap tiles unavailable');
});
test('a stalled TomTom status never blocks road acquisition or first simulated dots', async (t) => {
let calls = 0;
const { layer, viewer, tick } = setup(
t,
async (box) => {
calls++;
return roads(box);
},
() => new Promise(() => {}),
);
// The source contract is exercised independently of status by the production
// load path; the real source status cancellation case is in source.test.
layer.enable(viewer);
await tick(400);
await tick(2000);
assert.ok(calls > 0);
assert.ok(layer.getStats().count > 0);
});
test('explicit enable starts roads without the camera debounce', async (t) => {
let calls = 0;
const { layer, viewer, tick } = setup(t, async (box) => {
calls++;
return roads(box);
});
layer.enable(viewer);
await tick(0);
assert.ok(calls > 0, 'the first load starts before 320 ms');
});
test('moveEnd starts the destination request without another gesture debounce', async (t) => {
let calls = 0;
const { layer, viewer, move, tick } = setup(t, async (bounds) => {
calls++;
return roads(bounds);
});
layer.enable(viewer);
await tick(400);
const before = calls;
move(-0.1276, 51.5072);
await tick(0);
assert.ok(calls > before, 'arrival loads in the next task, before 320 ms');
});
test('traffic surface observers belong to enable and leave no listener after disable or destroy', async (t) => {
const { layer, viewer, tick } = setup(t, async (bounds) => roads(bounds));
viewer.camera.viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
viewer.camera.frustum = {
projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
};
viewer.scene.camera = viewer.camera;
viewer.scene.postRender = new Cesium.Event();
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
for (let i = 0; i < 3; i++) {
layer.enable(viewer);
layer.enable(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 1);
viewer.scene.postRender.raiseEvent();
await tick(1);
layer.disable(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
await tick(5000);
assert.equal(
viewer.scene.postRender.numberOfListeners,
0,
'late work cannot resubscribe',
);
}
layer.enable(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 1);
layer.destroy(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
});
test('coverage changes at the same center reload and disabling invalidates coverage without dropping source caches', async (t) => {
const calls = [];
const { layer, viewer, tick } = setup(t, async (bounds) => {
calls.push(bounds);
return roads(bounds);
});
layer.enable(viewer);
await tick(1000);
await tick(2000);
assert.ok(layer.getStats().count > 0);
const before = calls.length;
layer.disable(viewer);
layer.enable(viewer);
await tick(1000);
await tick(2000);
assert.ok(layer.getStats().count > 0, 'enable must repaint the same view');
assert.ok(calls.length >= before);
});
test('a settled footprint loads new tiles even when the camera centre stays put', async (t) => {
const seen = [];
const { layer, viewer, tick } = setup(
t,
async (bounds, { majorOnly, coverage }) => {
if (!majorOnly) seen.push({ ...bounds, coverage });
return roads(bounds);
},
);
layer.setParams({ roadMode: 'osm' });
let reach = 0.005;
const camera = viewer.camera;
camera.getPickRay = () => null;
camera.heading = 0;
camera.pitch = -Math.PI / 2;
camera.pickEllipsoid = (pixel, ellipsoid, result) =>
Cesium.Cartesian3.fromDegrees(
-97.744 + (pixel.x - 50) / 10000,
30.267 + (pixel.y / 100) * reach,
0,
ellipsoid,
result,
);
layer.enable(viewer);
await tick(400);
assert.equal(seen.length, 1);
const firstKey = seen[0].coverage.key;
camera.changed.raiseEvent();
// The final flight step occurs below the changed-event threshold.
reach = 0.08;
camera.pitch = -Math.PI / 12;
await tick(400);
assert.equal(seen.length, 2);
assert.notEqual(seen[1].coverage.key, firstKey);
assert.ok(
seen[1].north > 30.33,
'sample the final view when the debounce fires',
);
camera.moveEnd.raiseEvent();
await tick(400);
assert.equal(
seen.length,
2,
'an identical settled footprint does not reload',
);
});
test('partial coverage retries while coarse roads render, then stops after three retries', async (t) => {
let calls = 0;
const { layer, viewer, tick } = setup(t, async (bounds) => {
calls++;
const response = roads(bounds);
const data = await response.json();
return { ok: true, json: async () => ({ ...data, partial: true }) };
});
layer.enable(viewer);
await tick(400);
await tick(0);
assert.ok(layer.getStats().count > 0);
const initial = calls;
for (let i = 0; i < 12; i++) {
await tick(30000);
await tick(400);
}
assert.equal(calls, initial * 4, 'initial load plus three bounded retries');
assert.ok(layer.getStats().count > 0);
});
+11 -39
View File
@@ -1,36 +1,11 @@
import * as Cesium from 'cesium';
/**
* @file Street Traffic — animated dots along OSM road polylines, colored by
* live TomTom congestion when a key is configured.
*
* Road geometry: OSM Overpass API (free, no auth). Fetches road polylines for
* the camera viewport, spawns PointPrimitives that lerp along pre-computed
* Cartesian3 waypoints. Camera-gated: only active below ~8 km altitude.
*
* Two modes (decided once per session via `/api/tomtom/status`):
* - `sim` (keyless default): white dots at hardcoded per-road-class speeds —
* the original simulation, byte-identical behavior.
* - `live`: TomTom flow tiles (`flowTiles.js`) are matched onto the same
* Overpass roads (`flowMatch.js`); matched roads color/slow/densify their
* dots by real congestion (`trafficFlowStyle.js`), closed roads spawn no
* dots, and unmatched roads keep the simulated white.
*
* Architecture overview:
* - Camera-change listener triggers debounced road fetching per viewport tile.
* - Fetch bounds center on the camera's look-at point (`trafficBounds.js`, C4).
* - Roads are fetched in two passes: major-only (fast) then full graph (detailed).
* - Fetched tiles are cached by clamped bounding-box key to avoid re-fetching.
* - Dot budget allocation distributes a hard cap fairly across visible roads.
* - Each dot lerps along pre-computed Cartesian3 waypoints every preRender frame.
*
* @module data/traffic
* @file Street Traffic selects TomTom, OpenMapTiles or Hybrid motor roads,
* with directional TomTom congestion in live mode. Tile acquisition is
* bounded and camera-driven; animation lerps precomputed Cartesian waypoints.
*/
/** @const {string} Proxy endpoint for Overpass API queries */
export const OVERPASS_URL = '/api/overpass';
/** @const {number} Meters — hide all traffic dots above this camera altitude */
export const ACTIVATION_ALTITUDE = 8000;
@@ -55,6 +30,14 @@ export const OVERLAP_THRESHOLD = 0.6;
export const MAX_DOTS = 6000;
/** Keep a dense street view while bounding both dot and surface work at city scale. */
export function roadDotBudget(altitude) {
return Math.max(
400,
Math.round(MAX_DOTS * Math.pow(1000 / Math.max(1000, altitude), 2)),
);
}
/** @const {number} Polylines longer than this are simplified by sub-sampling */
export const MAX_WAYPOINTS_PER_ROAD = 80;
@@ -164,17 +147,6 @@ export const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252');
export const HEAT_SLOW_COLOR =
Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.2);
/**
* @const {number} Meters — jam dots depth-test-punch through the 3D tiles out
* to this camera distance so queues stay visible at city scale. The single
* start-of-road terrain sample puts much of a road below the rendered mesh
* at oblique city views (first A/B capture: 396 jam dots, zero visible), so
* the shipped 2 km window hides exactly the congestion this prototype is
* meant to surface. Live jam dots only; sim dots keep the shipped 2 km.
*/
export const JAM_DOT_DEPTH_PUNCH = 15000;
/** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */
export const JAM_DOT_FAR_SCALE = 0.55;
+19 -19
View File
@@ -9,7 +9,6 @@ import {
HEAT_JAM_BASE_ALPHA,
HEAT_LINE_SLOW_WIDTH,
TRAFFIC_TIMING_ENABLED,
MAX_DOTS,
} from './policy.js';
export function createRendering({
@@ -161,7 +160,7 @@ export function createRendering({
}
/**
* Clear existing dots and re-spawn them for the given road set and altitude.
* Reconcile road populations without respawning retained dots.
*
* When zoomed out (>5 km), only major road types are rendered to reduce clutter.
* Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`.
@@ -172,14 +171,20 @@ export function createRendering({
* @param {Object|null} [trace=null] - Development-only correlated load trace.
*/
function renderRoadsForAltitude(roads, altitude, label, trace = null) {
function renderRoadsForAltitude(
roads,
altitude,
label,
trace = null,
replace = true,
) {
const state =
TRAFFIC_TIMING_ENABLED && trace
? parts.timing.trafficTimingRenderState(trace, label)
: null;
const renderId = state ? ++trace.renderSequence : null;
parts.animation.clearDots();
layerState._roads = roads;
layerState._lastPaintStage = label;
layerState._lastRenderAltitude = altitude;
// At high altitude, drop minor roads to reduce visual noise
@@ -224,18 +229,13 @@ export function createRendering({
visibleRoadCount: filteredRoads.length,
})
: null;
const roadBudgets = parts.model.allocateRoadDotBudgets(
filteredRoads,
altitude,
MAX_DOTS,
);
for (let i = 0; i < filteredRoads.length; i++) {
const road = filteredRoads[i];
const budget = roadBudgets[i] || 0;
if (budget <= 0) continue;
parts.animation.spawnDotsForRoad(road, altitude, budget);
if (layerState._dots.length >= MAX_DOTS) break;
}
parts.retention.reconcile(filteredRoads, altitude, replace);
if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow))
services.credits?.showOsmCredit?.(layerState._viewer, 'traffic', {
openMapTiles: true,
});
else services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
parts.model.recolorDotsInPlace(label);
const renderMetrics = state
? {
@@ -268,7 +268,7 @@ export function createRendering({
renderMetrics,
)
: null;
rebuildHeatLines(filteredRoads);
if (!label.includes('tile')) rebuildHeatLines(filteredRoads);
if (state) {
const heatEnd = parts.timing.trafficTimingMark(
state,
@@ -290,7 +290,7 @@ export function createRendering({
);
}
layerState._count = layerState._dots.length;
parts.retention.refreshCounts();
layerState._lastUpdate = Date.now();
console.log(
`[Data:Traffic] ${label}: ${layerState._count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`,
+256
View File
@@ -0,0 +1,256 @@
import * as Cesium from 'cesium';
import { MAX_DOTS, roadDotBudget } from './policy.js';
export const DOT_CHANGE_BUDGET = 768;
export const DOT_FADE_MS = 180;
/** Geometry identity excludes heights, flow and snapshot/collection ordering. */
export function roadIdentity(road) {
return `${road.directFlow ? 'tomtom' : 'osm'}:${road.type}:${road.oneway}:${road.coords.map(([lon, lat]) => `${lon.toFixed(7)},${lat.toFixed(7)}`).join(';')}`;
}
/** Retain one road and its dot population across tile and surface publications. */
export function createRetention({ state, parts }) {
const records = new Map();
const heightStep = new Cesium.Cartesian3();
const screenFrom = new Cesium.Cartesian2();
const screenTo = new Cesium.Cartesian2();
const eye = new Cesium.Cartesian3();
function projectSafely(scene, point, screen) {
if (scene.camera.viewMatrix) {
Cesium.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye);
if (eye.z >= -(scene.camera.frustum?.near || 0)) return false;
}
return (
Boolean(
Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen),
) &&
Number.isFinite(screen.x) &&
Number.isFinite(screen.y)
);
}
function nearCanvas(scene, point, screen) {
return (
projectSafely(scene, point, screen) &&
screen.x >= -100 &&
screen.x <= scene.canvas.clientWidth + 100 &&
screen.y >= -100 &&
screen.y <= scene.canvas.clientHeight + 100
);
}
let timer = null;
let altitude = 0;
const schedule = () => {
if (timer !== null) return;
state._populationPending = true;
timer =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(pump)
: setTimeout(pump, 16);
};
function pump() {
timer = null;
if (!state._enabled) return;
let budget = DOT_CHANGE_BUDGET;
let pending = false;
const now = Date.now();
// Removing a road or reducing density fades only surplus dots. A retained
// dot never changes road, direction, segment or progress.
for (const record of records.values()) {
record.road.flow = record.road.source.flow;
if (
record.road.flow?.closure ||
(state._liveMode &&
!record.road.flow &&
state._uncoveredMode === 'hide')
)
record.target = 0;
for (let i = record.dots.length - 1; i >= 0 && budget > 0; i--) {
const dot = record.dots[i];
if (!dot.retiring || now - dot.retiring < DOT_FADE_MS) continue;
state._pointCollection.remove(dot.point);
const last = state._dots.pop();
if (last !== dot) {
state._dots[dot.index] = last;
last.index = dot.index;
}
record.dots.splice(i, 1);
budget--;
state._motion.removed++;
}
let active = 0;
for (const dot of record.dots) if (!dot.retiring) active++;
for (
let i = record.dots.length - 1;
active > record.target && i >= 0 && budget > 0;
i--
) {
const dot = record.dots[i];
if (dot.retiring) continue;
dot.retiring = now;
active--;
budget--;
}
if (active < record.target && budget > 0) {
const count = Math.min(
record.target - active,
budget,
MAX_DOTS - state._dots.length,
);
if (count > 0) {
const before = state._dots.length;
parts.animation.spawnDotsForRoad(record.road, altitude, count);
for (let i = before; i < state._dots.length; i++)
record.dots.push(state._dots[i]);
budget -= state._dots.length - before;
active += state._dots.length - before;
}
}
if (active !== record.target || record.dots.length !== active)
pending = true;
if (!record.target && !record.dots.length)
records.delete(record.road.key);
}
refreshCounts();
state._populationPending = pending;
if (pending) schedule();
}
function refreshCounts() {
const counts = state._bucketCounts;
counts.free = counts.slow = counts.jam = counts.sim = 0;
let count = 0;
for (const dot of state._dots) {
if (!dot.point.show) continue;
counts[dot.bucket || 'sim']++;
count++;
}
state._count = count;
}
function reconcile(roads, height, replace) {
altitude = height;
if (replace) for (const record of records.values()) record.wanted = false;
for (const candidate of roads) {
const key = candidate.key || roadIdentity(candidate);
candidate.key = key;
let record = records.get(key);
if (!record) {
// Surface sampling owns the incoming arrays. Animation owns copies,
// so an asynchronous height pass cannot move half a road in one frame.
const road = {
...candidate,
waypoints: candidate.waypoints.map((p) => Cesium.Cartesian3.clone(p)),
segmentDist: candidate.segmentDist.slice(),
source: candidate,
targetWaypoints: candidate.waypoints.map((p) =>
Cesium.Cartesian3.clone(p),
),
heightMoving: false,
};
record = { road, dots: [], target: 0, wanted: true };
records.set(key, record);
} else if (candidate !== record.road) {
record.road.source = candidate;
record.road.flow = candidate.flow;
record.road.densityWeight = candidate.densityWeight;
for (let i = 0; i < candidate.waypoints.length; i++) {
if (
!Cesium.Cartesian3.equalsEpsilon(
record.road.targetWaypoints[i],
candidate.waypoints[i],
0,
0.01,
)
)
record.road.heightMoving = state._heightPending = true;
Cesium.Cartesian3.clone(
candidate.waypoints[i],
record.road.targetWaypoints[i],
);
}
}
record.wanted = true;
}
const wanted = [];
for (const record of records.values()) {
if (record.wanted) wanted.push(record.road);
else record.target = 0;
}
const budgets = parts.model.allocateRoadDotBudgets(
wanted,
altitude,
roadDotBudget(altitude),
);
for (let i = 0; i < wanted.length; i++)
records.get(wanted[i].key).target = budgets[i];
state._roads = wanted;
state._motion.publishes++;
// First paint gets one bounded batch without an additional frame wait.
if (!state._dots.length && timer === null) pump();
else schedule();
}
function easeHeights(dt) {
const blend = Math.min(1, dt * 12);
const scene = state._viewer?.scene;
const canProject =
scene?.canvas?.clientHeight && scene.camera && scene.frameState;
state._heightPending = false;
for (let r = 0; r < state._roads.length; r++) {
const road = state._roads[r];
if (!road.heightMoving) continue;
let moving = false;
for (let i = 0; i < road.waypoints.length; i++) {
const point = road.waypoints[i],
target = road.targetWaypoints[i];
if (Cesium.Cartesian3.distanceSquared(point, target) < 0.0001)
Cesium.Cartesian3.clone(target, point);
else {
const visible = canProject && nearCanvas(scene, point, screenFrom);
let step = visible
? blend
: Math.min(
blend,
(120 * dt) / Cesium.Cartesian3.distance(point, target),
);
if (visible) {
let safe = false;
for (let attempt = 0; attempt < 16; attempt++) {
Cesium.Cartesian3.lerp(point, target, step, heightStep);
if (!projectSafely(scene, heightStep, screenTo)) {
step *= 0.5;
continue;
}
const pixels = Cesium.Cartesian2.distance(screenFrom, screenTo);
if (pixels <= 12) {
safe = true;
break;
}
step *= 11.5 / pixels;
}
if (!safe) step = 0;
}
state._heightPending ||= !canProject || visible;
Cesium.Cartesian3.lerp(point, target, step, point);
moving = true;
}
}
for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance(
road.waypoints[i],
road.waypoints[i + 1],
);
road.heightMoving = moving;
}
}
function clear() {
if (timer !== null) {
if (typeof cancelAnimationFrame === 'function')
cancelAnimationFrame(timer);
else clearTimeout(timer);
timer = null;
}
records.clear();
state._populationPending = false;
state._heightPending = false;
}
return { reconcile, easeHeights, clear, refreshCounts };
}
+247
View File
@@ -0,0 +1,247 @@
import { createFlowSegmentIndex } from '../../data/flowMatch.js';
/**
* @file Street Traffic road-source modes: which geometry the dots run on.
*
* - `tomtom`: TomTom flow-tile lines only, each coloured by its own flow.
* - `osm`: OpenFreeMap (OpenStreetMap) lines, TomTom flow matched onto them.
* - `hybrid`: TomTom lines where TomTom has a road, OpenFreeMap lines only
* where it does not (simulated).
*
* Pure and Cesium-free so selection and dedupe are unit-testable.
*
* @module layers/traffic/roadModes
*/
export const TRAFFIC_ROAD_MODES = Object.freeze(['tomtom', 'osm', 'hybrid']);
/** Status-line names for the geometry each mode draws. */
export const ROAD_SOURCE_LABELS = Object.freeze({
tomtom: 'TomTom',
osm: 'OpenStreetMap',
hybrid: 'TomTom + OpenStreetMap',
});
/** Sample spacing along OpenFreeMap roads when testing TomTom overlap. */
const DEDUPE_STEP_M = 10;
/** A partly duplicated road keeps only uncovered stretches at least this long. */
const MIN_FILL_M = 40;
/**
* Overlap radius when exactly one side is a controlled-access road
* (motorway/trunk mainline) and the other is not. The same road drawn by
* both sources agrees within a few metres (z12 flow tiles quantize to about
* 2.4 m), while frontage and service roads beside a freeway run 20-40 m
* from its line; 15 m keeps the first a duplicate and the second a road.
* Same-class overlap keeps the full 35 m matcher radius.
*/
const CLASS_MISMATCH_RADIUS_M = 15;
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
/**
* Validate an explicit choice.
* @param {*} value - Candidate mode.
* @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default".
*/
export function normalizeRoadMode(value) {
return TRAFFIC_ROAD_MODES.includes(value) ? value : null;
}
/**
* Resolve the mode actually drawn. Without a TomTom key every choice draws
* OpenStreetMap roads; with one, an explicit choice wins over Hybrid.
* @param {*} value - Requested mode (null for the default).
* @param {boolean} hasKey - The server reported a TomTom key.
* @returns {'tomtom'|'osm'|'hybrid'}
*/
export function resolveRoadMode(value, hasKey) {
return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm';
}
const FLOW_ROAD_TYPES = Object.freeze({
Motorway: 'motorway',
'International road': 'trunk',
'Major road': 'primary',
'Secondary road': 'secondary',
'Connecting road': 'tertiary',
'Major local road': 'tertiary',
'Local road': 'residential',
'Minor local road': 'residential',
});
/** Unknown/private/parking/walking categories never become vehicles or suppress OSM fill. */
export function isDrivableFlowSegment(segment) {
return (
Object.hasOwn(FLOW_ROAD_TYPES, segment?.roadType) &&
(!segment.roadCategory ||
[
'motorway',
'trunk',
'primary',
'secondary',
'tertiary',
'street',
].includes(segment.roadCategory))
);
}
/**
* Convert decoded TomTom flow lines into road records that carry their own
* flow. A line with `coverage: 'full'` describes both travel directions;
* `one_side` lines describe the direction they are drawn in.
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
* @returns {Array<object>} Road records (`directFlow: true`).
*/
export function flowSegmentsToRoads(segments) {
return (segments || [])
.filter(
(segment) =>
segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
)
.map((segment) => ({
coordinates: segment.coords,
type: FLOW_ROAD_TYPES[segment.roadType],
drivable: true,
roadClass: segment.roadType,
roadProperties: {
roadType: segment.roadType,
roadCategory: segment.roadCategory,
},
oneway: segment.coverage === 'full' ? 0 : 1,
flow: { level: segment.trafficLevel, closure: segment.closure === true },
directFlow: true,
}));
}
/** Cut a projected polyline to the arc-length interval [from, to]. */
function slicePolyline(coords, cum, from, to) {
const out = [];
const at = (i, distance) => {
const span = cum[i + 1] - cum[i] || 1;
const t = (distance - cum[i]) / span;
return [
coords[i][0] + (coords[i + 1][0] - coords[i][0]) * t,
coords[i][1] + (coords[i + 1][1] - coords[i][1]) * t,
];
};
for (let i = 0; i < coords.length - 1; i++) {
if (cum[i + 1] < from || cum[i] > to) continue;
if (!out.length) out.push(cum[i] >= from ? coords[i] : at(i, from));
if (cum[i + 1] <= to) out.push(coords[i + 1]);
else {
out.push(at(i, to));
break;
}
}
return out.length >= 2 ? out : null;
}
/**
* Stretches of one travel direction of an OpenFreeMap road that no TomTom
* line covers. A sample is covered when a TomTom line lies within the flow
* matcher's 35 m radius (15 m across a controlled-access class mismatch)
* and agrees with its travel bearing (30 degrees).
* Flow values are ignored: any geometric overlap counts, so an uncertain
* duplicate is dropped from OpenFreeMap, never from TomTom.
* @returns {number[][][]|'all'} Uncovered polylines, or 'all' when untouched.
*/
function uncoveredStretches(coords, direction, index, radiusFor) {
const xs = [],
ys = [],
cum = [0];
for (let i = 0; i < coords.length; i++) {
const [x, y] = index.project(coords[i][0], coords[i][1]);
xs.push(x);
ys.push(y);
if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1]));
}
const length = cum[cum.length - 1];
if (!(length > 0)) return [];
const count = Math.max(2, Math.ceil(length / DEDUPE_STEP_M));
const step = length / count;
const runs = [];
let runStart = null;
let cursor = 1;
for (let s = 0; s < count; s++) {
const target = (s + 0.5) * step;
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
const t = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const dx = xs[cursor] - xs[cursor - 1];
const dy = ys[cursor] - ys[cursor - 1];
const bearing =
(Math.atan2(dx, dy) * 180) / Math.PI + (direction === -1 ? 180 : 0);
const covered = Boolean(
index.nearest(
xs[cursor - 1] + dx * t,
ys[cursor - 1] + dy * t,
bearing,
radiusFor,
).best,
);
if (!covered && runStart === null) runStart = s;
if (covered && runStart !== null) {
runs.push([runStart * step, s * step]);
runStart = null;
}
}
if (runStart === 0) return 'all';
if (runStart !== null) runs.push([runStart * step, length]);
return runs
.filter(([from, to]) => to - from >= MIN_FILL_M)
.map(([from, to]) => slicePolyline(coords, cum, from, to))
.filter(Boolean);
}
/**
* Build the Hybrid OpenFreeMap fill for a set of TomTom flow lines: every
* OpenFreeMap road or travel direction that TomTom does not already draw,
* marked simulated. Returns a function so callers can memoize per road.
* @param {Array<object>} segments - Decoded TomTom flow lines.
* @returns {(road:object) => Array<object>} Fill records for one OSM road.
*/
export function createHybridFill(segments) {
const index = createFlowSegmentIndex(
(segments || []).filter(isDrivableFlowSegment),
);
return (road) => {
const coords = road?.coordinates;
if (!Array.isArray(coords) || coords.length < 2) return [];
const fill = { ...road, simulatedOnly: true };
if (!index) return [fill];
// Ramps leave the mainline, so they compare as ordinary roads.
const controlled = CONTROLLED_ACCESS_OSM.has(road.type) && !road.ramp;
const radiusFor = (segment) =>
CONTROLLED_ACCESS_FLOW.has(segment.roadType) === controlled
? Infinity
: CLASS_MISMATCH_RADIUS_M;
const directions = road.oneway ? [road.oneway] : [1, -1];
const stretches = directions.map((direction) =>
uncoveredStretches(coords, direction, index, radiusFor),
);
if (stretches.every((result) => result === 'all')) return [fill];
return directions.flatMap((direction, i) =>
(stretches[i] === 'all' ? [coords] : stretches[i]).map((piece) => ({
...fill,
coordinates: piece,
oneway: direction,
// A two-way road's single direction keeps its half share of dots.
densityWeight: road.oneway ? 1 : 0.5,
})),
);
};
}
/**
* Select the roads a mode draws from already-fetched inputs.
* @param {Array<object>} osmRoads - OpenFreeMap road records.
* @param {Array<object>} segments - Decoded TomTom flow lines.
* @param {'tomtom'|'osm'|'hybrid'} mode - Resolved mode.
* @returns {Array<object>} Road records for `parseRoads`.
*/
export function selectTrafficRoads(osmRoads, segments, mode) {
if (mode === 'osm') return osmRoads;
const tomtom = flowSegmentsToRoads(segments);
if (mode === 'tomtom') return tomtom;
const fill = createHybridFill(segments);
return [...tomtom, ...osmRoads.flatMap(fill)];
}
+554
View File
@@ -0,0 +1,554 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
flowSegmentsToRoads,
resolveRoadMode,
selectTrafficRoads,
} from './roadModes.js';
import { createTrafficSource } from './source.js';
import { createTrafficLayer } from './index.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
decodeLayerStateParams,
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
const coordinates = [
[-97.744, 30.267],
[-97.742, 30.267],
];
const main = { coordinates, type: 'primary', oneway: 1 };
const side = {
coordinates: [
[-97.743, 30.267],
[-97.743, 30.269],
],
type: 'residential',
oneway: 1,
};
const flow = {
coords: coordinates,
trafficLevel: 0.3,
closure: false,
roadType: 'Major road',
};
const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 };
test('road modes have key-aware defaults, explicit overrides and keyless fallback', () => {
assert.equal(resolveRoadMode(null, true), 'hybrid');
assert.equal(resolveRoadMode(null, false), 'osm');
assert.equal(resolveRoadMode('invalid', true), 'hybrid');
for (const mode of ['tomtom', 'osm', 'hybrid']) {
assert.equal(resolveRoadMode(mode, true), mode);
assert.equal(resolveRoadMode(mode, false), 'osm');
}
});
test('Hybrid keeps TomTom and the unmatched OSM side street, with simulated fill', () => {
const roads = selectTrafficRoads([main, side], [flow], 'hybrid');
assert.equal(roads.length, 2);
assert.equal(roads[0].directFlow, true);
assert.equal(roads[0].flow.level, 0.3);
assert.equal(roads[1].simulatedOnly, true);
assert.deepEqual(roads[1].coordinates, side.coordinates);
assert.equal(selectTrafficRoads([main, side], [flow], 'tomtom').length, 1);
assert.deepEqual(selectTrafficRoads([main, side], [flow], 'osm'), [
main,
side,
]);
});
test('opposite-direction carriageways and the reverse half of two-way roads survive', () => {
for (const reverse of [
{ ...main, oneway: -1 },
{ ...main, coordinates: [...coordinates].reverse() },
]) {
assert.equal(selectTrafficRoads([reverse], [flow], 'hybrid').length, 2);
}
const twoWay = selectTrafficRoads([{ ...main, oneway: 0 }], [flow], 'hybrid');
assert.equal(twoWay.length, 2);
assert.equal(twoWay[1].oneway, -1);
assert.equal(twoWay[1].densityWeight, 0.5);
});
test('uncertain flow values still dedupe OSM; every TomTom road and closure survives', () => {
const closed = { ...flow, trafficLevel: 0, closure: true };
const roads = selectTrafficRoads([main], [flow, closed], 'hybrid');
assert.equal(roads.length, 2);
assert.ok(roads.every((road) => road.directFlow));
assert.equal(roads[1].flow.closure, true);
});
test('a partly covered OSM road keeps only its uncovered stretch', () => {
// 0.004° of longitude at 30.267° N is about 385 m; TomTom covers the west half.
const long = {
coordinates: [
[-97.744, 30.267],
[-97.74, 30.267],
],
type: 'primary',
oneway: 1,
};
const roads = selectTrafficRoads([long], [flow], 'hybrid');
assert.equal(roads.length, 2);
const [piece] = roads.slice(1);
assert.equal(piece.simulatedOnly, true);
// The kept stretch starts within one 35 m match radius of the TomTom end.
assert.ok(piece.coordinates[0][0] > -97.742);
assert.ok(piece.coordinates[0][0] < -97.741);
assert.deepEqual(piece.coordinates.at(-1), [-97.74, 30.267]);
});
test('an uncovered sliver shorter than 40 m is dropped with its duplicate', () => {
// About 67 m past the TomTom end: the last ~32 m is beyond the 35 m radius.
const sliver = {
coordinates: [
[-97.744, 30.267],
[-97.7413, 30.267],
],
type: 'primary',
oneway: 1,
};
assert.equal(selectTrafficRoads([sliver], [flow], 'hybrid').length, 1);
});
test('a full-coverage TomTom line is two-way and covers both OSM directions', () => {
const both = { ...flow, coverage: 'full' };
assert.equal(flowSegmentsToRoads([both])[0].oneway, 0);
assert.equal(flowSegmentsToRoads([flow])[0].oneway, 1);
const roads = selectTrafficRoads([{ ...main, oneway: 0 }], [both], 'hybrid');
assert.equal(roads.length, 1);
assert.equal(roads[0].directFlow, true);
});
test('bearing and distance reject nearby crossing and separate OSM roads', () => {
const parallel = {
...main,
coordinates: coordinates.map(([lon, lat]) => [lon, lat + 0.001]),
};
assert.equal(
selectTrafficRoads([side, parallel], [flow], 'hybrid').length,
3,
);
});
function sourceFixture() {
let requests = 0;
const source = createTrafficSource({
mapTiles: {
async fetchBounds(bounds, { onTile }) {
requests++;
const tile = { roads: [main, side] };
onTile?.(tile);
return { tiles: [tile], partial: false };
},
clear() {},
},
});
return { source, requests: () => requests };
}
test('TomTom-only skips OSM requests; keyless selection requests OSM and labels fallback', async () => {
for (const hasKey of [true, false]) {
const f = sourceFixture();
const data = await (
await f.source.requestRoads(box, {
roadMode: 'tomtom',
flowSnapshot: Promise.resolve({
hasKey,
segments: hasKey ? [flow] : [],
}),
})
).json();
assert.equal(data.roadSource, hasKey ? 'TomTom' : 'OpenStreetMap');
assert.equal(f.requests(), hasKey ? 0 : 1);
assert.equal(data.roads.length, hasKey ? 1 : 2);
}
});
test('Hybrid streams OSM before flow settles then replaces duplicates with TomTom', async () => {
const f = sourceFixture();
let resolve;
const snapshots = [];
const pending = f.source.requestRoads(box, {
roadMode: 'hybrid',
flowSnapshot: new Promise((done) => {
resolve = done;
}),
onTile: (data) => snapshots.push(data),
});
await Promise.resolve();
assert.equal(snapshots[0].roadSource, 'OpenStreetMap');
assert.equal(snapshots[0].roads.length, 2);
resolve({ hasKey: true, segments: [flow] });
const final = await (await pending).json();
assert.equal(final.roadSource, 'TomTom + OpenStreetMap');
assert.equal(final.roads.length, 2);
assert.ok(snapshots.at(-1).replace);
});
test('road-source params persist in existing layer-state lo field and local storage', () => {
for (const mode of ['tomtom', 'osm', 'hybrid']) {
const state = createDefaultLayerState();
state.enabledLayerIds = ['traffic'];
state.options.traffic.roadMode = mode;
const params = new URLSearchParams('v=2');
encodeLayerStateParams(params, state);
assert.deepEqual([...params.keys()].sort(), ['l', 'lo', 'v']);
assert.equal(decodeLayerStateParams(params).options.traffic.roadMode, mode);
assert.equal(
parseStoredLayerState(serializeStoredLayerState(state)).options.traffic
.roadMode,
mode,
);
}
});
test('row chips use real params; query overrides passive restore and user can change it', () => {
const oldLocation = globalThis.location;
globalThis.location = { search: '?trafficRoads=tomtom' };
try {
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source: {
requestRoads() {},
getStatus() {},
fetchFlowForBounds() {},
getFlowSessionStats: () => ({}),
resetFlowTileCache() {},
},
});
layer.setParams({ roadMode: 'osm' }, { origin: 'local-restore' });
assert.equal(layer.getParams().roadMode, 'tomtom');
const chips = layer.getRowControls().chips;
assert.equal(chips.length, 3);
assert.equal(chips[0].active, true);
layer.setParams(chips[2].params, { origin: 'user' });
assert.equal(layer.getParams().roadMode, 'hybrid');
assert.match(layer.getStats().loadingLabel, /Hybrid needs a TomTom key/);
assert.equal(layer.getStats().roadMode, 'osm');
} finally {
if (oldLocation === undefined) delete globalThis.location;
else globalThis.location = oldLocation;
}
});
test('Hybrid retains TomTom on OSM outage with a named warning; TomTom failure never invents roads', async () => {
const source = createTrafficSource({
mapTiles: {
fetchBounds: async () => {
throw Object.assign(new Error('refused'), { status: 429 });
},
},
});
const data = await (
await source.requestRoads(box, {
roadMode: 'hybrid',
flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }),
})
).json();
assert.equal(data.roads.length, 1);
assert.equal(data.roads[0].directFlow, true);
assert.equal(data.partial, true);
assert.equal(data.roadWarning, 'OpenFreeMap tiles rate-limited');
await assert.rejects(
source.requestRoads(box, {
roadMode: 'tomtom',
flowSnapshot: Promise.resolve({
hasKey: true,
segments: [],
error: 'TomTom daily budget reached',
}),
}),
/TomTom daily budget reached/,
);
});
test('flow application preserves direct values and leaves Hybrid fill simulated', async () => {
const { createFlow } = await import('./flow.js');
const direct = {
coords: coordinates,
directFlow: true,
flow: { level: 0.15, closure: true },
};
const fill = { coords: coordinates, simulatedOnly: true };
const osm = { coords: coordinates };
const state = {
_enabled: true,
_liveMode: true,
_loadGeneration: 1,
_flowPending: 0,
_flowStatusPromise: Promise.resolve(),
};
const controller = createFlow({
state,
services: { credits: {} },
parts: {},
source: { fetchFlowForBounds: async () => [flow] },
});
await controller.applyFlowToRoads([direct, fill, osm], box, 1);
assert.deepEqual(direct.flow, { level: 0.15, closure: true });
assert.equal(fill.flow, null);
assert.deepEqual(osm.flow, { level: 0.3, closure: false });
assert.equal(state._flowPending, 0);
});
test('status names the drawn road source for each mode', async () => {
const { createModel } = await import('./model.js');
const { trafficFeedPresentation: present } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
assert.equal(
present({ liveMode: true, coveragePct: 100, roadSource: 'TomTom' })
.loadingLabel,
'LIVE · Roads: TomTom · Roads without flow hidden',
);
assert.equal(
present({ liveMode: true, coveragePct: 0, roadSource: 'TomTom' })
.loadingLabel,
'LIVE · Roads: TomTom · No flow roads in view',
);
assert.equal(
present({
liveMode: true,
coveragePct: 81,
roadSource: 'TomTom + OpenStreetMap',
}).loadingLabel,
'LIVE · Roads: TomTom + OpenStreetMap · Flow 81%',
);
assert.match(
present({ liveMode: true, coveragePct: 74 }).loadingLabel,
/^LIVE · Roads: OpenStreetMap · Flow: TomTom · 74% cov/,
);
});
test('only explicit origins clear the query override; restores keep it', () => {
const oldLocation = globalThis.location;
globalThis.location = { search: '?trafficRoads=osm' };
try {
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source: {
requestRoads() {},
getStatus() {},
fetchFlowForBounds() {},
getFlowSessionStats: () => ({}),
resetFlowTileCache() {},
},
});
for (const origin of ['share-restore', 'local-restore', 'programmatic']) {
layer.setParams({ roadMode: 'tomtom' }, { origin });
assert.equal(layer.getParams().roadMode, 'osm', origin);
}
layer.setParams({ roadMode: 'tomtom' }, { origin: 'voice' });
assert.equal(layer.getParams().roadMode, 'tomtom');
layer.setParams({ roadMode: null }, { origin: 'local-restore' });
assert.equal(layer.getParams().roadMode, null);
} finally {
if (oldLocation === undefined) delete globalThis.location;
else globalThis.location = oldLocation;
}
});
test('TomTom and Hybrid loads skip the OSM snapshot cache; TomTom skips the detail pass', async () => {
const { createIngestion } = await import('./ingestion.js');
for (const [roadMode, roadSource, perLoad] of [
[null, 'TomTom + OpenStreetMap', [true, false]],
['tomtom', 'TomTom', [true]],
]) {
const state = {
_loadGeneration: 0,
_tileCache: new Map(),
_enabled: true,
_liveMode: true,
_roadMode: roadMode,
_parseRoads: (data) => data.roads,
};
const passes = [];
const ingestion = createIngestion({
state,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 30.267, lon: -97.744 }),
},
flow: {
warmFlow: async () => [flow],
applyFlowThenRender: async () => true,
},
},
source: {
requestRoads: async (_, options) => {
passes.push(options.majorOnly);
assert.equal(options.roadMode, roadMode);
assert.equal((await options.flowSnapshot).hasKey, true);
return {
ok: true,
json: async () => ({ roads: [main], roadSource }),
};
},
},
});
await ingestion.loadRoadsForBounds(box, 350);
await ingestion.loadRoadsForBounds(box, 350);
assert.deepEqual(passes, [...perLoad, ...perLoad], roadSource);
assert.equal(state._tileCache.size, 0);
assert.equal(state._roadSource, roadSource);
}
});
test('a first keyed load whose flow fails never caches simulated fill as OSM roads', async () => {
const { createIngestion } = await import('./ingestion.js');
// Status unknown at the start of the first load; the probe then reports a
// key but flow fails, so Hybrid draws only simulated OpenStreetMap fill,
// labelled OpenStreetMap.
const state = {
_loadGeneration: 0,
_tileCache: new Map(),
_enabled: true,
_liveMode: false,
_roadMode: null,
_parseRoads: (data) => data.roads,
};
const requests = [];
const ingestion = createIngestion({
state,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 30.267, lon: -97.744 }),
},
flow: {
warmFlow: async () => {
state._liveMode = true;
state._flowStatusKnown = true;
throw Object.assign(new Error('flow'), { status: 502 });
},
deriveTrafficFlowError: () => 'TomTom upstream unreachable',
applyFlowThenRender: async () => true,
},
},
source: {
requestRoads: async (_, options) => {
const live = await options.flowSnapshot;
const mode = resolveRoadMode(options.roadMode, live.hasKey);
requests.push(mode);
const roads =
mode === 'osm' ? [main] : [{ ...main, simulatedOnly: true }];
return {
ok: true,
json: async () => ({
roads,
roadSource: 'OpenStreetMap',
roadMode: mode,
}),
};
},
},
});
await ingestion.loadRoadsForBounds(box, 350);
assert.deepEqual(requests, ['hybrid', 'hybrid']);
assert.equal(state._tileCache.size, 0, 'Hybrid fill is never cached');
// The user switches to OSM: fresh OpenStreetMap roads, not the fill.
state._roadMode = 'osm';
await ingestion.loadRoadsForBounds(box, 350);
assert.deepEqual(requests.slice(2), ['osm', 'osm']);
const [entry] = state._tileCache.values();
assert.ok(entry.full.every((road) => !road.simulatedOnly));
// ...and those plain OSM roads are what a revisit reuses.
await ingestion.loadRoadsForBounds(box, 350);
assert.equal(requests.length, 4);
});
test('a flow snapshot that misses the pass deadline cannot hold roads', async () => {
const never = new Promise(() => {});
const request = (roadMode, hasKey) =>
sourceFixture().source.requestRoads(box, {
roadMode,
flowSnapshot: never,
liveModeHint: () => hasKey,
timeoutSec: 0.02,
});
const keyless = await (await request(null, false)).json();
assert.equal(keyless.roadMode, 'osm');
assert.equal(keyless.roads.length, 2);
const hybrid = await (await request(null, true)).json();
assert.equal(hybrid.roadMode, 'hybrid');
assert.equal(hybrid.roadSource, 'OpenStreetMap');
assert.ok(hybrid.roads.every((road) => road.simulatedOnly));
await assert.rejects(request('tomtom', true), /TomTom flow timed out/);
});
test('frontage roads beside a TomTom motorway survive; same-class duplicates do not', () => {
const offset = (road, metres, extra = {}) => ({
...road,
...extra,
coordinates: road.coordinates.map(([lon, lat]) => [
lon,
lat + metres / 111320,
]),
});
const motorwayLine = { ...flow, roadType: 'Motorway' };
const osmMotorway = { ...main, type: 'motorway' };
const roads = selectTrafficRoads(
[
offset(osmMotorway, 4), // the same motorway: dropped
offset(main, 25, { type: 'residential' }), // frontage, north side
offset(main, -25, { type: 'tertiary' }), // frontage, south side
],
[motorwayLine],
'hybrid',
);
assert.equal(roads.length, 3);
assert.equal(roads[0].directFlow, true);
assert.deepEqual(
roads.slice(1).map((road) => road.type),
['residential', 'tertiary'],
);
// A ramp beside the mainline compares as an ordinary road.
assert.equal(
selectTrafficRoads(
[offset(osmMotorway, 25, { ramp: true })],
[motorwayLine],
'hybrid',
).length,
2,
);
// Across a class mismatch a true duplicate (within 15 m) is still dropped.
assert.equal(
selectTrafficRoads(
[offset(main, 6, { type: 'residential' })],
[motorwayLine],
'hybrid',
).length,
1,
);
// Same class, uncertain 25 m overlap: the OpenStreetMap copy goes.
assert.equal(
selectTrafficRoads(
[offset(main, 25, { type: 'residential' })],
[{ ...flow, roadType: 'Local road' }],
'hybrid',
).length,
1,
);
});
test('private, parking, walking and unknown flow never animate or suppress a public OSM road', () => {
for (const roadType of [
'Non public road',
'Parking road',
'Walkway road',
'',
'Other road',
]) {
const rejected = { ...flow, roadType };
assert.deepEqual(flowSegmentsToRoads([rejected]), []);
const roads = selectTrafficRoads([main], [rejected], 'hybrid');
assert.equal(roads.length, 1);
assert.equal(roads[0].simulatedOnly, true);
assert.deepEqual(roads[0].coordinates, main.coordinates);
}
assert.deepEqual(
flowSegmentsToRoads([{ ...flow, roadCategory: 'walkway' }]),
[],
);
const road = flowSegmentsToRoads([flow])[0];
assert.equal(road.drivable, true);
assert.equal(road.roadClass, 'Major road');
});
+336 -104
View File
@@ -1,142 +1,374 @@
import { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
export { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
import {
createHybridFill,
flowSegmentsToRoads,
resolveRoadMode,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
import { createFlowTileSource } from './flowSource.js';
function buildOverpassQuery(
south,
west,
north,
east,
{ majorOnly = false, timeoutSec = 25 } = {},
) {
// Regex matches the OSM `highway` tag value against allowed road types
const regex = majorOnly
? '^(motorway|trunk|primary|secondary)$'
: '^(motorway|trunk|primary|secondary|tertiary|residential|unclassified)$';
return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`;
import { tileToBBox, tilesForBounds } from '../../data/tomtomTiles.js';
import { clipTileLine } from '../../sources/openFreeMap.js';
import { createOpenFreeMapSource } from '../../sources/openFreeMap.js';
import { validTileBounds } from '../../sources/vectorTiles.js';
export { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
/** Shrink only the detail footprint, centered on the look-at fetch box, to fit 16 tiles. */
export function trafficDetailBounds(box) {
const center = {
lat: (box.north + box.south) / 2,
lon: (box.east + box.west) / 2,
};
let detail = { ...box };
while (tilesForBounds(detail, 14, { maxTiles: 17 }).length > 16) {
detail = {
south: center.lat + (detail.south - center.lat) * 0.9,
north: center.lat + (detail.north - center.lat) * 0.9,
west: center.lon + (detail.west - center.lon) * 0.9,
east: center.lon + (detail.east - center.lon) * 0.9,
};
}
return detail;
}
/**
* A road request upstream declined, carrying a line fit to show an operator.
*
* The status travels beside the message instead of only inside it, so a caller
* can branch on the code without parsing English — the same shape
* `LiveSourceError` uses in `src/sources/live/contract.js`.
*/
/** Keep coarse roads only outside the detailed tile cores. */
export function outsideDetailTiles(roads, tiles) {
return roads.flatMap((road) => {
let lines = [road.coordinates];
for (const tile of tiles) {
const box = tileToBBox(tile.z, tile.x, tile.y);
const regions = [
{ west: -180, east: box.west, south: -85, north: 85 },
{ west: box.east, east: 180, south: -85, north: 85 },
{ west: box.west, east: box.east, south: -85, north: box.south },
{ west: box.west, east: box.east, south: box.north, north: 85 },
];
lines = lines.flatMap((line) =>
regions.flatMap((region) => clipTileLine(line, region)),
);
}
return lines.map((coordinates) => ({ ...road, coordinates }));
});
}
/** A classified road failure with a display-safe reason and machine-readable status. */
export class RoadRequestError extends Error {
constructor(message, { status = null } = {}) {
super(message);
constructor(message, { status = null, cause } = {}) {
super(message, { cause });
this.name = 'RoadRequestError';
this.status = status;
}
}
/**
* Name an Overpass refusal in the words the traffic row will print.
*
* This layer has two upstreams — OpenStreetMap for road geometry and TomTom
* for flow — and only one of them can be down at a time. A row that says
* nothing more than "road data unavailable" sends the reader to check their
* TomTom key, which is the wrong half of the layer and costs them the
* afternoon. So the status upstream actually returned is reported: 406 from a
* public mirror is not a configuration problem the reader can fix, and saying
* so is the difference between a dead end and a next step.
*
* The first two lines match `alpr`'s Overpass source word for word; this is
* the same vocabulary, not a new one. What the status adds is *which* upstream
* and *how* it declined — the part a single-source layer never needs to say.
*
* Only what the browser can see is claimed. The proxy rotates mirrors behind
* `/api/overpass`, so a refusal arriving here means the proxy had nothing
* better to offer; how many mirrors it tried is not something this side knows,
* and the message does not pretend otherwise.
* @param {number} status - HTTP status the proxy returned; a code that is
* not a finite number is treated as absent rather than printed.
* @returns {RoadRequestError}
*/
export function roadRequestError(status) {
/** Name the road upstream without exposing raw transport errors or missing codes. */
export function roadRequestError(status, cause) {
const code = Number.isFinite(status) ? status : null;
const message =
code === 429
? 'Overpass rate-limited'
: code === 504
? 'Overpass timed out'
: // Our own proxy answers these, not a mirror: 502 when every mirror
// failed at the network level, 503 from its local concurrency
// limiter before any mirror was asked. Neither is a refusal.
code === 502
? 'Overpass mirrors unreachable'
: code === 503
? 'Overpass temporarily unavailable'
: // A source is injected, so a caller's adapter may hand back a
// refusal with no status on it. "HTTP undefined" on a panel row is
// worse than not naming a number, so an unreadable code falls back
// to what `alpr` says when it cannot be more specific either.
code === null
? 'Overpass temporarily unavailable'
: `Overpass refused the road query (HTTP ${code})`;
return new RoadRequestError(message, { status: code });
? 'OpenFreeMap tiles rate-limited'
: code === 504 || cause?.name === 'TimeoutError'
? 'OpenFreeMap tiles timed out'
: code === null
? 'OpenFreeMap tiles unavailable'
: `OpenFreeMap tiles unavailable (HTTP ${code})`;
return Object.assign(new RoadRequestError(message, { status: code, cause }), {
retryable: cause?.retryable !== false,
code: cause?.code,
});
}
/** Supply road responses, flow availability and one decoded flow cache. */
/** Supply tile-derived road geometry and flow availability without Overpass queries. */
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
mapTiles = createOpenFreeMapSource({ fetchImpl: tileFetchImpl }),
} = {}) {
const flow = createFlowTileSource({ fetchImpl });
return {
const api = {
...flow,
async requestRoads(
{ south, west, north, east },
{ majorOnly = false, timeoutSec = 25, signal } = {},
prefetch: () => mapTiles.getMetadata().catch(() => {}),
resetFlowTileCache() {
flow.resetFlowTileCache();
mapTiles.clear();
},
async requestOsmRoads(
box,
{ majorOnly = false, signal, onTile, coverage = box.coverage } = {},
) {
if (
![south, west, north, east].every(Number.isFinite) ||
south < -90 ||
north > 90 ||
west < -180 ||
east > 180 ||
north <= south ||
east <= west ||
north - south > 10 ||
east - west > 10 ||
!Number.isInteger(timeoutSec) ||
timeoutSec < 1 ||
timeoutSec > 30
!validTileBounds(box) ||
box.north - box.south > 10 ||
box.east - box.west > 10
)
throw new TypeError('A bounded road viewport and timeout are required');
signal?.throwIfAborted();
const query = buildOverpassQuery(south, west, north, east, {
majorOnly,
timeoutSec,
});
const response = await fetchImpl('/api/overpass', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'data=' + encodeURIComponent(query),
signal,
throw new TypeError('A bounded road viewport is required');
if (coverage) {
try {
const tileSnapshot = (tile) =>
onTile?.({
roads: tile.roads,
roadSource: 'OpenStreetMap',
roadMode: 'osm',
});
// Start nearest detail tiles before the coarse pass consumes workers.
const detailJob =
!majorOnly && coverage.detail.length
? mapTiles
.fetchBounds(box, {
zoom: 14,
tiles: coverage.detail,
signal,
onTile: onTile ? tileSnapshot : undefined,
})
.catch((error) => {
signal?.throwIfAborted();
if (
error?.name === 'AbortError' ||
error?.retryable === false
)
throw error;
return { tiles: [], loadedTiles: [], partial: true };
})
: null;
const coarseJob = mapTiles.fetchBounds(box, {
zoom: 12,
tiles: coverage.coarse,
signal,
onTile: majorOnly && onTile ? tileSnapshot : undefined,
});
const [detail, coarse] = await Promise.all([detailJob, coarseJob]);
signal?.throwIfAborted();
let roads = coarse.tiles.flatMap((tile) => tile.roads),
partial = coarse.partial;
if (detail) {
roads = [
...detail.tiles.flatMap((tile) => tile.roads),
...outsideDetailTiles(
roads,
detail.loadedTiles || (detail.partial ? [] : coverage.detail),
),
];
partial ||= detail.partial;
}
const data = {
roads,
roadSource: 'OpenStreetMap',
roadMode: 'osm',
partial,
detailLimited: false,
replace: true,
};
if (!majorOnly) onTile?.(data);
return {
ok: true,
status: 200,
headers: new Headers(),
json: async () => data,
};
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
throw roadRequestError(error?.status, error);
}
}
const area = majorOnly ? box : trafficDetailBounds(box);
const snapshot = (tiles, partial = false) => ({
roads: tiles
.flatMap((tile) => tile.roads)
.flatMap((road) =>
clipTileLine(road.coordinates, area).map((coordinates) => ({
...road,
coordinates,
})),
),
roadSource: 'OpenStreetMap',
roadMode: 'osm',
partial,
detailLimited:
!majorOnly && (area.north !== box.north || area.east !== box.east),
detailBounds: majorOnly ? null : area,
});
let result;
try {
result = await mapTiles.fetchBounds(area, {
zoom: majorOnly ? 12 : 14,
signal,
onTile: onTile ? (tile) => onTile(snapshot([tile])) : undefined,
});
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
throw roadRequestError(error?.status, error);
}
const data = snapshot(result.tiles, result.partial);
signal?.throwIfAborted();
return {
ok: response.ok,
status: response.status,
headers: response.headers,
ok: true,
status: 200,
headers: new Headers(),
async json() {
const body = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(body?.elements))
throw new Error('Malformed road snapshot');
return { roads: normalizeOverpassRoads(body) };
return data;
},
};
},
async getStatus({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/tomtom/status', { signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
const start = performance.now();
const timeout = AbortSignal.timeout(8000);
const response = await fetchImpl('/api/tomtom/status', {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
if (!response.ok)
throw Object.assign(new Error('TomTom status unavailable'), {
status: Number.isFinite(response.status) ? response.status : null,
});
const status = await response.json();
signal?.throwIfAborted();
if (typeof status?.hasKey !== 'boolean')
throw new Error('Malformed traffic status');
phaseTiming('status', start);
return status;
},
};
/**
* Request roads for the selected road source.
*
* `osm` streams OpenFreeMap tiles exactly as before. `tomtom` and `hybrid`
* wait for the flow snapshot the layer started alongside road acquisition;
* OpenFreeMap tiles stream meanwhile (Hybrid) and are drawn unfiltered until
* flow arrives, then one replacing snapshot swaps in TomTom lines and drops
* the duplicated OpenFreeMap stretches. Later tiles publish only their own
* fill, so parsing stays incremental. TomTom never requests OpenFreeMap
* unless the missing key forces the OpenStreetMap fallback.
*
* @param {{south:number, west:number, north:number, east:number}} box
* @param {Object} [options]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot]
* @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline.
* @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait.
* @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets.
*/
api.requestRoads = async (
box,
{
roadMode = null,
flowSnapshot,
liveModeHint = () => false,
onTile,
...options
} = {},
) => {
if (
!validTileBounds(box) ||
box.north - box.south > 10 ||
box.east - box.west > 10
)
throw new TypeError('A bounded road viewport is required');
if (roadMode === 'osm' || !flowSnapshot)
return api.requestOsmRoads(box, { ...options, onTile });
const signal = options.signal;
const osm = [];
let live = null,
mode = null,
metadata = {},
osmError = null,
tomtom = [],
fillFor = null;
const fillMemo = new Map();
const fill = (roads) =>
mode === 'hybrid'
? roads.flatMap((road) => {
if (!fillMemo.has(road)) fillMemo.set(road, fillFor(road));
return fillMemo.get(road);
})
: roads;
// Hybrid without any TomTom line (flow failed, or none here) draws only
// OpenStreetMap roads, and says so.
const source = () =>
!mode || (mode === 'hybrid' && !tomtom.length)
? ROAD_SOURCE_LABELS.osm
: ROAD_SOURCE_LABELS[mode];
const snapshot = (roads, replace) => ({
...metadata,
roads,
roadSource: source(),
roadMode: mode,
replace,
partial: Boolean(metadata.partial || osmError || live?.partial),
roadWarning: osmError && mode !== 'osm' ? osmError.message : null,
});
const publish = (roads, replace = false) => {
if (!signal?.aborted) onTile?.(snapshot(roads, replace));
};
const composed = () =>
mode === 'tomtom' ? tomtom : [...tomtom, ...fill(osm)];
const loadOsm = async () => {
try {
const response = await api.requestOsmRoads(box, {
...options,
onTile: (data) => {
if (data.replace) osm.splice(0, osm.length, ...data.roads);
else osm.push(...data.roads);
metadata = { ...data, roads: undefined };
// Before flow settles the tile is drawn as plain OpenStreetMap.
publish(
data.replace ? composed() : fill(data.roads),
Boolean(data.replace),
);
},
});
const data = await response.json();
metadata = { ...data, roads: undefined };
// Every tile streams through onTile; adopt the final list if not.
if (osm.length !== data.roads.length)
osm.splice(0, osm.length, ...data.roads);
} catch (error) {
if (error?.name === 'AbortError' || signal?.aborted) throw error;
osmError = error;
}
};
const osmJob = roadMode === 'tomtom' ? null : loadOsm();
osmJob?.catch(() => {});
// The snapshot waits on the status probe and flow tiles; never let it
// hold the road pass past its own deadline. A late snapshot counts as a
// flow failure: TomTom mode reports it, Hybrid falls back to OSM roads.
let deadline;
live = await Promise.race([
flowSnapshot,
new Promise((resolve) => {
deadline = setTimeout(
() =>
resolve({
segments: [],
hasKey: Boolean(liveModeHint()),
error: 'TomTom flow timed out',
}),
(options.timeoutSec ?? 20) * 1000,
);
}),
]).finally(() => clearTimeout(deadline));
signal?.throwIfAborted();
if (!live)
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
mode = resolveRoadMode(roadMode, live.hasKey);
if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error);
if (mode !== 'osm') {
tomtom = flowSegmentsToRoads(live.segments);
fillFor = createHybridFill(live.segments);
publish(composed(), true);
}
if (osmJob) await osmJob;
else if (mode === 'osm') await loadOsm();
signal?.throwIfAborted();
if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError;
const data = snapshot(composed(), true);
publish(data.roads, true);
return {
ok: true,
status: 200,
headers: new Headers(),
json: async () => data,
};
};
return api;
}
+720 -87
View File
@@ -1,10 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
import {
RoadRequestError,
createTrafficSource,
roadRequestError,
trafficDetailBounds,
createTrafficSource,
} from './source.js';
import { clampBoundsAroundCenter } from '../../data/trafficBounds.js';
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
@@ -46,6 +50,7 @@ test('a cancelled flow body cannot refill its source cache', async () => {
const source = createTrafficSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
arrayBuffer: async () => {
calls++;
if (calls === 1) controller.abort();
@@ -64,55 +69,36 @@ test('a cancelled flow body cannot refill its source cache', async () => {
'cancelled bytes were not admitted to the decode cache',
);
});
test('road requests have finite bounds and retain the two-pass query', async () => {
test('road requests validate bounds and select z12/z14 immutable tile passes', async () => {
const calls = [];
const source = createTrafficSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"elements":[]}');
mapTiles: {
async fetchBounds(box, options) {
calls.push({ box, ...options });
return { tiles: [{ roads: [] }], partial: false };
},
clear() {},
},
});
await assert.rejects(
source.requestRoads({ ...bounds, north: Infinity }),
/bounded road viewport/,
);
await assert.rejects(
source.requestRoads(bounds, { timeoutSec: '25];out;' }),
/bounded road viewport/,
);
assert.equal(calls.length, 0);
await source.requestRoads(bounds, { majorOnly: true, timeoutSec: 8 });
assert.equal(calls[0][0], '/api/overpass');
const query = new URLSearchParams(calls[0][1].body).get('data');
assert.match(query, /\[timeout:8\]/);
assert.doesNotMatch(query, /residential/);
await source.requestRoads(bounds, { majorOnly: true });
await source.requestRoads(bounds);
assert.match(
new URLSearchParams(calls[1][1].body).get('data'),
/residential/,
assert.deepEqual(
calls.map((c) => c.zoom),
[12, 14],
);
});
test('antimeridian clamps produce road bounds accepted on either side', async () => {
const calls = [];
const source = createTrafficSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"elements":[]}');
},
});
for (const centerLon of [179.99, -179.99]) {
const clamped = clampBoundsAroundCenter(
{
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
},
{ south: -0.02, north: 0.02, west: 179.98, east: -179.98 },
{ lat: 0, lon: centerLon },
);
await source.requestRoads(clamped);
}
assert.equal(calls.length, 2);
assert.equal(calls.length, 4);
});
test('malformed availability is an unavailable source rather than a keyless response', async () => {
const source = createTrafficSource({
@@ -136,91 +122,516 @@ test('traffic construction is inert and parameters belong to each layer', async
assert.equal(b.getParams().uncoveredRoads, 'sim');
});
test('road body parsing retains the source request cancellation signal', async () => {
const controller = new AbortController();
test('road tile replies respect cancellation even when the tile source ignores it', async () => {
const abort = new AbortController();
const source = createTrafficSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => {
controller.abort();
return { elements: [] };
mapTiles: {
async fetchBounds() {
abort.abort();
return { tiles: [{ roads: [] }], partial: false };
},
}),
},
});
const response = await source.requestRoads(bounds, {
signal: controller.signal,
await assert.rejects(source.requestRoads(bounds, { signal: abort.signal }), {
name: 'AbortError',
});
await assert.rejects(response.json(), { name: 'AbortError' });
});
test('road sources decode direction and coordinates before scene construction', async () => {
const geometry = [
{ lat: 30, lon: -97 },
{ lat: 30.001, lon: -97.001 },
];
test('keyed and keyless geometry is identical OpenFreeMap data, independent of TomTom failures', async () => {
const tile = decodeOpenFreeMapTile(
readFileSync(
new URL(
'../../data/fixtures/ofm-austin-14-3743-6745.pbf',
import.meta.url,
),
),
14,
3743,
6745,
);
const box = tileToBBox(14, 3743, 6745);
const calls = [];
const source = createTrafficSource({
fetchImpl: async () =>
Response.json({
elements: [
{ type: 'node', id: 1 },
{ type: 'way', geometry, tags: { highway: 'primary', oneway: '-1' } },
{ type: 'way', geometry, tags: { junction: 'roundabout' } },
{ type: 'way', geometry: [geometry[0]] },
],
}),
fetchImpl: async () => {
throw new Error('HTTP 429');
},
mapTiles: {
fetchBounds: async (_, options) => {
calls.push(options.zoom);
return { tiles: [tile], partial: false };
},
},
});
assert.deepEqual(await (await source.requestRoads(bounds)).json(), {
const keyed = await (await source.requestRoads(box, { live: true })).json();
const keyless = await (
await source.requestRoads(box, { live: false })
).json();
assert.deepEqual(keyed, keyless);
assert.equal(keyed.roadSource, 'OpenStreetMap');
assert.ok(keyed.roads.length > 0);
assert.ok(keyed.roads.every((road) => !road.flow));
assert.deepEqual(calls, [14, 14]);
await assert.rejects(source.fetchFlowForBounds(bounds), /HTTP 429/);
assert.deepEqual(await (await source.requestRoads(box)).json(), keyless);
});
test('traffic status is session-cached after settlement, but cancelled discovery can restart', async () => {
const { createFlow } = await import('./flow.js');
let calls = 0;
const state = {};
const flow = createFlow({
state,
services: { credits: { registerDynamicCredit() {} } },
parts: {},
source: {
async getStatus({ signal }) {
calls++;
signal?.throwIfAborted();
return { hasKey: false };
},
},
});
const first = new AbortController();
await flow.ensureFlowStatus(first.signal);
first.abort();
await flow.ensureFlowStatus(new AbortController().signal);
assert.equal(calls, 1);
const retryState = {};
const retryFlow = createFlow({
state: retryState,
services: { credits: {} },
parts: {},
source: {
async getStatus({ signal }) {
calls++;
signal.throwIfAborted();
return { hasKey: false };
},
},
});
await assert.rejects(retryFlow.ensureFlowStatus(first.signal), {
name: 'AbortError',
});
await retryFlow.ensureFlowStatus(new AbortController().signal);
assert.equal(calls, 3);
});
test('road parsing defers surface reads to the cancellable preparation pass', async () => {
const { createModel } = await import('./model.js');
let lookups = 0;
const model = createModel({
state: {
_viewer: {
scene: {
sampleHeightSupported: true,
sampleHeight() {
assert.fail('offscreen 3D height probe on keyless globe');
},
globe: {
show: true,
getHeight() {
lookups++;
return 0;
},
},
},
},
},
services: {},
parts: {},
source: {},
});
const roads = model.parseRoads({
roads: [
{
coordinates: [
[-97, 30],
[-97.001, 30.001],
[-97.74, 30.27],
[-97.741, 30.271],
],
type: 'primary',
oneway: -1,
},
{
coordinates: [
[-97, 30],
[-97.001, 30.001],
],
type: 'unclassified',
oneway: 1,
type: 'residential',
oneway: 0,
},
],
});
assert.equal(roads.length, 2);
assert.deepEqual(
roads.map((r) => r.oneway),
[1, -1],
);
assert.equal(roads[0].waypoints.length, 2);
assert.equal(lookups, 0);
});
test('an Overpass refusal is named by status, and keeps the status beside the words', () => {
// The panel prints this string verbatim, so the mapping is part of the
// layer's contract with the reader, not an implementation detail.
assert.equal(roadRequestError(429).message, 'Overpass rate-limited');
assert.equal(roadRequestError(504).message, 'Overpass timed out');
test('parsed road cache evicts old views by byte budget and never retains partial snapshots', async () => {
const { cacheRoadSnapshot } = await import('./ingestion.js');
const entries = new Map();
const entry = () => ({
major: [
{
coords: [
[1, 2],
[3, 4],
],
},
],
full: null,
});
const first = entry();
cacheRoadSnapshot(entries, 'old', first);
cacheRoadSnapshot(entries, 'new', entry(), {
maxBytes: first.cacheBytes + 1,
});
assert.deepEqual([...entries.keys()], ['new']);
cacheRoadSnapshot(entries, 'new', entry(), { retain: false });
assert.equal(entries.size, 0);
cacheRoadSnapshot(entries, 'oversized', entry(), { maxBytes: 1 });
assert.equal(entries.size, 0);
});
for (const lat of [51.5, 60])
test(`z14 detail at ${lat} stays centered within the sixteen-tile cap`, async () => {
const box = {
south: lat - 0.025,
north: lat + 0.025,
west: -0.155,
east: -0.105,
};
assert.ok(tilesForBounds(box, 14).length > 16);
const detail = trafficDetailBounds(box);
assert.ok(tilesForBounds(detail, 14).length <= 16);
assert.ok(Math.abs((detail.south + detail.north) / 2 - lat) < 1e-10);
const source = createTrafficSource({
mapTiles: { fetchBounds: async () => ({ tiles: [], partial: false }) },
});
const data = await (await source.requestRoads(box)).json();
assert.equal(data.detailLimited, true);
assert.deepEqual(data.detailBounds, detail);
});
test('TomTom buffers are clipped before caching and preserve direction and flow attributes', async () => {
const core = tileToBBox(12, 935, 1686);
const source = createTrafficSource({
fetchImpl: async () => new Response(fixture),
});
const result = await source.fetchFlowForBounds({
...core,
east: core.east - 1e-9,
south: core.south + 1e-9,
});
const decoded = decodeFlowTile(fixture, 12, 935, 1686);
assert.ok(
decoded.some((r) =>
r.coords.some(
([x, y]) =>
x < core.west || x > core.east || y < core.south || y > core.north,
),
),
);
assert.ok(
result.every((r) =>
r.coords.every(
([x, y]) =>
x >= core.west - 1e-10 &&
x <= core.east + 1e-10 &&
y >= core.south - 1e-10 &&
y <= core.north + 1e-10,
),
),
);
assert.ok(result.some((r) => r.closure));
});
test('a failed detail pass keeps major roads and exposes separate degraded status', async () => {
const { createIngestion } = await import('./ingestion.js');
for (const retained of [false, true]) {
const state = {
_loadGeneration: 0,
_tileCache: new Map(),
_enabled: true,
_parseRoads: (data) => data.roads,
};
let paints = 0;
const ingestion = createIngestion({
state,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: (b) => ({
lat: (b.north + b.south) / 2,
lon: (b.east + b.west) / 2,
}),
},
flow: {
warmFlow: async () => {},
applyFlowThenRender: async () => {
paints++;
if (paints === 1) state._retainedPreview = retained;
return true;
},
},
},
source: {
requestRoads: async (_, opts) => {
if (!opts.majorOnly) throw new Error('tile request failed');
return {
ok: true,
json: async () => ({
roads: [],
roadSource: 'OpenStreetMap tiles',
partial: false,
}),
};
},
},
});
await ingestion.loadRoadsForBounds(bounds, 350);
assert.equal(
paints,
retained ? 2 : 1,
'only an unpainted retained preview needs fallback work',
);
assert.equal(state._roadError, null);
assert.equal(
state._detailError,
'Detailed roads unavailable — OpenFreeMap tiles unavailable',
);
assert.equal(state._roadPartial, true);
assert.equal(state._fetching, false);
clearTimeout(state._retryTimer);
}
});
const ofmDetail = decodeOpenFreeMapTile(
readFileSync(
new URL('../../data/fixtures/ofm-austin-14-3743-6745.pbf', import.meta.url),
),
14,
3743,
6745,
);
async function matchingContext(fetchFlowForBounds) {
const { createState } = await import('./state.js');
const { createModel } = await import('./model.js');
const { createFlow } = await import('./flow.js');
const { createControls } = await import('./controls.js');
const state = createState({ services: {} });
Object.assign(state, {
_liveMode: true,
_enabled: true,
_flowStatusPromise: Promise.resolve(),
});
const source = {
fetchFlowForBounds,
getFlowSessionStats: () => ({ tilesFetched: 1 }),
};
const parts = {
style: {
presetProfileActive: () => false,
jamDensityOn: () => false,
baseDotSize: () => 2,
activeSizeDelta: () => 0,
},
rendering: {
rebuildHeatLines() {},
visibleRoadsForAltitude: (roads) => roads,
},
};
const context = { state, source, parts, services: { credits: {} } };
parts.model = createModel(context);
return {
state,
model: parts.model,
flow: createFlow(context),
controls: createControls(context).methods,
};
}
test('Austin TomTom fixture matches congestion onto OFM detail roads without changing geometry', async () => {
const source = createTrafficSource({
fetchImpl: async () => new Response(fixture),
});
const context = await matchingContext(source.fetchFlowForBounds);
const roads = context.model.parseRoads({ roads: ofmDetail.roads });
const coordinates = structuredClone(roads.map((r) => r.coords));
await context.flow.applyFlowToRoads(roads, tileToBBox(14, 3743, 6745), 0);
const matched = roads.filter((r) => r.flow);
assert.ok(matched.length > 0);
assert.ok(roads.length >= 94);
assert.ok(roads.every((r) => [1, -1].includes(r.oneway)));
assert.ok(matched.some((r) => r.flow.level < 0.8 && r.flow.level > 0));
assert.deepEqual(
roads.map((r) => r.coords),
coordinates,
'flow never substitutes its own polylines',
);
assert.equal(context.state._flowPending, 0);
// Inject closure attributes on the same checked-in TomTom geometry: the
// Austin detail snapshot has no real closures on its matched roads.
const segments = await source.fetchFlowForBounds(tileToBBox(14, 3743, 6745));
const closed = await matchingContext(async () =>
segments.map((s) => ({ ...s, closure: true })),
);
await closed.flow.applyFlowToRoads(roads, bounds, 0);
assert.equal(roads.filter((r) => r.flow?.closure).length, matched.length);
assert.ok(
roads
.filter((r) => r.flow)
.every((r) => closed.model.computeDotCount(r, 350) === 0),
);
});
test('late closures hide and stop dots; coverage counts shown matched dots, and reopening restores them', async () => {
const C = await import('cesium');
const { state, model, controls } = await matchingContext(async () => []);
const matched = { flow: { level: 1, closure: false }, type: 'primary' };
const sim = { flow: null, type: 'residential' };
const closed = { flow: { level: 0, closure: true }, type: 'primary' };
state._roads = [matched, sim, closed];
state._dots = [matched, matched, matched, sim, closed].map((road) => ({
road,
baseMps: 10,
mps: 10,
point: { show: true, color: C.Color.WHITE },
}));
model.recolorDotsInPlace('fixture');
assert.equal(state._dots[4].point.show, false);
assert.equal(state._dots[4].mps, 0);
assert.equal(controls.getStats().count, 4);
assert.equal(controls.getStats().flowCoveragePct, 75);
assert.equal(
state._dots[3].mps,
10,
'unmatched roads retain simulated speed',
);
assert.equal(state._dots[3].bucket, null);
closed.flow = { level: 1, closure: false };
model.recolorDotsInPlace('reopened');
assert.equal(state._dots[4].point.show, true);
assert.equal(state._dots[4].mps, 10);
assert.equal(controls.getStats().count, 5);
assert.equal(controls.getStats().flowCoveragePct, 80);
});
test('refresh failure removes cached matches and reports simulated flow', async () => {
const { flow, state, model, controls } = await matchingContext(async () => {
throw new Error('HTTP 429');
});
const roads = model.parseRoads({ roads: ofmDetail.roads });
roads[0].flow = { level: 0.1, closure: true };
await flow.applyFlowToRoads(roads, bounds, 0);
assert.ok(roads.every((r) => r.flow === null));
assert.equal(controls.getStats().flowCoveragePct, 0);
assert.match(
controls.getStats().loadingLabel,
/SIMULATED — TomTom daily budget reached/,
);
assert.equal(state._flowPending, 0);
});
test('superseded flow cannot apply matches after a camera move or disable', async () => {
let finish;
const { flow, state, model } = await matchingContext(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const roads = model.parseRoads({ roads: ofmDetail.roads });
const pending = flow.applyFlowToRoads(roads, bounds, 0);
while (!finish) await new Promise((resolve) => setTimeout(resolve, 0));
state._loadGeneration++;
state._flowPending = 0;
state._enabled = false;
finish(decodeFlowTile(fixture, 12, 935, 1686));
await pending;
assert.ok(roads.every((r) => r.flow === null));
assert.equal(state._flowPending, 0);
});
test('OSM keyed loads run z12 then z14 and reuse road snapshots while refreshing flow', async () => {
const { createIngestion } = await import('./ingestion.js');
const state = {
_loadGeneration: 0,
_tileCache: new Map(),
_enabled: true,
_liveMode: true,
_roadMode: 'osm',
_parseRoads: (data) => data.roads,
};
const passes = [],
paints = [];
const ingestion = createIngestion({
state,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 30.267, lon: -97.744 }),
},
flow: {
warmFlow: async () => {},
applyFlowThenRender: async (roads) => {
paints.push(roads);
return true;
},
},
},
source: {
requestRoads: async (_, options) => {
passes.push(options.majorOnly);
return {
ok: true,
json: async () => ({
roads: ofmDetail.roads,
roadSource: 'OpenStreetMap',
roadMode: 'osm',
}),
};
},
},
});
await ingestion.loadRoadsForBounds(bounds, 350);
assert.deepEqual(passes, [true, false]);
await ingestion.loadRoadsForBounds(bounds, 350);
assert.deepEqual(
passes,
[true, false],
'repeat view does not fetch road geometry',
);
assert.equal(
paints.length,
3,
'cached roads still pass through flow refresh',
);
});
test('an OpenFreeMap refusal is named by status, and keeps the status beside the words', () => {
assert.equal(roadRequestError(429).message, 'OpenFreeMap tiles rate-limited');
assert.equal(roadRequestError(504).message, 'OpenFreeMap tiles timed out');
assert.equal(
roadRequestError(406).message,
'Overpass refused the road query (HTTP 406)',
'OpenFreeMap tiles unavailable (HTTP 406)',
);
assert.equal(
roadRequestError(500).message,
'Overpass refused the road query (HTTP 500)',
'OpenFreeMap tiles unavailable (HTTP 500)',
);
// 502 and 503 come from the local proxy (mirrors unreachable, local
// limiter busy), so they must not read as a mirror refusing the query.
assert.equal(roadRequestError(502).message, 'Overpass mirrors unreachable');
assert.equal(
roadRequestError(502).message,
'OpenFreeMap tiles unavailable (HTTP 502)',
);
assert.equal(
roadRequestError(503).message,
'Overpass temporarily unavailable',
'OpenFreeMap tiles unavailable (HTTP 503)',
);
assert.equal(roadRequestError(503).status, 503);
// An injected source may refuse without a readable code; a row must never
// print "HTTP undefined" at a reader.
for (const missing of [undefined, null, NaN, 'four-oh-six']) {
const vague = roadRequestError(missing);
assert.equal(vague.message, 'Overpass temporarily unavailable');
assert.equal(vague.message, 'OpenFreeMap tiles unavailable');
assert.equal(vague.status, null, 'an unreadable code is absent, not zero');
}
@@ -234,3 +645,225 @@ test('an Overpass refusal is named by status, and keeps the status beside the wo
'a caller must be able to branch on the code without parsing English',
);
});
for (const phase of ['metadata', 'tile']) {
for (const failure of [406, 429, 503, 504, 'network', 'timeout']) {
test(`OpenFreeMap ${phase} ${failure} retains its reason through the road source`, async () => {
const source = createTrafficSource({
tileFetchImpl: async (url) => {
if (phase === 'tile' && url.endsWith('/planet'))
return Response.json({
tiles: ['https://tiles.openfreemap.org/test/{z}/{x}/{y}.pbf'],
});
if (failure === 'network') throw new TypeError('Failed to fetch');
if (failure === 'timeout')
throw new DOMException('deadline', 'TimeoutError');
return new Response('', { status: failure });
},
});
await assert.rejects(source.requestRoads(bounds), (error) => {
assert.ok(error instanceof RoadRequestError);
assert.equal(
error.status,
typeof failure === 'number' ? failure : null,
);
assert.equal(
error.message,
roadRequestError(
error.status,
failure === 'timeout' ? { name: 'TimeoutError' } : undefined,
).message,
);
assert.equal(error.retryable, true);
return true;
});
});
}
}
test('permanent metadata failures remain non-retryable through the road error wrapper', async () => {
const source = createTrafficSource({
tileFetchImpl: async () => Response.json({}),
});
await assert.rejects(source.requestRoads(bounds), (error) => {
assert.ok(error instanceof RoadRequestError);
assert.equal(error.retryable, false);
assert.equal(error.status, null);
return true;
});
});
test('reticle road requests pass exact bounded tiles and retain distant coarse roads', async () => {
const requests = [];
const source = createTrafficSource({
mapTiles: {
clear() {},
getMetadata: async () => ({}),
async fetchBounds(box, options) {
requests.push(options);
return { tiles: [{ roads: [] }], partial: false };
},
},
});
const box = { south: 30.2, west: -97.8, north: 30.4, east: -97.6 };
const coverage = {
coarse: [{ z: 12, x: 935, y: 1685 }],
detail: [{ z: 14, x: 3743, y: 6745 }],
};
const result = await source.requestOsmRoads(box, { coverage });
assert.deepEqual(
requests.map((r) => r.tiles),
[coverage.detail, coverage.coarse],
);
assert.equal((await result.json()).partial, false);
});
test('footprint requests preserve HTTP failures and cancellation semantics', async () => {
const coverage = { coarse: [{ z: 12, x: 935, y: 1686 }], detail: [] };
const source = createTrafficSource({
mapTiles: {
clear() {},
fetchBounds: async () => {
throw Object.assign(new Error('limited'), { status: 429 });
},
},
});
await assert.rejects(
source.requestOsmRoads(bounds, { coverage }),
(error) => error instanceof RoadRequestError && error.status === 429,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
source.requestOsmRoads(bounds, { coverage, signal: controller.signal }),
{ name: 'AbortError' },
);
});
test('near detail starts and streams while distant coarse tiles are still pending', async () => {
let releaseCoarse;
const coarse = new Promise((resolve) => {
releaseCoarse = resolve;
});
const order = [],
painted = [];
const source = createTrafficSource({
mapTiles: {
clear() {},
fetchBounds: async (box, options) => {
order.push(options.zoom);
if (options.zoom === 12) return coarse;
const tile = {
roads: [
{
coordinates: [
[-97.744, 30.267],
[-97.743, 30.268],
],
type: 'primary',
},
],
};
options.onTile?.(tile);
return { tiles: [tile], partial: false };
},
},
});
const pending = source.requestOsmRoads(bounds, {
coverage: {
detail: [{ z: 14, x: 3743, y: 6745 }],
coarse: [{ z: 12, x: 935, y: 1686 }],
},
onTile: (tile) => painted.push(tile),
});
await Promise.resolve();
assert.deepEqual(order, [14, 12]);
assert.equal(painted.length, 1);
assert.equal(painted[0].roads.length, 1);
releaseCoarse({ tiles: [], partial: false });
await pending;
});
test('failed detail cores retain real coarse roads while successful cores replace them', async () => {
const detail = [
{ z: 14, x: 3743, y: 6745 },
{ z: 14, x: 3744, y: 6745 },
];
const roads = detail.map((tile, i) => {
const b = tileToBBox(tile.z, tile.x, tile.y);
return {
type: 'primary',
id: i,
coordinates: [
[b.west + 0.002, b.south + 0.002],
[b.east - 0.002, b.north - 0.002],
],
};
});
for (const allFailed of [false, true]) {
const source = createTrafficSource({
mapTiles: {
async fetchBounds(box, { zoom }) {
if (zoom === 12) return { tiles: [{ roads }], partial: false };
if (allFailed) throw new Error('temporary tile failure');
return {
tiles: [{ roads: [] }],
loadedTiles: [detail[0]],
partial: true,
};
},
},
});
const snapshots = [];
const data = await (
await source.requestOsmRoads(bounds, {
coverage: { detail, coarse: [{ z: 12, x: 935, y: 1686 }] },
onTile: (value) => snapshots.push(value),
})
).json();
assert.equal(data.partial, true);
assert.equal(data.replace, true);
assert.deepEqual(data.roads, allFailed ? roads : [roads[1]]);
assert.deepEqual(snapshots.at(-1).roads, data.roads);
}
});
test('traffic separates API and public tile transports, including the default tile fetch', async (t) => {
const publicUrls = [],
apiUrls = [];
const publicFetch = async (url) => {
publicUrls.push(url);
return url.endsWith('/planet')
? Response.json({
tiles: ['https://tiles.openfreemap.org/test/{z}/{x}/{y}.pbf'],
})
: new Response(
readFileSync(
new URL(
'../../data/fixtures/ofm-austin-14-3743-6745.pbf',
import.meta.url,
),
),
);
};
t.mock.method(globalThis, 'fetch', publicFetch);
for (const tileFetchImpl of [undefined, publicFetch]) {
const source = createTrafficSource({
tileFetchImpl,
fetchImpl: async (url) => {
assert.ok(url.startsWith('/api/'));
apiUrls.push(url);
return url.endsWith('/status')
? Response.json({ hasKey: true })
: new Response(fixture);
},
});
await source.getStatus();
await source.fetchFlowForBounds(bounds);
assert.ok(
(await (await source.requestOsmRoads(bounds)).json()).roads.length,
);
}
assert.ok(publicUrls.length >= 4);
assert.ok(apiUrls.length >= 4);
});
+26 -7
View File
@@ -1,8 +1,17 @@
import { normalizeRoadMode } from './roadModes.js';
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js';
export function createState({ services }) {
const layerState = {};
layerState._roadMode =
typeof location === 'undefined'
? null
: normalizeRoadMode(
new URLSearchParams(location.search).get('trafficRoads'),
);
// The query override is consumed at construction and shields passive restoration.
layerState._roadModeUrlOverride = layerState._roadMode;
// ─── Module State ──────────────────────────────────────────
/** @type {Cesium.Viewer|null} */
@@ -16,6 +25,14 @@ export function createState({ services }) {
/** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */
layerState._dots = [];
layerState._nextDotId = 1;
layerState._motion = {
publishes: 0,
rebuilds: 0,
added: 0,
removed: 0,
recycled: 0,
};
/** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */
@@ -42,7 +59,7 @@ export function createState({ services }) {
layerState._lastBounds = null;
/** @type {boolean} True while an Overpass fetch is in flight */
/** @type {boolean} True while a road fetch is in flight */
layerState._fetching = false;
@@ -142,9 +159,7 @@ export function createState({ services }) {
/**
* Congestion heat-lines are GroundPolylinePrimitive batches draped onto the
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE) — polylines at the
* dots' single-sample heights depth-fail under the mesh across an oblique
* city view (first A/B capture: 198 lines rendered, zero visible). One
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One
* primitive per bucket so the jam batch can pulse via a single shared
* material uniform.
* @type {Cesium.GroundPolylinePrimitive|null}
@@ -212,6 +227,7 @@ export function createState({ services }) {
/** @type {Promise<void>|null} Session-cached status check (one fetch per session) */
layerState._flowStatusPromise = null;
layerState._flowStatusSignal = null;
/** @type {ReturnType<typeof setInterval>|null} Enable-time retry until the first load commits. */
@@ -221,10 +237,13 @@ export function createState({ services }) {
layerState._retryDelayMs = 1500;
layerState._retryBoundsKey = null;
layerState._roadError = null;
layerState._roadSource = 'OpenStreetMap';
layerState._roadPartial = false;
layerState._roadRetryStopped = false;
layerState._retryAttempts = 0;
/** @type {number} 0–100 int — matched roads / roads with any flow candidates */
layerState._flowCoveragePct = 0;
/** Parsed road set whose current flow request owns the feed status. */
layerState._flowRoads = null;
/** @type {Function|null} Development-only camera moveEnd timing disposer. */
+138
View File
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTrafficSource } from './source.js';
import { createIngestion } from './ingestion.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
for (const hasKey of [false, true])
for (const flowFirst of [false, true]) {
test(`${hasKey ? 'Hybrid' : 'keyless default'} source and ingestion replace snapshots with flow ${flowFirst ? 'before' : 'after'} tiles`, async () => {
const detail = { z: 14, x: 3743, y: 6745 };
const core = tileToBBox(detail.z, detail.x, detail.y);
const local = {
type: 'residential',
oneway: 1,
coordinates: [
[core.west + 0.002, core.south + 0.002],
[core.west + 0.004, core.south + 0.004],
],
};
const distant = {
type: 'primary',
oneway: 1,
coordinates: [
[core.east + 0.01, core.south],
[core.east + 0.02, core.south],
],
};
const flow = [
{
roadType: 'Major road',
trafficLevel: 0.5,
coords: [
[core.east + 0.01, core.north],
[core.east + 0.02, core.north],
],
},
];
const bounds = {
...core,
east: core.east + 0.03,
coverage: {
detail: [detail],
coarse: [{ z: 12, x: 935, y: 1686 }],
key: 'test',
},
};
const source = createTrafficSource({
mapTiles: {
async fetchBounds(box, { zoom, onTile }) {
const tile = { roads: zoom === 14 ? [local] : [distant] };
onTile?.(tile);
return {
tiles: [tile],
loadedTiles: zoom === 14 ? [detail] : bounds.coverage.coarse,
partial: false,
};
},
},
});
const returned = [],
streams = [];
const requestRoads = source.requestRoads;
source.requestRoads = async (box, options) => {
let streamed = [];
const response = await requestRoads(box, {
...options,
onTile(data) {
if (data.replace) streamed = data.roads.slice();
else streamed.push(...data.roads);
options.onTile(data);
},
});
const data = await response.json();
returned.push(data.roads);
streams.push(streamed);
return response;
};
const painted = [];
const state = {
_enabled: true,
_loadGeneration: 0,
_tileCache: new Map(),
_roadMode: hasKey ? 'hybrid' : null,
_liveMode: hasKey,
_parseRoads: (data) => data.roads,
};
const ingestion = createIngestion({
state,
source,
services: {},
parts: {
viewport: {
clampBounds: (b) => b,
getBoundsCenter: () => ({ lat: 30.26, lon: -97.74 }),
},
flow: {
warmFlow: () =>
flowFirst
? Promise.resolve(hasKey ? flow : [])
: new Promise((resolve) =>
setImmediate(() => resolve(hasKey ? flow : [])),
),
applyFlowThenRender: async (
roads,
box,
generation,
altitude,
label,
) => {
if (label === 'Loaded full') painted.push(roads);
return true;
},
},
},
});
await ingestion.loadRoadsForBounds(bounds, 350);
assert.equal(returned.length, 2);
assert.deepEqual(
streams,
returned,
'each completed stream equals its authoritative graph',
);
assert.deepEqual(
painted.at(-1),
returned.at(-1),
'ingestion paints that exact graph',
);
const ids = painted
.at(-1)
.map((r) =>
JSON.stringify([r.coordinates, r.oneway, Boolean(r.directFlow)]),
);
assert.equal(ids.length, hasKey ? 3 : 2);
assert.equal(new Set(ids).size, ids.length);
ingestion.cancelActiveFetch();
clearTimeout(state._retryTimer);
});
}
+1 -1
View File
@@ -104,7 +104,7 @@ export function createStyle({ state: layerState, services, parts, source }) {
const bucket = dot.bucket;
if (!bucket) continue; // sim/uncovered dots stay byte-identical
dot.point.color = layerState._activeBucketColors[bucket];
dot.point.pixelSize =
dot.nominalSize = dot.point.pixelSize =
baseDotSize(dot.road?.type, bucket) +
(bucket === 'jam' ? 1 : 0) +
activeSizeDelta(bucket);
+435
View File
@@ -0,0 +1,435 @@
import { phaseTiming } from '../../sources/phaseTiming.js';
import * as Cesium from 'cesium';
import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.js';
function surfaceTilesReady(scene) {
if (scene.globe?.show) return scene.globe.tilesLoaded !== false;
let found = false;
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
const primitive = scene.primitives.get(i);
if (!primitive.show || typeof primitive.tilesLoaded !== 'boolean') continue;
found = true;
if (!primitive.tilesLoaded) return false;
}
return found;
}
const renderedFrames = new WeakMap();
/** Observe completed frames only while an enabled traffic consumer owns them. */
export function observeTrafficSurface(scene) {
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix)
return () => {};
let frame = renderedFrames.get(scene);
if (!frame) {
frame = {
view: new Cesium.Matrix4(),
projection: new Cesium.Matrix4(),
owners: 0,
};
renderedFrames.set(scene, frame);
frame.remove = scene.postRender.addEventListener(() => {
Cesium.Matrix4.clone(scene.camera.viewMatrix, frame.view);
Cesium.Matrix4.clone(
scene.camera.frustum.projectionMatrix,
frame.projection,
);
frame.surface = trafficSurfaceKey(scene);
frame.ready = surfaceTilesReady(scene);
});
}
frame.owners++;
let released = false;
return () => {
if (released) return;
released = true;
if (--frame.owners === 0) {
frame.remove();
renderedFrames.delete(scene);
}
};
}
function currentRenderedFrame(scene) {
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix)
return { ready: surfaceTilesReady(scene) };
const frame = renderedFrames.get(scene);
return frame &&
frame.surface === trafficSurfaceKey(scene) &&
Cesium.Matrix4.equals(frame.view, scene.camera.viewMatrix) &&
Cesium.Matrix4.equals(
frame.projection,
scene.camera.frustum.projectionMatrix,
)
? frame
: null;
}
/** Only a completed render of this camera can certify cached surface heights. */
export function trafficSurfaceReady(scene) {
return currentRenderedFrame(scene)?.ready === true;
}
/** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */
export function roadSurfaceChunks(coordinates) {
const chunks = [];
let chunk = [coordinates[0]];
let chunkMetres = 0;
for (let i = 1; i < coordinates.length; i++) {
const a = coordinates[i - 1],
b = coordinates[i];
const metres =
Math.hypot(
(b[0] - a[0]) * Math.cos((a[1] * Math.PI) / 180),
b[1] - a[1],
) * 111320;
const steps = Math.max(1, Math.ceil(metres / 150));
for (let j = 1; j <= steps; j++) {
const point =
j === steps
? b
: [
a[0] + ((b[0] - a[0]) * j) / steps,
a[1] + ((b[1] - a[1]) * j) / steps,
];
const stepMetres = metres / steps;
if (chunk.length > 1 && chunkMetres + stepMetres > 600) {
chunks.push(chunk);
chunk = [chunk.at(-1)];
chunkMetres = 0;
}
chunk.push(point);
chunkMetres += stepMetres;
if (chunk.length === MAX_WAYPOINTS_PER_ROAD) {
chunks.push(chunk);
chunk = [point];
chunkMetres = 0;
}
}
}
if (chunk.length > 1) chunks.push(chunk);
return chunks;
}
/** Height stations remain at most 150 m apart without sampling every short bend. */
export function roadHeightStations(coords) {
const distances = [0];
const indices = [0];
let anchor = 0;
for (let i = 1; i < coords.length; i++) {
const a = coords[i - 1],
b = coords[i];
const metres =
Math.hypot(
(b[0] - a[0]) * Math.cos((a[1] * Math.PI) / 180),
b[1] - a[1],
) * 111320;
distances.push(distances[i - 1] + metres);
if (distances[i] - distances[anchor] > 150.001 && i - 1 > anchor) {
anchor = i - 1;
indices.push(anchor);
}
}
if (indices.at(-1) !== coords.length - 1) indices.push(coords.length - 1);
return { indices, distances };
}
const validHeight = (height) =>
Number.isFinite(height) && Math.abs(height) <= 9000;
/**
* Reuse the rendered depth buffer instead of drawing a new pick frustum per
* station. Iteration corrects the projection's height estimate. A depth hit
* is accepted only at this road coordinate (2 m), never on a foreground roof
* or hillside projected onto a different geographic point. Translucent traffic
* is absent from Cesium's default pickPosition depth pass.
*/
export function renderedRoadHeight(scene, lon, lat, estimate) {
if (
!scene.pickPositionSupported ||
scene.pickTranslucentDepth ||
!currentRenderedFrame(scene) ||
!scene.canvas?.clientWidth ||
!scene.frameState
)
return undefined;
let height = validHeight(estimate) ? estimate : 0;
const point = new Cesium.Cartesian3();
const screen = new Cesium.Cartesian2();
for (let attempt = 0; attempt < 4; attempt++) {
Cesium.Cartesian3.fromDegrees(lon, lat, height, undefined, point);
if (
!Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen) ||
screen.x < 0 ||
screen.y < 0 ||
screen.x >= scene.canvas.clientWidth ||
screen.y >= scene.canvas.clientHeight
)
return undefined;
let hit;
try {
hit = scene.pickPosition(screen);
} catch {
return undefined;
}
if (!hit) return undefined;
const location = Cesium.Cartographic.fromCartesian(hit);
if (!location || !validHeight(location.height)) return undefined;
const dx =
(Cesium.Math.toDegrees(location.longitude) - lon) *
Math.cos((lat * Math.PI) / 180) *
111320;
const dy = (Cesium.Math.toDegrees(location.latitude) - lat) * 111320;
if (Math.hypot(dx, dy) <= 2) return location.height;
height = location.height;
}
return undefined;
}
// Per-scene, coordinate-keyed LRU. Never writes raw samples into shared ground floors.
const sceneCaches = new WeakMap();
// Stable ids for the objects heights are sampled from (tilesets, terrain).
const surfaceIds = new WeakMap();
let nextSurfaceId = 1;
const surfaceId = (object) => {
if (!object || typeof object !== 'object') return 0;
if (!surfaceIds.has(object)) surfaceIds.set(object, nextSurfaceId++);
return surfaceIds.get(object);
};
/**
* Name what heights are sampled against: the terrain provider when the globe
* is shown, otherwise the set of visible 3D tilesets. A map-provider switch
* changes it and invalidates every cached height.
*/
const surfaceKeys = new WeakMap();
export function trafficSurfaceKey(scene) {
let state = surfaceKeys.get(scene);
if (!state) {
state = { globe: null, terrain: null, tiles: [], key: '' };
surfaceKeys.set(scene, state);
}
const globe = Boolean(scene.globe?.show);
const terrain = globe ? scene.globe.terrainProvider : null;
let changed = state.globe !== globe || state.terrain !== terrain;
let count = 0;
if (!globe) {
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
const primitive = scene.primitives.get(i);
if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean')
continue;
if (state.tiles[count] !== primitive) {
state.tiles[count] = primitive;
changed = true;
}
count++;
}
}
if (count !== state.tiles.length) {
state.tiles.length = count;
changed = true;
}
if (changed) {
state.globe = globe;
state.terrain = terrain;
// Allocate only when the actual map surface changes, never per frame.
state.key = globe
? `globe:${surfaceId(terrain)}`
: `tiles:${state.tiles.map(surfaceId).join(',')}`;
}
return state.key;
}
/**
* Level-of-detail band for a point seen from the camera: log2 of the
* camera-to-point distance in 100 m steps. Streamed meshes refine roughly
* one level per halving of distance, so a sample taken from a higher band
* came from coarser geometry than the view now shows.
*/
export function detailBand(cameraLon, cameraLat, cameraHeight, lon, lat) {
if (!Number.isFinite(cameraHeight)) return 0;
const dx = (lon - cameraLon) * Math.cos((lat * Math.PI) / 180) * 111320;
const dy = (lat - cameraLat) * 111320;
const distance = Math.sqrt(dx * dx + dy * dy + cameraHeight * cameraHeight);
return Math.max(0, Math.floor(Math.log2(distance / 100)));
}
// Yield to rendering/input between bounded work slices without reserving
// every slice for a separate vsync (which idles the remaining frame budget).
const nextSlice = (hasSurface) =>
hasSurface && globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
/** Prepare only admitted roads in bounded slices; locally missing surfaces defer that road. */
export async function prepareRoadSurfaces(
roads,
scene,
ground,
excluded,
signal,
{ onReady, onMetrics, frameBudgetMs = 12, revalidate = false } = {},
) {
// Cached heights are only as good as the surface and detail they came
// from: a provider switch drops them all, and a point now seen from a
// closer band than its sample is re-sampled against the finer mesh.
const surface = trafficSurfaceKey(scene);
let cache = sceneCaches.get(scene);
if (!cache || cache.surface !== surface) {
cache = { surface, heights: new Map() };
sceneCaches.set(scene, cache);
}
const cameraCarto = scene.camera?.positionCartographic;
const cameraLon = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.longitude)
: 0;
const cameraLat = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.latitude)
: 0;
const cameraHeight = cameraCarto ? cameraCarto.height : NaN;
const start = performance.now();
const metrics = {
sampleCount: 0,
sampleMs: 0,
cacheHits: 0,
depthHits: 0,
roads: roads.length,
pending: 0,
maxSliceMs: 0,
};
let sliceStart = performance.now();
const carto = new Cesium.Cartographic();
const ready = [],
pending = [];
for (const road of roads) {
let resolved = true;
let settledRoad = true;
const { indices, distances } = roadHeightStations(road.coords);
const heights = new Array(road.coords.length);
for (const i of indices) {
signal?.throwIfAborted();
const [lon, lat] = road.coords[i];
const key = `${lon.toFixed(6)},${lat.toFixed(6)}`;
const cached = cache.heights.get(key);
const settled = trafficSurfaceReady(scene);
const band = detailBand(cameraLon, cameraLat, cameraHeight, lon, lat);
let height =
cached &&
(!revalidate || cached.refined) &&
(cached.settled || !settled) &&
band >= cached.band
? cached.height
: undefined;
if (height !== undefined) {
if (!cached.settled) settledRoad = false;
metrics.cacheHits++;
cache.heights.delete(key);
cache.heights.set(key, cached);
} else {
if (!settled) settledRoad = false;
carto.longitude = Cesium.Math.toRadians(lon);
carto.latitude = Cesium.Math.toRadians(lat);
carto.height = 0;
const floor = ground?.cachedGroundFloor?.(lat, lon);
let sampled;
// sampleHeight reads the locally rendered mesh, not unresolved/offscreen
// tiles. Global tilesLoaded can stay false while this street is usable.
if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto);
else {
const estimate = Cesium.Cartographic.fromCartesian(
road.waypoints[i],
)?.height;
sampled = renderedRoadHeight(scene, lon, lat, estimate);
if (validHeight(sampled)) metrics.depthHits++;
}
if (
!scene.globe?.show &&
!validHeight(sampled) &&
scene.sampleHeightSupported
) {
const sampleStart = performance.now();
metrics.sampleCount++;
const shown = (excluded || []).filter((p) => p?.show);
for (const primitive of shown) primitive.show = false;
try {
sampled = scene.sampleHeight(carto, excluded);
} catch {
/* local mesh missing */
} finally {
for (const primitive of shown) primitive.show = true;
}
metrics.sampleMs += performance.now() - sampleStart;
}
// A shared floor is a safe provisional waypoint, but not permission to
// display a photoreal road: only local measured mesh heights admit it.
if (!validHeight(sampled)) {
// A coarser measured height still beats an unresolved road; the
// finer sample is retried on the next pass.
if (cached) height = cached.height;
else {
resolved = false;
height = validHeight(floor) ? floor : 0;
}
} else {
height = validHeight(floor) ? Math.max(sampled, floor) : sampled;
cache.heights.delete(key);
cache.heights.set(key, {
height,
settled,
band,
refined: revalidate,
});
while (cache.heights.size > 40000)
cache.heights.delete(cache.heights.keys().next().value);
}
}
heights[i] = height;
const elapsed = performance.now() - sliceStart;
if (elapsed >= frameBudgetMs) {
metrics.maxSliceMs = Math.max(metrics.maxSliceMs, elapsed);
// Before the first local mesh exists, give its tile/worker tasks the
// normal queue priority instead of repeatedly probing an empty scene.
await nextSlice(metrics.cacheHits + metrics.depthHits > 0);
sliceStart = performance.now();
}
}
let station = 1;
for (let i = 0; i < road.coords.length; i++) {
while (station < indices.length - 1 && i > indices[station]) station++;
const a = indices[station - 1],
b = indices[station];
const span = distances[b] - distances[a];
const t = span > 0 ? (distances[i] - distances[a]) / span : 0;
const height = heights[a] + (heights[b] - heights[a]) * t;
const [lon, lat] = road.coords[i];
const floor = ground?.cachedGroundFloor?.(lat, lon);
Cesium.Cartesian3.fromDegrees(
lon,
lat,
(validHeight(floor) ? Math.max(height, floor) : height) +
DOT_HEIGHT_OFFSET,
undefined,
road.waypoints[i],
);
}
for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance(
road.waypoints[i],
road.waypoints[i + 1],
);
road.surfaceReady = resolved;
road.surfaceSettled = settledRoad;
if (resolved) {
ready.push(road);
onReady?.(road);
} else pending.push(road);
}
metrics.pending = pending.length;
metrics.maxSliceMs = Math.max(
metrics.maxSliceMs,
performance.now() - sliceStart,
);
phaseTiming('surface', start, metrics);
onMetrics?.(metrics);
signal?.throwIfAborted();
return { ready, pending, metrics };
}
+405
View File
@@ -0,0 +1,405 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import {
detailBand,
renderedRoadHeight,
prepareRoadSurfaces,
roadSurfaceChunks,
roadHeightStations,
trafficSurfaceReady,
trafficSurfaceKey,
observeTrafficSurface,
} from './surface.js';
const road = (coords) => ({
coords,
waypoints: coords.map(() => new C.Cartesian3()),
segmentDist: coords.slice(1).map(() => 0),
});
test('surface points preserve bends, bound spacing, and split rather than decimate long roads', () => {
const chunks = roadSurfaceChunks([
[0, 0],
[0.2, 0],
[0.2, 0.1],
]);
assert.ok(chunks.length > 1);
assert.ok(chunks.every((c) => c.length <= 80));
for (const chunk of chunks) {
let length = 0;
for (let i = 1; i < chunk.length; i++)
length +=
Math.hypot(
(chunk[i][0] - chunk[i - 1][0]) *
Math.cos((chunk[i - 1][1] * Math.PI) / 180),
chunk[i][1] - chunk[i - 1][1],
) * 111320;
assert.ok(
length <= 600.01,
'short chunks keep the population budget near the visible road',
);
}
assert.ok(chunks.flat().some((p) => p[0] === 0.2 && p[1] === 0));
for (const c of chunks)
for (let i = 1; i < c.length; i++)
assert.ok(
Math.hypot(c[i][0] - c[i - 1][0], c[i][1] - c[i - 1][1]) * 111320 <=
150.01,
);
});
test('per-vertex mesh heights follow terrain, reject invalid samples, and leave shared cache unmodified', async () => {
const heights = [100, 200, NaN, 9001, -9001];
let samples = 0;
const r = road([
[0, 0],
[0.001, 0],
[0.002, 0],
[0.003, 0],
[0.004, 0],
]);
const tileset = { show: true, tilesLoaded: true };
const scene = {
globe: { show: false },
primitives: { length: 1, get: () => tileset },
sampleHeightSupported: true,
sampleHeight: () => heights[samples++],
};
const ground = {
cachedGroundFloor: () => 50,
reportMeshFloorCell: () => assert.fail('raw sample poisoned shared floor'),
};
let metrics;
const prepared = await prepareRoadSurfaces([r], scene, ground, [], null, {
onMetrics: (value) => (metrics = value),
});
assert.equal(metrics.sampleCount, 5);
assert.equal(prepared.pending.length, 1);
assert.equal(samples, 5);
const actual = r.waypoints.map((p) =>
Math.round(C.Cartographic.fromCartesian(p).height),
);
assert.deepEqual(actual, [103, 203, 53, 53, 53]);
tileset.tilesLoaded = false;
assert.equal(trafficSurfaceReady(scene), false);
scene.sampleHeight = () => 75;
const resumed = await prepareRoadSurfaces([r], scene, ground, []);
assert.equal(
resumed.ready.length,
1,
'other loading tiles do not block a local mesh',
);
let picks = 0;
scene.sampleHeight = () => {
picks++;
return 75;
};
await prepareRoadSurfaces([r], scene, ground, []);
assert.equal(picks, 0, 'validated coordinate heights survive cache hits');
const abort = new AbortController();
abort.abort();
await assert.rejects(
prepareRoadSurfaces([r], scene, ground, [], abort.signal),
{ name: 'AbortError' },
);
});
test('visible-globe roads floor each vertex without mesh picks', async () => {
const r = road([
[0, 0],
[0.001, 0],
]);
let calls = 0;
await prepareRoadSurfaces(
[r],
{
globe: { show: true, tilesLoaded: true, getHeight: () => ++calls * 100 },
sampleHeight: () => assert.fail('mesh sampled'),
},
{ cachedGroundFloor: () => 20 },
[],
);
assert.equal(calls, 2);
assert.deepEqual(
r.waypoints.map((p) => Math.round(C.Cartographic.fromCartesian(p).height)),
[103, 203],
);
});
test('cached heights follow detail and provider; same-detail revisits sample nothing', async () => {
const r = road([
[0, 0],
[0.001, 0],
]);
const tileset = { show: true, tilesLoaded: true };
let mesh = 40,
samples = 0;
const camera = (height) => ({
positionCartographic: { longitude: 0, latitude: 0, height },
});
const scene = {
globe: { show: false },
primitives: { length: 1, get: () => tileset },
sampleHeightSupported: true,
sampleHeight: () => (samples++, mesh),
camera: camera(3000),
};
const heightAt = () =>
Math.round(C.Cartographic.fromCartesian(r.waypoints[0]).height);
// Settled but coarse: sampled from 3 km.
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 2);
assert.equal(heightAt(), 43);
// Same detail band on a revisit: zero samples.
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 2);
// Zooming out never re-samples a finer height either.
scene.camera = camera(6000);
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 2);
// Closer view, finer mesh: re-sampled and the waypoint follows it.
mesh = 47;
scene.camera = camera(250);
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 4);
assert.equal(heightAt(), 50);
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 4, 'the finer sample is now the cached one');
// A different photoreal tileset (map provider switch) invalidates all,
// even from a coarser view that would otherwise reuse the finer sample.
const other = { show: true, tilesLoaded: true };
scene.primitives.get = () => other;
scene.camera = camera(3000);
mesh = 30;
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 6);
assert.equal(heightAt(), 33);
// A finer sample that fails keeps the coarser measured height.
scene.camera = camera(250);
mesh = NaN;
const kept = await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 8);
assert.equal(kept.ready.length, 1);
assert.equal(heightAt(), 33);
});
test('detail bands double with distance and ignore a missing camera', () => {
assert.equal(detailBand(0, 0, NaN, 0, 0), 0);
assert.equal(detailBand(0, 0, 150, 0, 0), 0);
assert.equal(detailBand(0, 0, 250, 0, 0), 1);
assert.equal(detailBand(0, 0, 3000, 0, 0), 4);
assert.ok(detailBand(0, 0, 250, 0.02, 0) > detailBand(0, 0, 250, 0, 0));
});
test('a camera jump cannot certify old rendered depth or cache coarse heights as settled', async () => {
const tileset = { show: true, tilesLoaded: true };
let height = 20,
samples = 0;
const scene = {
postRender: new C.Event(),
camera: {
viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY),
frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) },
},
globe: { show: false },
primitives: { length: 1, get: () => tileset },
sampleHeightSupported: true,
sampleHeight: () => (samples++, height),
};
assert.equal(trafficSurfaceReady(scene), false);
const removeSurfaceObserver = observeTrafficSurface(scene);
scene.postRender.raiseEvent();
assert.equal(trafficSurfaceReady(scene), true);
scene.camera.viewMatrix[12] = 100;
assert.equal(
trafficSurfaceReady(scene),
false,
'old camera readiness is stale',
);
const r = road([
[0, 0],
[0.001, 0],
]);
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(r.surfaceSettled, false);
height = 80;
scene.postRender.raiseEvent();
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 4, 'the first fresh render revalidates coarse heights');
assert.equal(r.surfaceSettled, true);
assert.ok(
Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 83) < 0.01,
);
height = 100;
await prepareRoadSurfaces([r, r], scene, null, [], null, {
revalidate: true,
});
assert.equal(
samples,
6,
'the bounded refinement replaces initially settled coarse samples too',
);
removeSurfaceObserver();
assert.ok(
Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 103) < 0.01,
);
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 6, 'ordinary same-band revisits still reuse the cache');
await prepareRoadSurfaces([r], scene, null, [], null, { revalidate: true });
assert.equal(
samples,
6,
'already refined same-band coordinates are not sampled twice',
);
});
test('height stations preserve bends while bounding sample spacing along the road', async () => {
const coords = Array.from({ length: 31 }, (_, i) => [
i * 0.0001,
(i % 2) * 0.00001,
]);
const { indices, distances } = roadHeightStations(coords);
assert.ok(indices.length <= 4);
for (let i = 1; i < indices.length; i++)
assert.ok(distances[indices[i]] - distances[indices[i - 1]] <= 150.001);
const r = road(coords);
let picks = 0;
await prepareRoadSurfaces(
[r],
{
globe: {
show: true,
tilesLoaded: true,
getHeight: (c) => {
picks++;
return C.Math.toDegrees(c.longitude) * 1000;
},
},
},
null,
[],
);
assert.equal(picks, indices.length);
for (let i = 0; i < coords.length; i++) {
const c = C.Cartographic.fromCartesian(r.waypoints[i]);
assert.ok(Math.abs(C.Math.toDegrees(c.longitude) - coords[i][0]) < 1e-9);
assert.ok(Math.abs(C.Math.toDegrees(c.latitude) - coords[i][1]) < 1e-9);
assert.ok(Math.abs(c.height - (coords[i][0] * 1000 + 3)) < 0.01);
}
});
test('rendered road depth is geographically checked, bounded, and avoids mesh redraws', async (t) => {
t.mock.method(
C.SceneTransforms,
'worldToWindowCoordinates',
(scene, point, out) => {
out.x = 100;
out.y = 100;
return out;
},
);
const scene = {
globe: { show: false },
frameState: {},
canvas: { clientWidth: 800, clientHeight: 600 },
pickPositionSupported: true,
sampleHeightSupported: true,
pickPosition: () => C.Cartesian3.fromDegrees(-97, 30, 100),
sampleHeight: () =>
assert.fail('valid depth must not draw another pick frustum'),
};
assert.ok(Math.abs(renderedRoadHeight(scene, -97, 30, 0) - 100) < 0.001);
const r = road([
[-97, 30],
[-97, 30.000005],
]);
r.waypoints = r.coords.map(([lon, lat]) =>
C.Cartesian3.fromDegrees(lon, lat, 50),
);
const result = await prepareRoadSurfaces([r], scene, null, []);
assert.equal(result.metrics.depthHits, 2);
assert.equal(result.metrics.sampleCount, 0);
let calls = 0;
scene.pickPosition = () => {
calls++;
return C.Cartesian3.fromDegrees(-97.001, 30, 150);
};
assert.equal(
renderedRoadHeight(scene, -97, 30, 0),
undefined,
'foreground roof is not the road coordinate',
);
assert.equal(calls, 4);
scene.pickPosition = () => C.Cartesian3.fromDegrees(-97, 30, 10000);
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined);
scene.pickPosition = () => {
throw new Error('depth unavailable');
};
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined);
scene.pickTranslucentDepth = true;
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined);
});
test('surface keys reuse identity without building arrays or strings on unchanged frames', (t) => {
const tiles = [
{ show: true, tilesLoaded: true },
{ show: true, tilesLoaded: false },
];
const scene = {
globe: { show: false, terrainProvider: {} },
primitives: {
get: (i) => tiles[i],
get length() {
return tiles.length;
},
},
camera: {
viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY),
frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) },
},
postRender: new C.Event(),
};
const first = trafficSurfaceKey(scene);
const remove = observeTrafficSurface(scene);
const originalMap = Array.prototype.map;
const originalJoin = Array.prototype.join;
let maps = 0,
joins = 0;
Array.prototype.map = function (...args) {
maps++;
return originalMap.apply(this, args);
};
Array.prototype.join = function (...args) {
joins++;
return originalJoin.apply(this, args);
};
try {
for (let i = 0; i < 1000; i++) {
scene.postRender.raiseEvent();
assert.equal(trafficSurfaceKey(scene), first);
}
} finally {
Array.prototype.map = originalMap;
Array.prototype.join = originalJoin;
}
assert.equal(maps, 0);
assert.equal(joins, 0);
tiles[1].show = false;
const hidden = trafficSurfaceKey(scene);
assert.notEqual(hidden, first);
tiles[1] = { show: true, tilesLoaded: true };
assert.notEqual(
trafficSurfaceKey(scene),
first,
'same-count tileset replacement is a new surface',
);
scene.globe.show = true;
const terrain = trafficSurfaceKey(scene);
scene.globe.terrainProvider = {};
assert.notEqual(trafficSurfaceKey(scene), terrain);
remove();
remove();
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(
trafficSurfaceReady(scene),
false,
'readiness cannot recreate a subscription',
);
});
+39 -89
View File
@@ -1,9 +1,6 @@
import * as Cesium from 'cesium';
import {
TRAFFIC_TIMING_ENABLED,
MAX_WAYPOINTS_PER_ROAD,
DOT_HEIGHT_OFFSET,
} from './policy.js';
import { roadSurfaceChunks } from './surface.js';
import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js';
export function createTiming({ state: layerState, services, parts, source }) {
/**
@@ -221,18 +218,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
_trafficTimingParseEnd,
{ roadCount: 0 },
);
trafficTimingAggregate(
'sample-height-total',
_trafficTimingState,
_trafficTimingParseStartTime,
0,
{
sampleHeightCalls: 0,
sampleHeightMeanMs: 0,
distinctCells: 0,
roadCount: 0,
},
);
trafficTimingAggregate(
'waypoint-materialization',
_trafficTimingState,
@@ -246,95 +232,59 @@ export function createTiming({ state: layerState, services, parts, source }) {
const roads = [];
/* TRACE_ONLY_BEGIN */
const _trafficTimingSampledCells = new Set();
let _trafficTimingSampleHeightCalls = 0;
let _trafficTimingSampleHeightMs = 0;
let _trafficTimingWaypointMaterializationMs = 0;
/* TRACE_ONLY_END */
for (const road of roadData.roads) {
if (!road.coordinates || road.coordinates.length < 2) continue;
const rawCoords = road.coordinates;
const simplifyStep =
rawCoords.length > MAX_WAYPOINTS_PER_ROAD
? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD)
: 1;
const coords = [];
for (let i = 0; i < rawCoords.length; i += simplifyStep) {
coords.push(rawCoords[i]);
}
for (const coords of roadSurfaceChunks(road.coordinates)) {
const type = road.type;
const oneway = road.oneway;
const last = rawCoords[rawCoords.length - 1];
const tail = coords[coords.length - 1];
if (!tail || tail[0] !== last[0] || tail[1] !== last[1]) {
coords.push(last);
}
if (coords.length < 2) continue;
const type = road.type;
const oneway = road.oneway;
let baseHeight = 0;
const firstCoord = coords[0];
if (layerState._viewer?.scene?.sampleHeightSupported && firstCoord) {
/* TRACE_ONLY_BEGIN */
_trafficTimingSampleHeightCalls += 1;
_trafficTimingSampledCells.add(
`${firstCoord[1].toFixed(3)},${firstCoord[0].toFixed(3)}`,
);
const _trafficTimingMaterializeStart = performance.now();
/* TRACE_ONLY_END */
const carto = Cesium.Cartographic.fromDegrees(
firstCoord[0],
firstCoord[1],
);
const waypoints = coords.map(([lng, lat]) => {
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
const h =
(Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor
: layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h);
});
const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]),
);
}
/* TRACE_ONLY_BEGIN */
const _trafficTimingSampleStart = performance.now();
_trafficTimingWaypointMaterializationMs +=
performance.now() - _trafficTimingMaterializeStart;
/* TRACE_ONLY_END */
const sampled = layerState._viewer.scene.sampleHeight(carto);
/* TRACE_ONLY_BEGIN */
_trafficTimingSampleHeightMs +=
performance.now() - _trafficTimingSampleStart;
/* TRACE_ONLY_END */
if (Number.isFinite(sampled)) baseHeight = sampled;
}
/* TRACE_ONLY_BEGIN */
const _trafficTimingMaterializeStart = performance.now();
/* TRACE_ONLY_END */
const waypoints = coords.map(([lng, lat]) => {
const h = baseHeight + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h);
});
const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]),
);
for (const direction of oneway ? [oneway] : [1, -1])
roads.push({
densityWeight: road.densityWeight ?? (oneway ? 1 : 0.5),
drivable: road.drivable,
roadClass: road.roadClass,
roadProperties: road.roadProperties,
directFlow: road.directFlow,
simulatedOnly: road.simulatedOnly,
coords,
type,
oneway: direction,
waypoints,
segmentDist,
flow: road.flow || null,
});
}
/* TRACE_ONLY_BEGIN */
_trafficTimingWaypointMaterializationMs +=
performance.now() - _trafficTimingMaterializeStart;
/* TRACE_ONLY_END */
roads.push({ coords, type, oneway, waypoints, segmentDist });
}
/* TRACE_ONLY_BEGIN */
const _trafficTimingMetrics = {
roadCount: roads.length,
sampleHeightCalls: _trafficTimingSampleHeightCalls,
sampleHeightMeanMs: _trafficTimingSampleHeightCalls
? _trafficTimingSampleHeightMs / _trafficTimingSampleHeightCalls
: 0,
distinctCells: _trafficTimingSampledCells.size,
};
trafficTimingAggregate(
'sample-height-total',
_trafficTimingState,
_trafficTimingParseStartTime,
_trafficTimingSampleHeightMs,
_trafficTimingMetrics,
);
trafficTimingAggregate(
'waypoint-materialization',
_trafficTimingState,
+51 -51
View File
@@ -1,3 +1,4 @@
import { createReticleFootprint } from './footprint.js';
import * as Cesium from 'cesium';
import {
deriveFetchCenter,
@@ -13,6 +14,7 @@ import {
} from './policy.js';
export function createViewport({ state: layerState, services, parts, source }) {
const reticleFootprint = createReticleFootprint();
// ─── Camera Monitoring ─────────────────────────────────────
/**
@@ -164,32 +166,16 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function clampBounds(bounds) {
return clampBoundsAroundCenter(bounds, getBoundsCenter(bounds));
return bounds.coverage
? bounds
: clampBoundsAroundCenter(bounds, getBoundsCenter(bounds));
}
/**
* Camera-change handler — the main entry point for viewport-driven road loading.
*
* Gating logic:
* 1. If the camera is above ACTIVATION_ALTITUDE, clear all dots and bail.
* 2. Clamp the view bounds and compute the viewport center.
* 3. Skip the fetch if the new viewport significantly overlaps the last-fetched
* bounds AND the center has shifted less than MIN_CENTER_SHIFT_KM. This
* prevents redundant fetches during small pans.
* 4. Otherwise, debounce and schedule `loadRoadsForBounds`.
*/
function onCameraChanged() {
/** Debounce camera events and sample the actual settled view, including final flight steps. */
function onCameraChanged(options) {
if (!layerState._enabled) return;
const alt = getCameraAltitude();
// Above activation altitude — remove all traffic and stop.
// Also null the last-fetch gate: otherwise zooming back down to the SAME
// 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._fetchTimeout);
if (getCameraAltitude() > ACTIVATION_ALTITUDE) {
clearTimeout(layerState._retryTimer);
layerState._retryTimer = null;
parts.ingestion.cancelActiveFetch();
@@ -202,38 +188,52 @@ export function createViewport({ state: layerState, services, parts, source }) {
layerState._lastViewCenter = null;
return;
}
const bounds = getViewBounds();
if (!bounds) return;
// C4 fix: center the fetch box on the camera's look-at ground point (with
// nadir fallback + 12 km horizon-gaze pull-back), NOT the view rectangle's
// midpoint — at oblique pitch that midpoint drifts toward the horizon.
const fetchCenter = getFetchCenter();
const clamped = fetchCenter
? clampBoundsAroundCenter(bounds, fetchCenter)
: clampBounds(bounds);
const center = getBoundsCenter(clamped);
// Skip re-fetch when viewport overlap is high and center shift is negligible
if (
layerState._lastBounds &&
layerState._lastViewCenter &&
boundsOverlap(clamped, layerState._lastBounds, OVERLAP_THRESHOLD) &&
distanceKm(center, layerState._lastViewCenter) < MIN_CENTER_SHIFT_KM
) {
return;
}
// Debounce: wait for camera to settle before triggering a fetch. In debug
// captures the final changed event that arms this exact timeout is its
// causal anchor; Cesium's later moveEnd notification is diagnostic only.
const interactionAnchor = TRAFFIC_TIMING_ENABLED
? parts.timing.markTrafficTimingCameraChange()
: null;
clearTimeout(layerState._fetchTimeout);
layerState._fetchTimeout = setTimeout(
() => layerState._loadRoadsForBounds(clamped, alt, interactionAnchor),
FETCH_DEBOUNCE,
() => {
if (!layerState._enabled) return;
const alt = getCameraAltitude();
if (alt > ACTIVATION_ALTITUDE) return onCameraChanged();
const footprint = reticleFootprint(layerState._viewer, alt);
const bounds = footprint || getViewBounds();
if (!bounds) return;
const center = footprint ? null : getFetchCenter();
const clamped =
footprint ||
(center
? clampBoundsAroundCenter(bounds, center)
: clampBounds(bounds));
const previous = layerState._lastBounds;
const sameCoverage = clamped.coverage
? previous?.coverage?.key === clamped.coverage.key &&
previous.coverage.viewKey === clamped.coverage.viewKey
: previous &&
layerState._lastViewCenter &&
boundsOverlap(clamped, previous, OVERLAP_THRESHOLD) &&
distanceKm(getBoundsCenter(clamped), layerState._lastViewCenter) <
MIN_CENTER_SHIFT_KM;
if (
sameCoverage &&
!layerState._roadPartial &&
!layerState._detailError &&
!layerState._roadError
) {
if (
Math.abs(alt - layerState._lastRenderAltitude) > 100 &&
layerState._roads.length
)
parts.rendering.renderRoadsForAltitude(
layerState._roads,
alt,
'Cached zoom',
);
return;
}
return layerState._loadRoadsForBounds(clamped, alt, interactionAnchor);
},
options?.immediate === true ? 0 : FETCH_DEBOUNCE,
);
}
return {