mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #427 from bilawalsidhu/refactor/traffic-bikeshare-components
refactor: separate traffic and bikeshare components
This commit is contained in:
45 files changed
+6159
-4363
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
);
|
||||
|
||||
Vendored
+46
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+189
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user