mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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.
215 lines
7.3 KiB
JavaScript
215 lines
7.3 KiB
JavaScript
/** 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;
|
||
}
|