From 903fe6ba37bddff209f8179c3784addcfe208615 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 14:10:38 -0700 Subject: [PATCH] refactor: separate traffic and bikeshare components --- CHANGELOG.md | 6 + docs/CURRENT-STATE.md | 8 + package.json | 4 +- scripts/format-scope.json | 38 +- scripts/package-boundaries.json | 48 + scripts/qa-traffic.mjs | 27 +- scripts/traffic-fixtures.mjs | 56 + src/data/bikeshare.js | 1696 +---------------- src/data/fixtures/README.md | 6 +- src/data/flowTiles.js | 167 +- src/data/traffic.js | 2519 +------------------------- src/data/trafficTiming.test.mjs | 19 +- src/layers/bikeshare/controls.js | 46 + src/layers/bikeshare/index.js | 50 + src/layers/bikeshare/ingestion.js | 212 +++ src/layers/bikeshare/lifecycle.js | 171 ++ src/layers/bikeshare/model.js | 162 ++ src/layers/bikeshare/policy.js | 85 + src/layers/bikeshare/queries.js | 75 + src/layers/bikeshare/registry.js | 394 ++++ src/layers/bikeshare/rendering.js | 282 +++ src/layers/bikeshare/selection.js | 206 +++ src/layers/bikeshare/source.js | 23 + src/layers/bikeshare/source.test.mjs | 44 + src/layers/bikeshare/state.js | 110 ++ src/layers/bikeshare/testing.js | 34 + src/layers/bikeshare/viewport.js | 257 +++ src/layers/traffic/animation.js | 314 ++++ src/layers/traffic/controls.js | 189 ++ src/layers/traffic/flow.js | 214 +++ src/layers/traffic/flowDecode.js | 70 + src/layers/traffic/flowSource.js | 97 + src/layers/traffic/index.js | 63 + src/layers/traffic/ingestion.js | 329 ++++ src/layers/traffic/lifecycle.js | 188 ++ src/layers/traffic/model.js | 417 +++++ src/layers/traffic/policy.js | 215 +++ src/layers/traffic/rendering.js | 318 ++++ src/layers/traffic/source.js | 76 + src/layers/traffic/source.test.mjs | 129 ++ src/layers/traffic/state.js | 274 +++ src/layers/traffic/style.js | 156 ++ src/layers/traffic/timing.js | 479 +++++ src/layers/traffic/viewport.js | 241 +++ src/testSupport/readLayerSource.mjs | 8 +- 45 files changed, 6159 insertions(+), 4363 deletions(-) create mode 100644 scripts/traffic-fixtures.mjs create mode 100644 src/layers/bikeshare/controls.js create mode 100644 src/layers/bikeshare/index.js create mode 100644 src/layers/bikeshare/ingestion.js create mode 100644 src/layers/bikeshare/lifecycle.js create mode 100644 src/layers/bikeshare/model.js create mode 100644 src/layers/bikeshare/policy.js create mode 100644 src/layers/bikeshare/queries.js create mode 100644 src/layers/bikeshare/registry.js create mode 100644 src/layers/bikeshare/rendering.js create mode 100644 src/layers/bikeshare/selection.js create mode 100644 src/layers/bikeshare/source.js create mode 100644 src/layers/bikeshare/source.test.mjs create mode 100644 src/layers/bikeshare/state.js create mode 100644 src/layers/bikeshare/testing.js create mode 100644 src/layers/bikeshare/viewport.js create mode 100644 src/layers/traffic/animation.js create mode 100644 src/layers/traffic/controls.js create mode 100644 src/layers/traffic/flow.js create mode 100644 src/layers/traffic/flowDecode.js create mode 100644 src/layers/traffic/flowSource.js create mode 100644 src/layers/traffic/index.js create mode 100644 src/layers/traffic/ingestion.js create mode 100644 src/layers/traffic/lifecycle.js create mode 100644 src/layers/traffic/model.js create mode 100644 src/layers/traffic/policy.js create mode 100644 src/layers/traffic/rendering.js create mode 100644 src/layers/traffic/source.js create mode 100644 src/layers/traffic/source.test.mjs create mode 100644 src/layers/traffic/state.js create mode 100644 src/layers/traffic/style.js create mode 100644 src/layers/traffic/timing.js create mode 100644 src/layers/traffic/viewport.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3c03787..29271a1f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8d3efa6e3..9e24ae2f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index 8d9acb9a6..daa475afa 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3b5209646..b1edbccca 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 84b16ad2e..3a9c70e14 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] } } diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 04a4e8927..38382f8c4 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -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. diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs new file mode 100644 index 000000000..54ad48358 --- /dev/null +++ b/scripts/traffic-fixtures.mjs @@ -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 }); +} diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index 087280453..a07140c74 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -1,1676 +1,24 @@ -/** - * @module bikeshare - * @description GBFS bikeshare station data overlay with real-time availability. - * - * Fetches station information and status from GBFS-compliant feeds for major - * US bikeshare systems, renders stations as color-coded point primitives on the - * Cesium globe, and provides click-to-inspect and HUD detection integration. - * - * Stations are loaded on-demand based on camera proximity and altitude gating, - * with periodic status polling to keep availability colors current. - */ +import { createBikeshareSource } from '../layers/bikeshare/source.js'; +import { createBikeshareLayer } from '../layers/bikeshare/index.js'; +import * as render from '../renderGovernor.js'; +import * as sprites from './spriteOrder.js'; +import * as picking from './pickRegistry.js'; +import * as overlays from '../overlays/worldOverlay.js'; -import * as Cesium from 'cesium'; -import { governorRequestRender } from '../renderGovernor.js'; -import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; -import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; - -export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected'; -export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 1, - collisionCapacity: 0, - moving: false, +const layer = createBikeshareLayer({ + source: createBikeshareSource(), + services: { render, sprites, picking, overlays }, }); - -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); -let _overlayHost = DEFAULT_OVERLAY_HOST; - -// --- Activation / display thresholds --- -/** Altitude (m) at which bikeshare layer becomes eligible for display. */ -const ACTIVATION_ALTITUDE_M = 50000; -/** Hysteresis enter threshold — layer activates when camera drops below this. */ -const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 2000; -/** Hysteresis exit threshold — layer deactivates when camera rises above this. */ -const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 2000; -/** Debounce interval (ms) for camera-change proximity checks. */ -const CAMERA_DEBOUNCE_MS = 340; -/** Default radius (km) for determining whether a city is in camera range. */ -const CITY_RANGE_BASE_KM = 100; -/** Polling interval (ms) for refreshing station status data. */ -const STATUS_POLL_MS = 60000; - -// --- Point rendering constants --- -/** Minimum rendered point size in pixels. */ -const POINT_SIZE_MIN = 4; -/** Maximum rendered point size in pixels. */ -const POINT_SIZE_MAX = 12; -/** Fallback station capacity when real data is unavailable. */ -const DEFAULT_CAPACITY = 15; -/** Vertical offset (m) above terrain for station points. */ -const POINT_HEIGHT_OFFSET_M = 2.0; -/** Hard cap on total rendered station points across all cities. */ -const MAX_TOTAL_POINTS = 8000; - -// --- Availability color palette --- -/** Station has >60% bikes available. */ -const COLOR_GREEN = Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95); -/** Station has 30-60% bikes available. */ -const COLOR_YELLOW = Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94); -/** Station has <30% bikes available. */ -const COLOR_RED = Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94); -/** No status data available for station. */ -const COLOR_NEUTRAL = Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62); -/** Station is offline (not installed, not renting, or not returning). */ -const COLOR_MUTED = Cesium.Color.fromCssColorString('#687581').withAlpha(0.48); -/** Outline color for all station points. */ -const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25); - -/** - * Build GBFS endpoint URLs for a BCycle-hosted system. - * @param {string} systemId - BCycle system identifier (e.g. 'bcycle_boulder'). - * @returns {{ stationInformationUrl: string, stationStatusUrl: string }} - */ -function buildBcycleUrls(systemId) { - return { - stationInformationUrl: `https://gbfs.bcycle.com/${systemId}/station_information.json`, - stationStatusUrl: `https://gbfs.bcycle.com/${systemId}/station_status.json`, - }; -} - -/** - * Convenience factory for a BCycle-hosted city registry entry. - * Merges caller-supplied metadata with auto-generated BCycle GBFS URLs. - * @param {Object} opts - * @param {string} opts.id - Unique city identifier. - * @param {string} opts.city - Human-readable city name. - * @param {number} opts.centerLat - City center latitude. - * @param {number} opts.centerLon - City center longitude. - * @param {string} opts.systemId - BCycle system identifier. - * @param {number} [opts.loadRadiusKm=100] - Activation radius in km. - * @param {string} [opts.provider='BCycle'] - Display provider name. - * @returns {Object} Raw registry entry suitable for RAW_GBFS_CITY_REGISTRY. - */ -function bcycleEntry({ id, city, centerLat, centerLon, systemId, loadRadiusKm = 100, provider = 'BCycle' }) { - return { - id, - city, - centerLat, - centerLon, - loadRadiusKm, - provider, - ...buildBcycleUrls(systemId), - }; -} - -/** - * Raw registry of supported GBFS bikeshare cities. - * Each entry specifies the city center, load radius, GBFS feed URLs, and provider name. - * Entries using BCycle hosting are constructed via bcycleEntry() for brevity. - * @type {Object[]} - */ -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.0150, - 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.8910, - 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.5120, - systemId: 'bcycle_cincyredbike', - provider: 'Red Bike', - }), - bcycleEntry({ - id: 'el-paso-bcycle', - city: 'El Paso, TX', - centerLat: 31.7619, - centerLon: -106.4850, - 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.0490, - systemId: 'bcycle_memphis', - }), - bcycleEntry({ - id: 'des-moines-bcycle', - city: 'Des Moines, IA', - centerLat: 41.5868, - centerLon: -93.6250, - 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.3940, - 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', - }), -]; - -/** - * Validate and normalize a raw GBFS city registry entry. - * Ensures required fields are present, URLs are HTTPS and match expected - * GBFS endpoint patterns, and coordinates are finite numbers. - * @param {Object} entry - Raw registry entry from RAW_GBFS_CITY_REGISTRY. - * @returns {Object} Normalized entry with validated fields and extracted hostnames. - * @throws {Error} If any required field is missing or invalid. - */ -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, - }; -} - -/** - * Validated and deduplicated city registry, built at module load time. - * Throws if any entry fails validation or has a duplicate id. - * @type {Object[]} - */ -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; - }); -})(); - -/** Lookup map from city id to its normalized registry entry. */ -const CITY_BY_ID = new Map(GBFS_CITY_REGISTRY.map((entry) => [entry.id, entry])); - -// --------------------------------------------------------------------------- -// Module-level mutable state -// --------------------------------------------------------------------------- - -/** @type {Cesium.Viewer|null} Active Cesium viewer instance. */ -let _viewer = null; -/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ -let _pointCollection = null; -/** Whether the bikeshare layer is currently enabled. */ -let _enabled = false; -/** Timer handle for camera-move debounce. */ -let _cameraDebounceTimer = null; -/** Whether the camera.changed listener is currently attached. */ -let _cameraChangedAttached = false; -/** Hysteresis flag for altitude-based activation. */ -let _altitudeGateEnabled = false; -/** Monotonic generation counter; incremented on each proximity check to cancel stale work. */ -let _proximityGeneration = 0; - -/** @type {Set} City ids currently considered in-range. */ -let _activeCityIds = new Set(); -/** @type {Map }>} Per-city runtime tracking of rendered station keys. */ -let _cityRuntime = new Map(); - -/** @type {Map>} Cached station information per city (cityId -> stationId -> StationInfo). */ -let _stationInfoCache = new Map(); -/** @type {Map }>} Cached station status per city. */ -let _statusCache = new Map(); -/** @type {Map} In-flight station info requests. */ -let _inFlightInfo = new Map(); -/** @type {Map} In-flight station status requests. */ -let _inFlightStatus = new Map(); - -/** @type {Map} Render records keyed by "cityId:stationId". */ -let _stationRenderMap = new Map(); -/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */ -let _clickHandler = null; -/** @type {string|null} Key of the currently selected station, or null. */ -let _selectedKey = null; -/** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */ -let _selectedEntity = null; - -/** Total number of currently rendered station points. */ -let _count = 0; -/** Timestamp (ms) of the last successful status update. */ -let _lastUpdate = null; -/** Whether any GBFS fetch is currently in progress. */ -let _loading = false; -/** Reference count of concurrent loading operations. */ -let _loadingOps = 0; -/** Most recent error message string, or null. */ -let _error = null; -/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */ -let _limitWarned = false; - -/** - * 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() { - _loadingOps++; - _loading = true; -} - -/** Decrement the loading reference count; clear loading flag when zero. */ -function endLoading() { - _loadingOps = Math.max(0, _loadingOps - 1); - _loading = _loadingOps > 0; -} - -/** - * 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}`; -} - -/** - * 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; -} - -/** - * 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)); -} - -/** - * 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)) { - _altitudeGateEnabled = false; - return false; - } - if (_altitudeGateEnabled) { - // Deactivate only after crossing the higher exit threshold - if (altitude >= ACTIVATION_EXIT_ALTITUDE_M) _altitudeGateEnabled = false; - } else if (altitude <= ACTIVATION_ENTER_ALTITUDE_M) { - // Activate when dropping below the lower enter threshold - _altitudeGateEnabled = true; - } - return _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} 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 = 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; -} - -/** - * 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} 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} 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; -} - -/** - * 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} Parsed JSON payload. - * @throws {Error} On non-OK HTTP status or malformed JSON. - */ -async function fetchGbfsJson(upstreamUrl, { signal } = {}) { - const response = await fetch(toProxyUrl(upstreamUrl), { - method: 'GET', - headers: { Accept: 'application/json' }, - signal, - }); - - if (!response.ok) { - throw new Error(`GBFS HTTP ${response.status}`); - } - - const payload = await response.json(); - if (!payload || typeof payload !== 'object') { - throw new Error('Malformed GBFS payload'); - } - return payload; -} - -/** - * Abort an in-flight GBFS request for a specific city and remove it from the map. - * @param {Map} 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 _inFlightInfo.keys()) abortInFlight(_inFlightInfo, cityId); - for (const cityId of _inFlightStatus.keys()) abortInFlight(_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 of stationId to station info. - * @throws {Error} On unknown city, fetch failure, or empty station list. - */ -async function loadCityStationInfo(cityId, generation) { - if (_stationInfoCache.has(cityId)) return _stationInfoCache.get(cityId); - const inFlight = _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 = parseStationInformation(payload); - if (stationMap.size === 0) { - throw new Error(`No station information for ${city.city}`); - } - _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 = _inFlightInfo.get(cityId); - if (current && current.controller === controller) _inFlightInfo.delete(cityId); - }); - - _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 of stationId to status objects. - * @throws {Error} On unknown city or fetch failure. - */ -async function loadCityStationStatus(cityId, generation) { - const inFlight = _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 = parseStationStatus(payload); - _statusCache.set(cityId, { - statusMap, - timestamp: Date.now(), - }); - return statusMap; - } finally { - endLoading(); - } - })().finally(() => { - const current = _inFlightStatus.get(cityId); - if (current && current.controller === controller) _inFlightStatus.delete(cityId); - }); - - _inFlightStatus.set(cityId, { promise, controller, generation }); - return promise; -} - -/** - * 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 = toNonNegativeInteger(capacityFromInfo); - if (fromInfo && fromInfo > 0) return fromInfo; - - // Derive capacity from current bikes + available docks - const bikes = toNonNegativeInteger(status?.bikesAvailable, 0); - const docks = 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 = 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; -} - -/** - * 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} - */ -export 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 (_selectedKey) { - const record = _stationRenderMap.get(_selectedKey); - if (record?.point) { - record.point.show = true; - } - } - - if (_selectedEntity && _viewer) { - _viewer.entities.remove(_selectedEntity); - } - - _selectedKey = null; - _selectedEntity = null; - _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 = _stationRenderMap.get(key); - if (!record || !record.point?.position || !_viewer) return; - - _selectedKey = key; - // Hide the base point so the highlight entity replaces it visually - record.point.show = false; - - _selectedEntity = _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) { - _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 (_clickHandler) return; - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking selected entity itself — ignore (don't deselect). - if (picked.id === _selectedEntity) return; - - // Check if the picked primitive or entity id matches a station key - const primitive = picked.primitive; - if (primitive && typeof primitive.id === 'string' && _stationRenderMap.has(primitive.id)) { - _selectStation(primitive.id); - return; - } - if (typeof picked.id === 'string' && _stationRenderMap.has(picked.id)) { - _selectStation(picked.id); - return; - } - } - - // Clicked empty space — deselect. - if (_selectedKey) _clearSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - document.addEventListener('keydown', _onKeyDown); -} - -/** - * 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 (_viewer?.scene?.sampleHeightSupported) { - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const sampled = _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 }} City runtime object. - */ -function ensureCityRuntime(cityId) { - if (_cityRuntime.has(cityId)) return _cityRuntime.get(cityId); - const runtime = { stationKeys: new Set() }; - _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} 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 = stationKey(cityId, station.stationId); - if (_stationRenderMap.has(key)) { - runtime.stationKeys.add(key); - continue; - } - - // Enforce global point cap - if (_stationRenderMap.size >= MAX_TOTAL_POINTS) { - if (!_limitWarned) { - _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 = _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, - }); - - _stationRenderMap.set(key, { - key, - cityId, - stationId: station.stationId, - stationName: station.name, - point, - capacity: toNonNegativeInteger(station.capacity), - bikesAvailable: null, - docksAvailable: null, - isInstalled: station.isInstalled, - isRenting: station.isRenting, - isReturning: station.isReturning, - }); - - runtime.stationKeys.add(key); - } - - _count = _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 = _cityRuntime.get(cityId); - if (!runtime) return; - - // Clear selection if it belongs to the city being removed - if (_selectedKey && runtime.stationKeys.has(_selectedKey)) { - _clearSelection(); - } - - for (const key of runtime.stationKeys) { - const record = _stationRenderMap.get(key); - if (!record) continue; - _pointCollection.remove(record.point); - _stationRenderMap.delete(key); - } - - _cityRuntime.delete(cityId); - _count = _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} statusMap - Parsed station status map. - */ -function applyStatusToPoints(cityId, statusMap) { - governorRequestRender('bikeshare-status'); - const runtime = _cityRuntime.get(cityId); - if (!runtime) return; - - for (const key of runtime.stationKeys) { - const record = _stationRenderMap.get(key); - if (!record) continue; - - const status = statusMap.get(record.stationId) || null; - const capacity = resolveCapacity(record.capacity, status); - record.capacity = capacity; - - record.bikesAvailable = toNonNegativeInteger(status?.bikesAvailable); - record.docksAvailable = toNonNegativeInteger(status?.docksAvailable); - record.isInstalled = normalizeGbfsBool(status?.isInstalled, true); - record.isRenting = normalizeGbfsBool(status?.isRenting, true); - record.isReturning = normalizeGbfsBool(status?.isReturning, true); - - // Update visual properties based on current status - record.point.pixelSize = capacityToPixelSize(capacity); - record.point.color = statusToColor(status, capacity); - } -} - -/** - * 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 (!_enabled || !_pointCollection || !_pointCollection.show || _stationRenderMap.size === 0) return []; - - // Gather all visible station records (include selected even though its point is hidden) - const records = []; - for (const record of _stationRenderMap.values()) { - const isSelected = record.key === _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 === _selectedKey, - }); - if (result.length >= maxCount) break; - } - - return result; -} - -/** - * 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'); - abortInFlight(_inFlightInfo, cityId); - abortInFlight(_inFlightStatus, cityId); - removeCityPoints(cityId); -} - -/** Deactivate all currently active cities and clear the active set. */ -function deactivateAllCities() { - const cityIds = Array.from(_activeCityIds); - for (const cityId of cityIds) deactivateCity(cityId); - _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 (!_enabled || !_activeCityIds.has(cityId)) return; - - try { - const stationMap = await loadCityStationInfo(cityId, generation); - // Bail if context changed during fetch - if (!_enabled || !_activeCityIds.has(cityId) || generation !== _proximityGeneration) return; - - ensureCityPoints(cityId, stationMap); - - // Apply any cached status immediately for snappier initial rendering - const cachedStatus = _statusCache.get(cityId)?.statusMap; - if (cachedStatus) { - applyStatusToPoints(cityId, cachedStatus); - } - - const statusMap = await loadCityStationStatus(cityId, generation); - if (!_enabled || !_activeCityIds.has(cityId) || generation !== _proximityGeneration) return; - applyStatusToPoints(cityId, statusMap); - _lastUpdate = Date.now(); - _error = null; - } catch (error) { - if (error?.name === 'AbortError') return; - console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); - _error = 'GBFS fetch error'; - deactivateCity(cityId); - _activeCityIds.delete(cityId); - } finally { - _count = _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} - */ -async function runProximityCheck() { - if (!_enabled || !_viewer) return; - - const generation = ++_proximityGeneration; - const altitude = getCameraAltitude(_viewer); - // Altitude gate: disable all cities when camera is too high - if (!shouldActivateAtAltitude(altitude)) { - deactivateAllCities(); - return; - } - - const center = getCameraCenterLatLon(_viewer); - if (!center) return; - - // Diff active set against newly computed in-range set - const nextActive = computeInRangeCities(center); - for (const cityId of _activeCityIds) { - if (!nextActive.has(cityId)) { - deactivateCity(cityId); - } - } - - _activeCityIds = nextActive; - if (_activeCityIds.size === 0) { - _count = 0; - return; - } - - // Only activate cities that don't already have rendered points - const toActivate = []; - for (const cityId of _activeCityIds) { - if (!_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(_cameraDebounceTimer); - _cameraDebounceTimer = setTimeout(() => { - void runProximityCheck(); - }, CAMERA_DEBOUNCE_MS); -} - -/** Camera change event handler — triggers a debounced proximity check. */ -function onCameraChanged() { - if (!_enabled) return; - scheduleProximityCheck(); -} - -/** - * Bikeshare data layer object, conforming to the God's Eye View layer interface. - * Manages lifecycle (init/enable/disable/update) and provides detection and - * stats hooks for the HUD and UI systems. - * @type {Object} - */ -const bikeshareLayer = { - id: 'bikeshare', - name: 'Bikeshare', - icon: '🚲', - source: 'GBFS', - updateInterval: STATUS_POLL_MS, - - /** - * 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) { - _viewer = viewer; - _pointCollection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - viewer.scene.primitives.add(_pointCollection); - registerSpriteCollection('bikeshare', _pointCollection); - _pointCollection.show = false; - - _enabled = false; - _cameraDebounceTimer = null; - _cameraChangedAttached = false; - _altitudeGateEnabled = false; - _proximityGeneration = 0; - - _activeCityIds = new Set(); - _cityRuntime = new Map(); - _stationInfoCache = new Map(); - _statusCache = new Map(); - _inFlightInfo = new Map(); - _inFlightStatus = new Map(); - _stationRenderMap = new Map(); - _clickHandler = null; - _selectedKey = null; - _selectedEntity = null; - _count = 0; - _lastUpdate = null; - _loading = false; - _loadingOps = 0; - _error = null; - _limitWarned = false; - - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false); - - _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) { - _enabled = true; - _error = null; - _pointCollection.show = true; - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true); - _installClickHandler(viewer); - // Pick-ownership (H2): station point ids are string render-map keys. - registerPickOwner('bikeshare', (pickedId) => _stationRenderMap.has(pickedId)); - - if (!_cameraChangedAttached) { - viewer.camera.changed.addEventListener(onCameraChanged); - viewer.camera.percentageChanged = Math.min(viewer.camera.percentageChanged || 1, 0.05); - _cameraChangedAttached = true; - } - - void 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) { - _enabled = false; - _proximityGeneration++; - _altitudeGateEnabled = false; - clearTimeout(_cameraDebounceTimer); - _cameraDebounceTimer = null; - _clearSelection(); - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false); - - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('bikeshare'); - - if (_cameraChangedAttached) { - viewer.camera.changed.removeEventListener(onCameraChanged); - _cameraChangedAttached = false; - } - - abortAllInFlight(); - deactivateAllCities(); - _cityRuntime.clear(); - _pointCollection.show = false; - _count = 0; - _loading = false; - _loadingOps = 0; - }, - - /** - * 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} - */ - async update() { - if (!_enabled || _activeCityIds.size === 0) return; - - const generation = _proximityGeneration; - const cityIds = Array.from(_activeCityIds); - await Promise.all(cityIds.map(async (cityId) => { - try { - const statusMap = await loadCityStationStatus(cityId, generation); - if (!_enabled || !_activeCityIds.has(cityId) || generation !== _proximityGeneration) return; - applyStatusToPoints(cityId, statusMap); - } catch (error) { - if (error?.name === 'AbortError') return; - console.warn(`[Data:Bikeshare] ${cityId} status update error:`, error); - _error = 'GBFS status update failed'; - } - })); - - _count = _stationRenderMap.size; - if (_count > 0) _lastUpdate = Date.now(); - }, - - /** - * 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 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: _count, - lastUpdate: _lastUpdate, - loading: _loading, - }; - if (_loading) { - stats.loadingLabel = _activeCityIds.size > 0 - ? `syncing ${_activeCityIds.size} city feeds...` - : 'scanning nearby systems...'; - } - if (_error) stats.error = _error; - return stats; - }, - - /** Permanently release primitives, handlers, and the selected host source. */ - destroy(viewer) { - if (_enabled) this.disable(viewer); - else { - _clearSelection(); - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('bikeshare'); - } - if (_cameraChangedAttached) { - viewer.camera.changed.removeEventListener(onCameraChanged); - _cameraChangedAttached = false; - } - abortAllInFlight(); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - _viewer = null; - }, -}; - -/** - * Global keydown handler — deselects the current station on Escape. - * @param {KeyboardEvent} e - Keyboard event. - */ -function _onKeyDown(e) { - if (e.key === 'Escape' && _selectedKey) { - _clearSelection(); - } -} - -/** Seed a selected-station runtime record while still exercising real select/clear paths. */ -export function _setBikeshareSelectionStateForTest({ viewer, key, record, overlayHost }) { - _viewer = viewer; - _stationRenderMap = new Map([[key, record]]); - _selectedKey = null; - _selectedEntity = null; - _overlayHost = overlayHost || DEFAULT_OVERLAY_HOST; -} - -/** Exercise the production selection path in focused runtime tests. */ -export function _selectBikeshareStationForTest(key) { - _selectStation(key); -} - -/** Exercise the production clear path and restore the production host seam. */ -export function _clearBikeshareSelectionForTest() { - _clearSelection(); - _overlayHost = DEFAULT_OVERLAY_HOST; -} - -export default bikeshareLayer; +export const createBikeshareSelectedOverlayEntry = + layer.createBikeshareSelectedOverlayEntry; +export const _setBikeshareSelectionStateForTest = + layer._setBikeshareSelectionStateForTest; +export const _selectBikeshareStationForTest = + layer._selectBikeshareStationForTest; +export const _clearBikeshareSelectionForTest = + layer._clearBikeshareSelectionForTest; +export { + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, +} from '../layers/bikeshare/index.js'; +export default layer; diff --git a/src/data/fixtures/README.md b/src/data/fixtures/README.md index fe0fabc73..7f22e94b0 100644 --- a/src/data/fixtures/README.md +++ b/src/data/fixtures/README.md @@ -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. diff --git a/src/data/flowTiles.js b/src/data/flowTiles.js index e4e6b29b2..07efb0d9a 100644 --- a/src/data/flowTiles.js +++ b/src/data/flowTiles.js @@ -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} - */ -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>} - * 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; diff --git a/src/data/traffic.js b/src/data/traffic.js index b5fe47cbe..05fed3bf1 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -1,2506 +1,13 @@ -import * as Cesium from 'cesium'; -import { deriveFetchCenter, clampBoundsAroundCenter } from './trafficBounds.js'; -import { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } from './flowTiles.js'; -import { matchFlowToRoads } from './flowMatch.js'; -import { flowBucket, flowSpeedScale, flowDensityMult } from './trafficFlowStyle.js'; -import { - trafficStyleProfile, - presetDotRgba, - presetSizeDelta, - presetDotOutline, - trafficBucketTier, -} from './trafficPresetStyle.js'; -import { queuePlatoons, locateAlongRoad } from './trafficQueue.js'; -import { registerDynamicCredit, TOMTOM_CREDIT } from './dataCredits.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; - -/** - * @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 */ -const OVERPASS_URL = '/api/overpass'; -/** @const {number} Meters — hide all traffic dots above this camera altitude */ -const ACTIVATION_ALTITUDE = 8000; -/** @const {number} Meters — above this altitude, only major roads are fetched */ -const FAST_FETCH_ALTITUDE = 4500; -/** @const {number} Milliseconds — debounce delay before fetching after camera settles */ -const FETCH_DEBOUNCE = 320; -/** @const {number} Meters — vertical offset to keep dots above clamped terrain surface */ -const DOT_HEIGHT_OFFSET = 3.0; -/** @const {number} Fraction (0-1) — skip re-fetch when viewport overlap exceeds this */ -const OVERLAP_THRESHOLD = 0.6; -/** @const {number} Hard cap on total rendered dot primitives for GPU/CPU performance */ -const MAX_DOTS = 6000; -/** @const {number} Polylines longer than this are simplified by sub-sampling */ -const MAX_WAYPOINTS_PER_ROAD = 80; -/** @const {number} Km — minimum viewport center shift before allowing refresh */ -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). - */ -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. - */ -const TRAFFIC_TIMING_ENABLED = import.meta.env?.DEV - && typeof window !== 'undefined' - && new URLSearchParams(window.location.search).get('trafficDebug') === '1'; - -/** @const {Object} Speed in meters per second by highway tag (approximate real-world values) */ -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} Density multiplier — higher values spawn more dots on important roads */ -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} Pixel size per road type (scaled up 25% for screen-recording visibility) */ -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} - */ -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). */ -const HEAT_LINE_CAP = 400; -/** @const {number} Px — glowing jam corridor line width. */ -const HEAT_LINE_JAM_WIDTH = 9; -/** @const {number} Px — flat slow corridor line width. */ -const HEAT_LINE_SLOW_WIDTH = 4; -/** @const {number} Jam heat-line alpha midpoint (pulse oscillates around it). */ -const HEAT_JAM_BASE_ALPHA = 0.55; -/** @const {number} Jam heat-line pulse amplitude (±, ~1.6 s period). */ -const HEAT_JAM_PULSE_ALPHA = 0.2; -/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ -const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252'); -/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ -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. - */ -const JAM_DOT_DEPTH_PUNCH = 15000; -/** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */ -const JAM_DOT_FAR_SCALE = 0.55; -/** @const {number} Speed multiplier while a stop-and-go jam dot bursts forward. */ -const CREEP_BURST = 2.2; -/** @const {number[]} Ms range a jam dot creeps forward before stopping. */ -const CREEP_MOVE_MS = [1200, 3000]; -/** @const {number[]} Ms range a jam dot sits stopped between creeps. */ -const CREEP_STOP_MS = [1500, 5000]; - -// ─── Module State ────────────────────────────────────────── -/** @type {Cesium.Viewer|null} */ -let _viewer = null; -/** @type {Cesium.PointPrimitiveCollection|null} */ -let _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 */ -let _dots = []; -/** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ -let _roads = []; -/** @type {boolean} Whether the layer is currently enabled */ -let _enabled = false; -/** @type {Function|null} Disposer returned by preRender event subscription */ -let _preRenderRemover = null; -/** @type {Function|null} Disposer returned by camera.changed event subscription */ -let _cameraRemover = null; -/** @type {ReturnType|null} Debounce timer for camera-change fetch */ -let _fetchTimeout = null; -/** @type {{south:number,west:number,north:number,east:number}|null} Last fetched clamped bounds */ -let _lastBounds = null; -/** @type {boolean} True while an Overpass fetch is in flight */ -let _fetching = false; -/** @type {number} Current count of rendered dots */ -let _count = 0; -/** @type {number|null} Timestamp of last successful render */ -let _lastUpdate = null; -/** @type {number} Monotonic generation counter — incremented on each load to discard stale responses */ -let _loadGeneration = 0; -/** @type {AbortController|null} Controller for the in-flight fetch, so it can be cancelled */ -let _activeFetchAbort = null; -/** @type {number} User-adjustable density multiplier (clamped 0.2–2.5) */ -let _densityScale = 1.0; -/** @type {number} User-adjustable speed multiplier (clamped 0.3–3.0) */ -let _speedScale = 1.0; -/** @type {{lat:number,lon:number}|null} Center of last-fetched viewport for shift gating */ -let _lastViewCenter = null; -/** @type {number|null} camera.percentageChanged value before we overrode it, restored on disable */ -let _prevPercentageChanged = null; -/** @type {boolean} Live TomTom flow mode — true iff /api/tomtom/status reports a key */ -let _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} - */ -let _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} - */ -let _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} - */ -let _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}} - */ -let _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; -/** @type {number} Roads in the current render skipped entirely as closed. */ -let _closedRoads = 0; -/** @type {'sim'|'hide'} Live-mode treatment of roads without flow data. */ -let _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'} - */ -let _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} - */ -let _heatJamPrim = null; -/** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */ -let _heatSlowPrim = null; -/** @type {number} Heat-lines in the current render (stats). */ -let _heatLineCount = 0; -/** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */ -let _heatSupported = null; -/** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ -let _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} - */ -let _stylePreset = 'normal'; -/** @type {'on'|'off'} Kill switch for preset-aware dot styling (A/B). */ -let _presetDots = 'on'; -/** @type {boolean} gev:style-change listener bound (bind once per page). */ -let _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}} - */ -let _activeBucketColors = { ...FLOW_BUCKET_COLORS }; -/** - * @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. - */ -const STYLED_MIN_BASE_PX = 5; - -/** @returns {boolean} A non-normal preset profile is active and enabled. */ -function presetProfileActive() { - return _presetDots === 'on' && trafficStyleProfile(_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 _presetDots === 'on' ? presetSizeDelta(_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 = _presetDots === 'on' ? presetDotRgba(_stylePreset, bucket) : null; - _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 = _presetDots === 'on' ? presetDotOutline(_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 (!_dots.length && !_heatLineCount) return; - for (const dot of _dots) { - const bucket = dot.bucket; - if (!bucket) continue; // sim/uncovered dots stay byte-identical - dot.point.color = _activeBucketColors[bucket]; - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) - + (bucket === 'jam' ? 1 : 0) - + activeSizeDelta(bucket); - applyOutline(dot.point, bucket); - } - rebuildHeatLines(visibleRoadsForAltitude(_roads, _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 === _stylePreset) return; - _stylePreset = next; - restyleDotsInPlace(); -} - -/** @returns {boolean} Density/queue/creep prototype active. */ -const jamDensityOn = () => _jamViz === 'density' || _jamViz === 'both'; -/** @returns {boolean} Heat-line prototype active. */ -const heatlineOn = () => _jamViz === 'heatline' || _jamViz === 'both'; -/** - * 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"). - */ -let _fadeScaleFar = 8000; -let _fadeTransFar = 10000; -/** @type {Promise|null} Session-cached status check (one fetch per session) */ -let _flowStatusPromise = null; -/** @type {ReturnType|null} Enable-time retry until the first load commits. */ -let _enableKickTimer = null; -/** @type {number} 0–100 int — matched roads / roads with any flow candidates */ -let _flowCoveragePct = 0; -/** @type {Function|null} Development-only camera moveEnd timing disposer. */ -let _trafficTimingMoveEndRemover = null; -/** @type {Set|null} Development-only one-shot postRender disposers. */ -let _trafficTimingPostRenderRemovers = null; -/** @type {{interactionId:number, timestamp:number}|null} Debug anchor for the pending load. */ -let _trafficTimingCurrentAnchor = null; -/** @type {number} Development-only unique mark/trace sequence. */ -let _trafficTimingSequence = 0; -/** @type {number} Development-only count of correlated trace objects created. */ -let _trafficTimingTracesCreated = 0; -/** @type {number} Development-only count of loads dropped for missing/stale anchors. */ -let _trafficTimingDroppedTraces = 0; - -/** - * 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}} - */ -export function getTrafficTimingDiagnostics() { - return { - enabled: Boolean(TRAFFIC_TIMING_ENABLED), - marksInstalled: _trafficTimingMoveEndRemover ? 1 : 0, - traceObjectsCreated: _trafficTimingTracesCreated, - uncorrelatedTracesDropped: _trafficTimingDroppedTraces, - }; -} - -/** - * 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} - */ -const _tileCache = new Map(); -/** @const {number} Maximum tile cache entries before LRU eviction */ -const TILE_CACHE_MAX_ENTRIES = 64; - -/** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */ -const _scratchLerp = new Cesium.Cartesian3(); - -// ─── 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. - */ -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;`; -} - -/** - * 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} 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 query = buildOverpassQuery(south, west, north, east, { majorOnly, timeoutSec }); - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingPass(trace, majorOnly ? 'major' : 'full', 'proxy') - : null; - if (state) trace.currentPass = state.pass; - const fetchStart = state ? trafficTimingMark(state, 'fetch-start') : null; - if (state) { - trafficTimingMeasure( - 'last-camera-change-to-fetch-start', state, trace.cameraChangeMark, fetchStart, - ); - } - const response = await fetch(OVERPASS_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(query)}`, - signal, - }); - - if (!response.ok) { - throw new Error(`Overpass API returned ${response.status}`); - } - - if (!state) return response.json(); - - if (state) { - state.proxyCache = response.headers.get('x-overpass-cache'); - state.proxyUpstream = response.headers.get('x-overpass-upstream'); - } - const responseStart = state ? trafficTimingMark(state, 'response-json-start', { - responseStatus: response.status, - }) : null; - if (state) { - trafficTimingMeasure('fetch-to-response', state, fetchStart, responseStart, { - responseStatus: response.status, - }); - } - const data = await response.json(); - if (state) { - const responseEnd = trafficTimingMark(state, 'response-json-end', { - responseStatus: response.status, - }); - trafficTimingMeasure('response-json', state, responseStart, responseEnd, { - responseStatus: response.status, - }); - } - return data; -} - -/** - * 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 (_viewer?.scene?.sampleHeightSupported && firstCoord) { - const carto = Cesium.Cartographic.fromDegrees(firstCoord[0], firstCoord[1]); - const sampled = _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 = _liveMode ? road.flow : null; - if (flow?.closure) return 0; - // Strict data-integrity view: uncovered roads spawn nothing when hidden. - if (_liveMode && !flow && _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) - * _densityScale - * (flow ? flowDensityMult(flow.level, { jamBoost: 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; -} - -/** - * 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 = _liveMode ? road.flow : null; - if (flow?.closure) return; - if (_liveMode && !flow && _uncoveredMode === 'hide') return; - - const count = Number.isFinite(budgetCount) - ? Math.max(0, Math.floor(budgetCount)) - : 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 = baseDotSize(road.type, bucket) - + (bucket === 'jam' ? 1 : 0) - + activeSizeDelta(bucket); - const flowColor = bucket ? _activeBucketColors[bucket] : null; - // Dark-halo outline under styled presets (null = shipped no-outline). - const outlineSpec = (bucket && _presetDots === 'on') ? presetDotOutline(_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' && 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 (_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 * _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, _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' && jamDensityOn(); - const point = _pointCollection.add({ - position: Cesium.Cartesian3.clone(_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, _fadeScaleFar, jamProminent ? JAM_DOT_FAR_SCALE : 0.3), - translucencyByDistance: new Cesium.NearFarScalar(100, 1.0, _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 } : {}), - }); - _bucketCounts[bucket || 'sim'] += 1; - - _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' && jamDensityOn()) - ? { moving: Math.random() < 0.4, until: now + Math.random() * 2000 } - : null, - }); - } -} - -// ─── Animation ───────────────────────────────────────────── - -/** @type {number} Timestamp of the last animation tick (ms) */ -let _lastAnimTime = 0; -/** @type {number} Running frame counter (for diagnostics) */ -let _animFrame = 0; - -/** - * 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 = _lastAnimTime ? Math.min((now - _lastAnimTime) / 1000, 0.1) : 0.016; - _lastAnimTime = now; - - for (let i = 0; i < _dots.length; i++) { - const dot = _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, _scratchLerp); - dot.point.position = _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 (_heatJamPrim?.appearance) { - _heatJamPrim.appearance.material.uniforms.color.alpha = - HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); - } - - _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; - } -} - -// ─── 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 = _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 = _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 = _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 = _viewer.scene.canvas; - const width = canvas.clientWidth || canvas.width; - const height = canvas.clientHeight || canvas.height; - if (width > 0 && height > 0) { - const hit = _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 (!_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) { - clearDots(); - _lastBounds = null; - _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 ( - _lastBounds - && _lastViewCenter - && boundsOverlap(clamped, _lastBounds, OVERLAP_THRESHOLD) - && distanceKm(center, _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 ? markTrafficTimingCameraChange() : null; - clearTimeout(_fetchTimeout); - _fetchTimeout = setTimeout( - () => _loadRoadsForBounds(clamped, alt, interactionAnchor), - FETCH_DEBOUNCE, - ); -} - -/** Abort any in-flight Overpass fetch and clear the controller reference. */ -function cancelActiveFetch() { - if (_activeFetchAbort) { - _activeFetchAbort.abort(); - _activeFetchAbort = null; - } -} - -// ─── 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. - */ -export 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'; -} - -/** - * 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}} - */ -export 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', - }; -} - -/** - * 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} Resolves when `_liveMode` is settled. - */ -function ensureFlowStatus() { - if (!_flowStatusPromise) { - _flowStatusPromise = fetch('/api/tomtom/status') - .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))) - .then((status) => { - _liveMode = Boolean(status?.hasKey); - _flowStatusUnavailable = false; - if (_liveMode) { - console.log('[Data:Traffic] TomTom key present — live flow mode'); - registerDynamicCredit(_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. - _liveMode = false; - _flowStatusUnavailable = true; - console.warn('[Data:Traffic] TomTom status unreachable — simulated traffic:', e?.message || e); - }); - } - return _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} - */ -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. - _flowPending += 1; - try { - if (!_flowStatusPromise) return; // status check not started — sim mode - await _flowStatusPromise; - if (!_liveMode || !_enabled) return; - if (generation !== _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 (!_activeFetchAbort) _activeFetchAbort = new AbortController(); - const segments = await fetchFlowForBounds(clamped, { signal: _activeFetchAbort.signal }); - if (generation !== _loadGeneration) return; - const { matches, matchedCount, candidateCount } = matchFlowToRoads(roads, segments); - for (let i = 0; i < roads.length; i++) { - roads[i].flow = matches[i]; - } - _flowCoveragePct = candidateCount > 0 - ? Math.round((matchedCount / candidateCount) * 100) - : 0; - _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 !== _loadGeneration || !_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(). - _flowError = deriveTrafficFlowError(e); - _flowCoveragePct = 0; - console.warn('[Data:Traffic] Flow fetch failed (sim colors remain):', e?.message || e); - } - } finally { - _flowPending -= 1; - } -} - -/** - * 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. - */ -const FLOW_RENDER_RACE_MS = 250; - -/** - * 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} True if this generation rendered. - */ -async function applyFlowThenRender(roads, clamped, generation, altitude, label, trace = null) { - const state = TRAFFIC_TIMING_ENABLED && trace - ? trafficTimingRenderState(trace, label) - : null; - const flowRaceStart = state ? 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 = trafficTimingMark(state, 'flow-render-race-end', { - deadlineMs: FLOW_RENDER_RACE_MS, - outcome, - }); - trafficTimingMeasure('flow-render-race', state, flowRaceStart, flowRaceEnd, { - deadlineMs: FLOW_RENDER_RACE_MS, - outcome, - }); - } - if (generation !== _loadGeneration) return false; - renderRoadsForAltitude(roads, altitude, label, trace); - if (outcome === 'timeout') { - flowJob.then(() => { - if (generation !== _loadGeneration) return; - recolorDotsInPlace(label); - }).catch(() => { /* applyFlowToRoads settles its own failures */ }); - } - return true; -} - -/** - * 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 (!_liveMode || !_dots.length) return; - _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; - let closedDots = 0; - const now = Date.now(); - for (const dot of _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 ? _activeBucketColors[bucket] : Cesium.Color.WHITE.withAlpha(0.85); - if (bucket === 'jam') { - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) + 1 + activeSizeDelta('jam'); - } else if (bucket && presetProfileActive()) { - // Preset profiles size-floor every bucket; the shipped normal path - // keeps its jam-only size touch (byte-identical behavior). - dot.point.pixelSize = baseDotSize(dot.road?.type, bucket) + 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 (presetProfileActive()) 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' && 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, _fadeScaleFar, JAM_DOT_FAR_SCALE); - dot.point.disableDepthTestDistance = JAM_DOT_DEPTH_PUNCH; - } else { - dot.creep = null; - } - _bucketCounts[bucket || 'sim'] += 1; - } - _closedRoads = _roads.reduce((n, r) => n + (r.flow?.closure ? 1 : 0), 0); - rebuildHeatLines(visibleRoadsForAltitude(_roads, _lastRenderAltitude)); - console.log(`[Data:Traffic] Flow recolor (${label}): ${_dots.length} dots, closedDots=${closedDots}`); -} - -/** - * 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 (_heatJamPrim) { - _viewer?.scene.groundPrimitives.remove(_heatJamPrim); - _heatJamPrim = null; - } - if (_heatSlowPrim) { - _viewer?.scene.groundPrimitives.remove(_heatSlowPrim); - _heatSlowPrim = null; - } - _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 (!_viewer || !_liveMode || !heatlineOn()) return; - if (_heatSupported === null) { - _heatSupported = Cesium.GroundPolylinePrimitive.isSupported(_viewer.scene); - if (!_heatSupported) console.warn('[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled'); - } - if (!_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 = _presetDots === 'on' && trafficStyleProfile(_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) { - _heatJamPrim = _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) { - _heatSlowPrim = _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 }), - }), - })); - } - - _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 - ? trafficTimingRenderState(trace, label) - : null; - const renderId = state ? ++trace.renderSequence : null; - clearDots(); - _roads = roads; - _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. - _closedRoads = _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 (_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(_viewer.camera.positionWC, wp)); - } - } - _fadeScaleFar = Math.max(8000, areaDist * 1.5); - _fadeTransFar = Math.max(10000, areaDist * 1.8); - - const dotStart = state ? trafficTimingMark(state, 'dot-construction-start', { - renderId, - renderLabel: label, - roadCount: roads.length, - visibleRoadCount: filteredRoads.length, - }) : null; - const roadBudgets = 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; - spawnDotsForRoad(road, altitude, budget); - if (_dots.length >= MAX_DOTS) break; - } - - const renderMetrics = state ? { - renderId, - renderLabel: label, - roadCount: roads.length, - visibleRoadCount: filteredRoads.length, - dotCount: _dots.length, - } : null; - if (state) { - const dotEnd = trafficTimingMark(state, 'dot-construction-end', renderMetrics); - trafficTimingMeasure('dot-construction', state, dotStart, dotEnd, renderMetrics); - } - - const heatStart = state ? trafficTimingMark(state, 'rebuild-heat-lines-start', renderMetrics) : null; - rebuildHeatLines(filteredRoads); - if (state) { - const heatEnd = trafficTimingMark(state, 'rebuild-heat-lines-end', { - ...renderMetrics, - heatLineCount: _heatLineCount, - }); - trafficTimingMeasure('rebuild-heat-lines', state, heatStart, heatEnd, { - ...renderMetrics, - heatLineCount: _heatLineCount, - }); - } - - _count = _dots.length; - _lastUpdate = Date.now(); - console.log(`[Data:Traffic] ${label}: ${_count} dots (roads=${roads.length}, alt=${Math.round(altitude)}m)`); - if (state) { - const renderEnd = trafficTimingMark(state, 'render-return', renderMetrics); - scheduleTrafficTimingPostRender(state, renderEnd, renderId, renderMetrics); - } -} - -// ─── 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}:${++_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 = ++_trafficTimingSequence; - const timestamp = performance.now(); - const anchor = { interactionId, timestamp }; - _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 = ++_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 (_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 = _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 (!_viewer?.scene) return; - let remove = null; - remove = _viewer.scene.postRender.addEventListener(() => { - remove?.(); - _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 } - ); - }); - _trafficTimingPostRenderRemovers?.add(remove); -} - -/** Start a scheduling-correlated debug trace, or count and drop an unpaired load. */ -async function loadRoadsForBoundsTimed(bounds, altitude, expectedAnchor) { - const generation = _loadGeneration + 1; - if (!expectedAnchor || expectedAnchor !== _trafficTimingCurrentAnchor) { - _trafficTimingDroppedTraces += 1; - await loadRoadsForBounds(bounds, altitude); - return; - } - _trafficTimingCurrentAnchor = null; - const clamped = clampBounds(bounds); - const trace = { - id: ++_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(), - }; - _trafficTimingTracesCreated += 1; - await loadRoadsForBounds(bounds, altitude, trace); -} - -// Disabled-path contract: these references resolve directly to the original -// functions. Instrumentation adds no load-path callbacks or per-item checks. -const _parseRoads = TRAFFIC_TIMING_ENABLED - ? (data, trace) => (trace ? parseRoadsTimed(data, trace) : parseRoads(data)) - : parseRoads; -const _loadRoadsForBounds = TRAFFIC_TIMING_ENABLED - ? loadRoadsForBoundsTimed - : loadRoadsForBounds; - -/** - * 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} - */ -async function loadRoadsForBounds(bounds, altitude, trace = null) { - // Increment generation to invalidate any in-flight responses from prior calls - const generation = ++_loadGeneration; - cancelActiveFetch(); - const clamped = 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. - ensureFlowStatus().then(() => { - if (_liveMode && _enabled && generation === _loadGeneration) { - fetchFlowForBounds(clamped, {}).catch(() => { /* warm-up only */ }); - } - }); - - _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 = _lastBounds; - const prevViewCenter = _lastViewCenter; - _lastBounds = clamped; - _lastViewCenter = getBoundsCenter(clamped); - let renderedSomething = false; - - try { - let cache = _tileCache.get(cacheKey); - if (!cache) { - // LRU eviction: drop the oldest entry when cache exceeds the cap - if (_tileCache.size >= TILE_CACHE_MAX_ENTRIES) { - const oldest = _tileCache.keys().next().value; - _tileCache.delete(oldest); - } - cache = { major: null, full: null }; - _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 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 applyFlowThenRender( - cache.major, clamped, generation, altitude, 'Cache major', trace - )) return; - renderedSomething = true; - } else { - // Fetch major roads first (smaller payload, faster response) - _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: _activeFetchAbort.signal }, - trace, - ); - // Discard stale response if a newer load was triggered while waiting - if (generation !== _loadGeneration) return; - cache.major = _parseRoads(majorData, trace); - if (!await 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.) - _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: _activeFetchAbort.signal }, - trace, - ); - if (generation !== _loadGeneration) return; - - cache.full = _parseRoads(fullData, trace); - if (!await 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 === _loadGeneration) { - _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) { - _lastBounds = prevBounds; - _lastViewCenter = prevViewCenter; - } - } - _activeFetchAbort = null; - } -} - -// ─── Cleanup ─────────────────────────────────────────────── - -/** Remove all point primitives and reset dot/road arrays and counters. */ -function clearDots() { - if (_pointCollection) _pointCollection.removeAll(); - removeHeatLines(); - _dots = []; - _roads = []; - _count = 0; - _bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; - _closedRoads = 0; -} - -// ─── Data Layer Interface ────────────────────────────────── - -/** - * Traffic data layer — conforms to the God's Eye View data-layer interface. - * - * Lifecycle: init -> enable -> (animate loop + camera-driven loads) -> disable -> destroy. - * The layer is self-updating: no external tick is needed (`updateInterval: 0`). - * - * @type {Object} - */ -const trafficLayer = { - id: 'traffic', - name: 'Street Traffic', - icon: '🚗', - source: 'OpenStreetMap', - /** @type {number} Zero — layer is self-managed via camera listener + preRender */ - updateInterval: 0, - - /** - * 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) { - _viewer = viewer; - _pointCollection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - // Add permanently — toggle with .show to avoid destroy-on-remove errors - viewer.scene.primitives.add(_pointCollection); - _pointCollection.show = false; - _dots = []; - _roads = []; - _count = 0; - _lastUpdate = null; - _lastBounds = null; - _fetching = false; - _loadGeneration = 0; - _densityScale = 1.0; - _speedScale = 1.0; - _lastViewCenter = null; - _flowCoveragePct = 0; - _flowError = null; - if (TRAFFIC_TIMING_ENABLED) { - _trafficTimingCurrentAnchor = null; - _trafficTimingSequence = 0; - _trafficTimingTracesCreated = 0; - _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') { - _stylePreset = document?.documentElement?.dataset?.gevStyle || 'normal'; - if (!_styleListenerBound) { - window.addEventListener('gev:style-change', (e) => setStylePreset(e?.detail?.style)); - _styleListenerBound = true; - } - } - 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) { - _enabled = true; - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) - _lastAnimTime = 0; - _pointCollection.show = true; - - // One status check per session decides sim vs live-TomTom mode. - ensureFlowStatus(); - - _preRenderRemover = viewer.scene.preRender.addEventListener(animate); - if (TRAFFIC_TIMING_ENABLED) { - clearTrafficTimingEntries(); - _trafficTimingCurrentAnchor = null; - _trafficTimingPostRenderRemovers = new Set(); - _trafficTimingMoveEndRemover = viewer.camera.moveEnd.addEventListener(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(onCameraChanged); - _prevPercentageChanged = viewer.camera.percentageChanged; - viewer.camera.percentageChanged = 0.05; - - // Kick off initial viewport check - 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(_enableKickTimer); - _enableKickTimer = setInterval(() => { - if (!_enabled || _lastUpdate) { - clearInterval(_enableKickTimer); - _enableKickTimer = null; - return; - } - if (!_fetching) 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) { - _enabled = false; - releaseContinuousRender('traffic'); - clearTimeout(_fetchTimeout); - clearInterval(_enableKickTimer); - _enableKickTimer = null; - cancelActiveFetch(); - _loadGeneration++; - clearDots(); - _lastViewCenter = null; - // A stale outage from the last session would misreport a fresh enable — - // the next load re-derives feed health from real evidence. - _flowError = null; - - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (TRAFFIC_TIMING_ENABLED) { - _trafficTimingMoveEndRemover?.(); - _trafficTimingMoveEndRemover = null; - for (const remove of _trafficTimingPostRenderRemovers || []) remove(); - _trafficTimingPostRenderRemovers = null; - _trafficTimingCurrentAnchor = null; - } - - viewer.camera.changed.removeEventListener(onCameraChanged); - // Restore the camera's global percentageChanged so other layers/listeners - // keep their expected sensitivity. - if (_prevPercentageChanged != null) { - viewer.camera.percentageChanged = _prevPercentageChanged; - _prevPercentageChanged = null; - } - if (_pointCollection) _pointCollection.show = false; - }, - - /** - * No-op — traffic updates are entirely camera-driven, not timer-driven. - * @returns {Promise} - */ - async update() { - // No-op — updates are camera-driven - }, - - /** - * 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') { - _densityScale = Math.max(0.2, Math.min(2.5, params.densityScale)); - } - if (typeof params.speedScale === 'number') { - _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') { - _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)) { - _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 !== _presetDots) { - _presetDots = params.presetDots; - restyleDotsInPlace(); - } - } - }, - - /** - * Return the current user-adjustable parameters. - * @returns {{densityScale:number, speedScale:number}} - */ - getParams() { - return { - densityScale: _densityScale, - speedScale: _speedScale, - uncoveredRoads: _uncoveredMode, - jamViz: _jamViz, - presetDots: _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 (!_enabled || _dots.length === 0) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : _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(_dots.length / maxCount)); - const start = seed % stride; - - const result = []; - for (let i = start; i < _dots.length; i += stride) { - const pos = _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 (_liveMode) { - const tier = trafficBucketTier(_dots[i].bucket || 'sim'); - if (tier) entry.tier = tier; - } - result.push(entry); - if (result.length >= maxCount) break; - } - return result; - }, - - /** - * 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 (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - removeHeatLines(); - _tileCache.clear(); - resetFlowTileCache(); - _count = 0; - _lastUpdate = null; - }, - - /** - * 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 = _fetching || _flowPending > 0; - const feed = trafficFeedPresentation({ - liveMode: _liveMode, - fetching: loading, - flowError: _flowError, - coveragePct: _flowCoveragePct, - statusUnavailable: _flowStatusUnavailable, - }); - return { - count: _count, - lastUpdate: _lastUpdate, - loading, - mode: feed.mode, - error: feed.error, - flowCoveragePct: _flowCoveragePct, - tilesFetched: getFlowSessionStats().tilesFetched, - ...(TRAFFIC_TIMING_ENABLED ? { trafficTiming: getTrafficTimingDiagnostics() } : {}), - // Per-bucket rendered-dot counts (sim = white ambient). Drives the - // qa-traffic color assertions and the sync-chip mode label below. - flowBuckets: { ..._bucketCounts }, - closedRoads: _closedRoads, - // Jam-viz prototype diagnostics (additive — harness contract untouched). - heatLines: _heatLineCount, - jamViz: _jamViz, - // Preset-styling diagnostics (additive): active style + profile. - stylePreset: _stylePreset, - styleProfile: _presetDots === 'on' ? trafficStyleProfile(_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, - }; - }, -}; - -export default trafficLayer; +import { createTrafficSource } from '../layers/traffic/source.js'; +import { createTrafficLayer } from '../layers/traffic/index.js'; +import * as credits from './dataCredits.js'; +import * as render from '../renderGovernor.js'; + +const layer = createTrafficLayer({ + source: createTrafficSource(), + services: { credits, render }, +}); +export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics; +export const deriveTrafficFlowError = layer.deriveTrafficFlowError; +export const trafficFeedPresentation = layer.trafficFeedPresentation; +export default layer; diff --git a/src/data/trafficTiming.test.mjs b/src/data/trafficTiming.test.mjs index eb354cda4..f7589afb3 100644 --- a/src/data/trafficTiming.test.mjs +++ b/src/data/trafficTiming.test.mjs @@ -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', ); diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js new file mode 100644 index 000000000..bfeccf924 --- /dev/null +++ b/src/layers/bikeshare/controls.js @@ -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 }; +} diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js new file mode 100644 index 000000000..b66bbae35 --- /dev/null +++ b/src/layers/bikeshare/index.js @@ -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'; diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js new file mode 100644 index 000000000..732ec9c90 --- /dev/null +++ b/src/layers/bikeshare/ingestion.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} 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} 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 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 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} + */ + 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, + }; +} diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js new file mode 100644 index 000000000..aeabac64b --- /dev/null +++ b/src/layers/bikeshare/lifecycle.js @@ -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 }; +} diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js new file mode 100644 index 000000000..89628f613 --- /dev/null +++ b/src/layers/bikeshare/model.js @@ -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} 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} 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, + }; +} diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js new file mode 100644 index 000000000..14f1edf6e --- /dev/null +++ b/src/layers/bikeshare/policy.js @@ -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); diff --git a/src/layers/bikeshare/queries.js b/src/layers/bikeshare/queries.js new file mode 100644 index 000000000..550dbf030 --- /dev/null +++ b/src/layers/bikeshare/queries.js @@ -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 }; +} diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js new file mode 100644 index 000000000..ff752f664 --- /dev/null +++ b/src/layers/bikeshare/registry.js @@ -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 }; diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js new file mode 100644 index 000000000..f0c737d1d --- /dev/null +++ b/src/layers/bikeshare/rendering.js @@ -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 }} 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} 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} 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, + }; +} diff --git a/src/layers/bikeshare/selection.js b/src/layers/bikeshare/selection.js new file mode 100644 index 000000000..74e540230 --- /dev/null +++ b/src/layers/bikeshare/selection.js @@ -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, + }; +} diff --git a/src/layers/bikeshare/source.js b/src/layers/bikeshare/source.js new file mode 100644 index 000000000..effcb9a57 --- /dev/null +++ b/src/layers/bikeshare/source.js @@ -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; + }, + }; +} diff --git a/src/layers/bikeshare/source.test.mjs b/src/layers/bikeshare/source.test.mjs new file mode 100644 index 000000000..cee7e2ab1 --- /dev/null +++ b/src/layers/bikeshare/source.test.mjs @@ -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:pass@example.test/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' }, + ); +}); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js new file mode 100644 index 000000000..381384147 --- /dev/null +++ b/src/layers/bikeshare/state.js @@ -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} City ids currently considered in-range. */ + + layerState._activeCityIds = new Set(); + + /** @type {Map }>} Per-city runtime tracking of rendered station keys. */ + + layerState._cityRuntime = new Map(); + + /** @type {Map>} Cached station information per city (cityId -> stationId -> StationInfo). */ + + layerState._stationInfoCache = new Map(); + + /** @type {Map }>} Cached station status per city. */ + + layerState._statusCache = new Map(); + + /** @type {Map} In-flight station info requests. */ + + layerState._inFlightInfo = new Map(); + + /** @type {Map} In-flight station status requests. */ + + layerState._inFlightStatus = new Map(); + + /** @type {Map} 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; +} diff --git a/src/layers/bikeshare/testing.js b/src/layers/bikeshare/testing.js new file mode 100644 index 000000000..6dca0e658 --- /dev/null +++ b/src/layers/bikeshare/testing.js @@ -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, + }; +} diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js new file mode 100644 index 000000000..e4c03a690 --- /dev/null +++ b/src/layers/bikeshare/viewport.js @@ -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} 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} + */ + + 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, + }; +} diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js new file mode 100644 index 000000000..bda32e78a --- /dev/null +++ b/src/layers/traffic/animation.js @@ -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 }; +} diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js new file mode 100644 index 000000000..22fe85407 --- /dev/null +++ b/src/layers/traffic/controls.js @@ -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 }; +} diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js new file mode 100644 index 000000000..591bd88ce --- /dev/null +++ b/src/layers/traffic/flow.js @@ -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} 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} + */ + + 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} 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, + }; +} diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js new file mode 100644 index 000000000..016ce576b --- /dev/null +++ b/src/layers/traffic/flowDecode.js @@ -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; +} diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js new file mode 100644 index 000000000..dc1ed8b90 --- /dev/null +++ b/src/layers/traffic/flowSource.js @@ -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} + */ + 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>} + * 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 }; +} diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js new file mode 100644 index 000000000..4039ada17 --- /dev/null +++ b/src/layers/traffic/index.js @@ -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'; diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js new file mode 100644 index 000000000..f4c43ba76 --- /dev/null +++ b/src/layers/traffic/ingestion.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} 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} + */ + + 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} + */ + async update() { + // No-op — updates are camera-driven + }, + }; + + return { fetchRoads, cancelActiveFetch, loadRoadsForBounds, methods }; +} diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js new file mode 100644 index 000000000..175ed8bf2 --- /dev/null +++ b/src/layers/traffic/lifecycle.js @@ -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 }; +} diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js new file mode 100644 index 000000000..db8f9536b --- /dev/null +++ b/src/layers/traffic/model.js @@ -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, + }; +} diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js new file mode 100644 index 000000000..003fbb5c3 --- /dev/null +++ b/src/layers/traffic/policy.js @@ -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} 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} 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} 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} + */ + +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; diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js new file mode 100644 index 000000000..3a6a122b9 --- /dev/null +++ b/src/layers/traffic/rendering.js @@ -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, + }; +} diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js new file mode 100644 index 000000000..616f4d0c7 --- /dev/null +++ b/src/layers/traffic/source.js @@ -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; + }, + }; +} diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs new file mode 100644 index 000000000..9be3315ba --- /dev/null +++ b/src/layers/traffic/source.test.mjs @@ -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' }); +}); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js new file mode 100644 index 000000000..c6665a55e --- /dev/null +++ b/src/layers/traffic/state.js @@ -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|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|null} Session-cached status check (one fetch per session) */ + + layerState._flowStatusPromise = null; + + /** @type {ReturnType|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|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} + */ + + 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; +} diff --git a/src/layers/traffic/style.js b/src/layers/traffic/style.js new file mode 100644 index 000000000..cb2f08349 --- /dev/null +++ b/src/layers/traffic/style.js @@ -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, + }; +} diff --git a/src/layers/traffic/timing.js b/src/layers/traffic/timing.js new file mode 100644 index 000000000..59cb2d2c0 --- /dev/null +++ b/src/layers/traffic/timing.js @@ -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, + }; +} diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js new file mode 100644 index 000000000..954f705b9 --- /dev/null +++ b/src/layers/traffic/viewport.js @@ -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, + }; +} diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index d2e7047aa..593087d20 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -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'))