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