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 = `
-
+
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