mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-13 22:20:37 +02:00
Add bounds-sizing helpers for LOCATE camera moves
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user