Files
gods-eye-view/src/search/coordinateParser.js
T
Rudy Mizrahi Celekli b07352ea6a fix(search): read hemisphere letters separated from the number (#1340)
Support spaced hemisphere coordinates while rejecting malformed numeric fragments and ambiguous component assignments. Include regression tests and runtime documentation.

Contribution by @rudycelekli.
2026-10-24 23:39:51 -07:00

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) };
}