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

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Traffic and bikeshare components
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
- Give each flow source its own bounded decode cache and cancellation checks.
- Separate bikeshare registry, station requests, rendering, selection and proximity handling.
## Installation and context components
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
+8
View File
@@ -1,5 +1,13 @@
# God's Eye View Current State
Traffic and bikeshare expose factories through `./layers/traffic` and
`./layers/bikeshare`. Traffic separates road requests, ingestion, animation,
flow matching, styling, viewport lifecycle and development timing. Each source
owns its decoded flow cache; cancelled bodies cannot refill it. Bikeshare
separates its city registry, station source, parsing, rendering, selection and
proximity lifecycle. Existing standalone sources, live/simulated labels, road
budgets, station availability and polling behavior are retained.
Installations and proximity context now expose `./layers/installations` and
`./layers/awareness` factories. Each owns its records, selection, presentation,
navigation and lifecycle. The standalone entries supply existing scene operations
+3 -1
View File
@@ -152,6 +152,8 @@
"./layers/satellites": "./src/layers/satellites/index.js",
"./layers/launches": "./src/layers/launches/index.js",
"./layers/installations": "./src/layers/installations/index.js",
"./layers/awareness": "./src/layers/awareness/index.js"
"./layers/awareness": "./src/layers/awareness/index.js",
"./layers/traffic": "./src/layers/traffic/index.js",
"./layers/bikeshare": "./src/layers/bikeshare/index.js"
}
}
+37 -1
View File
@@ -390,5 +390,41 @@
"src/layers/awareness/subject.js",
"src/data/militaryInstallations.js",
"src/data/militaryAwareness.js",
"src/layers/installations/source.test.mjs"
"src/layers/installations/source.test.mjs",
"src/layers/traffic/flow.js",
"src/layers/traffic/lifecycle.js",
"src/layers/traffic/viewport.js",
"src/layers/traffic/controls.js",
"src/layers/traffic/source.js",
"src/layers/traffic/state.js",
"src/layers/traffic/policy.js",
"src/layers/traffic/rendering.js",
"src/layers/traffic/ingestion.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/animation.js",
"src/layers/traffic/index.js",
"src/layers/traffic/flowDecode.js",
"src/layers/traffic/model.js",
"src/layers/traffic/timing.js",
"src/layers/traffic/style.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/viewport.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/controls.js",
"src/layers/bikeshare/source.js",
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/rendering.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/index.js",
"src/layers/bikeshare/model.js",
"src/layers/bikeshare/selection.js",
"src/data/traffic.js",
"src/data/bikeshare.js",
"src/data/flowTiles.js",
"src/layers/traffic/source.test.mjs",
"src/layers/bikeshare/source.test.mjs",
"scripts/traffic-fixtures.mjs"
]
+48
View File
@@ -808,5 +808,53 @@
"src/ui/panelRailGeometry.js"
],
"external": ["cesium"]
},
"traffic-layer": {
"exports": ["./layers/traffic"],
"modules": [
"src/data/flowMatch.js",
"src/data/tomtomTiles.js",
"src/data/trafficBounds.js",
"src/data/trafficFlowStyle.js",
"src/data/trafficPresetStyle.js",
"src/data/trafficQueue.js",
"src/layers/traffic/animation.js",
"src/layers/traffic/controls.js",
"src/layers/traffic/flow.js",
"src/layers/traffic/flowDecode.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/index.js",
"src/layers/traffic/ingestion.js",
"src/layers/traffic/lifecycle.js",
"src/layers/traffic/model.js",
"src/layers/traffic/policy.js",
"src/layers/traffic/rendering.js",
"src/layers/traffic/source.js",
"src/layers/traffic/state.js",
"src/layers/traffic/style.js",
"src/layers/traffic/timing.js",
"src/layers/traffic/viewport.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
},
"bikeshare-layer": {
"exports": ["./layers/bikeshare"],
"modules": [
"src/layers/bikeshare/controls.js",
"src/layers/bikeshare/index.js",
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/rendering.js",
"src/layers/bikeshare/selection.js",
"src/layers/bikeshare/source.js",
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js"
],
"external": ["cesium"]
}
}
+21 -6
View File
@@ -25,6 +25,8 @@
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
* that mode does not qualify live source access or quota accounting.
* Exits non-zero on any FAIL. Does not commit anything.
*/
@@ -44,6 +46,8 @@ const getOpt = (name, dflt) => {
};
const APP_URL = getOpt('--url', 'http://localhost:4410');
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -112,7 +116,8 @@ async function main() {
process.exit(2);
}
const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exit(2);
@@ -133,6 +138,11 @@ async function main() {
let exitCode = 0;
try {
const page = await browser.newPage();
const onFixture = (request) => {
const response = fixtureResponse(request);
if (response) void request.respond(response); else void request.continue();
};
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
await page.setViewport({ width: 1440, height: 900 });
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
@@ -152,7 +162,8 @@ async function main() {
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
await sleep(5000);
await page.keyboard.press('Escape');
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
// coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for
@@ -240,16 +251,17 @@ async function main() {
// ── (iii) budget honesty ─────────────────────────────────────────────────
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
{
const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
const ok = grew >= 0 && grew <= flowRequests.length;
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok,
`before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
FIXTURES ? 'Synthetic source mode; live quota was not exercised' : `before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
if (!ok) exitCode = 1;
}
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
if (FIXTURES) page.off('request', onFixture);
await page.setRequestInterception(true);
const keylessFlowReqs = [];
page.on('request', (req) => {
@@ -263,6 +275,8 @@ async function main() {
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
return;
}
const response = fixtureResponse(req);
if (response) { void req.respond(response); return; }
try { req.continue(); } catch { /* already handled */ }
});
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -270,7 +284,8 @@ async function main() {
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
await sleep(5000);
await page.keyboard.press('Escape');
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
if (!simStats || simStats.count === 0) {
// Public Overpass can throttle bursts across harness runs — one retry.
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs';
import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js';
import { tilesForBounds } from '../src/data/tomtomTiles.js';
const tile = readFileSync(
new URL(
'../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf',
import.meta.url,
),
);
const json = (body) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */
export function trafficFixtureResponse(request) {
const url = new URL(request.url());
if (url.pathname === '/api/tomtom/status')
return json({ hasKey: true, dailyCount: 0 });
if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname))
return { status: 200, contentType: 'application/x-protobuf', body: tile };
if (url.pathname !== '/api/overpass') return null;
const query = new URLSearchParams(request.postData()).get('data') || '';
if (!query.includes('highway')) return null;
const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/);
if (!match) throw new Error('Missing road fixture viewport');
const [south, west, north, east] = match.slice(1).map(Number);
const inside = ([lon, lat]) =>
lat >= south && lat <= north && lon >= west && lon <= east;
const segments = tilesForBounds({ south, west, north, east }, 12).flatMap(
({ z, x, y }) => decodeFlowTile(tile, z, x, y),
);
const elements = segments
.filter((segment) => segment.coords.some(inside))
.slice(0, 240)
.map((segment, index) => ({
type: 'way',
id: index + 1,
tags: { highway: 'primary', oneway: 'yes' },
geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })),
}));
const latitude = (south + north) / 2,
longitude = (west + east) / 2;
elements.push({
type: 'way',
id: 99999,
tags: { highway: 'residential' },
geometry: [
{ lat: latitude, lon: longitude },
{ lat: latitude + 0.003, lon: longitude + 0.003 },
],
});
return json({ elements });
}
+22 -1674
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -4,6 +4,6 @@
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
z12 x935 y1686, captured 2026-07-16 from
`api.tomtom.com/traffic/map/4/tile/flow/relative/12/935/1686.pbf`
(22,980 bytes). Used ONLY by `src/data/flowTiles.test.mjs` to pin MVT
decoding offline — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never served to the app. © TomTom.
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
browser mode — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never loaded by ordinary application startup. © TomTom.
+6 -161
View File
@@ -1,161 +1,6 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tilesForBounds } from './tomtomTiles.js';
/**
* @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
*/
export { tilesForBounds };
/** @const {string} MVT layer name in TomTom flow tiles (verified live 2026-07-16). */
const FLOW_LAYER_NAME = 'Traffic flow';
/** @const {number} Ms — per-tile decode cache TTL (matches the proxy's 120 s tile TTL). */
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;
/**
* Decode one TomTom flow tile (MVT protobuf) into flow segments.
*
* @param {Uint8Array|Buffer|ArrayBuffer} data - Raw .pbf tile bytes.
* @param {number} z - Tile zoom (for tile-local → lon/lat projection).
* @param {number} x - Tile column.
* @param {number} y - Tile row.
* @returns {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>}
* Flow polylines in [lon, lat] degrees. Returns [] for undecodable buffers
* or tiles without a "Traffic flow" layer (defensive — a corrupt tile must
* not kill the traffic layer).
*/
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;
}
/** 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.
*/
export async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
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 fetch(`/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);
cacheSet(key, { at: Date.now(), segments });
return segments;
}));
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).
*/
export function getFlowSessionStats() {
return { tilesFetched: _tilesFetched };
}
/** Clear the decode cache (tests + layer teardown). Session stats persist. */
export function resetFlowTileCache() {
_decodeCache.clear();
}
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
export { tilesForBounds } from './tomtomTiles.js';
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
const source = createFlowTileSource();
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
source;
+13 -2506
View File
File diff suppressed because it is too large. Load diff
+9 -10
View File
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { getTrafficTimingDiagnostics } from './traffic.js';
const SOURCE = readFileSync(new URL('./traffic.js', import.meta.url), 'utf8');
const SOURCE = readLayerSource(new URL('./traffic.js', import.meta.url), 'utf8').replace(/parts\.(?:model|timing)\./g, '');
function functionBody(name) {
const declaration = `function ${name}(`;
@@ -166,9 +166,8 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
plugins: [{
name: 'traffic-timing-test-hooks',
transform(code, id) {
if (!id.endsWith('/src/data/traffic.js')) return null;
return `${code}\nexport const __trafficTimingTestHooks = {\n` +
` currentAnchor: () => _trafficTimingCurrentAnchor,\n};\n`;
if (!id.endsWith('/src/layers/traffic/index.js')) return null;
return code.replace('return Object.assign(', 'return Object.assign({ __trafficTimingTestHooks: { currentAnchor: () => state._trafficTimingCurrentAnchor } },');
},
}],
});
@@ -248,7 +247,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
// and moveEnd arrives only after cameraEventWaitTime (~500 ms).
setLongitude(-97.72);
changed.raise();
const anchorA = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorA = traffic.default.__trafficTimingTestHooks.currentAnchor();
const anchorMarkA = performance.getEntriesByType('mark').find((entry) => (
entry.detail?.segment === 'last-camera-change'
&& entry.detail?.interactionId === anchorA.interactionId
@@ -262,7 +261,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
traceObjectsCreated: 1,
uncorrelatedTracesDropped: 0,
});
assert.equal(traffic.__trafficTimingTestHooks.currentAnchor(), null);
assert.equal(traffic.default.__trafficTimingTestHooks.currentAnchor(), null);
const fetchFromA = performance.getEntriesByType('measure').find((entry) => (
entry.detail?.segment === 'last-camera-change-to-fetch-start'
&& entry.detail?.interactionId === anchorA.interactionId
@@ -288,16 +287,16 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
// and must count one drop without consuming C's current anchor.
setLongitude(-97.70);
changed.raise();
const anchorB = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorB = traffic.default.__trafficTimingTestHooks.currentAnchor();
const [timerBId, timerB] = pendingDebounce();
setLongitude(-97.68);
changed.raise();
const anchorC = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorC = traffic.default.__trafficTimingTestHooks.currentAnchor();
assert.notEqual(anchorC.interactionId, anchorB.interactionId);
assert.equal(timeouts.has(timerBId), false, 'the ordinary debounce path must cancel B');
await timerB.callback();
assert.equal(
traffic.__trafficTimingTestHooks.currentAnchor().interactionId,
traffic.default.__trafficTimingTestHooks.currentAnchor().interactionId,
anchorC.interactionId,
'a stale callback must not consume the newer C anchor',
);
+46
View File
@@ -0,0 +1,46 @@
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
id: 'bikeshare',
name: 'Bikeshare',
icon: '🚲',
source: 'GBFS',
updateInterval: STATUS_POLL_MS,
/**
* Return a sampled array of detectable station objects for HUD overlay rendering.
* @param {Object} [options] - Sampling options (maxCount, seed).
* @returns {Array<{ position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean }>}
*/
getDetectableObjects(options = {}) {
return parts.queries.collectDetectableStations(options);
},
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
*/
getStats() {
const stats = {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
};
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
}
if (layerState._error) stats.error = layerState._error;
return stats;
},
};
return { methods };
}
+50
View File
@@ -0,0 +1,50 @@
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
import { createQueries } from './queries.js';
import { createTesting } from './testing.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 createBikeshareLayer({ services, source }) {
if (typeof source?.getStations !== 'function')
throw new TypeError('A bikeshare source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
parts.selection = createSelection(context);
parts.queries = createQueries(context);
parts.testing = createTesting(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion?.methods,
{
createBikeshareSelectedOverlayEntry:
parts.selection.createBikeshareSelectedOverlayEntry,
_setBikeshareSelectionStateForTest:
parts.testing._setBikeshareSelectionStateForTest,
_selectBikeshareStationForTest:
parts.testing._selectBikeshareStationForTest,
_clearBikeshareSelectionForTest:
parts.testing._clearBikeshareSelectionForTest,
},
);
}
export {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from './policy.js';
export { createBikeshareSource } from './source.js';
+212
View File
@@ -0,0 +1,212 @@
import { CITY_BY_ID } from './registry.js';
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
/**
* Convert an upstream GBFS URL into a local proxy URL.
* The dev server proxies /api/gbfs/* to avoid CORS issues with third-party feeds.
* @param {string} upstreamUrl - Full HTTPS GBFS endpoint URL.
* @returns {string} Relative proxy URL.
*/
function toProxyUrl(upstreamUrl) {
return `/api/gbfs/${encodeURIComponent(upstreamUrl)}`;
}
/** Increment the loading reference count and mark loading state active. */
function beginLoading() {
layerState._loadingOps++;
layerState._loading = true;
}
/** Decrement the loading reference count; clear loading flag when zero. */
function endLoading() {
layerState._loadingOps = Math.max(0, layerState._loadingOps - 1);
layerState._loading = layerState._loadingOps > 0;
}
/**
* Fetch and parse JSON from a GBFS endpoint via the local proxy.
* @param {string} upstreamUrl - Full HTTPS GBFS endpoint URL.
* @param {Object} [options]
* @param {AbortSignal} [options.signal] - Optional abort signal for cancellation.
* @returns {Promise<Object>} Parsed JSON payload.
* @throws {Error} On non-OK HTTP status or malformed JSON.
*/
async function fetchGbfsJson(upstreamUrl, options) {
return source.getStations(upstreamUrl, options);
}
/**
* Abort an in-flight GBFS request for a specific city and remove it from the map.
* @param {Map<string, { controller: AbortController }>} map - In-flight request map.
* @param {string} cityId - City whose request should be cancelled.
*/
function abortInFlight(map, cityId) {
const entry = map.get(cityId);
if (!entry) return;
try {
entry.controller?.abort();
} catch {
// no-op
}
map.delete(cityId);
}
/** Abort all in-flight station info and status requests across all cities. */
function abortAllInFlight() {
for (const cityId of layerState._inFlightInfo.keys())
abortInFlight(layerState._inFlightInfo, cityId);
for (const cityId of layerState._inFlightStatus.keys())
abortInFlight(layerState._inFlightStatus, cityId);
}
/**
* Load station information for a city. Returns cached data if available,
* deduplicates concurrent requests, and caches the result on success.
* Station info is static metadata (location, name, capacity) and is
* fetched once per city per session.
* @param {string} cityId - City identifier.
* @param {number} generation - Proximity generation to detect stale requests.
* @returns {Promise<Map<string, Object>>} Map of stationId to station info.
* @throws {Error} On unknown city, fetch failure, or empty station list.
*/
async function loadCityStationInfo(cityId, generation) {
if (layerState._stationInfoCache.has(cityId))
return layerState._stationInfoCache.get(cityId);
const inFlight = layerState._inFlightInfo.get(cityId);
if (inFlight) return inFlight.promise;
const city = CITY_BY_ID.get(cityId);
if (!city) throw new Error(`Unknown GBFS city "${cityId}"`);
const controller = new AbortController();
const promise = (async () => {
beginLoading();
try {
const payload = await fetchGbfsJson(city.stationInformationUrl, {
signal: controller.signal,
});
const stationMap = parts.model.parseStationInformation(payload);
if (stationMap.size === 0) {
throw new Error(`No station information for ${city.city}`);
}
layerState._stationInfoCache.set(cityId, stationMap);
return stationMap;
} finally {
endLoading();
}
})().finally(() => {
// Clean up in-flight entry only if it is still ours (not replaced by a newer request)
const current = layerState._inFlightInfo.get(cityId);
if (current && current.controller === controller)
layerState._inFlightInfo.delete(cityId);
});
layerState._inFlightInfo.set(cityId, { promise, controller, generation });
return promise;
}
/**
* Load real-time station status for a city (bike/dock counts, operational flags).
* Unlike station info, status is NOT served from cache — it is always re-fetched
* to keep availability data current. Concurrent requests are deduplicated.
* @param {string} cityId - City identifier.
* @param {number} generation - Proximity generation to detect stale requests.
* @returns {Promise<Map<string, Object>>} Map of stationId to status objects.
* @throws {Error} On unknown city or fetch failure.
*/
async function loadCityStationStatus(cityId, generation) {
const inFlight = layerState._inFlightStatus.get(cityId);
if (inFlight) return inFlight.promise;
const city = CITY_BY_ID.get(cityId);
if (!city) throw new Error(`Unknown GBFS city "${cityId}"`);
const controller = new AbortController();
const promise = (async () => {
beginLoading();
try {
const payload = await fetchGbfsJson(city.stationStatusUrl, {
signal: controller.signal,
});
const statusMap = parts.model.parseStationStatus(payload);
layerState._statusCache.set(cityId, {
statusMap,
timestamp: Date.now(),
});
return statusMap;
} finally {
endLoading();
}
})().finally(() => {
const current = layerState._inFlightStatus.get(cityId);
if (current && current.controller === controller)
layerState._inFlightStatus.delete(cityId);
});
layerState._inFlightStatus.set(cityId, { promise, controller, generation });
return promise;
}
const methods = {
/**
* Periodic update tick — re-fetches station status for all active cities
* and refreshes point colors/sizes. Called by the layer manager at
* STATUS_POLL_MS intervals.
* @returns {Promise<void>}
*/
async update() {
if (!layerState._enabled || layerState._activeCityIds.size === 0) return;
const generation = layerState._proximityGeneration;
const cityIds = Array.from(layerState._activeCityIds);
await Promise.all(
cityIds.map(async (cityId) => {
try {
const statusMap = await loadCityStationStatus(cityId, generation);
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(
`[Data:Bikeshare] ${cityId} status update error:`,
error,
);
layerState._error = 'GBFS status update failed';
}
}),
);
layerState._count = layerState._stationRenderMap.size;
if (layerState._count > 0) layerState._lastUpdate = Date.now();
},
};
return {
toProxyUrl,
beginLoading,
endLoading,
fetchGbfsJson,
abortInFlight,
abortAllInFlight,
loadCityStationInfo,
loadCityStationStatus,
methods,
};
}
+171
View File
@@ -0,0 +1,171 @@
import * as Cesium from 'cesium';
import { GBFS_CITY_REGISTRY } from './registry.js';
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const methods = {
/**
* Initialize the bikeshare layer. Creates the point primitive collection,
* resets all internal state, and installs the click handler.
* Called once during app bootstrap.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
init(viewer) {
layerState._viewer = viewer;
layerState._pointCollection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(layerState._pointCollection);
registerSpriteCollection('bikeshare', layerState._pointCollection);
layerState._pointCollection.show = false;
layerState._enabled = false;
layerState._cameraDebounceTimer = null;
layerState._cameraChangedAttached = false;
layerState._altitudeGateEnabled = false;
layerState._proximityGeneration = 0;
layerState._activeCityIds = new Set();
layerState._cityRuntime = new Map();
layerState._stationInfoCache = new Map();
layerState._statusCache = new Map();
layerState._inFlightInfo = new Map();
layerState._inFlightStatus = new Map();
layerState._stationRenderMap = new Map();
layerState._clickHandler = null;
layerState._selectedKey = null;
layerState._selectedEntity = null;
layerState._count = 0;
layerState._lastUpdate = null;
layerState._loading = false;
layerState._loadingOps = 0;
layerState._error = null;
layerState._limitWarned = false;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
false,
);
parts.selection._installClickHandler(viewer);
restoreSpriteOrder(viewer);
console.log(
`[Data:Bikeshare] Initialized with ${GBFS_CITY_REGISTRY.length} cities`,
);
},
/**
* Enable the bikeshare layer. Shows points, attaches the camera listener,
* and triggers an initial proximity check.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
enable(viewer) {
layerState._enabled = true;
layerState._error = null;
layerState._pointCollection.show = true;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
true,
);
parts.selection._installClickHandler(viewer);
// Pick-ownership (H2): station point ids are string render-map keys.
registerPickOwner('bikeshare', (pickedId) =>
layerState._stationRenderMap.has(pickedId),
);
if (!layerState._cameraChangedAttached) {
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
viewer.camera.percentageChanged = Math.min(
viewer.camera.percentageChanged || 1,
0.05,
);
layerState._cameraChangedAttached = true;
}
void parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
},
/**
* Disable the bikeshare layer. Hides points, removes event listeners,
* aborts all pending fetches, and tears down all city data.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
disable(viewer) {
layerState._enabled = false;
layerState._proximityGeneration++;
layerState._altitudeGateEnabled = false;
clearTimeout(layerState._cameraDebounceTimer);
layerState._cameraDebounceTimer = null;
parts.selection._clearSelection();
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
false,
);
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
document.removeEventListener('keydown', parts.selection._onKeyDown);
unregisterPickOwner('bikeshare');
if (layerState._cameraChangedAttached) {
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities();
layerState._cityRuntime.clear();
layerState._pointCollection.show = false;
layerState._count = 0;
layerState._loading = false;
layerState._loadingOps = 0;
},
/** Permanently release primitives, handlers, and the selected host source. */
destroy(viewer) {
if (layerState._enabled) this.disable(viewer);
else {
parts.selection._clearSelection();
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
false,
);
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
document.removeEventListener('keydown', parts.selection._onKeyDown);
unregisterPickOwner('bikeshare');
}
if (layerState._cameraChangedAttached) {
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
layerState._viewer = null;
},
};
return { methods };
}
+162
View File
@@ -0,0 +1,162 @@
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
* @param {string} stationId - Station identifier within the city.
* @returns {string} Composite key in "cityId:stationId" format.
*/
function stationKey(cityId, stationId) {
return `${cityId}:${stationId}`;
}
/**
* Compute the great-circle distance between two points using the Haversine formula.
* @param {number} aLat - Latitude of point A in degrees.
* @param {number} aLon - Longitude of point A in degrees.
* @param {number} bLat - Latitude of point B in degrees.
* @param {number} bLon - Longitude of point B in degrees.
* @returns {number} Distance in kilometers.
*/
function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (value) => (value * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const p1 = toRad(aLat);
const p2 = toRad(bLat);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
stationKey,
haversineKm,
extractStationsArray,
parseStationInformation,
parseStationStatus,
};
}
+85
View File
@@ -0,0 +1,85 @@
import * as Cesium from 'cesium';
export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected';
export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 0,
moving: false,
});
// --- Activation / display thresholds ---
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
export const ACTIVATION_ALTITUDE_M = 50000;
/** Hysteresis enter threshold — layer activates when camera drops below this. */
export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 2000;
/** Hysteresis exit threshold — layer deactivates when camera rises above this. */
export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 2000;
/** Debounce interval (ms) for camera-change proximity checks. */
export const CAMERA_DEBOUNCE_MS = 340;
/** Default radius (km) for determining whether a city is in camera range. */
export const CITY_RANGE_BASE_KM = 100;
/** Polling interval (ms) for refreshing station status data. */
export const STATUS_POLL_MS = 60000;
// --- Point rendering constants ---
/** Minimum rendered point size in pixels. */
export const POINT_SIZE_MIN = 4;
/** Maximum rendered point size in pixels. */
export const POINT_SIZE_MAX = 12;
/** Fallback station capacity when real data is unavailable. */
export const DEFAULT_CAPACITY = 15;
/** Vertical offset (m) above terrain for station points. */
export const POINT_HEIGHT_OFFSET_M = 2.0;
/** Hard cap on total rendered station points across all cities. */
export const MAX_TOTAL_POINTS = 8000;
// --- Availability color palette ---
/** Station has >60% bikes available. */
export const COLOR_GREEN =
Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95);
/** Station has 30-60% bikes available. */
export const COLOR_YELLOW =
Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94);
/** Station has <30% bikes available. */
export const COLOR_RED =
Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94);
/** No status data available for station. */
export const COLOR_NEUTRAL =
Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62);
/** Station is offline (not installed, not renting, or not returning). */
export const COLOR_MUTED =
Cesium.Color.fromCssColorString('#687581').withAlpha(0.48);
/** Outline color for all station points. */
export const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25);
+75
View File
@@ -0,0 +1,75 @@
export function createQueries({ state: layerState, services, parts, source }) {
/**
* Build a compact HUD detection ID string for a station.
* Truncates long names to 24 chars for readability.
* @param {Object} record - Render record from _stationRenderMap.
* @returns {string} Formatted label like "Station Name [5/20]".
*/
function buildDetectionId(record) {
const bikes = Number.isFinite(record.bikesAvailable)
? record.bikesAvailable
: '?';
const capacity = Number.isFinite(record.capacity) ? record.capacity : '?';
// Render record stores the station name under `stationName` (see the render-map
// shape), so `record.name` was always undefined → every label read "Dock N".
const label = record.stationName || `Dock ${record.stationId}`;
// Truncate long station names to keep HUD readable
const short = label.length > 24 ? label.slice(0, 22) + '…' : label;
return `🚲 ${short} [${bikes}/${capacity}]`;
}
/**
* Collect a sampled subset of visible stations for HUD detection overlay rendering.
* Uses a deterministic stride pattern controlled by options.seed and options.maxCount
* to avoid overcrowding the HUD while still providing broad coverage.
* @param {Object} [options]
* @param {number} [options.maxCount] - Maximum number of detectable objects to return.
* @param {number} [options.seed] - Seed for deterministic stride offset selection.
* @returns {Array<{ position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean }>}
*/
function collectDetectableStations(options = {}) {
if (
!layerState._enabled ||
!layerState._pointCollection ||
!layerState._pointCollection.show ||
layerState._stationRenderMap.size === 0
)
return [];
// Gather all visible station records (include selected even though its point is hidden)
const records = [];
for (const record of layerState._stationRenderMap.values()) {
const isSelected = record.key === layerState._selectedKey;
if ((!record.point?.show && !isSelected) || !record.point?.position)
continue;
records.push(record);
}
if (records.length === 0) return [];
// Deterministic subsampling: pick every Nth station, offset by seed
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(1, Math.ceil(records.length / maxCount));
const start = ((seed % stride) + stride) % stride;
const result = [];
for (let i = start; i < records.length; i += stride) {
const record = records[i];
result.push({
position: record.point.position,
sourceId: record.key,
id: buildDetectionId(record),
type: 'VEH',
skipLabel: record.key === layerState._selectedKey,
});
if (result.length >= maxCount) break;
}
return result;
}
return { buildDetectionId, collectDetectableStations };
}
+394
View File
@@ -0,0 +1,394 @@
function buildBcycleUrls(systemId) {
return {
stationInformationUrl: `https://gbfs.bcycle.com/${systemId}/station_information.json`,
stationStatusUrl: `https://gbfs.bcycle.com/${systemId}/station_status.json`,
};
}
function bcycleEntry({
id,
city,
centerLat,
centerLon,
systemId,
loadRadiusKm = 100,
provider = 'BCycle',
}) {
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
...buildBcycleUrls(systemId),
};
}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
city: 'New York, NY',
centerLat: 40.7484,
centerLon: -73.9967,
loadRadiusKm: 140,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_status.json',
provider: 'Citi Bike',
},
{
id: 'chicago-divvy',
city: 'Chicago, IL',
centerLat: 41.8781,
centerLon: -87.6298,
loadRadiusKm: 120,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json',
provider: 'Divvy',
},
{
id: 'dc-capital-bikeshare',
city: 'Washington, DC',
centerLat: 38.9072,
centerLon: -77.0369,
loadRadiusKm: 120,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_status.json',
provider: 'Capital Bikeshare',
},
{
id: 'sf-bay-wheels',
city: 'San Francisco, CA',
centerLat: 37.7749,
centerLon: -122.4194,
loadRadiusKm: 110,
stationInformationUrl:
'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_information.json',
stationStatusUrl:
'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json',
provider: 'Bay Wheels',
},
{
id: 'boston-bluebikes',
city: 'Boston, MA',
centerLat: 42.3601,
centerLon: -71.0589,
loadRadiusKm: 100,
stationInformationUrl:
'https://gbfs.bluebikes.com/gbfs/en/station_information.json',
stationStatusUrl: 'https://gbfs.bluebikes.com/gbfs/en/station_status.json',
provider: 'Blue Bikes',
},
{
id: 'philadelphia-indego',
city: 'Philadelphia, PA',
centerLat: 39.9526,
centerLon: -75.1652,
loadRadiusKm: 100,
stationInformationUrl:
'https://gbfs.bcycle.com/bcycle_indego/station_information.json',
stationStatusUrl:
'https://gbfs.bcycle.com/bcycle_indego/station_status.json',
provider: 'Indego',
},
{
id: 'portland-biketown',
city: 'Portland, OR',
centerLat: 45.5152,
centerLon: -122.6784,
loadRadiusKm: 95,
stationInformationUrl:
'https://gbfs.biketownpdx.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.biketownpdx.com/gbfs/2.3/en/station_status.json',
provider: 'BIKETOWN',
},
{
id: 'la-metro-bike',
city: 'Los Angeles, CA',
centerLat: 34.0522,
centerLon: -118.2437,
loadRadiusKm: 120,
stationInformationUrl:
'https://gbfs.bcycle.com/bcycle_lametro/station_information.json',
stationStatusUrl:
'https://gbfs.bcycle.com/bcycle_lametro/station_status.json',
provider: 'Metro Bike',
},
{
id: 'austin-capmetro',
city: 'Austin, TX',
centerLat: 30.2672,
centerLon: -97.7431,
loadRadiusKm: 90,
stationInformationUrl:
'https://austin.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://austin.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'CapMetro',
},
{
id: 'honolulu-biki',
city: 'Honolulu, HI',
centerLat: 21.3069,
centerLon: -157.8583,
loadRadiusKm: 90,
stationInformationUrl:
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Biki',
},
{
id: 'columbus-cogo',
city: 'Columbus, OH',
centerLat: 39.9612,
centerLon: -82.9988,
loadRadiusKm: 90,
stationInformationUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json',
provider: 'CoGo',
},
{
id: 'chattanooga-bikechatt',
city: 'Chattanooga, TN',
centerLat: 35.0456,
centerLon: -85.3097,
loadRadiusKm: 90,
stationInformationUrl:
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Chattanooga',
},
bcycleEntry({
id: 'boulder-bcycle',
city: 'Boulder, CO',
centerLat: 40.015,
centerLon: -105.2705,
systemId: 'bcycle_boulder',
}),
bcycleEntry({
id: 'milwaukee-bublr',
city: 'Milwaukee, WI',
centerLat: 43.0389,
centerLon: -87.9065,
systemId: 'bcycle_bublr',
provider: 'Bublr',
}),
bcycleEntry({
id: 'madison-bcycle',
city: 'Madison, WI',
centerLat: 43.0731,
centerLon: -89.4012,
systemId: 'bcycle_madison',
}),
bcycleEntry({
id: 'nashville-bcycle',
city: 'Nashville, TN',
centerLat: 36.1627,
centerLon: -86.7816,
systemId: 'bcycle_nashville',
}),
bcycleEntry({
id: 'salt-lake-greenbike',
city: 'Salt Lake City, UT',
centerLat: 40.7608,
centerLon: -111.891,
systemId: 'bcycle_greenbikeslc',
provider: 'GREENbike',
}),
bcycleEntry({
id: 'san-antonio-bcycle',
city: 'San Antonio, TX',
centerLat: 29.4241,
centerLon: -98.4936,
systemId: 'bcycle_sanantonio',
}),
bcycleEntry({
id: 'cincinnati-red-bike',
city: 'Cincinnati, OH',
centerLat: 39.1031,
centerLon: -84.512,
systemId: 'bcycle_cincyredbike',
provider: 'Red Bike',
}),
bcycleEntry({
id: 'el-paso-bcycle',
city: 'El Paso, TX',
centerLat: 31.7619,
centerLon: -106.485,
systemId: 'bcycle_elpaso',
}),
bcycleEntry({
id: 'indianapolis-pacers',
city: 'Indianapolis, IN',
centerLat: 39.7684,
centerLon: -86.1581,
systemId: 'bcycle_pacersbikeshare',
provider: 'Pacers Bikeshare',
}),
bcycleEntry({
id: 'fort-lauderdale-broward',
city: 'Fort Lauderdale, FL',
centerLat: 26.1224,
centerLon: -80.1373,
systemId: 'bcycle_broward',
provider: 'Broward B-cycle',
}),
bcycleEntry({
id: 'memphis-bcycle',
city: 'Memphis, TN',
centerLat: 35.1495,
centerLon: -90.049,
systemId: 'bcycle_memphis',
}),
bcycleEntry({
id: 'des-moines-bcycle',
city: 'Des Moines, IA',
centerLat: 41.5868,
centerLon: -93.625,
systemId: 'bcycle_desmoines',
}),
bcycleEntry({
id: 'tucson-tugo',
city: 'Tucson, AZ',
centerLat: 32.2226,
centerLon: -110.9747,
systemId: 'bcycle_tugo',
provider: 'Tugo',
}),
bcycleEntry({
id: 'fort-worth-trinity',
city: 'Fort Worth, TX',
centerLat: 32.7555,
centerLon: -97.3308,
systemId: 'bcycle_fortworth',
provider: 'Trinity Metro',
}),
bcycleEntry({
id: 'omaha-heartland',
city: 'Omaha, NE',
centerLat: 41.2565,
centerLon: -95.9345,
systemId: 'bcycle_heartland',
provider: 'Heartland B-cycle',
}),
bcycleEntry({
id: 'lincoln-bikelnk',
city: 'Lincoln, NE',
centerLat: 40.8136,
centerLon: -96.7026,
systemId: 'bcycle_bikelnk',
provider: 'BikeLNK',
}),
bcycleEntry({
id: 'greenville-sc-bcycle',
city: 'Greenville, SC',
centerLat: 34.8526,
centerLon: -82.394,
systemId: 'bcycle_greenville',
}),
bcycleEntry({
id: 'buffalo-reddy',
city: 'Buffalo, NY',
centerLat: 42.8864,
centerLon: -78.8784,
systemId: 'bcycle_reddy',
provider: 'Reddy Bikeshare',
}),
bcycleEntry({
id: 'las-vegas-rtc-bike-share',
city: 'Las Vegas, NV',
centerLat: 36.1699,
centerLon: -115.1398,
systemId: 'bcycle_rtcbikeshare',
provider: 'RTC Bike Share',
}),
bcycleEntry({
id: 'santa-barbara-bcycle',
city: 'Santa Barbara, CA',
centerLat: 34.4208,
centerLon: -119.6982,
systemId: 'bcycle_santabarbara',
}),
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
.trim()
.toLowerCase();
const city = String(entry?.city || '').trim();
const centerLat = Number(entry?.centerLat);
const centerLon = Number(entry?.centerLon);
const loadRadiusKm = Number(entry?.loadRadiusKm);
const stationInformationUrl = new URL(
String(entry?.stationInformationUrl || '').trim(),
);
const stationStatusUrl = new URL(
String(entry?.stationStatusUrl || '').trim(),
);
if (!id) throw new Error('GBFS entry id is required');
if (!city) throw new Error(`GBFS entry "${id}" city is required`);
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLon)) {
throw new Error(`GBFS entry "${id}" has invalid center coordinates`);
}
// Enforce HTTPS-only for GBFS feeds
if (
stationInformationUrl.protocol !== 'https:' ||
stationStatusUrl.protocol !== 'https:'
) {
throw new Error(`GBFS entry "${id}" must use https URLs`);
}
// Validate that URLs end with expected GBFS endpoint filenames
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
}
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
}
// Deduplicate hostnames across both feed URLs for proxy allowlisting
const hosts = Array.from(
new Set([
stationInformationUrl.hostname.toLowerCase(),
stationStatusUrl.hostname.toLowerCase(),
]),
);
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm:
Number.isFinite(loadRadiusKm) && loadRadiusKm > 0
? loadRadiusKm
: CITY_RANGE_BASE_KM,
stationInformationUrl: stationInformationUrl.toString(),
stationStatusUrl: stationStatusUrl.toString(),
provider: String(entry?.provider || 'GBFS').trim() || 'GBFS',
hosts,
};
}
const GBFS_CITY_REGISTRY = (() => {
const seen = new Set();
return RAW_GBFS_CITY_REGISTRY.map((entry) => {
const normalized = normalizeRegistryEntry(entry);
if (seen.has(normalized.id)) {
throw new Error(`Duplicate GBFS city id: ${normalized.id}`);
}
seen.add(normalized.id);
return normalized;
});
})();
const CITY_BY_ID = new Map(
GBFS_CITY_REGISTRY.map((entry) => [entry.id, entry]),
);
export { GBFS_CITY_REGISTRY, CITY_BY_ID };
+282
View File
@@ -0,0 +1,282 @@
import * as Cesium from 'cesium';
import {
DEFAULT_CAPACITY,
POINT_SIZE_MIN,
POINT_SIZE_MAX,
COLOR_NEUTRAL,
COLOR_MUTED,
COLOR_GREEN,
COLOR_YELLOW,
COLOR_RED,
POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS,
COLOR_OUTLINE,
} from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const { governorRequestRender } = services.render;
/**
* Resolve the effective dock capacity for a station.
* Prefers the explicit capacity from station_information; falls back to
* bikes+docks from status data; uses DEFAULT_CAPACITY as last resort.
* @param {number|null} capacityFromInfo - Capacity from station_information, or null.
* @param {Object|null} status - Station status object with bikesAvailable/docksAvailable.
* @returns {number} Resolved capacity (always > 0).
*/
function resolveCapacity(capacityFromInfo, status) {
const fromInfo = parts.model.toNonNegativeInteger(capacityFromInfo);
if (fromInfo && fromInfo > 0) return fromInfo;
// Derive capacity from current bikes + available docks
const bikes = parts.model.toNonNegativeInteger(status?.bikesAvailable, 0);
const docks = parts.model.toNonNegativeInteger(status?.docksAvailable, 0);
const derived = bikes + docks;
if (derived > 0) return derived;
return DEFAULT_CAPACITY;
}
/**
* Map station capacity to a point pixel size using a sqrt scale.
* Larger-capacity stations render as bigger dots; clamped to [POINT_SIZE_MIN, POINT_SIZE_MAX].
* @param {number} capacity - Station dock capacity.
* @returns {number} Point size in pixels.
*/
function capacityToPixelSize(capacity) {
const c = Math.max(1, Math.min(80, Number(capacity) || DEFAULT_CAPACITY));
// sqrt scale so large stations don't dominate visually
const normalized = Math.sqrt(c) / Math.sqrt(80);
const size =
POINT_SIZE_MIN + normalized * (POINT_SIZE_MAX - POINT_SIZE_MIN);
return Math.max(POINT_SIZE_MIN, Math.min(POINT_SIZE_MAX, size));
}
/**
* Determine the display color for a station based on its availability ratio.
* - No status data: neutral gray.
* - Offline (not installed/renting/returning): muted gray.
* - >60% bikes available: green.
* - 30-60% bikes available: yellow.
* - <30% bikes available: red.
* @param {Object|null} status - Station status object.
* @param {number} capacity - Resolved station capacity.
* @returns {Cesium.Color} Color to apply to the station point.
*/
function statusToColor(status, capacity) {
if (!status) return COLOR_NEUTRAL;
if (!status.isInstalled || !status.isRenting || !status.isReturning)
return COLOR_MUTED;
const bikes = parts.model.toNonNegativeInteger(status.bikesAvailable);
if (!Number.isFinite(bikes)) return COLOR_NEUTRAL;
const cap = Math.max(1, Number(capacity) || DEFAULT_CAPACITY);
const ratio = bikes / cap;
if (ratio > 0.6) return COLOR_GREEN;
if (ratio >= 0.3) return COLOR_YELLOW;
return COLOR_RED;
}
/**
* Create a Cartesian3 position for a station, terrain-clamped when possible.
* Falls back to a fixed small height offset if terrain sampling is unsupported.
* @param {Object} station - Station info object with lat/lon.
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
*/
function createStationPosition(station) {
const lon = Number(station?.lon);
const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
let height = POINT_HEIGHT_OFFSET_M;
// Sample terrain height so points sit on ground rather than at ellipsoid level
if (layerState._viewer?.scene?.sampleHeightSupported) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const sampled = layerState._viewer.scene.sampleHeight(carto);
if (Number.isFinite(sampled)) {
height = sampled + POINT_HEIGHT_OFFSET_M;
}
}
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
}
/**
* Get or create the runtime tracking object for a city.
* @param {string} cityId - City identifier.
* @returns {{ stationKeys: Set<string> }} City runtime object.
*/
function ensureCityRuntime(cityId) {
if (layerState._cityRuntime.has(cityId))
return layerState._cityRuntime.get(cityId);
const runtime = { stationKeys: new Set() };
layerState._cityRuntime.set(cityId, runtime);
return runtime;
}
/**
* Ensure point primitives exist for all stations in a city.
* Creates new points for stations not yet rendered; skips existing ones.
* Respects the MAX_TOTAL_POINTS global cap to prevent GPU overload.
* @param {string} cityId - City identifier.
* @param {Map<string, Object>} stationMap - Parsed station info map for the city.
*/
function ensureCityPoints(cityId, stationMap) {
// Deferred debounce/fetch completions mutate point primitives after the
// camera settled — each commit needs one frame in idle mode. (perf wave 2 fix)
governorRequestRender('bikeshare-points');
const runtime = ensureCityRuntime(cityId);
for (const station of stationMap.values()) {
const key = parts.model.stationKey(cityId, station.stationId);
if (layerState._stationRenderMap.has(key)) {
runtime.stationKeys.add(key);
continue;
}
// Enforce global point cap
if (layerState._stationRenderMap.size >= MAX_TOTAL_POINTS) {
if (!layerState._limitWarned) {
layerState._limitWarned = true;
console.warn(
`[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`,
);
}
break;
}
const position = createStationPosition(station);
if (!position) continue;
// Add a point primitive with distance-based scale and translucency falloff
const point = layerState._pointCollection.add({
position,
pixelSize: capacityToPixelSize(station.capacity),
color: COLOR_NEUTRAL,
outlineColor: COLOR_OUTLINE,
outlineWidth: 1,
scaleByDistance: new Cesium.NearFarScalar(200, 1.35, 130000, 0.4),
translucencyByDistance: new Cesium.NearFarScalar(
200,
1.0,
180000,
0.15,
),
disableDepthTestDistance: 2500,
id: key,
});
layerState._stationRenderMap.set(key, {
key,
cityId,
stationId: station.stationId,
stationName: station.name,
point,
capacity: parts.model.toNonNegativeInteger(station.capacity),
bikesAvailable: null,
docksAvailable: null,
isInstalled: station.isInstalled,
isRenting: station.isRenting,
isReturning: station.isReturning,
});
runtime.stationKeys.add(key);
}
layerState._count = layerState._stationRenderMap.size;
}
/**
* Remove all rendered point primitives for a city and clean up runtime state.
* Also clears any active selection if it belongs to this city.
* @param {string} cityId - City identifier to remove.
*/
function removeCityPoints(cityId) {
const runtime = layerState._cityRuntime.get(cityId);
if (!runtime) return;
// Clear selection if it belongs to the city being removed
if (
layerState._selectedKey &&
runtime.stationKeys.has(layerState._selectedKey)
) {
parts.selection._clearSelection();
}
for (const key of runtime.stationKeys) {
const record = layerState._stationRenderMap.get(key);
if (!record) continue;
layerState._pointCollection.remove(record.point);
layerState._stationRenderMap.delete(key);
}
layerState._cityRuntime.delete(cityId);
layerState._count = layerState._stationRenderMap.size;
}
/**
* Apply real-time status data to rendered station points for a city.
* Updates each point's color (availability ratio) and pixel size (capacity),
* and refreshes the render record's cached availability fields.
* @param {string} cityId - City identifier.
* @param {Map<string, Object>} statusMap - Parsed station status map.
*/
function applyStatusToPoints(cityId, statusMap) {
governorRequestRender('bikeshare-status');
const runtime = layerState._cityRuntime.get(cityId);
if (!runtime) return;
for (const key of runtime.stationKeys) {
const record = layerState._stationRenderMap.get(key);
if (!record) continue;
const status = statusMap.get(record.stationId) || null;
const capacity = resolveCapacity(record.capacity, status);
record.capacity = capacity;
record.bikesAvailable = parts.model.toNonNegativeInteger(
status?.bikesAvailable,
);
record.docksAvailable = parts.model.toNonNegativeInteger(
status?.docksAvailable,
);
record.isInstalled = parts.model.normalizeGbfsBool(
status?.isInstalled,
true,
);
record.isRenting = parts.model.normalizeGbfsBool(status?.isRenting, true);
record.isReturning = parts.model.normalizeGbfsBool(
status?.isReturning,
true,
);
// Update visual properties based on current status
record.point.pixelSize = capacityToPixelSize(capacity);
record.point.color = statusToColor(status, capacity);
}
}
return {
resolveCapacity,
capacityToPixelSize,
statusToColor,
createStationPosition,
ensureCityRuntime,
ensureCityPoints,
removeCityPoints,
applyStatusToPoints,
};
}
+206
View File
@@ -0,0 +1,206 @@
import * as Cesium from 'cesium';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from './policy.js';
export function createSelection({
state: layerState,
services,
parts,
source,
}) {
/**
* Build a multi-line text label for the selected station popup.
* Shows station name, availability counts, and any abnormal operational flags.
* @param {Object} record - Render record from _stationRenderMap.
* @returns {string} Newline-delimited source text for the selected host card.
*/
function buildSelectionLabel(record) {
const stationName = String(record?.stationName || '').trim();
const stationLabel =
stationName ||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
const docks = Number.isFinite(record?.docksAvailable)
? record.docksAvailable
: '?';
const capacity = Number.isFinite(record?.capacity) ? record.capacity : '?';
const lines = [
stationLabel,
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
return lines.join('\n');
}
/**
* Build the protected selected-station entry from source-owned copy.
* @param {string} key Stable city/station composite key.
* @param {Object} record Bikeshare render record.
* @returns {Object|null}
*/
function createBikeshareSelectedOverlayEntry(key, record) {
const position = record?.point?.position;
if (!key || !position) return null;
const [title, ...details] = buildSelectionLabel(record).split('\n');
return {
id: String(key),
position,
variant: 'selected',
selected: true,
protected: true,
paintLane: 'selected',
collisionGroup: 'ambient-card',
priority: Number.MAX_SAFE_INTEGER,
title,
details,
accent: '#00ffff',
interactive: false,
anchorRadiusPx: 9,
minAnchorGapPx: 11,
verticalOnly: true,
placement: 'above',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
};
}
/**
* Clear the current station selection.
* Re-shows the hidden point primitive and removes the highlight entity.
*/
function _clearSelection() {
if (layerState._selectedKey) {
const record = layerState._stationRenderMap.get(layerState._selectedKey);
if (record?.point) {
record.point.show = true;
}
}
if (layerState._selectedEntity && layerState._viewer) {
layerState._viewer.entities.remove(layerState._selectedEntity);
}
layerState._selectedKey = null;
layerState._selectedEntity = null;
layerState._overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID);
}
/**
* Select a station by key: hides the original point primitive and adds a
* highlighted cyan entity plus a protected shared-host availability card.
* @param {string} key - Composite "cityId:stationId" key.
*/
function _selectStation(key) {
_clearSelection();
const record = layerState._stationRenderMap.get(key);
if (!record || !record.point?.position || !layerState._viewer) return;
layerState._selectedKey = key;
// Hide the base point so the highlight entity replaces it visually
record.point.show = false;
layerState._selectedEntity = layerState._viewer.entities.add({
position: record.point.position,
point: {
pixelSize: 14,
color: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
const entry = createBikeshareSelectedOverlayEntry(key, record);
if (entry) {
layerState._overlayHost.setEntries(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
[entry],
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
);
}
}
/**
* Install a screen-space click handler for station selection/deselection.
* Also registers a global keydown listener for Escape-to-deselect.
* Idempotent — does nothing if a handler is already installed.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
*/
function _installClickHandler(viewer) {
if (layerState._clickHandler) return;
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
const picked = viewer.scene.pick(click.position);
if (picked) {
// Clicking selected entity itself — ignore (don't deselect).
if (picked.id === layerState._selectedEntity) return;
// Check if the picked primitive or entity id matches a station key
const primitive = picked.primitive;
if (
primitive &&
typeof primitive.id === 'string' &&
layerState._stationRenderMap.has(primitive.id)
) {
_selectStation(primitive.id);
return;
}
if (
typeof picked.id === 'string' &&
layerState._stationRenderMap.has(picked.id)
) {
_selectStation(picked.id);
return;
}
}
// Clicked empty space — deselect.
if (layerState._selectedKey) _clearSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
document.addEventListener('keydown', _onKeyDown);
}
/**
* Global keydown handler — deselects the current station on Escape.
* @param {KeyboardEvent} e - Keyboard event.
*/
function _onKeyDown(e) {
if (e.key === 'Escape' && layerState._selectedKey) {
_clearSelection();
}
}
return {
buildSelectionLabel,
createBikeshareSelectedOverlayEntry,
_clearSelection,
_selectStation,
_installClickHandler,
_onKeyDown,
};
}
+23
View File
@@ -0,0 +1,23 @@
/** Read a GBFS station document through the existing bounded server proxy. */
export function createBikeshareSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getStations(upstreamUrl, { signal } = {}) {
const url = new URL(upstreamUrl);
if (url.protocol !== 'https:' || url.username || url.password || url.hash)
throw new TypeError('A public HTTPS GBFS URL is required');
signal?.throwIfAborted();
const response = await fetchImpl(
'/api/gbfs?url=' + encodeURIComponent(url.href),
{ method: 'GET', headers: { Accept: 'application/json' }, signal },
);
if (!response.ok) throw new Error('GBFS HTTP ' + response.status);
const payload = await response.json();
signal?.throwIfAborted();
if (!payload || typeof payload !== 'object')
throw new Error('Malformed GBFS payload');
return payload;
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBikeshareSource } from './source.js';
test('station source keeps upstream URLs behind the fixed GBFS endpoint', async () => {
const calls = [];
const source = createBikeshareSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"data":{"stations":[]}}');
},
});
for (const url of [
'file:///etc/passwd',
'http://example.test/stations',
'https://user:[email protected]/stations',
])
await assert.rejects(source.getStations(url), /HTTPS GBFS/);
assert.equal(calls.length, 0);
await source.getStations('https://example.test/stations.json');
const url = new URL(calls[0][0], 'https://app.example');
assert.equal(url.pathname, '/api/gbfs');
assert.equal(
url.searchParams.get('url'),
'https://example.test/stations.json',
);
});
test('cancelled station parsing never publishes the response', async () => {
const controller = new AbortController();
const source = createBikeshareSource({
fetchImpl: async () => ({
ok: true,
json: async () => {
controller.abort();
return { data: { stations: [] } };
},
}),
});
await assert.rejects(
source.getStations('https://example.test/stations', {
signal: controller.signal,
}),
{ name: 'AbortError' },
);
});
+110
View File
@@ -0,0 +1,110 @@
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
services.overlays;
const layerState = {};
layerState.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
// ---------------------------------------------------------------------------
// Module-level mutable state
// ---------------------------------------------------------------------------
/** @type {Cesium.Viewer|null} Active Cesium viewer instance. */
layerState._viewer = null;
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null;
/** Whether the bikeshare layer is currently enabled. */
layerState._enabled = false;
/** Timer handle for camera-move debounce. */
layerState._cameraDebounceTimer = null;
/** Whether the camera.changed listener is currently attached. */
layerState._cameraChangedAttached = false;
/** Hysteresis flag for altitude-based activation. */
layerState._altitudeGateEnabled = false;
/** Monotonic generation counter; incremented on each proximity check to cancel stale work. */
layerState._proximityGeneration = 0;
/** @type {Set<string>} City ids currently considered in-range. */
layerState._activeCityIds = new Set();
/** @type {Map<string, { stationKeys: Set<string> }>} Per-city runtime tracking of rendered station keys. */
layerState._cityRuntime = new Map();
/** @type {Map<string, Map<string, Object>>} Cached station information per city (cityId -> stationId -> StationInfo). */
layerState._stationInfoCache = new Map();
/** @type {Map<string, { timestamp: number, statusMap: Map<string, Object> }>} Cached station status per city. */
layerState._statusCache = new Map();
/** @type {Map<string, { promise: Promise, controller: AbortController, generation: number }>} In-flight station info requests. */
layerState._inFlightInfo = new Map();
/** @type {Map<string, { promise: Promise, controller: AbortController, generation: number }>} In-flight station status requests. */
layerState._inFlightStatus = new Map();
/** @type {Map<string, Object>} Render records keyed by "cityId:stationId". */
layerState._stationRenderMap = new Map();
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */
layerState._clickHandler = null;
/** @type {string|null} Key of the currently selected station, or null. */
layerState._selectedKey = null;
/** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */
layerState._selectedEntity = null;
/** Total number of currently rendered station points. */
layerState._count = 0;
/** Timestamp (ms) of the last successful status update. */
layerState._lastUpdate = null;
/** Whether any GBFS fetch is currently in progress. */
layerState._loading = false;
/** Reference count of concurrent loading operations. */
layerState._loadingOps = 0;
/** Most recent error message string, or null. */
layerState._error = null;
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
layerState._limitWarned = false;
return layerState;
}
+34
View File
@@ -0,0 +1,34 @@
export function createTesting({ state: layerState, services, parts, source }) {
/** Seed a selected-station runtime record while still exercising real select/clear paths. */
function _setBikeshareSelectionStateForTest({
viewer,
key,
record,
overlayHost,
}) {
layerState._viewer = viewer;
layerState._stationRenderMap = new Map([[key, record]]);
layerState._selectedKey = null;
layerState._selectedEntity = null;
layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST;
}
/** Exercise the production selection path in focused runtime tests. */
function _selectBikeshareStationForTest(key) {
parts.selection._selectStation(key);
}
/** Exercise the production clear path and restore the production host seam. */
function _clearBikeshareSelectionForTest() {
parts.selection._clearSelection();
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
}
return {
_setBikeshareSelectionStateForTest,
_selectBikeshareStationForTest,
_clearBikeshareSelectionForTest,
};
}
+257
View File
@@ -0,0 +1,257 @@
import * as Cesium from 'cesium';
import { GBFS_CITY_REGISTRY } from './registry.js';
import {
ACTIVATION_EXIT_ALTITUDE_M,
ACTIVATION_ENTER_ALTITUDE_M,
CITY_RANGE_BASE_KM,
CAMERA_DEBOUNCE_MS,
} from './policy.js';
export function createViewport({ state: layerState, services, parts, source }) {
const { governorRequestRender } = services.render;
/**
* Get the camera's current altitude in meters above the ellipsoid.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
* @returns {number} Altitude in meters, or Infinity if unavailable.
*/
function getCameraAltitude(viewer) {
const carto = viewer?.camera?.positionCartographic;
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
}
/**
* Determine the lat/lon the camera is currently looking at.
* Prefers the center of the computed view rectangle; falls back to the
* camera's own cartographic position when the rectangle is unavailable.
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
* @returns {{ lat: number, lon: number }|null} Center coordinates in degrees, or null.
*/
function getCameraCenterLatLon(viewer) {
// Try view rectangle center first (more accurate for tilted views)
const rect = viewer?.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
if (rect) {
const center = Cesium.Rectangle.center(rect);
return {
lat: Cesium.Math.toDegrees(center.latitude),
lon: Cesium.Math.toDegrees(center.longitude),
};
}
// Fallback: use the camera's own position
const carto = viewer?.camera?.positionCartographic;
if (carto) {
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
}
return null;
}
/**
* Determine whether the layer should be active at the given camera altitude.
* Uses hysteresis (separate enter/exit thresholds) to avoid rapid toggling
* when the camera hovers near the activation boundary.
* @param {number} altitude - Camera altitude in meters.
* @returns {boolean} True if the layer should remain/become active.
*/
function shouldActivateAtAltitude(altitude) {
if (!Number.isFinite(altitude)) {
layerState._altitudeGateEnabled = false;
return false;
}
if (layerState._altitudeGateEnabled) {
// Deactivate only after crossing the higher exit threshold
if (altitude >= ACTIVATION_EXIT_ALTITUDE_M)
layerState._altitudeGateEnabled = false;
} else if (altitude <= ACTIVATION_ENTER_ALTITUDE_M) {
// Activate when dropping below the lower enter threshold
layerState._altitudeGateEnabled = true;
}
return layerState._altitudeGateEnabled;
}
/**
* Determine which cities are close enough to the camera to warrant loading.
* Compares haversine distance from the camera center to each city's center
* against the city's configured load radius.
* @param {{ lat: number, lon: number }} center - Camera center in degrees.
* @returns {Set<string>} Set of city ids currently within load range.
*/
function computeInRangeCities(center) {
const active = new Set();
if (!center) return active;
for (const city of GBFS_CITY_REGISTRY) {
const distance = parts.model.haversineKm(
center.lat,
center.lon,
city.centerLat,
city.centerLon,
);
const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm);
if (distance <= radius) active.add(city.id);
}
return active;
}
/**
* Fully deactivate a single city: abort pending fetches and remove rendered points.
* @param {string} cityId - City identifier to deactivate.
*/
function deactivateCity(cityId) {
governorRequestRender('bikeshare-deactivate');
parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId);
parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId);
parts.rendering.removeCityPoints(cityId);
}
/** Deactivate all currently active cities and clear the active set. */
function deactivateAllCities() {
const cityIds = Array.from(layerState._activeCityIds);
for (const cityId of cityIds) deactivateCity(cityId);
layerState._activeCityIds.clear();
}
/**
* Activate a city: fetch station info, create point primitives, fetch status,
* and apply availability colors. Checks generation at each async boundary
* to bail out if the proximity context has changed.
* @param {string} cityId - City identifier to activate.
* @param {number} generation - Proximity generation at time of invocation.
*/
async function activateCity(cityId, generation) {
if (!layerState._enabled || !layerState._activeCityIds.has(cityId)) return;
try {
const stationMap = await parts.ingestion.loadCityStationInfo(
cityId,
generation,
);
// Bail if context changed during fetch
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
parts.rendering.ensureCityPoints(cityId, stationMap);
// Apply any cached status immediately for snappier initial rendering
const cachedStatus = layerState._statusCache.get(cityId)?.statusMap;
if (cachedStatus) {
parts.rendering.applyStatusToPoints(cityId, cachedStatus);
}
const statusMap = await parts.ingestion.loadCityStationStatus(
cityId,
generation,
);
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._lastUpdate = Date.now();
layerState._error = null;
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
layerState._error = 'GBFS fetch error';
deactivateCity(cityId);
layerState._activeCityIds.delete(cityId);
} finally {
layerState._count = layerState._stationRenderMap.size;
}
}
/**
* Core proximity check: determines which cities are in camera range at the
* current altitude, deactivates out-of-range cities, and activates newly
* in-range ones. Increments the generation counter to invalidate stale work.
* @returns {Promise<void>}
*/
async function runProximityCheck() {
if (!layerState._enabled || !layerState._viewer) return;
const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(layerState._viewer);
// Altitude gate: disable all cities when camera is too high
if (!shouldActivateAtAltitude(altitude)) {
deactivateAllCities();
return;
}
const center = getCameraCenterLatLon(layerState._viewer);
if (!center) return;
// Diff active set against newly computed in-range set
const nextActive = computeInRangeCities(center);
for (const cityId of layerState._activeCityIds) {
if (!nextActive.has(cityId)) {
deactivateCity(cityId);
}
}
layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) {
layerState._count = 0;
return;
}
// Only activate cities that don't already have rendered points
const toActivate = [];
for (const cityId of layerState._activeCityIds) {
if (!layerState._cityRuntime.has(cityId)) toActivate.push(cityId);
}
if (toActivate.length === 0) return;
await Promise.all(
toActivate.map((cityId) => activateCity(cityId, generation)),
);
}
/** Schedule a debounced proximity check after camera movement. */
function scheduleProximityCheck() {
clearTimeout(layerState._cameraDebounceTimer);
layerState._cameraDebounceTimer = setTimeout(() => {
void runProximityCheck();
}, CAMERA_DEBOUNCE_MS);
}
/** Camera change event handler — triggers a debounced proximity check. */
function onCameraChanged() {
if (!layerState._enabled) return;
scheduleProximityCheck();
}
return {
getCameraAltitude,
getCameraCenterLatLon,
shouldActivateAtAltitude,
computeInRangeCities,
deactivateCity,
deactivateAllCities,
activateCity,
runProximityCheck,
scheduleProximityCheck,
onCameraChanged,
};
}
+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,
};
}
+7 -1
View File
@@ -15,6 +15,8 @@ export function readLayerSource(file) {
'rocketLaunches.js',
'militaryInstallations.js',
'militaryAwareness.js',
'traffic.js',
'bikeshare.js',
].includes(basename(path))
)
return readFileSync(path, 'utf8');
@@ -34,7 +36,11 @@ export function readLayerSource(file) {
? '../layers/installations'
: basename(path) === 'militaryAwareness.js'
? '../layers/awareness'
: '../layers/vessels',
: basename(path) === 'traffic.js'
? '../layers/traffic'
: basename(path) === 'bikeshare.js'
? '../layers/bikeshare'
: '../layers/vessels',
);
return readdirSync(directory)
.filter((name) => name.endsWith('.js'))