Files
gods-eye-view/src/layers/streetLevel/tileMath.js
T
Sameh Khamis 84714030c8 test: show a capped overview no longer holds repeat tiles
Adds a case that fails before de-duplication (a 24-slot overview held repeats
and reported itself truncated) and says why the date-line halves overlap.
2026-10-14 16:35:47 -07:00

215 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
const MAX_LAT = 85.05112878;
function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/** Wrap a longitude, or a difference of two, into [-180, 180]: the short way round. */
export function wrapLon(lon) {
if (lon >= -180 && lon <= 180) return lon;
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
/** Fractional tile column of a longitude in [-180, 180] at zoom z. */
function tileXAt(lon, z) {
return ((lon + 180) / 360) * 2 ** z;
}
/** Fractional tile row of a latitude at zoom z. */
function tileYAt(lat, z) {
const rad = (clampLat(lat) * Math.PI) / 180;
return (
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
);
}
export function lonToTileX(lon, z) {
const n = 2 ** z;
// 180° is the east edge of the last column, not the west edge of the first.
if (lon >= 180) return n - 1;
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
}
export function latToTileY(lat, z) {
const n = 2 ** z;
return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z))));
}
/** West longitude of tile column x at zoom z. */
function tileXToLon(x, z) {
return (x / 2 ** z) * 360 - 180;
}
/** North latitude of tile row y at zoom z. */
function tileYToLat(y, z) {
const n = Math.PI - (2 * Math.PI * y) / 2 ** z;
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
/** @returns {{west:number,south:number,east:number,north:number}} degrees */
export function tileBounds(x, y, z) {
return {
west: tileXToLon(x, z),
east: tileXToLon(x + 1, z),
north: tileYToLat(y, z),
south: tileYToLat(y + 1, z),
};
}
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
: input && typeof input === 'object'
? [input.west, input.south, input.east, input.north]
: null;
if (!values || values.length !== 4) return null;
const [w, s, e, n] = values.map(Number);
if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(w, e));
const south = Math.max(-MAX_LAT, Math.min(s, n));
const north = Math.min(MAX_LAT, Math.max(s, n));
if (east - west <= 0 || north - south <= 0) return null;
return { west, south, east, north };
}
/**
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
* `from` → `to` at zoom z, measured the short way round the date line.
*/
function segmentDistance2(tile, from, to, z) {
const n = 2 ** z;
const px = tile.x + 0.5;
const py = tile.y + 0.5;
const ax = tileXAt(from.lon, z);
const ay = tileYAt(from.lat, z);
let bx = to ? tileXAt(to.lon, z) : ax;
const by = to ? tileYAt(to.lat, z) : ay;
if (bx - ax > n / 2) bx -= n;
else if (ax - bx > n / 2) bx += n;
let best = Infinity;
for (const shift of [-n, 0, n]) {
const x = px + shift;
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy;
const t = len2
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
: 0;
const dx = x - (ax + t * vx);
const dy = py - (ay + t * vy);
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
/**
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* from the segment `from` (ground under the camera) to `to` (screen centre):
* a tilted view's box centre can sit kilometres ahead of both.
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
const n = 2 ** z;
const tiles = [];
let centre;
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
// A box across the date line comes as west > east: split it at ±180°.
const [west, south, east, north] = bbox.map(Number);
for (const half of [
[west, south, 180, north],
[-180, south, east, north],
]) {
const box = normalizeBbox(half);
if (box) tiles.push(...tileGrid(box, z).tiles);
}
centre = {
x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n,
y: (latToTileY(north, z) + latToTileY(south, z)) / 2,
};
} else {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const grid = tileGrid(box, z);
tiles.push(...grid.tiles);
centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 };
}
const distance2 = focus?.from
? (tile) => segmentDistance2(tile, focus.from, focus.to, z)
: (tile) => {
// Measured the short way round the date line.
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
// The date-line halves share a column when the gap between east and west is
// narrower than a tile; count that column once so a capped overview does not
// spend its slots on repeats. Each tile's distance is computed once; ties keep
// their row order.
const unique = [
...new Map(
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
).values(),
];
const ranked = unique
.map((tile) => ({ tile, d: distance2(tile) }))
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
const truncated = ranked.length > limit;
return {
tiles: truncated ? ranked.slice(0, limit) : ranked,
truncated,
total: ranked.length,
};
}
/** The tiles at zoom z in a normalized box, row by row, and their range. */
function tileGrid(box, z) {
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
const y0 = latToTileY(box.north, z);
const y1 = latToTileY(box.south, z);
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
return { tiles, x0, x1, y0, y1 };
}
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180;
const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n;
const lat =
(180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc)));
return [lon, lat];
}
/** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null;
if (heightM > 12_000) return 11;
if (heightM > 5_000) return 12;
if (heightM > 1_800) return 13;
return 14;
}
/**
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* null below, where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
if (heightM > 15_000_000) return 0;
if (heightM > 7_000_000) return 1;
if (heightM > 3_000_000) return 2;
if (heightM > 1_200_000) return 3;
if (heightM > 400_000) return 4;
return 5;
}