mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Support spaced hemisphere coordinates while rejecting malformed numeric fragments and ambiguous component assignments. Include regression tests and runtime documentation. Contribution by @rudycelekli.
133 lines
5.1 KiB
JavaScript
133 lines
5.1 KiB
JavaScript
/**
|
|
* Strict decimal-degree coordinate parsing for the search box.
|
|
*
|
|
* A search that guesses is worse than one that declines: reading `12junk, 34oops`
|
|
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
|
|
* successful search. So every rule here is exact — a component is a decimal
|
|
* number, optionally signed, optionally marked with one hemisphere letter, and
|
|
* nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
|
|
* Degrees/minutes/seconds and grid references are not handled;
|
|
* they fall through to the geocoders, which is what happens today.
|
|
*/
|
|
|
|
/** One decimal-degree component: an optional hemisphere letter on either side. */
|
|
const COMPONENT =
|
|
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
|
|
|
|
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
|
|
const GEO_URI =
|
|
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
|
|
|
|
/** Hemisphere letters, by the axis each one can name. */
|
|
const LATITUDE_LETTERS = new Set(['N', 'S']);
|
|
|
|
/**
|
|
* Read one component into a signed value plus the axis its letter names.
|
|
*
|
|
* @param {string} text
|
|
* @returns {{ value: number, axis: 'lat'|'lon'|null } | null}
|
|
*/
|
|
function readComponent(text) {
|
|
const match = COMPONENT.exec(String(text).trim());
|
|
if (!match) return null;
|
|
const [, before, digits, after] = match;
|
|
// Two letters on one component is a contradiction, not a coordinate.
|
|
if (before && after) return null;
|
|
const letter = (before || after || '').toUpperCase();
|
|
const value = Number(digits);
|
|
if (!Number.isFinite(value)) return null;
|
|
// A sign and a hemisphere letter both claim the direction; "-40N" means
|
|
// neither one thing nor the other, so it is refused rather than guessed.
|
|
if (letter && /^[+-]/.test(digits)) return null;
|
|
if (!letter) return { value, axis: null };
|
|
const magnitude = Math.abs(value);
|
|
return {
|
|
value: letter === 'S' || letter === 'W' ? -magnitude : magnitude,
|
|
axis: LATITUDE_LETTERS.has(letter) ? 'lat' : 'lon',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Split on a comma, a semicolon, or whitespace — never on anything else.
|
|
* A hemisphere letter stays with the number it names, so `40.7 N 74.0 W`
|
|
* is two components. Splitting on the space before that letter used to
|
|
* leave three pieces and the search declined the coordinate.
|
|
*/
|
|
function splitComponents(query) {
|
|
const text = String(query).trim();
|
|
if (!text) return null;
|
|
if (/[,;]/.test(text)) {
|
|
const trimmed = text.split(/[,;]/).map((part) => part.trim());
|
|
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
|
|
}
|
|
const words = text.split(/\s+/);
|
|
// Each component has at most a number, a degree sign and one hemisphere.
|
|
if (words.length > 6) return null;
|
|
// Keep every character: extracting tokens would turn `1e3` or `1.2.3`
|
|
// into invented coordinates. A lone letter between numbers can name either
|
|
// component; require an unambiguous split rather than guessing its owner.
|
|
let result = null;
|
|
for (let boundary = words.length - 1; boundary > 0; boundary -= 1) {
|
|
const parts = [
|
|
words.slice(0, boundary).join(' '),
|
|
words.slice(boundary).join(' '),
|
|
];
|
|
if (parts.every((part) => readComponent(part))) {
|
|
if (result) return null;
|
|
result = parts;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Present a parsed pair the way a coordinate is normally written. */
|
|
export function formatCoordinateLabel(lat, lon) {
|
|
const latLetter = lat >= 0 ? 'N' : 'S';
|
|
const lonLetter = lon >= 0 ? 'E' : 'W';
|
|
return `${Math.abs(lat).toFixed(4)}° ${latLetter}, ${Math.abs(lon).toFixed(4)}° ${lonLetter}`;
|
|
}
|
|
|
|
/**
|
|
* Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
|
|
*
|
|
* Both orders are accepted when the hemisphere letters say which is which
|
|
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
|
|
* latitude-then-longitude order applies. Two letters naming the same axis, a
|
|
* letter that fixes nothing, an out-of-range latitude or longitude, and any
|
|
* trailing text all decline.
|
|
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
|
|
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
|
|
*
|
|
* @param {string} query
|
|
* @returns {{ lat: number, lon: number, label: string } | null}
|
|
*/
|
|
export function parseCoordinateQuery(query) {
|
|
if (typeof query !== 'string') return null;
|
|
const text = query.trim();
|
|
const parts = /^geo:/i.test(text)
|
|
? GEO_URI.exec(text)?.slice(1, 3)
|
|
: splitComponents(text);
|
|
if (!parts) return null;
|
|
|
|
const first = readComponent(parts[0]);
|
|
const second = readComponent(parts[1]);
|
|
if (!first || !second) return null;
|
|
// Both components naming the same axis leaves the other one unstated.
|
|
if (first.axis && first.axis === second.axis) return null;
|
|
|
|
let lat;
|
|
let lon;
|
|
if (first.axis === 'lon' || second.axis === 'lat') {
|
|
lat = second.value;
|
|
lon = first.value;
|
|
} else {
|
|
lat = first.value;
|
|
lon = second.value;
|
|
}
|
|
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
if (lat < -90 || lat > 90) return null;
|
|
if (lon < -180 || lon > 180) return null;
|
|
return { lat, lon, label: formatCoordinateLabel(lat, lon) };
|
|
}
|