mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'origin/main' into feat/king-county-camera-pack
# Conflicts: # CHANGELOG.md # docs/CURRENT-STATE.md
This commit is contained in:
commit
031df60014
201 files changed
+26687
-1996
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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&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.')));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+1
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+93
-2
@@ -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',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Vendored
+172
-11
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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('|'),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user