refactor: separate traffic and bikeshare components

This commit is contained in:
Sameh Khamis committed 2026-09-13 14:10:38 -07:00
1 parent 3143d00c90
commit 903fe6ba37
45 files changed
+6159 -4363

No files matched your search

+314
View File
@@ -0,0 +1,314 @@
import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js';
import { presetDotOutline } from '../../data/trafficPresetStyle.js';
import * as Cesium from 'cesium';
import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js';
import {
SPEED_MPS,
MAX_DOTS,
JAM_DOT_FAR_SCALE,
JAM_DOT_DEPTH_PUNCH,
CREEP_MOVE_MS,
CREEP_STOP_MS,
CREEP_BURST,
HEAT_JAM_BASE_ALPHA,
HEAT_JAM_PULSE_ALPHA,
} from './policy.js';
export function createAnimation({
state: layerState,
services,
parts,
source,
}) {
/**
* Spawn animated dot primitives along a single road.
*
* Each dot is placed at a random position along the road, assigned a
* randomized speed (base +/-30%), and given a direction (alternating
* forward/backward to simulate two-way traffic).
*
* @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road
* Parsed road object with pre-computed waypoints.
* @param {number} altitude - Camera altitude (used if budgetCount is null).
* @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back
* to `computeDotCount` when null.
*/
function spawnDotsForRoad(road, altitude, budgetCount = null) {
// Live flow styling (`road.flow` only exists in live mode; keyless path is
// byte-identical): closures spawn nothing, congestion colors/slows dots.
const flow = layerState._liveMode ? road.flow : null;
if (flow?.closure) return;
if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide')
return;
const count = Number.isFinite(budgetCount)
? Math.max(0, Math.floor(budgetCount))
: parts.model.computeDotCount(road, altitude);
const numSegments = road.waypoints.length - 1;
if (numSegments < 1 || count <= 0) return;
const baseMps = SPEED_MPS[road.type] || 5;
const bucket = flow ? flowBucket(flow.level) : null;
// Jam dots get +1px: a red queue should read as a queue at a glance.
// Preset-aware styling adds its own size delta and floors the base (0 /
// no floor under the normal profile) so NVG/FLIR/CRT dots stay PRESENT.
const pixelSize =
parts.style.baseDotSize(road.type, bucket) +
(bucket === 'jam' ? 1 : 0) +
parts.style.activeSizeDelta(bucket);
const flowColor = bucket ? layerState._activeBucketColors[bucket] : null;
// Dark-halo outline under styled presets (null = shipped no-outline).
const outlineSpec =
bucket && layerState._presetDots === 'on'
? presetDotOutline(layerState._stylePreset, bucket)
: null;
const outlineColor = outlineSpec
? new Cesium.Color(
outlineSpec.rgba[0] / 255,
outlineSpec.rgba[1] / 255,
outlineSpec.rgba[2] / 255,
outlineSpec.rgba[3],
)
: null;
const flowSpeed = flow ? flowSpeedScale(flow.level) : 1;
const now = Date.now();
// Jam-viz density prototype: jam-road dots spawn as bumper-to-bumper
// platoons (one shared direction per queue) instead of uniform scatter.
// Unreachable in sim mode — `bucket` requires flow.
let placements = null;
if (bucket === 'jam' && parts.style.jamDensityOn()) {
let totalLen = 0;
for (const d of road.segmentDist) totalLen += d;
const platoons = queuePlatoons(totalLen, count);
if (platoons.length) {
placements = [];
for (let p = 0; p < platoons.length; p++) {
const dir = road.oneway ? road.oneway : p % 2 === 0 ? 1 : -1;
for (const s of platoons[p]) {
const { segIdx, t } = locateAlongRoad(road.segmentDist, s);
placements.push({ segIdx, t, direction: dir });
}
}
}
}
for (let i = 0; i < count; i++) {
if (layerState._dots.length >= MAX_DOTS) return;
// Random start position: pick a random segment and offset within it —
// unless this is a queued jam dot with a platoon placement.
const segIdx = placements
? placements[i].segIdx
: Math.floor(Math.random() * numSegments);
const t = placements ? placements[i].t : Math.random();
// Speed noise: base speed +/-30% for organic variation. baseMps (noise
// included, flow excluded) is kept on the dot so a late-arriving flow
// match can rescale speed in place (recolorDotsInPlace).
const noisedMps =
baseMps * layerState._speedScale * (0.7 + Math.random() * 0.6);
const mps = noisedMps * flowSpeed;
// One-way roads flow only their legal direction; two-way alternates
// (per platoon in queue mode — a queue moves as one).
const direction = placements
? placements[i].direction
: road.oneway
? road.oneway
: i % 2 === 0
? 1
: -1;
// Compute initial Cartesian3 position via linear interpolation
Cesium.Cartesian3.lerp(
road.waypoints[segIdx],
road.waypoints[segIdx + 1],
t,
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.
const jamProminent = bucket === 'jam' && parts.style.jamDensityOn();
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.clone(layerState._scratchLerp),
pixelSize,
// No flow data → today's exact simulated white.
color: flowColor || Cesium.Color.WHITE.withAlpha(0.85),
scaleByDistance: new Cesium.NearFarScalar(
100,
1.5,
layerState._fadeScaleFar,
jamProminent ? JAM_DOT_FAR_SCALE : 0.3,
),
translucencyByDistance: new Cesium.NearFarScalar(
100,
1.0,
layerState._fadeTransFar,
0.0,
),
// visible through tiles only when very close (jam: city-scale punch)
disableDepthTestDistance: jamProminent ? JAM_DOT_DEPTH_PUNCH : 2000,
// Preset dark halo (spread only when present — the keyless/normal
// path passes the exact shipped option set).
...(outlineSpec
? { outlineColor, outlineWidth: outlineSpec.width }
: {}),
});
layerState._bucketCounts[bucket || 'sim'] += 1;
layerState._dots.push({
point,
road,
bucket, // flow bucket at spawn (null = sim) — drives preset restyle/pulse
waypoints: road.waypoints,
segmentDist: road.segmentDist,
numSegments,
segIdx,
t,
mps, // meters per second (flow-scaled)
baseMps: noisedMps, // pre-flow speed, for in-place flow rescale
direction,
stoppedUntil: 0,
// Stop-and-go creep state (jam-viz density prototype): jam dots
// alternate move-bursts and stops. Null in sim mode and for non-jam.
creep:
bucket === 'jam' && parts.style.jamDensityOn()
? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 }
: null,
});
}
}
/**
* Per-frame animation callback registered on `scene.preRender`.
*
* For every active dot:
* 1. Skip if currently paused by a simulated stop-light.
* 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.
* 4. Linearly interpolate between the two bounding waypoints and update the
* point primitive's position.
*
* Delta time is capped at 100 ms to prevent large jumps after background tabs.
*/
function animate() {
const now = Date.now();
// Delta time in seconds, capped to avoid jumps when returning from background tab
const dt = layerState._lastAnimTime
? Math.min((now - layerState._lastAnimTime) / 1000, 0.1)
: 0.016;
layerState._lastAnimTime = now;
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;
// 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;
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;
}
// 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;
}
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;
}
maybeStopLight(dot, now);
}
// Lerp between pre-computed Cartesian3 waypoints (no trig needed).
// Pass the scratch directly: PointPrimitive's position setter clones the
// value into its own storage (and skips the VBO dirty flag when equal), so
// the extra defensive clone here allocated 360–720k Cartesian3/s of pure
// garbage across 6000 dots. (perf item 5)
const a = dot.waypoints[dot.segIdx];
const b = dot.waypoints[dot.segIdx + 1];
Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp);
dot.point.position = layerState._scratchLerp;
}
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
// the whole jam batch, no geometry rebuild. Null outside live heatline mode.
if (layerState._heatJamPrim?.appearance) {
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
}
layerState._animFrame++;
}
/**
* Randomly pause a dot near road endpoints to simulate stop-light behaviour.
*
* Only triggers within the first 2 or last 2 segments of the road, and only
* with a very low per-frame probability (0.8%) to keep traffic flowing.
*
* @param {Object} dot - The dot state object.
* @param {number} now - Current timestamp in milliseconds.
*/
function maybeStopLight(dot, now) {
const nearEnd = dot.segIdx <= 1 || dot.segIdx >= dot.numSegments - 2;
if (nearEnd && Math.random() < 0.008) {
// Pause for 2–6 seconds
dot.stoppedUntil = now + 2000 + Math.random() * 4000;
}
}
// ─── Cleanup ───────────────────────────────────────────────
/** Remove all point primitives and reset dot/road arrays and counters. */
function clearDots() {
if (layerState._pointCollection) layerState._pointCollection.removeAll();
parts.rendering.removeHeatLines();
layerState._dots = [];
layerState._roads = [];
layerState._count = 0;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
layerState._closedRoads = 0;
}
return { spawnDotsForRoad, animate, maybeStopLight, clearDots };
}
+189
View File
@@ -0,0 +1,189 @@
import {
trafficBucketTier,
trafficStyleProfile,
} from '../../data/trafficPresetStyle.js';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const { getFlowSessionStats } = source;
const methods = {
id: 'traffic',
name: 'Street Traffic',
icon: '🚗',
source: 'OpenStreetMap',
/** @type {number} Zero — layer is self-managed via camera listener + preRender */
updateInterval: 0,
/**
* Update user-adjustable parameters (density and speed scaling).
*
* @param {Object} [params]
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
*/
setParams(params = {}) {
if (typeof params.densityScale === 'number') {
layerState._densityScale = Math.max(
0.2,
Math.min(2.5, params.densityScale),
);
}
if (typeof params.speedScale === 'number') {
layerState._speedScale = Math.max(
0.3,
Math.min(3.0, params.speedScale),
);
}
// 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
// data-integrity view). Owner-explorable; re-render applies on the next
// camera-driven load.
if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') {
layerState._uncoveredMode = params.uncoveredRoads;
}
// Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior;
// live mode only, applies on the next camera-driven load like the
// uncoveredRoads param above.
if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) {
layerState._jamViz = params.jamViz;
}
// Preset-aware dot styling kill switch (owner A/B): 'off' forces the
// shipped palette under every post-FX preset. Applies immediately via
// in-place restyle — no refetch — so A/B legs share identical dots.
if (params.presetDots === 'on' || params.presetDots === 'off') {
if (params.presetDots !== layerState._presetDots) {
layerState._presetDots = params.presetDots;
parts.style.restyleDotsInPlace();
}
}
},
/**
* Return the current user-adjustable parameters.
* @returns {{densityScale:number, speedScale:number}}
*/
getParams() {
return {
densityScale: layerState._densityScale,
speedScale: layerState._speedScale,
uncoveredRoads: layerState._uncoveredMode,
jamViz: layerState._jamViz,
presetDots: layerState._presetDots,
};
},
/**
* Return a sub-sampled list of active dot positions for detection overlays
* (e.g. CCTV bounding-box rendering).
*
* Uses a deterministic stride-based sampling so different seeds yield
* non-overlapping subsets without sorting or shuffling.
*
* @param {Object} [options]
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Integer seed to offset the sampling start.
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._dots.length === 0) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._dots.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
// Stride-based sampling: step through dots evenly to get ~maxCount samples
const stride = Math.max(1, Math.ceil(layerState._dots.length / maxCount));
const start = seed % stride;
const result = [];
for (let i = start; i < layerState._dots.length; i += stride) {
const pos = layerState._dots[i].point.position;
if (!pos) continue;
const entry = {
position: pos,
id: `VEH-${String(i).padStart(4, '0')}`,
type: 'VEH',
};
// Live mode: the detection bracket carries the congestion signal —
// its canvas sits ABOVE the post-FX chain, so tier colors survive
// every preset (owner round 2: "bounding boxes do the heavy
// lifting"). Keyless mode sets no tier: contacts keep the stock
// 'vehicle' bracket and the keyless experience stays untouched.
if (layerState._liveMode) {
const tier = trafficBucketTier(layerState._dots[i].bucket || 'sim');
if (tier) entry.tier = tier;
}
result.push(entry);
if (result.length >= maxCount) break;
}
return result;
},
/**
* 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
* 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}}
*/
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 feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
flowError: layerState._flowError,
coveragePct: layerState._flowCoveragePct,
statusUnavailable: layerState._flowStatusUnavailable,
});
return {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading,
mode: feed.mode,
error: feed.error,
flowCoveragePct: layerState._flowCoveragePct,
tilesFetched: getFlowSessionStats().tilesFetched,
...(TRAFFIC_TIMING_ENABLED
? { trafficTiming: parts.timing.getTrafficTimingDiagnostics() }
: {}),
// Per-bucket rendered-dot counts (sim = white ambient). Drives the
// qa-traffic color assertions and the sync-chip mode label below.
flowBuckets: { ...layerState._bucketCounts },
closedRoads: layerState._closedRoads,
// Jam-viz prototype diagnostics (additive — harness contract untouched).
heatLines: layerState._heatLineCount,
jamViz: layerState._jamViz,
// Preset-styling diagnostics (additive): active style + profile.
stylePreset: layerState._stylePreset,
styleProfile:
layerState._presetDots === 'on'
? trafficStyleProfile(layerState._stylePreset)
: 'normal',
// Sync-chip text: shown while busy, and flashed on its own for 1.5 s
// after each completed load (ui.js _updateTrafficSyncChip semantics).
// The settled flash carries NO progress number beside it — this label's
// coverage figure is the chip's only percentage — so a label that ends
// 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,
};
},
};
return { methods };
}
+214
View File
@@ -0,0 +1,214 @@
import { matchFlowToRoads } from '../../data/flowMatch.js';
import { TRAFFIC_TIMING_ENABLED, FLOW_RENDER_RACE_MS } from './policy.js';
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
const { fetchFlowForBounds } = source;
// ─── Live Flow (TomTom) ────────────────────────────────────
/**
* Map a failed flow fetch onto one short, honest user-facing reason.
*
* `fetchFlowForBounds` only rejects when EVERY covering tile failed, so a
* non-null result here always means "there is no live flow to show right
* now" — the dots fall back to simulated white. Mirrors the
* `deriveAisFeedError` honesty helper.
*
* @param {Error|{name?:string, message?:string}|null|undefined} error - Rejection from the flow fetch.
* @returns {string|null} Short reason, or null for an aborted (superseded) fetch.
*/
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]);
if (status === 503) return 'TomTom key unavailable';
if (status === 429) return 'TomTom daily budget reached';
if (status === 502 || status === 504) return 'TomTom upstream unreachable';
if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`;
return 'TomTom flow unavailable';
}
/**
* Check `/api/tomtom/status` once per session and cache the result.
* Live mode iff the server holds a TomTom key; the TomTom attribution credit
* registers the first time live mode activates. Keyless or unreachable →
* simulation mode, exactly today's behavior.
*
* @returns {Promise<void>} Resolves when `_liveMode` is settled.
*/
function ensureFlowStatus() {
if (!layerState._flowStatusPromise) {
layerState._flowStatusPromise = source
.getStatus()
.then((status) => {
layerState._liveMode = Boolean(status?.hasKey);
layerState._flowStatusUnavailable = false;
if (layerState._liveMode) {
console.log('[Data:Traffic] TomTom key present — live flow mode');
registerDynamicCredit(layerState._viewer, TOMTOM_CREDIT);
}
})
.catch((e) => {
// 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;
console.warn(
'[Data:Traffic] TomTom status unreachable — simulated traffic:',
e?.message || e,
);
});
}
return layerState._flowStatusPromise;
}
/**
* Live mode only: fetch TomTom flow for the clamped bounds, match it onto the
* parsed roads, and attach `road.flow` (`{level, closure}` or null).
*
* Reuses the load-generation guard: stale flow responses are discarded, and
* the shared AbortController lets `cancelActiveFetch()` (next load / disable)
* cancel an in-flight flow fetch. Any failure leaves roads unmatched — the
* dots then render in today's simulated white, never a phantom color — and is
* recorded in `_flowError` so `getStats()` degrades honestly instead of
* reporting a stale "LIVE · N% cov" over simulated dots.
*
* @param {Array} roads - Parsed road objects (mutated: `road.flow`).
* @param {{south:number,west:number,north:number,east:number}} clamped - Fetch bounds.
* @param {number} generation - `_loadGeneration` at call time.
* @returns {Promise<void>}
*/
async function applyFlowToRoads(roads, clamped, generation) {
// 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;
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 (!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];
}
layerState._flowCoveragePct =
candidateCount > 0
? Math.round((matchedCount / candidateCount) * 100)
: 0;
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)
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;
console.warn(
'[Data:Traffic] Flow fetch failed (sim colors remain):',
e?.message || e,
);
}
} finally {
layerState._flowPending -= 1;
}
}
/**
* 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.
*/
async function applyFlowThenRender(
roads,
clamped,
generation,
altitude,
label,
trace = null,
) {
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) 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 */
});
}
return true;
}
return {
deriveTrafficFlowError,
ensureFlowStatus,
applyFlowToRoads,
applyFlowThenRender,
};
}
+70
View File
@@ -0,0 +1,70 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
/**
* @file TomTom traffic-flow vector-tile client: fetch + MVT decode.
*
* Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy
* (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`).
*
* Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string,
* closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed
* ratio (1 = free flow). Features with a missing/non-finite `traffic_level`
* are skipped unless `road_closure` is true (closures decode with level 0).
*
* Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and
* tree-shakeable; decoding happens client-side so the proxy stays a dumb
* binary cache.
*
* @module data/flowTiles
*/
const FLOW_LAYER_NAME = 'Traffic flow';
export function decodeFlowTile(data, z, x, y) {
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 {
return [];
}
if (!layer) return [];
const segments = [];
for (let i = 0; i < layer.length; i++) {
let feature;
let geometry;
try {
feature = layer.feature(i);
geometry = feature.toGeoJSON(x, y, z).geometry;
} catch {
continue; // one malformed feature must not drop the tile
}
const props = feature.properties || {};
const closure =
props.road_closure === true || props.road_closure === 'true';
const rawLevel = props.traffic_level;
const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel);
// Skip features we can't color — unless closed (closures render dot-free
// regardless of level, so they stay useful without one).
if (!hasLevel && !closure) continue;
const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0;
const roadType = typeof props.road_type === 'string' ? props.road_type : '';
const lines =
geometry.type === 'LineString'
? [geometry.coordinates]
: geometry.type === 'MultiLineString'
? geometry.coordinates
: [];
for (const coords of lines) {
if (!Array.isArray(coords) || coords.length < 2) continue;
segments.push({ coords, trafficLevel, roadType, closure });
}
}
return segments;
}
+97
View File
@@ -0,0 +1,97 @@
import { tilesForBounds } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
/** Own one decoded flow cache and its session counters. */
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 };
}
+63
View File
@@ -0,0 +1,63 @@
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import { createStyle } from './style.js';
import { createTiming } from './timing.js';
import { createModel } from './model.js';
import { createIngestion } from './ingestion.js';
import { createAnimation } from './animation.js';
import { createViewport } from './viewport.js';
import { createFlow } from './flow.js';
import { createRendering } from './rendering.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createTrafficLayer({ services, source }) {
if (
![
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
].every((key) => typeof source?.[key] === 'function')
)
throw new TypeError('A traffic source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.style = createStyle(context);
parts.timing = createTiming(context);
parts.model = createModel(context);
parts.ingestion = createIngestion(context);
parts.animation = createAnimation(context);
parts.viewport = createViewport(context);
parts.flow = createFlow(context);
parts.rendering = createRendering(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
state._parseRoads = TRAFFIC_TIMING_ENABLED
? (data, trace) =>
trace
? parts.timing.parseRoadsTimed(data, trace)
: parts.model.parseRoads(data)
: parts.model.parseRoads;
state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED
? parts.timing.loadRoadsForBoundsTimed
: parts.ingestion.loadRoadsForBounds;
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion?.methods,
{
getTrafficTimingDiagnostics: parts.timing.getTrafficTimingDiagnostics,
deriveTrafficFlowError: parts.flow.deriveTrafficFlowError,
trafficFeedPresentation: parts.model.trafficFeedPresentation,
},
);
}
export { createTrafficSource } from './source.js';
+329
View File
@@ -0,0 +1,329 @@
import {
TRAFFIC_TIMING_ENABLED,
OVERPASS_URL,
TILE_CACHE_MAX_ENTRIES,
FAST_FETCH_ALTITUDE,
} from './policy.js';
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
const { fetchFlowForBounds } = source;
/**
* Fetch road geometries from the Overpass API via the local proxy.
*
* Sends a POST with the query as form-encoded `data`. 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).
* @param {number} west - Western longitude bound (degrees).
* @param {number} north - Northern latitude bound (degrees).
* @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 {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.
* @throws {Error} If the HTTP response status is not OK.
*/
async function fetchRoads(
south,
west,
north,
east,
{ majorOnly = false, timeoutSec = 25, signal } = {},
trace = null,
) {
const state =
TRAFFIC_TIMING_ENABLED && trace
? parts.timing.trafficTimingPass(
trace,
majorOnly ? 'major' : 'full',
'proxy',
)
: null;
if (state) trace.currentPass = state.pass;
const fetchStart = state
? parts.timing.trafficTimingMark(state, 'fetch-start')
: null;
if (state) {
parts.timing.trafficTimingMeasure(
'last-camera-change-to-fetch-start',
state,
trace.cameraChangeMark,
fetchStart,
);
}
const response = await source.requestRoads(
{ south, west, north, east },
{ majorOnly, timeoutSec, signal },
);
if (!response.ok) {
throw new Error(`Overpass API returned ${response.status}`);
}
if (!state) {
const data = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(data?.elements))
throw new Error('Malformed road snapshot');
return data;
}
if (state) {
state.proxyCache = response.headers.get('x-overpass-cache');
state.proxyUpstream = response.headers.get('x-overpass-upstream');
}
const responseStart = state
? parts.timing.trafficTimingMark(state, 'response-json-start', {
responseStatus: response.status,
})
: null;
if (state) {
parts.timing.trafficTimingMeasure(
'fetch-to-response',
state,
fetchStart,
responseStart,
{
responseStatus: response.status,
},
);
}
const data = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(data?.elements))
throw new Error('Malformed road snapshot');
if (state) {
const responseEnd = parts.timing.trafficTimingMark(
state,
'response-json-end',
{
responseStatus: response.status,
},
);
parts.timing.trafficTimingMeasure(
'response-json',
state,
responseStart,
responseEnd,
{
responseStatus: response.status,
},
);
}
return data;
}
/** Abort any in-flight Overpass fetch and clear the controller reference. */
function cancelActiveFetch() {
if (layerState._activeFetchAbort) {
layerState._activeFetchAbort.abort();
layerState._activeFetchAbort = null;
}
}
/**
* Load road data for the given viewport bounds and render traffic dots.
*
* Implements a two-pass fetch strategy with tile caching:
*
* 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.
* 3. If altitude is low enough (< FAST_FETCH_ALTITUDE), fetch the full
* road graph (includes tertiary/residential). Render again to upgrade.
*
* Each fetch is guarded by a monotonic `_loadGeneration` counter so that
* stale responses from superseded requests are silently discarded.
*
* @param {{south:number, west:number, north:number, east:number}} bounds
* Viewport bounds (will be clamped internally).
* @param {number} altitude - Camera altitude in meters.
* @param {Object|null} [trace=null] - Development-only correlated load trace.
* @returns {Promise<void>}
*/
async function loadRoadsForBounds(bounds, altitude, trace = null) {
// Increment generation to invalidate any in-flight responses from prior calls
const generation = ++layerState._loadGeneration;
cancelActiveFetch();
const clamped = parts.viewport.clampBounds(bounds);
// 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)}`;
// 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, {}).catch(() => {
/* warm-up only */
});
}
});
layerState._fetching = true;
// Only COMMIT these on success. Committing up-front means a failed Overpass
// 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.
const prevBounds = layerState._lastBounds;
const prevViewCenter = layerState._lastViewCenter;
layerState._lastBounds = clamped;
layerState._lastViewCenter = parts.viewport.getBoundsCenter(clamped);
let renderedSomething = false;
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);
}
// 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.
if (cache.full) {
renderedSomething = await parts.flow.applyFlowThenRender(
cache.full,
clamped,
generation,
altitude,
'Cache full',
trace,
);
return;
}
// Intermediate path: render cached major roads while fetching the rest
if (cache.major) {
if (
!(await parts.flow.applyFlowThenRender(
cache.major,
clamped,
generation,
altitude,
'Cache major',
trace,
))
)
return;
renderedSomething = true;
} else {
// Fetch major roads first (smaller payload, faster response)
layerState._activeFetchAbort = new AbortController();
console.log(`[Data:Traffic] Fast fetch major roads [${cacheKey}]`);
const majorData = await fetchRoads(
clamped.south,
clamped.west,
clamped.north,
clamped.east,
{
majorOnly: true,
timeoutSec: 12,
signal: layerState._activeFetchAbort.signal,
},
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(
cache.major,
clamped,
generation,
altitude,
'Loaded major',
trace,
))
)
return;
renderedSomething = true;
}
// At higher altitude, major roads provide sufficient motion density
if (altitude > FAST_FETCH_ALTITUDE) return;
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
layerState._activeFetchAbort = new AbortController();
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
const fullData = await fetchRoads(
clamped.south,
clamped.west,
clamped.north,
clamped.east,
{
majorOnly: false,
timeoutSec: 20,
signal: layerState._activeFetchAbort.signal,
},
trace,
);
if (generation !== layerState._loadGeneration) return;
cache.full = layerState._parseRoads(fullData, trace);
if (
!(await parts.flow.applyFlowThenRender(
cache.full,
clamped,
generation,
altitude,
'Loaded full',
trace,
))
)
return;
renderedSomething = true;
} catch (e) {
if (e?.name === 'AbortError') return;
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
// 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;
}
}
layerState._activeFetchAbort = null;
}
}
const methods = {
/**
* No-op — traffic updates are entirely camera-driven, not timer-driven.
* @returns {Promise<void>}
*/
async update() {
// No-op — updates are camera-driven
},
};
return { fetchRoads, cancelActiveFetch, loadRoadsForBounds, methods };
}
+188
View File
@@ -0,0 +1,188 @@
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { resetFlowTileCache } = source;
const methods = {
/**
* One-time initialisation. Creates the PointPrimitiveCollection and adds it
* to the scene (hidden). The collection is never removed and re-added — only
* toggled via `.show` to avoid destroy-on-remove errors.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
init(viewer) {
layerState._viewer = viewer;
layerState._pointCollection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
// Add permanently — toggle with .show to avoid destroy-on-remove errors
viewer.scene.primitives.add(layerState._pointCollection);
layerState._pointCollection.show = false;
layerState._dots = [];
layerState._roads = [];
layerState._count = 0;
layerState._lastUpdate = null;
layerState._lastBounds = null;
layerState._fetching = false;
layerState._loadGeneration = 0;
layerState._densityScale = 1.0;
layerState._speedScale = 1.0;
layerState._lastViewCenter = null;
layerState._flowCoveragePct = 0;
layerState._flowError = null;
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingSequence = 0;
layerState._trafficTimingTracesCreated = 0;
layerState._trafficTimingDroppedTraces = 0;
}
// Preset-aware dot styling: adopt the active post-FX style (persisted
// style restore may run before layer registration, so read the dataset)
// and follow StyleManager's gev:style-change event thereafter. Guarded
// for non-browser contexts; bound once per page (init survives layer
// destroy/re-register).
if (typeof window !== 'undefined') {
layerState._stylePreset =
document?.documentElement?.dataset?.gevStyle || 'normal';
if (!layerState._styleListenerBound) {
window.addEventListener('gev:style-change', (e) =>
parts.style.setStylePreset(e?.detail?.style),
);
layerState._styleListenerBound = true;
}
}
parts.style.refreshBucketColors();
console.log('[Data:Traffic] Initialized');
},
/**
* Enable the traffic layer. Shows the point collection, subscribes to the
* preRender animation loop and camera-change events, and kicks off an
* initial viewport check.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
enable(viewer) {
layerState._enabled = true;
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,
);
if (TRAFFIC_TIMING_ENABLED) {
parts.timing.clearTrafficTimingEntries();
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingPostRenderRemovers = new Set();
layerState._trafficTimingMoveEndRemover =
viewer.camera.moveEnd.addEventListener(
parts.timing.markTrafficTimingMoveEnd,
);
}
// Subscribe to camera changes with a 5% movement threshold. Save the prior
// value so disable() can restore it — percentageChanged is a shared global
// on the camera and leaving it mutated affects every other camera.changed
// listener in the app.
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
layerState._prevPercentageChanged = viewer.camera.percentageChanged;
viewer.camera.percentageChanged = 0.05;
// Kick off initial viewport check
parts.viewport.onCameraChanged();
// Boot-order guard (field-test round 1: layer sat empty until the user
// moved): when the persisted layer state re-enables traffic during the
// intro flyTo, the initial check bails at high altitude — and a camera
// that then parks never re-fires camera.changed. Retry cheaply until the
// first load commits, then self-clear. Also acts as a safety kick if a
// failed first fetch left the viewport unloaded while parked.
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = setInterval(() => {
if (!layerState._enabled || layerState._lastUpdate) {
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
return;
}
if (!layerState._fetching) parts.viewport.onCameraChanged();
}, 1500);
},
/**
* Disable the traffic layer. Cancels pending fetches, clears all dots,
* unsubscribes from events, and hides the point collection.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
disable(viewer) {
layerState._enabled = false;
releaseContinuousRender('traffic');
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
parts.ingestion.cancelActiveFetch();
layerState._loadGeneration++;
parts.animation.clearDots();
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;
if (layerState._preRenderRemover) {
layerState._preRenderRemover();
layerState._preRenderRemover = null;
}
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingMoveEndRemover?.();
layerState._trafficTimingMoveEndRemover = null;
for (const remove of layerState._trafficTimingPostRenderRemovers || [])
remove();
layerState._trafficTimingPostRenderRemovers = null;
layerState._trafficTimingCurrentAnchor = null;
}
viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged);
// Restore the camera's global percentageChanged so other layers/listeners
// keep their expected sensitivity.
if (layerState._prevPercentageChanged != null) {
viewer.camera.percentageChanged = layerState._prevPercentageChanged;
layerState._prevPercentageChanged = null;
}
if (layerState._pointCollection) layerState._pointCollection.show = false;
},
/**
* Permanently tear down the layer. Disables it, removes the point collection
* from the scene, and clears the tile cache.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
destroy(viewer) {
this.disable(viewer);
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
parts.rendering.removeHeatLines();
layerState._tileCache.clear();
resetFlowTileCache();
layerState._count = 0;
layerState._lastUpdate = null;
},
};
return { methods };
}
+417
View File
@@ -0,0 +1,417 @@
import * as Cesium from 'cesium';
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 }) {
// ─── Overpass API ──────────────────────────────────────────
/**
* Build an Overpass QL query string to fetch road ways within a bounding box.
*
* The query uses a highway tag regex filter. When `majorOnly` is true, only
* motorway/trunk/primary/secondary are included; otherwise all drivable road
* classes are fetched. The `out geom qt;` suffix returns inline geometry
* (lat/lon per node) sorted by quadtile for faster server response.
*
* @param {number} south - Southern latitude bound (degrees).
* @param {number} west - Western longitude bound (degrees).
* @param {number} north - Northern latitude bound (degrees).
* @param {number} east - Eastern longitude bound (degrees).
* @param {Object} [opts]
* @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only.
* @param {number} [opts.timeoutSec=25] - Overpass server-side timeout.
* @returns {string} Overpass QL query body.
*/
/**
* Parse an Overpass `out geom;` JSON response into internal road objects.
*
* Each OSM `way` element carries an inline `geometry` array of `{lat, lon}`
* objects, so no separate node look-up or osmtogeojson conversion is needed.
*
* Processing per way:
* 1. Extract [lon, lat] coordinate pairs.
* 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices.
* 3. Sample terrain height once at the first vertex (avoids per-vertex cost).
* 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances.
*
* @param {Object} overpassData - Raw JSON response from the Overpass API.
* @param {Array} overpassData.elements - Array of OSM elements.
* @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>}
* Parsed road objects ready for dot spawning.
*/
function parseRoads(overpassData) {
if (!overpassData || !overpassData.elements) return [];
const roads = [];
for (const el of overpassData.elements) {
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
const rawCoords = el.geometry.map((g) => [g.lon, g.lat]);
// 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]);
}
// 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 = el.tags?.highway || 'unclassified';
// One-way capture (field-test round 1: "a car would never go in
// reverse"): dots on one-way roads all travel the legal direction.
// OSM: oneway=yes/1/true → digitization order; '-1' → reversed;
// roundabouts are one-way by definition. 0 = two-way (alternate).
const onewayTag = el.tags?.oneway;
const oneway =
onewayTag === 'yes' ||
onewayTag === '1' ||
onewayTag === 'true' ||
el.tags?.junction === 'roundabout'
? 1
: onewayTag === '-1'
? -1
: 0;
// 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;
}
// ─── Road Length Estimation ────────────────────────────────
/**
* Estimate the total length of a road in meters from its degree-based coordinates.
*
* Uses Euclidean distance in degree-space then multiplies by the equatorial
* approximation of 111 km per degree. Accurate enough for dot density spacing
* but not for navigation.
*
* @param {number[][]} coords - Array of [lon, lat] pairs.
* @returns {number} Approximate road length in meters.
*/
function estimateRoadLengthDeg(coords) {
let len = 0;
for (let i = 0; i < coords.length - 1; i++) {
const dx = coords[i + 1][0] - coords[i][0];
const dy = coords[i + 1][1] - coords[i][1];
len += Math.sqrt(dx * dx + dy * dy);
}
// Rough conversion: 1 degree ~ 111,000 meters at equator
return len * 111000;
}
// ─── Dot Spawning ──────────────────────────────────────────
/**
* Compute the ideal number of dots for a single road at a given camera altitude.
*
* Spacing increases with altitude so fewer dots are rendered when zoomed out.
* The result is further scaled by the road-type density multiplier and the
* user-adjustable `_densityScale`.
*
* @param {{coords:number[][], type:string}} road - Parsed road object.
* @param {number} altitude - Current camera altitude in meters.
* @returns {number} Ideal dot count (minimum 1).
*/
function computeDotCount(road, altitude) {
// Live flow: closed roads carry zero traffic; congestion packs more dots.
// `road.flow` is only ever set in live mode, so the keyless path is
// untouched (flow stays undefined → multiplier 1, identical output).
const flow = layerState._liveMode ? road.flow : null;
if (flow?.closure) return 0;
// Strict data-integrity view: uncovered roads spawn nothing when hidden.
if (layerState._liveMode && !flow && layerState._uncoveredMode === 'hide')
return 0;
const lengthM = estimateRoadLengthDeg(road.coords);
// Altitude-adaptive spacing: closer camera = denser dots
let spacing;
if (altitude < 1000) spacing = 30;
else if (altitude < 3000) spacing = 80;
else if (altitude < 5000) spacing = 150;
else spacing = 250;
const mult =
(DENSITY_MULT[road.type] || 1) *
layerState._densityScale *
(flow
? flowDensityMult(flow.level, { jamBoost: parts.style.jamDensityOn() })
: 1);
return Math.max(1, Math.floor((lengthM / spacing) * mult));
}
/**
* Distribute a fixed dot budget fairly across all visible roads.
*
* Algorithm:
* 1. Compute ideal dot count per road via `computeDotCount`.
* 2. Seed one dot to every road that wants at least one (fairness pass).
* 3. Distribute remaining budget proportionally to each road's ideal count.
* 4. Assign leftover dots (from floor rounding) to roads with the highest
* fractional residuals (largest-remainder method).
*
* This prevents high-density motorways from starving smaller residential roads
* when the global MAX_DOTS cap is reached.
*
* @param {Array} roads - Parsed road objects.
* @param {number} altitude - Camera altitude in meters (affects spacing).
* @param {number} dotCap - Maximum total dots to allocate.
* @returns {number[]} Per-road dot budgets, same length as `roads`.
*/
function allocateRoadDotBudgets(roads, altitude, dotCap) {
const planned = roads.map((road) => computeDotCount(road, altitude));
const budgets = new Array(roads.length).fill(0);
let remaining = Math.max(0, dotCap);
// Pass 1 — fairness seed: give one dot to every road (highest-demand first)
const firstPassOrder = planned
.map((count, index) => ({ count, index }))
.sort((a, b) => b.count - a.count);
for (const entry of firstPassOrder) {
if (remaining <= 0) break;
if (entry.count <= 0) continue;
budgets[entry.index] = 1;
remaining -= 1;
}
if (remaining <= 0) return budgets;
// Pass 2 — proportional distribution of the remaining budget
let totalRemainder = 0;
for (let i = 0; i < planned.length; i++) {
totalRemainder += Math.max(0, planned[i] - budgets[i]);
}
if (totalRemainder <= 0) return budgets;
const residuals = [];
let assigned = 0;
for (let i = 0; i < planned.length; i++) {
const cap = Math.max(0, planned[i] - budgets[i]);
if (cap <= 0) continue;
const ideal = (cap / totalRemainder) * remaining;
const add = Math.min(cap, Math.floor(ideal));
budgets[i] += add;
assigned += add;
residuals.push({ index: i, residual: ideal - add });
}
// Pass 3 — largest-remainder: hand out leftover dots from floor rounding
let leftover = remaining - assigned;
if (leftover > 0 && residuals.length > 0) {
residuals.sort((a, b) => b.residual - a.residual);
let cursor = 0;
while (leftover > 0 && residuals.length > 0) {
const idx = residuals[cursor % residuals.length].index;
if (budgets[idx] < planned[idx]) {
budgets[idx] += 1;
leftover -= 1;
}
cursor += 1;
// Safety valve: avoid infinite loop if all roads are already at their ideal
if (cursor > residuals.length * 3 && leftover > 0) break;
}
}
return budgets;
}
/**
* Derive the layer's honest feed presentation from its live-flow state.
*
* The three states a user can be in, and what each must read as:
* - keyless → `mode:'sim'` (the manager maps that to a FALLBACK chip) with a
* label that never claims live data;
* - live and healthy → LIVE with real coverage;
* - live but flow-down → an `error` string, so the chip degrades and says
* the colors on screen are simulated. Never a stale "LIVE · N% cov".
*
* @param {Object} [input]
* @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 {boolean} [input.statusUnavailable] - The status probe itself failed.
* @returns {{mode:'live'|'sim', error:string|null, loadingLabel:string}}
*/
function trafficFeedPresentation({
liveMode = false,
fetching = false,
flowError = null,
coveragePct = 0,
statusUnavailable = false,
} = {}) {
// `mode` is the CONFIGURED source (live key present vs keyless), not this
// instant's health — health rides on `error`. The qa-traffic harness pins
// that meaning.
const mode = liveMode ? 'live' : 'sim';
if (liveMode && flowError) {
// One string for both fields. The manager's meta line renders `error` and
// drops `loadingLabel` in its error branch, so the owner's SIMULATED copy
// has to BE the error text or the steady state reverts to a bare
// "TomTom daily budget reached" that never says what is on screen.
const degraded = `SIMULATED — ${flowError}`;
return { mode, error: degraded, loadingLabel: degraded };
}
if (liveMode) {
return {
mode,
error: null,
loadingLabel: fetching
? 'syncing LIVE traffic flow'
: `LIVE · TomTom flow · ${coveragePct}% cov`,
};
}
// Keyless simulation — one terse line that names the mode and the remedy
// (owner's copy shape). The chip's own progress text carries "working";
// this line must never imply a live feed.
return {
mode,
error: null,
loadingLabel: statusUnavailable
? 'SIMULATED — traffic service unreachable'
: 'SIMULATED — add TomTom key for live',
};
}
/**
* Apply late-arriving flow data to already-rendered dots without a respawn:
* color, jam size, and speed update in place; closed roads' dots hide.
* Density bunching intentionally waits for the next natural re-render —
* color and speed are the live signal, dot count is a refinement.
* @param {string} label - Render log label (for the console trace).
*/
function recolorDotsInPlace(label) {
if (!layerState._liveMode || !layerState._dots.length) return;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
let closedDots = 0;
const now = Date.now();
for (const dot of layerState._dots) {
const flow = dot.road ? dot.road.flow : null;
if (flow?.closure) {
dot.point.show = false;
closedDots += 1;
continue;
}
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);
}
// 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())
parts.style.applyOutline(dot.point, bucket);
dot.mps = dot.baseMps * (flow ? flowSpeedScale(flow.level) : 1);
// Late flow tags/untags stop-and-go creep + city-scale prominence the
// same way it rescales speed. Queue *positions* wait for the next
// natural re-render, like density bunching.
if (bucket === 'jam' && parts.style.jamDensityOn()) {
if (!dot.creep)
dot.creep = {
moving: Math.random() < 0.4,
until: now + Math.random() * 2000,
};
dot.point.scaleByDistance = new Cesium.NearFarScalar(
100,
1.5,
layerState._fadeScaleFar,
JAM_DOT_FAR_SCALE,
);
dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH;
} else {
dot.creep = null;
}
layerState._bucketCounts[bucket || 'sim'] += 1;
}
layerState._closedRoads = layerState._roads.reduce(
(n, r) => n + (r.flow?.closure ? 1 : 0),
0,
);
parts.rendering.rebuildHeatLines(
parts.rendering.visibleRoadsForAltitude(
layerState._roads,
layerState._lastRenderAltitude,
),
);
console.log(
`[Data:Traffic] Flow recolor (${label}): ${layerState._dots.length} dots, closedDots=${closedDots}`,
);
}
return {
parseRoads,
estimateRoadLengthDeg,
computeDotCount,
allocateRoadDotBudgets,
trafficFeedPresentation,
recolorDotsInPlace,
};
}
+215
View File
@@ -0,0 +1,215 @@
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
*/
/** @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;
/** @const {number} Meters — above this altitude, only major roads are fetched */
export const FAST_FETCH_ALTITUDE = 4500;
/** @const {number} Milliseconds — debounce delay before fetching after camera settles */
export const FETCH_DEBOUNCE = 320;
/** @const {number} Meters — vertical offset to keep dots above clamped terrain surface */
export const DOT_HEIGHT_OFFSET = 3.0;
/** @const {number} Fraction (0-1) — skip re-fetch when viewport overlap exceeds this */
export const OVERLAP_THRESHOLD = 0.6;
/** @const {number} Hard cap on total rendered dot primitives for GPU/CPU performance */
export const MAX_DOTS = 6000;
/** @const {number} Polylines longer than this are simplified by sub-sampling */
export const MAX_WAYPOINTS_PER_ROAD = 80;
/** @const {number} Km — minimum viewport center shift before allowing refresh */
export const MIN_CENTER_SHIFT_KM = 0.35;
/**
* @const {number} Km — max great-circle distance the fetch center may sit from
* the camera nadir. Traffic only activates below ACTIVATION_ALTITUDE (8 km),
* so a look-at ground point farther than this is horizon-gazing and gets
* pulled back toward nadir (C4 oblique-bounds fix).
*/
export const MAX_LOOKAT_PULL_KM = 12;
/**
* Development-only causal timing. Vite folds `import.meta.env.DEV` to false
* in production, so the query-string read, nullable trace branches, and every
* debug helper are removed from production builds. The remaining load-path
* selectors point directly at the original functions: no marks, listeners,
* observers, timers, counters, or per-road timing checks are installed.
*/
export const TRAFFIC_TIMING_ENABLED =
import.meta.env?.DEV &&
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('trafficDebug') === '1';
/** @const {Object<string,number>} Speed in meters per second by highway tag (approximate real-world values) */
export const SPEED_MPS = {
motorway: 25, // ~90 km/h
trunk: 20, // ~72 km/h
primary: 14, // ~50 km/h
secondary: 11, // ~40 km/h
tertiary: 8, // ~30 km/h
residential: 5, // ~18 km/h
unclassified: 5,
};
/** @const {Object<string,number>} Density multiplier — higher values spawn more dots on important roads */
export const DENSITY_MULT = {
motorway: 3.0,
trunk: 2.5,
primary: 2.0,
secondary: 1.5,
tertiary: 1.0,
residential: 0.5,
unclassified: 0.4,
};
/** @const {Object<string,number>} Pixel size per road type (scaled up 25% for screen-recording visibility) */
export const SIZE_BY_TYPE = {
motorway: 6,
trunk: 6,
primary: 5,
secondary: 5,
tertiary: 4,
residential: 4,
unclassified: 4,
};
/**
* Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`):
* green free flow / amber slow / red jam, all at 0.9 alpha.
* Roads without flow data (`road.flow == null`) keep the sim's white.
* @const {Object<string, Cesium.Color>}
*/
export const FLOW_BUCKET_COLORS = {
free: Cesium.Color.fromCssColorString('#2ecc71').withAlpha(0.9),
slow: Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.9),
jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9),
};
// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ────────
/** @const {number} Max congestion heat-line polylines per render (jam first). */
export const HEAT_LINE_CAP = 400;
/** @const {number} Px — glowing jam corridor line width. */
export const HEAT_LINE_JAM_WIDTH = 9;
/** @const {number} Px — flat slow corridor line width. */
export const HEAT_LINE_SLOW_WIDTH = 4;
/** @const {number} Jam heat-line alpha midpoint (pulse oscillates around it). */
export const HEAT_JAM_BASE_ALPHA = 0.55;
/** @const {number} Jam heat-line pulse amplitude (±, ~1.6 s period). */
export const HEAT_JAM_PULSE_ALPHA = 0.2;
/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */
export const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252');
/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */
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;
/** @const {number} Speed multiplier while a stop-and-go jam dot bursts forward. */
export const CREEP_BURST = 2.2;
/** @const {number[]} Ms range a jam dot creeps forward before stopping. */
export const CREEP_MOVE_MS = [1200, 3000];
/** @const {number[]} Ms range a jam dot sits stopped between creeps. */
export const CREEP_STOP_MS = [1500, 5000];
/**
* @const {number} Minimum base pixel size for COLORED dots while a styled
* preset is active — residential-road dots spawn at 4 px and vanish into
* post-FX pixelation; presence is the dots' whole job there (owner round
* 2). Sim dots and the normal profile keep SIZE_BY_TYPE untouched.
*/
export const STYLED_MIN_BASE_PX = 5;
/** @const {number} Maximum tile cache entries before LRU eviction */
export const TILE_CACHE_MAX_ENTRIES = 64;
/**
* Milliseconds the first dot paint will wait for flow data. Cached flow
* settles within this window (decode cache, 120 s TTL) and renders fully
* colored; a cold tile fetch loses the race, dots paint immediately in
* white, and recolorDotsInPlace applies the colors when flow arrives —
* field-test round 1's "takes forever to load" was the sequential wait.
*/
export const FLOW_RENDER_RACE_MS = 250;
+318
View File
@@ -0,0 +1,318 @@
import * as Cesium from 'cesium';
import { flowBucket } from '../../data/trafficFlowStyle.js';
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import {
HEAT_LINE_CAP,
HEAT_JAM_COLOR,
HEAT_SLOW_COLOR,
HEAT_LINE_JAM_WIDTH,
HEAT_JAM_BASE_ALPHA,
HEAT_LINE_SLOW_WIDTH,
TRAFFIC_TIMING_ENABLED,
MAX_DOTS,
} from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
/**
* At high altitude only major roads render — shared by the render pass and
* the late-flow heat-line rebuild so lines never mark roads without dots.
* @param {Array} roads - Parsed road objects.
* @param {number} altitude - Camera altitude in meters.
* @returns {Array} The roads visible at this altitude.
*/
function visibleRoadsForAltitude(roads, altitude) {
return altitude > 5000
? roads.filter(
(r) =>
r.type === 'motorway' || r.type === 'trunk' || r.type === 'primary',
)
: roads;
}
/** Remove both heat-line ground primitives from the scene. */
function removeHeatLines() {
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
);
layerState._heatJamPrim = null;
}
if (layerState._heatSlowPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatSlowPrim,
);
layerState._heatSlowPrim = null;
}
layerState._heatLineCount = 0;
}
/**
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing
* pulsing red for jam, faint flat amber for slow — with the dots animating
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam
* first, longest first); overflow is logged. No-op in sim mode, when the
* heatline mode is off, or without ground-primitive support.
*
* @param {Array} roads - Road objects visible in the current render.
*/
function rebuildHeatLines(roads) {
removeHeatLines();
if (
!layerState._viewer ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return;
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
);
if (!layerState._heatSupported)
console.warn(
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
);
}
if (!layerState._heatSupported) return;
const candidates = [];
for (const road of roads) {
const flow = road.flow;
if (!flow || flow.closure) continue;
const bucket = flowBucket(flow.level);
if (bucket === 'free') continue;
let len = 0;
for (const d of road.segmentDist) len += d;
candidates.push({ road, bucket, len });
}
candidates.sort((a, b) =>
a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1,
);
const kept = candidates.slice(0, HEAT_LINE_CAP);
const instancesFor = (bucket, width) =>
kept
.filter((c) => c.bucket === bucket)
.map(
(c) =>
new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions: c.road.waypoints,
width,
}),
}),
);
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
// luminance like the dots: jam = white glow, slow = faint gray.
const monoHeat =
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono';
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = monoHeat
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
if (jamInstances.length) {
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: jamInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
}),
}),
);
}
const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH);
if (slowInstances.length) {
layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: slowInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('Color', {
color: slowLineColor,
}),
}),
}),
);
}
layerState._heatLineCount = kept.length;
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
);
}
}
/**
* Clear existing dots and re-spawn them for the given road set and altitude.
*
* When zoomed out (>5 km), only major road types are rendered to reduce clutter.
* Dot budgets are allocated fairly across visible roads via `allocateRoadDotBudgets`.
*
* @param {Array} roads - Parsed road objects to render.
* @param {number} altitude - Camera altitude in meters.
* @param {string} label - Logging label (e.g. "Cache full", "Loaded major").
* @param {Object|null} [trace=null] - Development-only correlated load trace.
*/
function renderRoadsForAltitude(roads, altitude, label, trace = null) {
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._lastRenderAltitude = altitude;
// At high altitude, drop minor roads to reduce visual noise
const filteredRoads = visibleRoadsForAltitude(roads, altitude);
// Closed roads spawn zero dots (computeDotCount/spawnDotsForRoad) — count
// them here so the closure signal is visible in stats even at zero dots.
layerState._closedRoads = layerState._liveMode
? filteredRoads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0)
: 0;
// Fade distances must track the camera-to-AREA distance, not assume a
// nadir view: oblique pitches put the loaded roads many km away even at
// low altitude. Probe three roads and stretch the curves accordingly.
let areaDist = altitude;
if (layerState._viewer && filteredRoads.length) {
const probes = [
filteredRoads[0],
filteredRoads[Math.floor(filteredRoads.length / 2)],
filteredRoads[filteredRoads.length - 1],
];
for (const probe of probes) {
const wp = probe?.waypoints?.[0];
if (wp)
areaDist = Math.max(
areaDist,
Cesium.Cartesian3.distance(
layerState._viewer.camera.positionWC,
wp,
),
);
}
}
layerState._fadeScaleFar = Math.max(8000, areaDist * 1.5);
layerState._fadeTransFar = Math.max(10000, areaDist * 1.8);
const dotStart = state
? parts.timing.trafficTimingMark(state, 'dot-construction-start', {
renderId,
renderLabel: label,
roadCount: roads.length,
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;
}
const renderMetrics = state
? {
renderId,
renderLabel: label,
roadCount: roads.length,
visibleRoadCount: filteredRoads.length,
dotCount: layerState._dots.length,
}
: null;
if (state) {
const dotEnd = parts.timing.trafficTimingMark(
state,
'dot-construction-end',
renderMetrics,
);
parts.timing.trafficTimingMeasure(
'dot-construction',
state,
dotStart,
dotEnd,
renderMetrics,
);
}
const heatStart = state
? parts.timing.trafficTimingMark(
state,
'rebuild-heat-lines-start',
renderMetrics,
)
: null;
rebuildHeatLines(filteredRoads);
if (state) {
const heatEnd = parts.timing.trafficTimingMark(
state,
'rebuild-heat-lines-end',
{
...renderMetrics,
heatLineCount: layerState._heatLineCount,
},
);
parts.timing.trafficTimingMeasure(
'rebuild-heat-lines',
state,
heatStart,
heatEnd,
{
...renderMetrics,
heatLineCount: layerState._heatLineCount,
},
);
}
layerState._count = layerState._dots.length;
layerState._lastUpdate = Date.now();
console.log(
`[Data:Traffic] ${label}: ${layerState._count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`,
);
if (state) {
const renderEnd = parts.timing.trafficTimingMark(
state,
'render-return',
renderMetrics,
);
parts.timing.scheduleTrafficTimingPostRender(
state,
renderEnd,
renderId,
renderMetrics,
);
}
}
return {
visibleRoadsForAltitude,
removeHeatLines,
rebuildHeatLines,
renderRoadsForAltitude,
};
}
+76
View File
@@ -0,0 +1,76 @@
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;`;
}
/** Supply road responses, flow availability and one decoded flow cache. */
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const flow = createFlowTileSource({ fetchImpl });
return {
...flow,
async requestRoads(
{ south, west, north, east },
{ majorOnly = false, timeoutSec = 25, signal } = {},
) {
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
)
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,
});
signal?.throwIfAborted();
return {
ok: response.ok,
status: response.status,
headers: response.headers,
async json() {
const body = await response.json();
signal?.throwIfAborted();
return body;
},
};
},
async getStatus({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/tomtom/status', { signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
const status = await response.json();
signal?.throwIfAborted();
if (typeof status?.hasKey !== 'boolean')
throw new Error('Malformed traffic status');
return status;
},
};
}
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createTrafficSource } from './source.js';
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
const fixture = readFileSync(
new URL(
'../../data/fixtures/tomtom-flow-austin-12-935-1686.pbf',
import.meta.url,
),
);
test('flow caches and diagnostics belong to their constructed source', async () => {
let requestsA = 0,
requestsB = 0;
const a = createTrafficSource({
fetchImpl: async () => {
requestsA++;
return new Response(fixture);
},
});
const b = createTrafficSource({
fetchImpl: async () => {
requestsB++;
return new Response(fixture);
},
});
const first = await a.fetchFlowForBounds(bounds);
assert.ok(first.length > 0);
await a.fetchFlowForBounds(bounds);
assert.equal(requestsA, 1);
assert.equal(b.getFlowSessionStats().tilesFetched, 0);
b.resetFlowTileCache();
await a.fetchFlowForBounds(bounds);
assert.equal(requestsA, 1);
await b.fetchFlowForBounds(bounds);
assert.equal(requestsB, 1);
});
test('a cancelled flow body cannot refill its source cache', async () => {
const controller = new AbortController();
let calls = 0;
const source = createTrafficSource({
fetchImpl: async () => ({
ok: true,
arrayBuffer: async () => {
calls++;
if (calls === 1) controller.abort();
return fixture;
},
}),
});
await assert.rejects(
source.fetchFlowForBounds(bounds, { signal: controller.signal }),
{ name: 'AbortError' },
);
await source.fetchFlowForBounds(bounds);
assert.equal(
calls,
2,
'cancelled bytes were not admitted to the decode cache',
);
});
test('road requests have finite bounds and retain the two-pass query', async () => {
const calls = [];
const source = createTrafficSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"elements":[]}');
},
});
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);
assert.match(
new URLSearchParams(calls[1][1].body).get('data'),
/residential/,
);
});
test('malformed availability is an unavailable source rather than a keyless response', async () => {
const source = createTrafficSource({
fetchImpl: async () => new Response('{}'),
});
await assert.rejects(source.getStatus(), /Malformed traffic status/);
});
test('traffic construction is inert and parameters belong to each layer', async () => {
const { createTrafficLayer } = await import('./index.js');
const source = createTrafficSource({
fetchImpl: () => assert.fail('construction fetched data'),
});
const services = { credits: {}, render: {} };
const a = createTrafficLayer({ services, source });
const b = createTrafficLayer({ services, source });
a.setParams({ densityScale: 2, speedScale: 3, uncoveredRoads: 'hide' });
assert.equal(a.getParams().densityScale, 2);
assert.equal(b.getParams().densityScale, 1);
assert.equal(b.getParams().speedScale, 1);
assert.equal(b.getParams().uncoveredRoads, 'sim');
});
test('road body parsing retains the source request cancellation signal', async () => {
const controller = new AbortController();
const source = createTrafficSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => {
controller.abort();
return { elements: [] };
},
}),
});
const response = await source.requestRoads(bounds, {
signal: controller.signal,
});
await assert.rejects(response.json(), { name: 'AbortError' });
});
+274
View File
@@ -0,0 +1,274 @@
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js';
export function createState({ services }) {
const layerState = {};
// ─── Module State ──────────────────────────────────────────
/** @type {Cesium.Viewer|null} */
layerState._viewer = null;
/** @type {Cesium.PointPrimitiveCollection|null} */
layerState._pointCollection = null;
/** @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 = [];
/** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */
layerState._roads = [];
/** @type {boolean} Whether the layer is currently enabled */
layerState._enabled = false;
/** @type {Function|null} Disposer returned by preRender event subscription */
layerState._preRenderRemover = null;
/** @type {Function|null} Disposer returned by camera.changed event subscription */
layerState._cameraRemover = null;
/** @type {ReturnType<typeof setTimeout>|null} Debounce timer for camera-change fetch */
layerState._fetchTimeout = null;
/** @type {{south:number,west:number,north:number,east:number}|null} Last fetched clamped bounds */
layerState._lastBounds = null;
/** @type {boolean} True while an Overpass fetch is in flight */
layerState._fetching = false;
/** @type {number} Current count of rendered dots */
layerState._count = 0;
/** @type {number|null} Timestamp of last successful render */
layerState._lastUpdate = null;
/** @type {number} Monotonic generation counter — incremented on each load to discard stale responses */
layerState._loadGeneration = 0;
/** @type {AbortController|null} Controller for the in-flight fetch, so it can be cancelled */
layerState._activeFetchAbort = null;
/** @type {number} User-adjustable density multiplier (clamped 0.2–2.5) */
layerState._densityScale = 1.0;
/** @type {number} User-adjustable speed multiplier (clamped 0.3–3.0) */
layerState._speedScale = 1.0;
/** @type {{lat:number,lon:number}|null} Center of last-fetched viewport for shift gating */
layerState._lastViewCenter = null;
/** @type {number|null} camera.percentageChanged value before we overrode it, restored on disable */
layerState._prevPercentageChanged = null;
/** @type {boolean} Live TomTom flow mode — true iff /api/tomtom/status reports a key */
layerState._liveMode = false;
/**
* Short user-facing reason live flow is currently unavailable, or null while
* healthy. Only ever set in live mode: keyless simulation is a designed
* fallback, not a fault, and must never read as an error.
* @type {string|null}
*/
layerState._flowError = null;
/**
* True when `/api/tomtom/status` itself could not be reached, so the layer is
* simulating because it could not ask — not because the server said "no key".
* @type {boolean}
*/
layerState._flowStatusUnavailable = false;
/**
* Flow requests this layer still owns. The 250 ms paint race lets a flow
* fetch outlive the road load that started it (cached roads settle
* instantly), so `_fetching` alone under-reports the work in flight: the
* loading batch would close with LOAD COMPLETE and a failure landing after
* it could never be announced. Counted, not boolean — recolor-after-timeout
* means two loads can overlap.
* @type {number}
*/
layerState._flowPending = 0;
/**
* Rendered-dot counts per flow bucket (sim = white ambient, no flow data).
* Reset with the dots in clearDots; drives the data-panel diagnostics and
* the qa-traffic harness color assertions.
* @type {{free:number, slow:number, jam:number, sim:number}}
*/
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
/** @type {number} Roads in the current render skipped entirely as closed. */
layerState._closedRoads = 0;
/** @type {'sim'|'hide'} Live-mode treatment of roads without flow data. */
layerState._uncoveredMode = 'sim';
/**
* Jam-viz prototype mode: 'density' = deep-jam density boost + platoon queues
* + stop-and-go creep; 'heatline' = congestion corridor polylines; 'both';
* 'none' = shipped main behavior. Live mode only — the keyless simulation
* never has `road.flow`, so every jamViz path is unreachable there.
* Default 'density' — owner A/B verdict 2026-07-23 (heatline stays available
* via setParams).
* @type {'none'|'density'|'heatline'|'both'}
*/
layerState._jamViz = 'density';
/**
* 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
* primitive per bucket so the jam batch can pulse via a single shared
* material uniform.
* @type {Cesium.GroundPolylinePrimitive|null}
*/
layerState._heatJamPrim = null;
/** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */
layerState._heatSlowPrim = null;
/** @type {number} Heat-lines in the current render (stats). */
layerState._heatLineCount = 0;
/** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */
layerState._heatSupported = null;
/** @type {number} Altitude of the last render, for late-flow heat rebuilds. */
layerState._lastRenderAltitude = 0;
/**
* Active post-FX style (StyleManager preset name), synced from
* `document.documentElement.dataset.gevStyle` at init and the
* `gev:style-change` window event thereafter. Drives the preset-aware dot
* styling (`trafficPresetStyle.js`): NVG/FLIR/noir re-encode congestion in
* luminance + size (their shaders discard hue), retro/CRT gets saturated
* hues + a size boost to survive pixelation. 'normal' → shipped palette.
* @type {string}
*/
layerState._stylePreset = 'normal';
/** @type {'on'|'off'} Kill switch for preset-aware dot styling (A/B). */
layerState._presetDots = 'on';
/** @type {boolean} gev:style-change listener bound (bind once per page). */
layerState._styleListenerBound = false;
/**
* Effective per-bucket dot colors: preset override when one applies, else
* the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only —
* spawn/recolor/restyle all read from here, no per-dot allocation.
* @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}}
*/
layerState._activeBucketColors = { ...FLOW_BUCKET_COLORS };
/**
* Dot fade-out distances, recomputed per render from the camera-to-area
* distance. The original constants (8 km scale / 10 km translucency)
* assumed a nadir view; at oblique pitch the loaded area legitimately sits
* 7–12+ km from the CAMERA and every dot faded to invisible (field-test
* round 1: "the screen itself was empty").
*/
layerState._fadeScaleFar = 8000;
layerState._fadeTransFar = 10000;
/** @type {Promise<void>|null} Session-cached status check (one fetch per session) */
layerState._flowStatusPromise = null;
/** @type {ReturnType<typeof setInterval>|null} Enable-time retry until the first load commits. */
layerState._enableKickTimer = null;
/** @type {number} 0–100 int — matched roads / roads with any flow candidates */
layerState._flowCoveragePct = 0;
/** @type {Function|null} Development-only camera moveEnd timing disposer. */
layerState._trafficTimingMoveEndRemover = null;
/** @type {Set<Function>|null} Development-only one-shot postRender disposers. */
layerState._trafficTimingPostRenderRemovers = null;
/** @type {{interactionId:number, timestamp:number}|null} Debug anchor for the pending load. */
layerState._trafficTimingCurrentAnchor = null;
/** @type {number} Development-only unique mark/trace sequence. */
layerState._trafficTimingSequence = 0;
/** @type {number} Development-only count of correlated trace objects created. */
layerState._trafficTimingTracesCreated = 0;
/** @type {number} Development-only count of loads dropped for missing/stale anchors. */
layerState._trafficTimingDroppedTraces = 0;
/**
* Tile cache keyed by "s,w,n,e" string.
* Each entry stores separately fetched major-only and full road sets
* so the major pass can be served from cache while a full fetch continues.
* @type {Map<string, {major: Array|null, full: Array|null}>}
*/
layerState._tileCache = new Map();
/** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */
layerState._scratchLerp = new Cesium.Cartesian3();
// ─── Animation ─────────────────────────────────────────────
/** @type {number} Timestamp of the last animation tick (ms) */
layerState._lastAnimTime = 0;
/** @type {number} Running frame counter (for diagnostics) */
layerState._animFrame = 0;
// Disabled-path contract: these references resolve directly to the original
// functions. Instrumentation adds no load-path callbacks or per-item checks.
return layerState;
}
+156
View File
@@ -0,0 +1,156 @@
import {
trafficStyleProfile,
presetSizeDelta,
presetDotRgba,
presetDotOutline,
} from '../../data/trafficPresetStyle.js';
import * as Cesium from 'cesium';
import {
SIZE_BY_TYPE,
STYLED_MIN_BASE_PX,
FLOW_BUCKET_COLORS,
} from './policy.js';
export function createStyle({ state: layerState, services, parts, source }) {
/** @returns {boolean} A non-normal preset profile is active and enabled. */
function presetProfileActive() {
return (
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) !== 'normal'
);
}
/**
* Pixel-size delta the active preset adds for a bucket (0 when the kill
* switch is off or the profile is normal).
* @param {'free'|'slow'|'jam'|null} bucket - Flow bucket.
* @returns {number} Pixels to add on top of the shipped sizing.
*/
function activeSizeDelta(bucket) {
return layerState._presetDots === 'on'
? presetSizeDelta(layerState._stylePreset, bucket)
: 0;
}
/**
* Base pixel size for a dot: shipped SIZE_BY_TYPE, floored at
* STYLED_MIN_BASE_PX for colored dots while a styled preset is active.
* @param {string} roadType - OSM highway class.
* @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim).
* @returns {number} Base pixel size before jam/preset deltas.
*/
function baseDotSize(roadType, bucket) {
const base = SIZE_BY_TYPE[roadType] || 4;
return bucket && presetProfileActive()
? Math.max(base, STYLED_MIN_BASE_PX)
: base;
}
/** Recompute `_activeBucketColors` from the active style + kill switch. */
function refreshBucketColors() {
for (const bucket of ['free', 'slow', 'jam']) {
const rgba =
layerState._presetDots === 'on'
? presetDotRgba(layerState._stylePreset, bucket)
: null;
layerState._activeBucketColors[bucket] = rgba
? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3])
: FLOW_BUCKET_COLORS[bucket];
}
}
/**
* Apply the active preset's dark-halo outline to a colored dot (or clear
* it back to the shipped no-outline state). NVG's auto-gain saturates the
* scene, so brightness alone cannot separate a dot from a bright road —
* the dark ring restores local contrast through every luma-mapping shader.
* @param {Cesium.PointPrimitive} point - The dot primitive.
* @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim).
*/
function applyOutline(point, bucket) {
const spec =
layerState._presetDots === 'on'
? presetDotOutline(layerState._stylePreset, bucket)
: null;
if (spec) {
point.outlineColor = new Cesium.Color(
spec.rgba[0] / 255,
spec.rgba[1] / 255,
spec.rgba[2] / 255,
spec.rgba[3],
);
point.outlineWidth = spec.width;
} else {
point.outlineWidth = 0;
}
}
/**
* Re-apply dot styling in place after a style/param change: colored dots
* get the (new) effective bucket color and size; sim (white) dots are
* never touched. No refetch, no respawn — heat-lines rebuild for their
* per-preset colors.
*/
function restyleDotsInPlace() {
refreshBucketColors();
if (!layerState._dots.length && !layerState._heatLineCount) return;
for (const dot of layerState._dots) {
const bucket = dot.bucket;
if (!bucket) continue; // sim/uncovered dots stay byte-identical
dot.point.color = layerState._activeBucketColors[bucket];
dot.point.pixelSize =
baseDotSize(dot.road?.type, bucket) +
(bucket === 'jam' ? 1 : 0) +
activeSizeDelta(bucket);
applyOutline(dot.point, bucket);
}
parts.rendering.rebuildHeatLines(
parts.rendering.visibleRoadsForAltitude(
layerState._roads,
layerState._lastRenderAltitude,
),
);
}
/**
* Adopt a new active style preset (from the gev:style-change event or the
* dataset read at init) and restyle live dots immediately.
* @param {string|null|undefined} name - StyleManager preset name.
*/
function setStylePreset(name) {
const next = typeof name === 'string' && name ? name : 'normal';
if (next === layerState._stylePreset) return;
layerState._stylePreset = next;
restyleDotsInPlace();
}
/** @returns {boolean} Density/queue/creep prototype active. */
function jamDensityOn() {
return layerState._jamViz === 'density' || layerState._jamViz === 'both';
}
/** @returns {boolean} Heat-line prototype active. */
function heatlineOn() {
return layerState._jamViz === 'heatline' || layerState._jamViz === 'both';
}
return {
presetProfileActive,
activeSizeDelta,
baseDotSize,
refreshBucketColors,
applyOutline,
restyleDotsInPlace,
setStylePreset,
jamDensityOn,
heatlineOn,
};
}
+479
View File
@@ -0,0 +1,479 @@
import * as Cesium from 'cesium';
import {
TRAFFIC_TIMING_ENABLED,
MAX_WAYPOINTS_PER_ROAD,
DOT_HEIGHT_OFFSET,
} from './policy.js';
export function createTiming({ state: layerState, services, parts, source }) {
/**
* Return development timing counters for the capture harness and inertness test.
* This named export is unused by the application and removed from production.
* @returns {{enabled:boolean, marksInstalled:number, traceObjectsCreated:number,
* uncorrelatedTracesDropped:number}}
*/
function getTrafficTimingDiagnostics() {
return {
enabled: Boolean(TRAFFIC_TIMING_ENABLED),
marksInstalled: layerState._trafficTimingMoveEndRemover ? 1 : 0,
traceObjectsCreated: layerState._trafficTimingTracesCreated,
uncorrelatedTracesDropped: layerState._trafficTimingDroppedTraces,
};
}
// ─── Development-only causal timing ───────────────────────
/**
* Return (and optionally update) the current trace's state for a render pass.
* This function is only reachable when `TRAFFIC_TIMING_ENABLED` is true.
*
* @param {Object|null} trace - Correlated load trace.
* @param {'major'|'full'} pass - Road-fetch/render pass.
* @param {string} [source] - Client cache or proxy/network source.
* @returns {Object|null} Mutable pass timing state.
*/
function trafficTimingPass(trace, pass, source) {
if (!trace) return null;
let state = trace.passes.get(pass);
if (!state) {
state = {
trace,
pass,
source: source || 'unknown',
proxyCache: null,
proxyUpstream: null,
};
trace.passes.set(pass, state);
} else if (source) {
state.source = source;
}
return state;
}
/** Build a structured-clone-safe detail object for User Timing entries. */
function trafficTimingDetail(state, segment, extra = {}) {
return {
trafficTiming: true,
segment,
traceId: state?.trace?.id ?? null,
interactionId: state?.trace?.interactionId ?? null,
cameraChangeTimestamp: state?.trace?.cameraChangeTimestamp ?? null,
generation: state?.trace?.generation ?? null,
cacheKey: state?.trace?.cacheKey ?? null,
pass: state?.pass || 'load',
source: state?.source || 'unknown',
proxyCache: state?.proxyCache || null,
proxyUpstream: state?.proxyUpstream || null,
...extra,
};
}
/** Add a uniquely named User Timing mark and return its name. */
function trafficTimingMark(state, phase, extra = {}, startTime) {
const traceId = state?.trace?.id ?? 'interaction';
const pass = state?.pass || 'load';
const name = `traffic:${traceId}:${pass}:${phase}:${++layerState._trafficTimingSequence}`;
const options = { detail: trafficTimingDetail(state, phase, extra) };
if (Number.isFinite(startTime)) options.startTime = startTime;
performance.mark(name, options);
return name;
}
/** Emit a named User Timing measure between two marks. */
function trafficTimingMeasure(segment, state, start, end, extra = {}) {
performance.measure(`traffic:${segment}:${state?.pass || 'load'}`, {
start,
end,
detail: trafficTimingDetail(state, segment, extra),
});
}
/** Emit an aggregate-duration measure without pretending its work was contiguous. */
function trafficTimingAggregate(
segment,
state,
anchorTime,
duration,
extra = {},
) {
const start = trafficTimingMark(
state,
`${segment}-aggregate-start`,
extra,
anchorTime,
);
const end = trafficTimingMark(
state,
`${segment}-aggregate-end`,
extra,
anchorTime + duration,
);
trafficTimingMeasure(segment, state, start, end, {
aggregate: true,
...extra,
});
}
/** Clear only this module's stale User Timing entries before a new debug run. */
function clearTrafficTimingEntries() {
const markNames = new Set(
performance
.getEntriesByType('mark')
.filter((entry) => entry.name.startsWith('traffic:'))
.map((entry) => entry.name),
);
const measureNames = new Set(
performance
.getEntriesByType('measure')
.filter((entry) => entry.name.startsWith('traffic:'))
.map((entry) => entry.name),
);
for (const name of markNames) performance.clearMarks(name);
for (const name of measureNames) performance.clearMeasures(name);
}
/** Return the stable User Timing mark name for a scheduling interaction. */
function trafficTimingCameraChangeMarkName(interactionId) {
return `traffic:interaction:${interactionId}:last-camera-change`;
}
/** Mint and mark the exact camera-change interaction that armed a debounced load. */
function markTrafficTimingCameraChange() {
const interactionId = ++layerState._trafficTimingSequence;
const timestamp = performance.now();
const anchor = { interactionId, timestamp };
layerState._trafficTimingCurrentAnchor = anchor;
performance.mark(trafficTimingCameraChangeMarkName(interactionId), {
startTime: timestamp,
detail: {
trafficTiming: true,
segment: 'last-camera-change',
interactionId,
timestamp,
},
});
return anchor;
}
/**
* Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this
* about 500 ms after stillness, so fetch has typically already started and
* never waits for this boundary.
*/
function markTrafficTimingMoveEnd() {
const diagnosticId = ++layerState._trafficTimingSequence;
const timestamp = performance.now();
const name = `traffic:diagnostic:${diagnosticId}:camera-move-end`;
performance.mark(name, {
startTime: timestamp,
detail: {
trafficTiming: true,
segment: 'camera-move-end',
diagnosticOnly: true,
cameraEventWaitTimeMs: 500,
fetchWaitsForMoveEnd: false,
timestamp,
},
});
}
/**
* Instrumented twin of `parseRoads`. Operation ordering and road output match
* the normal function; debug-only clocks accumulate synchronous height and
* waypoint-materialization time independently.
*/
function parseRoadsTimed(overpassData, trace) {
/* TRACE_ONLY_BEGIN */
const _trafficTimingState = trafficTimingPass(
trace,
trace?.currentPass || 'full',
);
const _trafficTimingParseStartTime = performance.now();
const _trafficTimingParseStart = trafficTimingMark(
_trafficTimingState,
'road-parse-start',
{},
_trafficTimingParseStartTime,
);
/* TRACE_ONLY_END */
if (!overpassData || !overpassData.elements) {
/* TRACE_ONLY_BEGIN */
const _trafficTimingParseEnd = trafficTimingMark(
_trafficTimingState,
'road-parse-end',
{ roadCount: 0 },
);
trafficTimingMeasure(
'road-parse-total',
_trafficTimingState,
_trafficTimingParseStart,
_trafficTimingParseEnd,
{ roadCount: 0 },
);
trafficTimingAggregate(
'sample-height-total',
_trafficTimingState,
_trafficTimingParseStartTime,
0,
{
sampleHeightCalls: 0,
sampleHeightMeanMs: 0,
distinctCells: 0,
roadCount: 0,
},
);
trafficTimingAggregate(
'waypoint-materialization',
_trafficTimingState,
_trafficTimingParseStartTime,
0,
{ roadCount: 0 },
);
/* TRACE_ONLY_END */
return [];
}
const roads = [];
/* TRACE_ONLY_BEGIN */
const _trafficTimingSampledCells = new Set();
let _trafficTimingSampleHeightCalls = 0;
let _trafficTimingSampleHeightMs = 0;
let _trafficTimingWaypointMaterializationMs = 0;
/* TRACE_ONLY_END */
for (const el of overpassData.elements) {
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
const rawCoords = el.geometry.map((g) => [g.lon, g.lat]);
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]);
}
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 = el.tags?.highway || 'unclassified';
const onewayTag = el.tags?.oneway;
const oneway =
onewayTag === 'yes' ||
onewayTag === '1' ||
onewayTag === 'true' ||
el.tags?.junction === 'roundabout'
? 1
: onewayTag === '-1'
? -1
: 0;
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)}`,
);
/* TRACE_ONLY_END */
const carto = Cesium.Cartographic.fromDegrees(
firstCoord[0],
firstCoord[1],
);
/* TRACE_ONLY_BEGIN */
const _trafficTimingSampleStart = performance.now();
/* 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]),
);
}
/* 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,
_trafficTimingParseStartTime,
_trafficTimingWaypointMaterializationMs,
_trafficTimingMetrics,
);
const _trafficTimingParseEnd = trafficTimingMark(
_trafficTimingState,
'road-parse-end',
_trafficTimingMetrics,
);
trafficTimingMeasure(
'road-parse-total',
_trafficTimingState,
_trafficTimingParseStart,
_trafficTimingParseEnd,
_trafficTimingMetrics,
);
/* TRACE_ONLY_END */
return roads;
}
/** Resolve a render label into its correlated pass and data source. */
function trafficTimingRenderState(trace, label) {
const pass = label.toLowerCase().includes('major') ? 'major' : 'full';
const source = label.startsWith('Cache') ? 'client-cache' : 'proxy';
return trafficTimingPass(trace, pass, source);
}
/** Record the first Cesium postRender following a completed dot render. */
function scheduleTrafficTimingPostRender(
state,
renderEnd,
renderId,
renderMetrics,
) {
if (!layerState._viewer?.scene) return;
let remove = null;
remove = layerState._viewer.scene.postRender.addEventListener(() => {
remove?.();
layerState._trafficTimingPostRenderRemovers?.delete(remove);
const visibleTime = performance.now();
const postRender = trafficTimingMark(
state,
'next-post-render',
{
renderId,
...renderMetrics,
},
visibleTime,
);
const firstVisible = trafficTimingMark(
state,
'first-visible-pixel',
{
renderId,
visibleBoundary: 'next-postRender',
...renderMetrics,
},
visibleTime,
);
trafficTimingMeasure(
'render-to-post-render',
state,
renderEnd,
postRender,
{
renderId,
visibleBoundary: 'next-postRender',
...renderMetrics,
},
);
trafficTimingMeasure(
'last-camera-change-to-first-visible',
state,
state.trace.cameraChangeMark,
firstVisible,
{ renderId, visibleBoundary: 'next-postRender', ...renderMetrics },
);
});
layerState._trafficTimingPostRenderRemovers?.add(remove);
}
/** Start a scheduling-correlated debug trace, or count and drop an unpaired load. */
async function loadRoadsForBoundsTimed(bounds, altitude, expectedAnchor) {
const generation = layerState._loadGeneration + 1;
if (
!expectedAnchor ||
expectedAnchor !== layerState._trafficTimingCurrentAnchor
) {
layerState._trafficTimingDroppedTraces += 1;
await parts.ingestion.loadRoadsForBounds(bounds, altitude);
return;
}
layerState._trafficTimingCurrentAnchor = null;
const clamped = parts.viewport.clampBounds(bounds);
const trace = {
id: ++layerState._trafficTimingSequence,
interactionId: expectedAnchor.interactionId,
cameraChangeTimestamp: expectedAnchor.timestamp,
cameraChangeMark: trafficTimingCameraChangeMarkName(
expectedAnchor.interactionId,
),
generation,
cacheKey: `${clamped.south.toFixed(4)},${clamped.west.toFixed(4)},${clamped.north.toFixed(4)},${clamped.east.toFixed(4)}`,
currentPass: null,
renderSequence: 0,
passes: new Map(),
};
layerState._trafficTimingTracesCreated += 1;
await parts.ingestion.loadRoadsForBounds(bounds, altitude, trace);
}
return {
getTrafficTimingDiagnostics,
trafficTimingPass,
trafficTimingDetail,
trafficTimingMark,
trafficTimingMeasure,
trafficTimingAggregate,
clearTrafficTimingEntries,
trafficTimingCameraChangeMarkName,
markTrafficTimingCameraChange,
markTrafficTimingMoveEnd,
parseRoadsTimed,
trafficTimingRenderState,
scheduleTrafficTimingPostRender,
loadRoadsForBoundsTimed,
};
}
+241
View File
@@ -0,0 +1,241 @@
import * as Cesium from 'cesium';
import {
deriveFetchCenter,
clampBoundsAroundCenter,
} from '../../data/trafficBounds.js';
import {
MAX_LOOKAT_PULL_KM,
ACTIVATION_ALTITUDE,
OVERLAP_THRESHOLD,
MIN_CENTER_SHIFT_KM,
TRAFFIC_TIMING_ENABLED,
FETCH_DEBOUNCE,
} from './policy.js';
export function createViewport({ state: layerState, services, parts, source }) {
// ─── Camera Monitoring ─────────────────────────────────────
/**
* Get the current camera altitude in meters above the ellipsoid.
* @returns {number} Camera height in meters, or Infinity if unavailable.
*/
function getCameraAltitude() {
const carto = layerState._viewer.camera.positionCartographic;
return carto ? carto.height : Infinity;
}
/**
* Compute the current camera view rectangle in degrees.
* @returns {{south:number, west:number, north:number, east:number}|null}
* Bounding box in degrees, or null if the rectangle cannot be computed.
*/
function getViewBounds() {
const rect = layerState._viewer.camera.computeViewRectangle();
if (!rect) return null;
return {
south: Cesium.Math.toDegrees(rect.south),
west: Cesium.Math.toDegrees(rect.west),
north: Cesium.Math.toDegrees(rect.north),
east: Cesium.Math.toDegrees(rect.east),
};
}
/**
* Derive the road-fetch center from the camera's look-at ground point.
*
* C4 fix: at oblique pitch `computeViewRectangle()` spans toward the horizon,
* so its midpoint can sit tens of km from what the user is looking at. Instead
* we pick the ellipsoid under the canvas center (`camera.pickEllipsoid` — this
* works with the globe hidden under Google 3D tiles, where `scene.globe.pick`
* is NOT reliable), fall back to the camera nadir on a sky/horizon look, and
* pull horizon-gaze hits back to MAX_LOOKAT_PULL_KM from nadir.
*
* @returns {{lat:number, lon:number, source:string}|null} Fetch center in
* degrees, or null when the camera position is unavailable.
*/
function getFetchCenter() {
const carto = layerState._viewer.camera.positionCartographic;
if (!carto) return null;
const nadirLat = Cesium.Math.toDegrees(carto.latitude);
const nadirLon = Cesium.Math.toDegrees(carto.longitude);
let hitLat;
let hitLon;
const canvas = layerState._viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (width > 0 && height > 0) {
const hit = layerState._viewer.camera.pickEllipsoid(
new Cesium.Cartesian2(width / 2, height / 2),
Cesium.Ellipsoid.WGS84,
);
if (hit) {
const hitCarto = Cesium.Cartographic.fromCartesian(hit);
hitLat = Cesium.Math.toDegrees(hitCarto.latitude);
hitLon = Cesium.Math.toDegrees(hitCarto.longitude);
}
}
return deriveFetchCenter({
nadirLat,
nadirLon,
hitLat,
hitLon,
maxPullKm: MAX_LOOKAT_PULL_KM,
});
}
/**
* Compute the geographic center of a bounding box.
* @param {{south:number, west:number, north:number, east:number}} bounds
* @returns {{lat:number, lon:number}} Center point in degrees.
*/
function getBoundsCenter(bounds) {
return {
lat: (bounds.south + bounds.north) / 2,
lon: (bounds.west + bounds.east) / 2,
};
}
/**
* Approximate great-circle distance between two points in kilometres.
*
* Uses an equirectangular projection (cosine correction on longitude)
* with the 111 km/degree approximation. Sufficient for the small
* viewport-center shifts being compared.
*
* @param {{lat:number, lon:number}} a - First point.
* @param {{lat:number, lon:number}} b - Second point.
* @returns {number} Distance in kilometres.
*/
function distanceKm(a, b) {
const dLat = (a.lat - b.lat) * 111;
const avgLat = ((a.lat + b.lat) / 2) * (Math.PI / 180);
const dLon = (a.lon - b.lon) * 111 * Math.cos(avgLat);
return Math.sqrt(dLat * dLat + dLon * dLon);
}
/**
* Check whether two bounding boxes overlap by at least a given fraction of
* the first box's area. Used to decide if a camera move is large enough to
* warrant a new road fetch.
*
* @param {{south:number, west:number, north:number, east:number}} a - Reference bounds.
* @param {{south:number, west:number, north:number, east:number}} b - Bounds to compare.
* @param {number} threshold - Minimum overlap fraction (0-1) relative to `a`.
* @returns {boolean} True if overlap area / a area >= threshold.
*/
function boundsOverlap(a, b, threshold) {
// Compute the intersection rectangle
const overlapS = Math.max(a.south, b.south);
const overlapN = Math.min(a.north, b.north);
const overlapW = Math.max(a.west, b.west);
const overlapE = Math.min(a.east, b.east);
// No intersection if the rectangle is degenerate
if (overlapN <= overlapS || overlapE <= overlapW) return false;
const overlapArea = (overlapN - overlapS) * (overlapE - overlapW);
const aArea = (a.north - a.south) * (a.east - a.west);
return aArea > 0 && overlapArea / aArea >= threshold;
}
/**
* Clamp a bounding box to a maximum span to avoid overloading the Overpass API.
*
* The box is centered on the input's midpoint with each axis capped at 0.05
* degrees (~5.5 km at the equator). This keeps query area and response size
* manageable while still covering the visible neighbourhood.
*
* NOTE (C4): the camera-driven path in `onCameraChanged` centers on the
* derived look-at point via `clampBoundsAroundCenter` instead; this
* midpoint-centered variant remains as the internal re-clamp guard in
* `loadRoadsForBounds` (idempotent on already-clamped bounds).
*
* @param {{south:number, west:number, north:number, east:number}} bounds
* @returns {{south:number, west:number, north:number, east:number}} Clamped bounds.
*/
function clampBounds(bounds) {
return 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() {
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) {
parts.animation.clearDots();
layerState._lastBounds = null;
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,
);
}
return {
getCameraAltitude,
getViewBounds,
getFetchCenter,
getBoundsCenter,
distanceKm,
boundsOverlap,
clampBounds,
onCameraChanged,
};
}