mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled worldwide name index; wide views show named points. ALPR reads an hourly OpenStreetMap extract. Cockpit place context and country, state and US county outlines use bundled Natural Earth and US Census data. Overpass is used only when an operator configures their own endpoint. Also: camera arrivals no longer land below ground, keyless terrain retries on 429, bounded relation geometry for configured Overpass, one shared OpenStreetMap credit, and separate tile and API transports for embedders. Co-authored-by: theGaryLarson <[email protected]> Co-authored-by: Milan Khanal <[email protected]>
This commit is contained in:
200 files changed
+26665
-1977
No files matched your search
@@ -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