From 463216388c6f7632a421ed7ac3828ebe8c9b169a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 23:04:11 -0400 Subject: [PATCH] feat(search): accept a typed coordinate as a "Go to" result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search now recognises a latitude/longitude typed in any common notation and offers a "Go to coordinate" hit pinned to the top of the results, so Enter jumps straight there. Lenient about how it's entered: - decimal degrees "-32.4943, 60.931" "32.4943 S 60.931 E" - degrees-decimal-minutes "32°29.66'S, 060°55.86'E" "39°27.6′N 104°39.6′W" - degrees-minutes-seconds "32 29 40 S 60 55 52 E" °/′/″ marks optional (plain ' and " accepted), hemisphere by N/S/E/W (either case) or a leading −, lat/lon separated by comma/semicolon or whitespace. New util.parseLatLon is the inverse of fmtLatLon (round-trips); non-coordinates and out-of-range values fall through to the normal chart/feature search. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.view.mjs | 2 +- web/src/lib/util.mjs | 62 ++++++++++++++++++++++++++++++++++ web/src/lib/util.test.mjs | 48 ++++++++++++++++++++++++++ web/src/plugins/search-box.mjs | 18 +++++++--- 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/util.test.mjs diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index b13fe38..c6d8b85 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -554,7 +554,7 @@ export const CHROME = ` - +
NOAA ENC® · · not for navigation
diff --git a/web/src/lib/util.mjs b/web/src/lib/util.mjs index f3e7d11..4a83f22 100644 --- a/web/src/lib/util.mjs +++ b/web/src/lib/util.mjs @@ -146,6 +146,68 @@ export function fmtLatLon(lat, lng) { return dm(lat, 2) + (lat >= 0 ? "N" : "S") + " " + dm(x, 3) + (x >= 0 ? "E" : "W"); } +// parseLatLon — the lenient inverse of fmtLatLon: turn a typed coordinate into +// { lat, lng }, or null if it doesn't look like one. Forgiving about notation so +// users can paste whatever their other kit prints: +// • decimal degrees "-32.4943, 60.931" "32.4943 S 60.931 E" +// • degrees-decimal-minutes "32°29.66'S, 060°55.86'E" "39°27.6′N 104°39.6′W" +// • degrees-minutes-seconds "32 29 40 S 60 55 52 E" +// °/′/″ marks optional (plain ' and " accepted), hemisphere by N/S/E/W (either +// case) or a leading −; lat,lon separated by comma/semicolon or whitespace. The +// first value is latitude. Returns null on anything out of range or ambiguous. +export function parseLatLon(input) { + if (typeof input !== "string") return null; + // Normalise degree/minute/second marks to spaces; keep digits, signs, NSEW, separators. + const s = input.trim() + .replace(/[°º∘]/g, " ") + .replace(/[’′ʹ`']/g, " ") + .replace(/[”″"]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!s) return null; + + // Candidate splits into [latToken, lonToken], tried in order of confidence. + const tries = []; + const csv = s.split(/\s*[,;]\s*/); + if (csv.length === 2) tries.push(csv); // explicit separator + const hemi = s.match(/^(.*?[NSns])\s+(.*)$/); // split after the latitude hemisphere + if (hemi && /[EWew]/.test(hemi[2])) tries.push([hemi[1], hemi[2]]); + if (!/[NSEWnsew]/.test(s)) { // no letters: split the numbers down the middle + const nums = s.match(/[+-]?\d+(?:\.\d+)?/g) || []; + if (nums.length >= 2 && nums.length % 2 === 0) { + const h = nums.length / 2; + tries.push([nums.slice(0, h).join(" "), nums.slice(h).join(" ")]); + } + } + + for (const [a, b] of tries) { + const lat = parseCoordComponent(a, "NS"); + const lng = parseCoordComponent(b, "EW"); + if (lat != null && lng != null && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) { + return { lat, lng }; + } + } + return null; +} + +// Parse one coordinate value (already mark-normalised to spaces) into a signed +// decimal degree. `axis` is "NS" (latitude) or "EW" (longitude); a hemisphere +// letter for the wrong axis rejects. Numbers are taken in order as deg[, min[, +// sec]] so decimal/DMM/DMS all fall out of the same path. +function parseCoordComponent(token, axis) { + if (!token) return null; + const hemiMatch = token.match(/[NSEWnsew]/); + const hemi = hemiMatch ? hemiMatch[0].toUpperCase() : null; + if (hemi && !axis.includes(hemi)) return null; // e.g. an E in the latitude slot + const nums = token.match(/[+-]?\d+(?:\.\d+)?/g); + if (!nums || nums.length === 0 || nums.length > 3) return null; + let val = Math.abs(parseFloat(nums[0])); + if (nums.length >= 2) val += Math.abs(parseFloat(nums[1])) / 60; + if (nums.length >= 3) val += Math.abs(parseFloat(nums[2])) / 3600; + const negative = hemi === "S" || hemi === "W" || /^-/.test(nums[0]); + return negative ? -val : val; +} + // True when the page was opened as a snapshot share link (/#share or // ?share) — boot() then reconstructs the publisher's scene from /api/share. export function isShareUrl() { diff --git a/web/src/lib/util.test.mjs b/web/src/lib/util.test.mjs new file mode 100644 index 0000000..ef2dbc4 --- /dev/null +++ b/web/src/lib/util.test.mjs @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseLatLon, fmtLatLon } from "./util.mjs"; + +const near = (a, b, eps = 1e-3) => Math.abs(a - b) <= eps; + +test("parseLatLon — degrees-decimal-minutes with hemispheres (the spec example)", () => { + const r = parseLatLon("32°29.66’S, 060°55.86’E"); + assert.ok(r); + assert.ok(near(r.lat, -(32 + 29.66 / 60)), `lat ${r.lat}`); + assert.ok(near(r.lng, 60 + 55.86 / 60), `lng ${r.lng}`); +}); + +test("parseLatLon — plain apostrophe, no comma, fmtLatLon's own ′ output", () => { + assert.ok(near(parseLatLon("32 29.66'S 060 55.86'E").lat, -32.49433)); + const r = parseLatLon("39°27.6′N 104°39.6′W"); + assert.ok(near(r.lat, 39 + 27.6 / 60)); + assert.ok(near(r.lng, -(104 + 39.6 / 60))); +}); + +test("parseLatLon — decimal degrees, signed and lettered", () => { + assert.deepEqual(roundLL(parseLatLon("-32.4943, 60.931")), { lat: -32.4943, lng: 60.931 }); + assert.deepEqual(roundLL(parseLatLon("32.4943 S 60.931 E")), { lat: -32.4943, lng: 60.931 }); + assert.deepEqual(roundLL(parseLatLon("38.97 -76.47")), { lat: 38.97, lng: -76.47 }); +}); + +test("parseLatLon — degrees-minutes-seconds", () => { + const r = parseLatLon("32 29 40 S 60 55 52 E"); + assert.ok(near(r.lat, -(32 + 29 / 60 + 40 / 3600))); + assert.ok(near(r.lng, 60 + 55 / 60 + 52 / 3600)); +}); + +test("parseLatLon — round-trips through fmtLatLon", () => { + for (const [lat, lng] of [[38.978, -76.478], [-32.4943, 60.931], [0, 0], [-33.86, 151.21]]) { + const r = parseLatLon(fmtLatLon(lat, lng)); + assert.ok(r && near(r.lat, lat, 0.02) && near(r.lng, lng, 0.02), `${lat},${lng} -> ${JSON.stringify(r)}`); + } +}); + +test("parseLatLon — rejects non-coordinates and out-of-range", () => { + for (const bad of ["", "spa creek", "US5MD1MC", "32", "hello world", "200, 60", "45, 999", "abc, def"]) { + assert.equal(parseLatLon(bad), null, `should reject: ${JSON.stringify(bad)}`); + } +}); + +function roundLL(r) { + return r && { lat: Math.round(r.lat * 1e4) / 1e4, lng: Math.round(r.lng * 1e4) / 1e4 }; +} diff --git a/web/src/plugins/search-box.mjs b/web/src/plugins/search-box.mjs index dab3d7f..fc3888d 100644 --- a/web/src/plugins/search-box.mjs +++ b/web/src/plugins/search-box.mjs @@ -19,7 +19,7 @@ // sb.doSearch(query); // input → render results // sb.gotoHit(i); // result-click / Enter → fly + close -import { esc } from "../lib/util.mjs"; +import { esc, parseLatLon, fmtLatLon } from "../lib/util.mjs"; export class SearchBox { constructor(opts) { @@ -40,8 +40,12 @@ export class SearchBox { doSearch(q) { const el = this._getResultsEl(); if (!el) return; - const needle = q.trim().toLowerCase(); + const raw = (q || "").trim(); + const needle = raw.toLowerCase(); if (needle.length < 2) { el.hidden = true; el.innerHTML = ""; this._hits = []; this.position(); return; } + // 0) A typed coordinate (any common notation) → a "Go to" hit pinned on top, + // so Enter jumps straight there. Other matches still list below. + const coord = parseLatLon(raw); // 1) Catalog cells (chart titles / numbers), fuzzy-matched. Best score wins; // ties break to the coarser chart (overview before an arbitrary harbour inset). const cells = []; @@ -53,11 +57,16 @@ export class SearchBox { cells.sort((a, b) => (b.score - a.score) || ((b.c.s || 0) - (a.c.s || 0))); // 2) Every loaded chart feature, fuzzy-matched across its attribute data. const feats = this._searchFeatures(needle); - const hits = [...cells.slice(0, 5).map(({ c }) => ({ type: "cell", c })), ...feats.slice(0, 8)]; + const hits = [ + ...(coord ? [{ type: "coord", lat: coord.lat, lng: coord.lng }] : []), + ...cells.slice(0, 5).map(({ c }) => ({ type: "cell", c })), + ...feats.slice(0, 8), + ]; this._hits = hits; el.innerHTML = hits.length ? hits.map((h, i) => { const sel = i === 0 ? " sel" : ""; + if (h.type === "coord") return `
${esc(fmtLatLon(h.lat, h.lng))}
Go to coordinate
`; if (h.type === "cell") return `
${esc(h.c.l || h.c.n)}
Chart · ${esc(h.c.n)} · 1:${(h.c.s || 0).toLocaleString()}
`; return `
${esc(h.label)}
${esc(h.sub)}
`; }).join("") @@ -103,7 +112,8 @@ export class SearchBox { const h = (this._hits || [])[i]; const map = this._getMap(); if (!h || !map) return; - if (h.type === "feat") map.flyTo({ center: [h.lng, h.lat], zoom: Math.max(map.getZoom(), 14), duration: 800 }); + if (h.type === "coord") map.flyTo({ center: [h.lng, h.lat], zoom: Math.max(map.getZoom(), 13), duration: 800 }); + else if (h.type === "feat") map.flyTo({ center: [h.lng, h.lat], zoom: Math.max(map.getZoom(), 14), duration: 800 }); else { const c = h.c; map.fitBounds([[c.bb[0], c.bb[1]], [c.bb[2], c.bb[3]]], { padding: 80, maxZoom: 13, duration: 800 }); } const el = this._getResultsEl(); if (el) el.hidden = true; // Keep the query (and selected highlight) so reopening search returns you to