/** * @file Pure tile math + budget accounting for the TomTom traffic-flow proxy. * * Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation, * daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles * cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so * both sides can unit-test against it with node:test. * * Slippy scheme: standard Web Mercator XYZ, y grows southward * (https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames) — the scheme * TomTom's `traffic/map/4/tile/flow` endpoints use. * * @module data/tomtomTiles */ /** @const {number} Min supported TomTom flow-tile zoom (proxy validation). */ export const MIN_TILE_ZOOM = 8; /** @const {number} Max supported TomTom flow-tile zoom (proxy validation). */ export const MAX_TILE_ZOOM = 16; /** @const {number} Web Mercator latitude limit (degrees). */ const MERCATOR_LAT_LIMIT = 85.05112878; /** * Validate a z/x/y tile coordinate for the TomTom flow proxy. * * @param {number} z - Zoom level; integer within [MIN_TILE_ZOOM, MAX_TILE_ZOOM]. * @param {number} x - Tile column; integer within [0, 2^z - 1]. * @param {number} y - Tile row; integer within [0, 2^z - 1]. * @param {{minZoom?: number, maxZoom?: number}} [bounds] Supported source zooms. * @returns {boolean} True when the coordinate is a fetchable tile. */ export function isValidTileCoord( z, x, y, { minZoom = MIN_TILE_ZOOM, maxZoom = MAX_TILE_ZOOM } = {}, ) { if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) return false; if ( !Number.isInteger(minZoom) || !Number.isInteger(maxZoom) || minZoom < 0 || maxZoom > 30 || minZoom > maxZoom ) return false; if (z < minZoom || z > maxZoom) return false; const n = 2 ** z; return x >= 0 && x < n && y >= 0 && y < n; } /** * Convert a lon/lat (degrees) to the containing slippy tile at zoom `z`. * Latitude is clamped to the Web Mercator limit; results are clamped into * [0, 2^z - 1] so antimeridian/pole inputs stay valid. * * @param {number} lon - Longitude in degrees. * @param {number} lat - Latitude in degrees. * @param {number} z - Zoom level. * @returns {{x:number, y:number}} Tile column/row. */ export function lonLatToTile(lon, lat, z) { const n = 2 ** z; const clampedLat = Math.max( -MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, lat), ); const latRad = (clampedLat * Math.PI) / 180; const x = Math.floor(((lon + 180) / 360) * n); const y = Math.floor( ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n, ); return { x: Math.max(0, Math.min(n - 1, x)), y: Math.max(0, Math.min(n - 1, y)), }; } /** * Compute the geographic bounding box of a slippy tile. * * @param {number} z - Zoom level. * @param {number} x - Tile column. * @param {number} y - Tile row. * @returns {{west:number, south:number, east:number, north:number}} Degrees. */ export function tileToBBox(z, x, y) { const n = 2 ** z; const lonAt = (col) => (col / n) * 360 - 180; const latAt = (row) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * row) / n))) * 180) / Math.PI; return { west: lonAt(x), east: lonAt(x + 1), north: latAt(y), south: latAt(y + 1), }; } /** * List the tiles covering a lat/lon bounding box at the given zoom. * * Traffic fetch bounds are clamped to a 0.05° span, so this is 1–4 tiles at * the default z12 in practice; `maxTiles` is a defensive truncation cap for * malformed/oversized inputs (row-major from the northwest corner). * * @param {{south:number, west:number, north:number, east:number}} bounds - Degrees. * @param {number} [zoom=12] - Tile zoom level. * @param {Object} [opts] * @param {number} [opts.maxTiles=64] - Safety cap on returned tiles. * @returns {Array<{z:number, x:number, y:number}>} Covering tiles. */ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) { if (!bounds) return []; const { south, west, north, east } = bounds; if (![south, west, north, east].every(Number.isFinite)) return []; // Northwest corner has the min x and min y (y grows southward). const nw = lonLatToTile(Math.min(west, east), Math.max(south, north), zoom); const se = lonLatToTile(Math.max(west, east), Math.min(south, north), zoom); const tiles = []; for (let y = nw.y; y <= se.y; y++) { for (let x = nw.x; x <= se.x; x++) { if (tiles.length >= maxTiles) return tiles; tiles.push({ z: zoom, x, y }); } } return tiles; } // ─── Daily budget accounting ─────────────────────────────── /** * UTC calendar-day key for budget bucketing. * * @param {number} [epochMs=Date.now()] - Timestamp in ms. * @returns {string} 'YYYY-MM-DD' in UTC. */ export function utcDayKey(epochMs = Date.now()) { return new Date(epochMs).toISOString().slice(0, 10); } /** * Normalize a persisted budget state against today's UTC day key. * Rolls the counter to zero on day change; replaces missing/corrupt state. * Returns the SAME object when it is already valid for `dayKey` (cheap to * call on every request). * * @param {{date:string, count:number}|null|undefined} state - Persisted state. * @param {string} dayKey - Today's UTC day key (from `utcDayKey`). * @returns {{date:string, count:number}} Valid state for `dayKey`. */ export function normalizeBudget(state, dayKey) { const valid = Boolean(state) && state.date === dayKey && Number.isFinite(state.count) && state.count >= 0; return valid ? state : { date: dayKey, count: 0 }; } /** * Whether the daily soft cap has been reached. * * @param {{count:number}} state - Normalized budget state. * @param {number} limit - Daily tile budget; non-positive/invalid never blocks. * @returns {boolean} True when `count >= limit`. */ export function isOverBudget(state, limit) { if (!Number.isFinite(limit) || limit <= 0) return false; return state.count >= limit; }