Add bounds-sizing helpers for LOCATE camera moves

This commit is contained in:
Ethan Morchy
2026-08-11 00:40:47 -07:00
parent 882c27b1eb
commit 664f218a6f
2 changed files with 460 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
import { describe, it, expect } from 'vitest';
import {
parseCoordinateInput,
boundsForCoordinate,
sanitizeGeocodeBbox,
boundsForPlaceRank,
clampZoom,
} from '@/lib/mapZoom';
describe('parseCoordinateInput', () => {
it('counts decimals as typed, not as parsed', () => {
// 78.00 and 78 are the same float but not the same claim.
expect(parseCoordinateInput('78.00, -119.00')?.decimals).toBe(2);
expect(parseCoordinateInput('78, -119')?.decimals).toBe(0);
});
it('takes the less precise axis so one sloppy field cannot drive framing', () => {
expect(parseCoordinateInput('78.00, -119')?.decimals).toBe(0);
});
it('accepts space separation and a trailing dot', () => {
expect(parseCoordinateInput('34.05 -118.24')?.decimals).toBe(2);
expect(parseCoordinateInput('78. -119.')?.decimals).toBe(0);
});
it('rejects out-of-range and non-coordinate input', () => {
expect(parseCoordinateInput('91, 0')).toBeNull();
expect(parseCoordinateInput('0, 181')).toBeNull();
expect(parseCoordinateInput('Tokyo')).toBeNull();
});
});
describe('boundsForCoordinate', () => {
it('brackets the point by half an uncertainty cell', () => {
const [w, s, e, n] = boundsForCoordinate(78, -119, 0);
expect(w).toBeCloseTo(-119.5, 6);
expect(e).toBeCloseTo(-118.5, 6);
expect(s).toBeCloseTo(77.5, 6);
expect(n).toBeCloseTo(78.5, 6);
});
it('shrinks by 10x per decimal place', () => {
const [w, , e] = boundsForCoordinate(34.052341, -118.243607, 6);
expect(e - w).toBeCloseTo(0.000001, 9);
});
it('does not produce latitudes beyond the poles', () => {
const [, s, , n] = boundsForCoordinate(90, 0, 0);
expect(n).toBeLessThanOrEqual(90);
expect(s).toBeGreaterThanOrEqual(-90);
});
});
describe('sanitizeGeocodeBbox', () => {
it('accepts an honest city-sized box and converts to [w,s,e,n]', () => {
// Monaco the country, place_rank 4. The box is padded with territorial
// water and the point sits on its northern edge, but at 26km across the
// lopsidedness is too small to matter.
expect(
sanitizeGeocodeBbox(
['43.5165358', '43.7519173', '7.4090279', '7.5329917'],
43.7323492,
7.4276832,
),
).toEqual([7.4090279, 43.5165358, 7.5329917, 43.7519173]);
});
it('accepts a town', () => {
// Zermatt, place_rank 16.
expect(
sanitizeGeocodeBbox(
['45.9167499', '46.0643293', '7.5749926', '7.9086793'],
46.0207133,
7.7491027,
),
).not.toBeNull();
});
it('keeps the honest box of a country far larger than its rank implies', () => {
// Canada is rank 4, the same rank as Monaco, and 4600km across. Sizing
// rules keyed off place_rank threw this away and flew to a 1000km box
// over the Northwest Territories.
expect(
sanitizeGeocodeBbox(
['41.6765597', '83.3362128', '-141.0027500', '-52.3237664'],
61.0666922,
-107.9917071,
),
).toEqual([-141.00275, 41.6765597, -52.3237664, 83.3362128]);
});
it('keeps the honest box of an oversized state', () => {
// Texas is rank 8 and 1300km wide; the old cap allowed 1000km.
expect(
sanitizeGeocodeBbox(
['25.8371638', '36.5007041', '-106.6456461', '-93.5078063'],
31.2638905,
-98.5456116,
),
).not.toBeNull();
});
it('rebuilds an antimeridian-degraded box from its latitude axis', () => {
// Russia: Nominatim returns the whole planet in longitude. MapLibre's
// adjustAntiMeridian() reads that as a legitimate world box, so we must
// catch it — but the latitude axis survived and still sizes the country.
const bounds = sanitizeGeocodeBbox(
['41.1850968', '82.0586232', '-180.0000000', '180.0000000'],
64.6863136,
97.7453061,
);
expect(bounds).not.toBeNull();
const [west, south, east, north] = bounds!;
// The latitude axis is real data and survives untouched. Re-deriving it
// from the result's own point pushes north past 85, where Mercator's
// stretch alone drags the zoom down to the floor.
expect([south, north]).toEqual([41.1850968, 82.0586232]);
expect(east - west).toBeLessThan(180);
expect(west).toBeLessThan(97.7453061);
expect(east).toBeGreaterThan(97.7453061);
});
it('caps a latitude axis that overseas territories also smeared', () => {
// The United States reaches 14S at American Samoa and 71N at Point
// Barrow: 9600km of latitude for a 4500km country. Uncapped, the camera
// frames a hemisphere.
const [, south, , north] = sanitizeGeocodeBbox(
['-14.7608358', '71.5889534', '-180.0000000', '180.0000000'],
39.7837304,
-100.4458825,
)!;
expect(north - south).toBeLessThan(50);
expect(south).toBeLessThan(39.7837304);
expect(north).toBeGreaterThan(39.7837304);
});
it('rejects a box smeared by overseas territories', () => {
// France: Kerguelen to French Polynesia, 350 deg of longitude. Never
// quite +/-180, so the dateline check alone would miss it.
expect(
sanitizeGeocodeBbox(
['-50.2187169', '51.3055721', '-178.3873749', '172.3057152'],
46.603354,
1.8883335,
),
).toBeNull();
});
it('rejects an island-chain prefecture', () => {
// "Tokyo" resolves to Tokyo Metropolis (rank 8), whose bbox reaches
// 1800km south to the Ogasawara Islands, leaving the city itself 1.4%
// from the northern edge. The box is roughly square, so an aspect-ratio
// test does not catch it.
expect(
sanitizeGeocodeBbox(
['20.2145811', '35.8984245', '135.8536855', '154.2055410'],
35.6768601,
139.7638947,
),
).toBeNull();
});
it('rejects a padded node box', () => {
// Mount Everest is an OSM node; Nominatim pads it to +/-0.00005 deg.
// Not zero-area, so a degenerate-bounds guard never fires.
expect(
sanitizeGeocodeBbox(
['27.9880114', '27.9881114', '86.9251600', '86.9252600'],
27.9880614,
86.92521,
),
).toBeNull();
});
it('rejects malformed input', () => {
expect(sanitizeGeocodeBbox(undefined, 0, 0)).toBeNull();
expect(sanitizeGeocodeBbox(['1', '2'], 0, 0)).toBeNull();
expect(sanitizeGeocodeBbox(['a', 'b', 'c', 'd'], 0, 0)).toBeNull();
expect(sanitizeGeocodeBbox(['1', '2', '3', '4'], NaN, 0)).toBeNull();
});
});
describe('boundsForPlaceRank', () => {
it('sizes a country far wider than a building', () => {
const [cw, , ce] = boundsForPlaceRank(48.85, 2.35, 4);
const [bw, , be] = boundsForPlaceRank(48.85, 2.35, 30);
expect(ce - cw).toBeGreaterThan((be - bw) * 100);
});
it('widens longitude at high latitude to keep the box roughly square', () => {
const [ew, , ee] = boundsForPlaceRank(0, 0, 16);
const [aw, , ae] = boundsForPlaceRank(78, 0, 16);
expect(ae - aw).toBeGreaterThan(ee - ew);
});
it('falls back to a city-sized box when rank is unknown', () => {
expect(boundsForPlaceRank(39.7, -104.9, undefined)).toEqual(
boundsForPlaceRank(39.7, -104.9, 15),
);
});
});
describe('clampZoom', () => {
it('holds the camera inside the usable range', () => {
expect(clampZoom(22)).toBe(17);
expect(clampZoom(0)).toBe(2);
expect(clampZoom(11.4)).toBe(11.4);
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* Sizing the LOCATE camera.
*
* Everything here produces a bounding box rather than a zoom number, so the
* caller can hand it to map.cameraForBounds(). That matters: in Web Mercator
* the cos(lat) shrinkage of a longitude degree is exactly cancelled by the
* projection's horizontal stretch, so only the latitude axis needs the term.
* Hand-rolled compensation usually corrects the wrong one. cameraForBounds
* gets both axes and the viewport aspect ratio right for free.
*/
/** [west, south, east, north] - the order MapLibre's LngLatBoundsLike takes. */
export type Bounds = [number, number, number, number];
export const ZOOM_MIN = 2;
/** ~0.6 m/px. Deeper than this, every tile source is upscaled blur. */
export const ZOOM_MAX = 17;
/** OSM.org's own default for a bare coordinate: "somewhere around here". */
export const ZOOM_FALLBACK = 11;
const KM_PER_DEGREE = 111.32;
/**
* A bbox axis is judged "smeared by an outlier" when the result's own
* coordinate sits outside the middle 80% of it. Nominatim's lat/lon is the
* representative point of the main body, so a box that genuinely describes
* that body brackets it; a box stretched to a remote island does not.
*
* This catches the severe cases (Tokyo's box is 70x longer on the far side of
* the city than the near side) and deliberately lets mild ones through.
* Australia keeps a box widened by Heard Island, 4000km to the south-west,
* because its centroid is still only 15% off-centre — no threshold separates
* that from Brazil or Japan, which are similarly off-centre and honest. The
* country still lands on screen whole, just not centred. That is also what
* openstreetmap.org does with the same bounding box.
*/
const CENTRE_MARGIN = 0.1;
/**
* Only axes longer than this are worth judging. Below it, a lopsided box is
* territorial-water padding rather than an outlying territory, and the error
* is too small to see. Monaco's country box is 26km of mostly sea with the
* point on its northern edge; framing that is harmless.
*/
const CENTRE_TEST_MIN_KM = 100;
/**
* No country's true north-south extent reaches this. Canada, the largest,
* spans 41.7N to 83.3N (~4630km). A latitude span beyond the cap is therefore
* an overseas territory dragging the box, not the country.
*/
const MAX_COUNTRY_EXTENT_KM = 5000;
export const clampZoom = (z: number): number =>
Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
const COORD_RE = /^([+-]?\d+(?:\.(\d*))?)[,\s]+([+-]?\d+(?:\.(\d*))?)$/;
/**
* Parse "31.8, 34.8" / "-12.3 45.6", reporting how precisely it was typed.
* Decimals are counted from the raw string because 78 and 78.00 parse to the
* same float but make different claims about precision.
*/
export function parseCoordinateInput(
raw: string,
): { lat: number; lng: number; decimals: number } | null {
const m = raw.trim().match(COORD_RE);
if (!m) return null;
const lat = parseFloat(m[1]);
const lng = parseFloat(m[3]);
if (!(lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180)) return null;
// The less precise axis wins: one sloppy field must not drive the framing.
const decimals = Math.min((m[2] ?? '').length, (m[4] ?? '').length);
return { lat, lng, decimals };
}
/** The uncertainty cell implied by `decimals` places of decimal degrees. */
export function boundsForCoordinate(
lat: number,
lng: number,
decimals: number,
): Bounds {
const half = 0.5 * Math.pow(10, -decimals);
return [
lng - half,
Math.max(-90, lat - half),
lng + half,
Math.min(90, lat + half),
];
}
/**
* Is `value` bracketed by [lo, hi] rather than sitting out at one end?
* Short axes always pass: see CENTRE_TEST_MIN_KM.
*/
function axisBracketsPoint(lo: number, hi: number, value: number): boolean {
const span = hi - lo;
if (span * KM_PER_DEGREE < CENTRE_TEST_MIN_KM) return true;
const position = (value - lo) / span;
return position >= CENTRE_MARGIN && position <= 1 - CENTRE_MARGIN;
}
/**
* Convert a Nominatim boundingbox to Bounds, discarding or repairing the ones
* that would frame the wrong thing. Every rule below is a real query.
*
* `lat`/`lng` are the result's own coordinate. They do the heavy lifting: a
* bounding box is trustworthy when it brackets its own representative point,
* and suspect when that point sits at one edge. Sizing rules cannot do this
* job — the extent Nominatim's place_rank implies is an administrative level,
* not a size, and countries at rank 4 run from Monaco (2km) to Canada
* (4600km). Capping on a multiple of the rank extent throws away the honest
* boxes of every large country and state.
*/
export function sanitizeGeocodeBbox(
bbox: unknown,
lat: number,
lng: number,
): Bounds | null {
if (!Array.isArray(bbox) || bbox.length !== 4) return null;
// Nominatim order is [min_lat, max_lat, min_lon, max_lon], as strings.
const [south, north, west, east] = bbox.map(Number);
if (![south, north, west, east].every(Number.isFinite)) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const latSpan = north - south;
const lonSpan = east - west;
if (latSpan < 0 || lonSpan < 0) return null;
// Russia, the United States, Fiji, New Zealand: a box crossing the
// antimeridian comes back as -180..180, which MapLibre's
// adjustAntiMeridian() reads as a legitimate whole-world box. Longitude is
// unrecoverable, but latitude usually survives, so rebuild a square-ish box
// from that rather than throwing the whole result away.
if (lonSpan >= 359) return boundsFromLatitudeSpan(lat, lng, south, north);
// France: mainland plus Kerguelen and French Polynesia, 350 deg wide but
// never quite touching +/-180, so the dateline rule above misses it.
// Nothing real spans more than a hemisphere of longitude.
if (lonSpan > 180) return null;
// Tokyo: the prefecture's box reaches 1800km south to the Ogasawara
// Islands, leaving the city itself 1.4% from the box's northern edge. The
// box is 15.7 x 18.4 deg — roughly square, so an aspect-ratio test misses
// it, and it is only 5x the extent its rank claims, so a size cap loose
// enough to admit Texas misses it too.
if (!axisBracketsPoint(south, north, lat)) return null;
if (!axisBracketsPoint(west, east, lng)) return null;
// Everest: an OSM node, padded to +/-0.00005 deg. Never zero-area, so a
// degenerate-bounds guard would not catch it - and fitBounds on a box that
// small silently flies to the map's maxZoom.
if (Math.max(latSpan, lonSpan) < 0.0002) return null;
return [west, south, east, north];
}
/**
* Rebuild bounds for a result whose longitude axis was destroyed by the
* antimeridian, using the latitude axis it kept. There is no information left
* about how wide the place is, so the box is made square about the result's
* own longitude — the neutral choice, and one that lands the camera on the
* populated middle of Russia rather than on empty Arctic.
*/
function boundsFromLatitudeSpan(
lat: number,
lng: number,
south: number,
north: number,
): Bounds | null {
const latSpan = north - south;
if (!(latSpan > 0)) return null;
// Territories smear latitude too, so the point still has to be bracketed.
if (!axisBracketsPoint(south, north, lat)) return null;
const extentKm = latSpan * KM_PER_DEGREE;
if (extentKm > MAX_COUNTRY_EXTENT_KM) {
// The United States: American Samoa at 14S and Point Barrow at 71N give
// 9600km of latitude for a 4500km country, and the midpoint of that lands
// in the Caribbean. Neither endpoint is usable, so fall back to a capped
// box around the result's own coordinate.
return boundsAround(lat, lng, MAX_COUNTRY_EXTENT_KM);
}
// Russia: 41N to 82N is the real extent of the country and belongs on the
// camera as-is. Deriving latitude from the result's point instead would
// push the box past 85N, where Mercator's stretch alone forces the zoom
// down to the floor.
const halfLng =
latSpan / 2 / Math.max(Math.cos(((south + north) / 2) * (Math.PI / 180)), 0.01);
return [lng - halfLng, south, lng + halfLng, north];
}
/**
* Nominatim's own extent estimate per search rank, in km. Ranks 13-25 are
* documented; 4-12 are our estimates, since the docs give no figure there.
* Keyed off place_rank rather than class/type/admin_level because Nominatim
* has already normalized admin levels across countries (German admin_level 5
* is rank 10, Swedish admin_level 4 is rank 12).
*/
const RANK_EXTENT_KM: ReadonlyArray<readonly [number, number]> = [
[3, 5000],
[4, 1000],
[8, 250],
[12, 60],
[16, 15],
[18, 4],
[19, 2],
[20, 1],
[25, 0.5],
[27, 0.3],
[30, 0.1],
];
/** Used when Nominatim gave no rank at all, e.g. the local_only fallback. */
const UNKNOWN_RANK = 15;
function extentKmForRank(placeRank?: number): number {
const rank = typeof placeRank === 'number' ? placeRank : UNKNOWN_RANK;
for (const [maxRank, km] of RANK_EXTENT_KM) {
if (rank <= maxRank) return km;
}
return RANK_EXTENT_KM[RANK_EXTENT_KM.length - 1][1];
}
/** A roughly square box `extentKm` across, centred on (lat, lng). */
function boundsAround(lat: number, lng: number, extentKm: number): Bounds {
const halfLat = extentKm / 2 / KM_PER_DEGREE;
// Synthesizing a geographic box, not a pixel measurement, so the cos term
// genuinely applies here. Floored to keep the poles finite.
const cos = Math.max(Math.cos((lat * Math.PI) / 180), 0.01);
const halfLng = halfLat / cos;
return [
lng - halfLng,
Math.max(-90, lat - halfLat),
lng + halfLng,
Math.min(90, lat + halfLat),
];
}
/**
* A box of the rank's typical extent, centred on the result. The last resort,
* for results whose bounding box was unusable and for the local_only path,
* which carries no extent data at all.
*/
export function boundsForPlaceRank(
lat: number,
lng: number,
placeRank?: number,
): Bounds {
return boundsAround(lat, lng, extentKmForRank(placeRank));
}