mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
A failed flow tile now carries the server's reason. A hosted deployment that limits each account answers USAGE_LIMIT_REACHED or RATE_LIMITED, which the traffic panel labels as the monthly allowance or a rate limit; a 503 with another reason reads as the traffic service being unavailable, not a missing key. The local proxy's no_key and daily budget labels are unchanged.
153 lines
5.4 KiB
JavaScript
153 lines
5.4 KiB
JavaScript
import { isDrivableFlowSegment } from './roadModes.js';
|
|
import { createVectorTileSource } from '../../sources/vectorTiles.js';
|
|
import { clipTileLine } from '../../sources/openFreeMap.js';
|
|
import { tileToBBox } from '../../data/tomtomTiles.js';
|
|
import { decodeFlowTile } from './flowDecode.js';
|
|
|
|
const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
|
|
|
|
/**
|
|
* Whether the proxy answered a tile from its cache past the refresh window
|
|
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
|
|
* fails), and when the proxy fetched it.
|
|
*/
|
|
/**
|
|
* The machine-readable reason a failed tile carries: `code` (hosted usage,
|
|
* e.g. USAGE_LIMIT_REACHED or RATE_LIMITED) or the local proxy's `error`
|
|
* (`no_key`, `budget`). Only short identifiers are kept; prose is ignored.
|
|
*/
|
|
async function failureCode(response) {
|
|
if (!/json/i.test(response.headers?.get?.('content-type') || '')) return null;
|
|
try {
|
|
const text = await response.text();
|
|
if (text.length > 4096) return null;
|
|
const body = JSON.parse(text);
|
|
const code = body?.code ?? body?.error;
|
|
return typeof code === 'string' && /^[A-Za-z_]{1,40}$/.test(code)
|
|
? code
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tileFreshness(response) {
|
|
const status = response.headers?.get?.('x-tomtom-cache') || '';
|
|
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
|
|
return {
|
|
stale: status.startsWith('STALE'),
|
|
fetchedAt: fetchedAt > 0 ? fetchedAt : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clip a flow line to a box. Every piece keeps its uncut line as `line`, so
|
|
* pieces of one line that doubles back are never paired with each other.
|
|
*/
|
|
function clipFlowSegment(segment, bbox) {
|
|
const line = segment.line || segment;
|
|
return clipTileLine(segment.coords, bbox).map((coords) => ({
|
|
...segment,
|
|
coords,
|
|
line,
|
|
}));
|
|
}
|
|
|
|
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
|
export function createFlowTileSource({
|
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
} = {}) {
|
|
// Freshness travels in response headers, but the tile cache keeps only
|
|
// decoded segments: hold each response's freshness until its tile decodes,
|
|
// then key it by the decoded tile, which the cache hands back on a hit.
|
|
const pending = new Map();
|
|
const freshness = new WeakMap();
|
|
const tiles = createVectorTileSource({
|
|
template: FLOW_TILE_PATH('{z}', '{x}', '{y}'),
|
|
allowedOrigin: 'http://localhost',
|
|
decode: (bytes, z, x, y) => {
|
|
// Take the mark before decoding, so a tile that fails to decode
|
|
// leaves nothing behind.
|
|
const path = FLOW_TILE_PATH(z, x, y);
|
|
const entry = pending.get(path);
|
|
const mark = entry?.mark;
|
|
entry?.release();
|
|
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
|
.filter(isDrivableFlowSegment)
|
|
.flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y)));
|
|
if (mark) freshness.set(segments, mark);
|
|
return segments;
|
|
},
|
|
fetchImpl: async (url, init) => {
|
|
const response = await fetchImpl(url, init);
|
|
if (!response.ok) {
|
|
const code = await failureCode(response);
|
|
await response.body?.cancel().catch(() => {});
|
|
throw Object.assign(
|
|
// Classified for display by deriveTrafficFlowError.
|
|
new Error(`TomTom flow unavailable (HTTP ${response.status})`),
|
|
{ status: response.status, code },
|
|
);
|
|
}
|
|
if (!init.signal.aborted) {
|
|
const path = String(url);
|
|
const entry = {
|
|
mark: tileFreshness(response),
|
|
release() {
|
|
if (pending.get(path) === entry) pending.delete(path);
|
|
init.signal.removeEventListener('abort', entry.release);
|
|
},
|
|
};
|
|
pending.get(path)?.release();
|
|
pending.set(path, entry);
|
|
// The vector source aborts this signal for failed/cancelled body reads,
|
|
// including size-limit failures that never reach decode.
|
|
init.signal.addEventListener('abort', entry.release, { once: true });
|
|
}
|
|
return response;
|
|
},
|
|
ttlMs: 120_000,
|
|
maxTiles: 16,
|
|
});
|
|
let partial = false;
|
|
/**
|
|
* Flow segments for bounds, with whether any covering tile failed to load
|
|
* for this request, whether any came from the proxy's stale cache, and when
|
|
* the oldest tile was fetched.
|
|
*/
|
|
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
|
|
const result = await tiles.fetchBounds(bounds, {
|
|
zoom,
|
|
signal,
|
|
tiles: bounds.coverage?.coarse,
|
|
});
|
|
partial = result.partial;
|
|
const segments = bounds.coverage
|
|
? result.tiles.flat()
|
|
: result.tiles
|
|
.flat()
|
|
.flatMap((segment) => clipFlowSegment(segment, bounds));
|
|
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
|
const times = marks
|
|
.map((mark) => mark.fetchedAt)
|
|
.filter((at) => Number.isFinite(at));
|
|
return {
|
|
segments,
|
|
partial: result.partial === true,
|
|
stale: marks.some((mark) => mark.stale === true),
|
|
oldestTileAt: times.length ? Math.min(...times) : null,
|
|
};
|
|
}
|
|
return {
|
|
fetchFlowDetail,
|
|
async fetchFlowForBounds(bounds, options) {
|
|
return (await fetchFlowDetail(bounds, options)).segments;
|
|
},
|
|
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
|
resetFlowTileCache: () => {
|
|
for (const entry of pending.values()) entry.release();
|
|
tiles.clear();
|
|
},
|
|
};
|
|
}
|