refactor: separate traffic and bikeshare components

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

No files matched your search

+22 -1674
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -4,6 +4,6 @@
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
z12 x935 y1686, captured 2026-07-16 from
`api.tomtom.com/traffic/map/4/tile/flow/relative/12/935/1686.pbf`
(22,980 bytes). Used ONLY by `src/data/flowTiles.test.mjs` to pin MVT
decoding offline — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never served to the app. © TomTom.
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
browser mode — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never loaded by ordinary application startup. © TomTom.
+6 -161
View File
@@ -1,161 +1,6 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tilesForBounds } from './tomtomTiles.js';
/**
* @file TomTom traffic-flow vector-tile client: fetch + MVT decode.
*
* Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy
* (the TomTom key never reaches the browser) and decodes the Mapbox Vector
* Tile layer "Traffic flow" into plain lon/lat polylines with congestion
* attributes. Consumed by the traffic layer's live mode
* (`src/data/traffic.js` → `src/data/flowMatch.js`).
*
* Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string,
* closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed
* ratio (1 = free flow). Features with a missing/non-finite `traffic_level`
* are skipped unless `road_closure` is true (closures decode with level 0).
*
* Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and
* tree-shakeable; decoding happens client-side so the proxy stays a dumb
* binary cache.
*
* @module data/flowTiles
*/
export { tilesForBounds };
/** @const {string} MVT layer name in TomTom flow tiles (verified live 2026-07-16). */
const FLOW_LAYER_NAME = 'Traffic flow';
/** @const {number} Ms — per-tile decode cache TTL (matches the proxy's 120 s tile TTL). */
const DECODE_CACHE_TTL_MS = 120_000;
/** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */
const DECODE_CACHE_MAX_ENTRIES = 64;
/**
* Decoded-tile cache keyed by "z/x/y".
* @type {Map<string, {at:number, segments:Array}>}
*/
const _decodeCache = new Map();
/** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */
let _tilesFetched = 0;
/**
* Decode one TomTom flow tile (MVT protobuf) into flow segments.
*
* @param {Uint8Array|Buffer|ArrayBuffer} data - Raw .pbf tile bytes.
* @param {number} z - Tile zoom (for tile-local → lon/lat projection).
* @param {number} x - Tile column.
* @param {number} y - Tile row.
* @returns {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>}
* Flow polylines in [lon, lat] degrees. Returns [] for undecodable buffers
* or tiles without a "Traffic flow" layer (defensive — a corrupt tile must
* not kill the traffic layer).
*/
export function decodeFlowTile(data, z, x, y) {
let layer;
try {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const tile = new VectorTile(new PbfReader(bytes));
layer = tile.layers[FLOW_LAYER_NAME];
} catch {
return [];
}
if (!layer) return [];
const segments = [];
for (let i = 0; i < layer.length; i++) {
let feature;
let geometry;
try {
feature = layer.feature(i);
geometry = feature.toGeoJSON(x, y, z).geometry;
} catch {
continue; // one malformed feature must not drop the tile
}
const props = feature.properties || {};
const closure = props.road_closure === true || props.road_closure === 'true';
const rawLevel = props.traffic_level;
const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel);
// Skip features we can't color — unless closed (closures render dot-free
// regardless of level, so they stay useful without one).
if (!hasLevel && !closure) continue;
const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0;
const roadType = typeof props.road_type === 'string' ? props.road_type : '';
const lines = geometry.type === 'LineString'
? [geometry.coordinates]
: geometry.type === 'MultiLineString'
? geometry.coordinates
: [];
for (const coords of lines) {
if (!Array.isArray(coords) || coords.length < 2) continue;
segments.push({ coords, trafficLevel, roadType, closure });
}
}
return segments;
}
/** Insert into the decode cache with oldest-entry eviction. */
function cacheSet(key, entry) {
if (!_decodeCache.has(key) && _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES) {
const oldest = _decodeCache.keys().next().value;
_decodeCache.delete(oldest);
}
_decodeCache.set(key, entry);
}
/**
* Fetch + decode all flow tiles covering the given bounds.
*
* Tiles are fetched from the local proxy in parallel; each decoded tile is
* cached in memory for 120 s (keyed z/x/y), so repeat calls for the same
* viewport are free. Partial tile failures return the segments that DID
* decode (last-good philosophy); the promise rejects only when every tile
* failed (e.g. keyless 503, aborted signal, proxy down).
*
* @param {{south:number, west:number, north:number, east:number}} bounds - Degrees.
* @param {Object} [opts]
* @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled).
* @param {number} [opts.zoom=12] - Flow tile zoom level.
* @returns {Promise<Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>>}
* Flat array of flow segments across all covering tiles.
*/
export async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
const tiles = tilesForBounds(bounds, zoom);
if (tiles.length === 0) return [];
const now = Date.now();
const results = await Promise.allSettled(tiles.map(async ({ z, x, y }) => {
const key = `${z}/${x}/${y}`;
const cached = _decodeCache.get(key);
if (cached && now - cached.at < DECODE_CACHE_TTL_MS) return cached.segments;
_tilesFetched += 1;
const res = await fetch(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { signal });
if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`);
const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y);
cacheSet(key, { at: Date.now(), segments });
return segments;
}));
const fulfilled = results.filter((r) => r.status === 'fulfilled');
if (fulfilled.length === 0) {
throw results[0].reason instanceof Error ? results[0].reason : new Error('flow fetch failed');
}
return fulfilled.flatMap((r) => r.value);
}
/**
* Session diagnostics for `getStats()` surfaces.
* @returns {{tilesFetched:number}} Count of tile requests issued to the proxy
* this session (decode-cache hits excluded).
*/
export function getFlowSessionStats() {
return { tilesFetched: _tilesFetched };
}
/** Clear the decode cache (tests + layer teardown). Session stats persist. */
export function resetFlowTileCache() {
_decodeCache.clear();
}
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
export { tilesForBounds } from './tomtomTiles.js';
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
const source = createFlowTileSource();
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
source;
+13 -2506
View File
File diff suppressed because it is too large. Load diff
+9 -10
View File
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { getTrafficTimingDiagnostics } from './traffic.js';
const SOURCE = readFileSync(new URL('./traffic.js', import.meta.url), 'utf8');
const SOURCE = readLayerSource(new URL('./traffic.js', import.meta.url), 'utf8').replace(/parts\.(?:model|timing)\./g, '');
function functionBody(name) {
const declaration = `function ${name}(`;
@@ -166,9 +166,8 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
plugins: [{
name: 'traffic-timing-test-hooks',
transform(code, id) {
if (!id.endsWith('/src/data/traffic.js')) return null;
return `${code}\nexport const __trafficTimingTestHooks = {\n` +
` currentAnchor: () => _trafficTimingCurrentAnchor,\n};\n`;
if (!id.endsWith('/src/layers/traffic/index.js')) return null;
return code.replace('return Object.assign(', 'return Object.assign({ __trafficTimingTestHooks: { currentAnchor: () => state._trafficTimingCurrentAnchor } },');
},
}],
});
@@ -248,7 +247,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
// and moveEnd arrives only after cameraEventWaitTime (~500 ms).
setLongitude(-97.72);
changed.raise();
const anchorA = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorA = traffic.default.__trafficTimingTestHooks.currentAnchor();
const anchorMarkA = performance.getEntriesByType('mark').find((entry) => (
entry.detail?.segment === 'last-camera-change'
&& entry.detail?.interactionId === anchorA.interactionId
@@ -262,7 +261,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
traceObjectsCreated: 1,
uncorrelatedTracesDropped: 0,
});
assert.equal(traffic.__trafficTimingTestHooks.currentAnchor(), null);
assert.equal(traffic.default.__trafficTimingTestHooks.currentAnchor(), null);
const fetchFromA = performance.getEntriesByType('measure').find((entry) => (
entry.detail?.segment === 'last-camera-change-to-fetch-start'
&& entry.detail?.interactionId === anchorA.interactionId
@@ -288,16 +287,16 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
// and must count one drop without consuming C's current anchor.
setLongitude(-97.70);
changed.raise();
const anchorB = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorB = traffic.default.__trafficTimingTestHooks.currentAnchor();
const [timerBId, timerB] = pendingDebounce();
setLongitude(-97.68);
changed.raise();
const anchorC = traffic.__trafficTimingTestHooks.currentAnchor();
const anchorC = traffic.default.__trafficTimingTestHooks.currentAnchor();
assert.notEqual(anchorC.interactionId, anchorB.interactionId);
assert.equal(timeouts.has(timerBId), false, 'the ordinary debounce path must cancel B');
await timerB.callback();
assert.equal(
traffic.__trafficTimingTestHooks.currentAnchor().interactionId,
traffic.default.__trafficTimingTestHooks.currentAnchor().interactionId,
anchorC.interactionId,
'a stale callback must not consume the newer C anchor',
);