Skip to content

Commit d0efa29

Browse files
authored
Merge pull request #23 from beetlebugorg/feature/search-coordinates
feat(search): accept a typed coordinate as a "Go to" result
2 parents 2acaa83 + 4632163 commit d0efa29

4 files changed

Lines changed: 125 additions & 5 deletions

File tree

web/src/chartplotter.view.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ export const CHROME = `
554554
<label>Go to scale&nbsp;1:<input id="scale-input" type="text" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="40000"></label>
555555
<button id="scale-go" type="button">Go</button>
556556
</div>
557-
<div id="search" hidden><input id="search-input" type="search" placeholder="Search charts & features…" autocomplete="off" spellcheck="false"><div id="search-results" hidden></div></div>
557+
<div id="search" hidden><input id="search-input" type="search" placeholder="Search charts, features, or a coordinate…" autocomplete="off" spellcheck="false"><div id="search-results" hidden></div></div>
558558
<div id="noaa-attr"><a href="${NOAA_ENC_URL}" target="_blank" rel="noopener">NOAA ENC®</a> · <button id="attr-terms" class="attr-link" type="button">Terms</button> · not for navigation</div>
559559
<!-- The NOAA ENC User Agreement modal moved into <chart-library> (it owns the
560560
download flow); the "Terms" link reaches into it. -->

web/src/lib/util.mjs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,68 @@ export function fmtLatLon(lat, lng) {
146146
return dm(lat, 2) + (lat >= 0 ? "N" : "S") + " " + dm(x, 3) + (x >= 0 ? "E" : "W");
147147
}
148148

149+
// parseLatLon — the lenient inverse of fmtLatLon: turn a typed coordinate into
150+
// { lat, lng }, or null if it doesn't look like one. Forgiving about notation so
151+
// users can paste whatever their other kit prints:
152+
// • decimal degrees "-32.4943, 60.931" "32.4943 S 60.931 E"
153+
// • degrees-decimal-minutes "32°29.66'S, 060°55.86'E" "39°27.6′N 104°39.6′W"
154+
// • degrees-minutes-seconds "32 29 40 S 60 55 52 E"
155+
// °/′/″ marks optional (plain ' and " accepted), hemisphere by N/S/E/W (either
156+
// case) or a leading −; lat,lon separated by comma/semicolon or whitespace. The
157+
// first value is latitude. Returns null on anything out of range or ambiguous.
158+
export function parseLatLon(input) {
159+
if (typeof input !== "string") return null;
160+
// Normalise degree/minute/second marks to spaces; keep digits, signs, NSEW, separators.
161+
const s = input.trim()
162+
.replace(/[°º]/g, " ")
163+
.replace(/[ʹ`']/g, " ")
164+
.replace(/["]/g, " ")
165+
.replace(/\s+/g, " ")
166+
.trim();
167+
if (!s) return null;
168+
169+
// Candidate splits into [latToken, lonToken], tried in order of confidence.
170+
const tries = [];
171+
const csv = s.split(/\s*[,;]\s*/);
172+
if (csv.length === 2) tries.push(csv); // explicit separator
173+
const hemi = s.match(/^(.*?[NSns])\s+(.*)$/); // split after the latitude hemisphere
174+
if (hemi && /[EWew]/.test(hemi[2])) tries.push([hemi[1], hemi[2]]);
175+
if (!/[NSEWnsew]/.test(s)) { // no letters: split the numbers down the middle
176+
const nums = s.match(/[+-]?\d+(?:\.\d+)?/g) || [];
177+
if (nums.length >= 2 && nums.length % 2 === 0) {
178+
const h = nums.length / 2;
179+
tries.push([nums.slice(0, h).join(" "), nums.slice(h).join(" ")]);
180+
}
181+
}
182+
183+
for (const [a, b] of tries) {
184+
const lat = parseCoordComponent(a, "NS");
185+
const lng = parseCoordComponent(b, "EW");
186+
if (lat != null && lng != null && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
187+
return { lat, lng };
188+
}
189+
}
190+
return null;
191+
}
192+
193+
// Parse one coordinate value (already mark-normalised to spaces) into a signed
194+
// decimal degree. `axis` is "NS" (latitude) or "EW" (longitude); a hemisphere
195+
// letter for the wrong axis rejects. Numbers are taken in order as deg[, min[,
196+
// sec]] so decimal/DMM/DMS all fall out of the same path.
197+
function parseCoordComponent(token, axis) {
198+
if (!token) return null;
199+
const hemiMatch = token.match(/[NSEWnsew]/);
200+
const hemi = hemiMatch ? hemiMatch[0].toUpperCase() : null;
201+
if (hemi && !axis.includes(hemi)) return null; // e.g. an E in the latitude slot
202+
const nums = token.match(/[+-]?\d+(?:\.\d+)?/g);
203+
if (!nums || nums.length === 0 || nums.length > 3) return null;
204+
let val = Math.abs(parseFloat(nums[0]));
205+
if (nums.length >= 2) val += Math.abs(parseFloat(nums[1])) / 60;
206+
if (nums.length >= 3) val += Math.abs(parseFloat(nums[2])) / 3600;
207+
const negative = hemi === "S" || hemi === "W" || /^-/.test(nums[0]);
208+
return negative ? -val : val;
209+
}
210+
149211
// True when the page was opened as a snapshot share link (<origin>/#share or
150212
// ?share) — boot() then reconstructs the publisher's scene from /api/share.
151213
export function isShareUrl() {

web/src/lib/util.test.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { parseLatLon, fmtLatLon } from "./util.mjs";
4+
5+
const near = (a, b, eps = 1e-3) => Math.abs(a - b) <= eps;
6+
7+
test("parseLatLon — degrees-decimal-minutes with hemispheres (the spec example)", () => {
8+
const r = parseLatLon("32°29.66’S, 060°55.86’E");
9+
assert.ok(r);
10+
assert.ok(near(r.lat, -(32 + 29.66 / 60)), `lat ${r.lat}`);
11+
assert.ok(near(r.lng, 60 + 55.86 / 60), `lng ${r.lng}`);
12+
});
13+
14+
test("parseLatLon — plain apostrophe, no comma, fmtLatLon's own ′ output", () => {
15+
assert.ok(near(parseLatLon("32 29.66'S 060 55.86'E").lat, -32.49433));
16+
const r = parseLatLon("39°27.6′N 104°39.6′W");
17+
assert.ok(near(r.lat, 39 + 27.6 / 60));
18+
assert.ok(near(r.lng, -(104 + 39.6 / 60)));
19+
});
20+
21+
test("parseLatLon — decimal degrees, signed and lettered", () => {
22+
assert.deepEqual(roundLL(parseLatLon("-32.4943, 60.931")), { lat: -32.4943, lng: 60.931 });
23+
assert.deepEqual(roundLL(parseLatLon("32.4943 S 60.931 E")), { lat: -32.4943, lng: 60.931 });
24+
assert.deepEqual(roundLL(parseLatLon("38.97 -76.47")), { lat: 38.97, lng: -76.47 });
25+
});
26+
27+
test("parseLatLon — degrees-minutes-seconds", () => {
28+
const r = parseLatLon("32 29 40 S 60 55 52 E");
29+
assert.ok(near(r.lat, -(32 + 29 / 60 + 40 / 3600)));
30+
assert.ok(near(r.lng, 60 + 55 / 60 + 52 / 3600));
31+
});
32+
33+
test("parseLatLon — round-trips through fmtLatLon", () => {
34+
for (const [lat, lng] of [[38.978, -76.478], [-32.4943, 60.931], [0, 0], [-33.86, 151.21]]) {
35+
const r = parseLatLon(fmtLatLon(lat, lng));
36+
assert.ok(r && near(r.lat, lat, 0.02) && near(r.lng, lng, 0.02), `${lat},${lng} -> ${JSON.stringify(r)}`);
37+
}
38+
});
39+
40+
test("parseLatLon — rejects non-coordinates and out-of-range", () => {
41+
for (const bad of ["", "spa creek", "US5MD1MC", "32", "hello world", "200, 60", "45, 999", "abc, def"]) {
42+
assert.equal(parseLatLon(bad), null, `should reject: ${JSON.stringify(bad)}`);
43+
}
44+
});
45+
46+
function roundLL(r) {
47+
return r && { lat: Math.round(r.lat * 1e4) / 1e4, lng: Math.round(r.lng * 1e4) / 1e4 };
48+
}

web/src/plugins/search-box.mjs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
// sb.doSearch(query); // input → render results
2020
// sb.gotoHit(i); // result-click / Enter → fly + close
2121

22-
import { esc } from "../lib/util.mjs";
22+
import { esc, parseLatLon, fmtLatLon } from "../lib/util.mjs";
2323

2424
export class SearchBox {
2525
constructor(opts) {
@@ -40,8 +40,12 @@ export class SearchBox {
4040
doSearch(q) {
4141
const el = this._getResultsEl();
4242
if (!el) return;
43-
const needle = q.trim().toLowerCase();
43+
const raw = (q || "").trim();
44+
const needle = raw.toLowerCase();
4445
if (needle.length < 2) { el.hidden = true; el.innerHTML = ""; this._hits = []; this.position(); return; }
46+
// 0) A typed coordinate (any common notation) → a "Go to" hit pinned on top,
47+
// so Enter jumps straight there. Other matches still list below.
48+
const coord = parseLatLon(raw);
4549
// 1) Catalog cells (chart titles / numbers), fuzzy-matched. Best score wins;
4650
// ties break to the coarser chart (overview before an arbitrary harbour inset).
4751
const cells = [];
@@ -53,11 +57,16 @@ export class SearchBox {
5357
cells.sort((a, b) => (b.score - a.score) || ((b.c.s || 0) - (a.c.s || 0)));
5458
// 2) Every loaded chart feature, fuzzy-matched across its attribute data.
5559
const feats = this._searchFeatures(needle);
56-
const hits = [...cells.slice(0, 5).map(({ c }) => ({ type: "cell", c })), ...feats.slice(0, 8)];
60+
const hits = [
61+
...(coord ? [{ type: "coord", lat: coord.lat, lng: coord.lng }] : []),
62+
...cells.slice(0, 5).map(({ c }) => ({ type: "cell", c })),
63+
...feats.slice(0, 8),
64+
];
5765
this._hits = hits;
5866
el.innerHTML = hits.length
5967
? hits.map((h, i) => {
6068
const sel = i === 0 ? " sel" : "";
69+
if (h.type === "coord") return `<div class="sr-item${sel}" data-i="${i}"><div class="t">${esc(fmtLatLon(h.lat, h.lng))}</div><div class="s">Go to coordinate</div></div>`;
6170
if (h.type === "cell") return `<div class="sr-item${sel}" data-i="${i}"><div class="t">${esc(h.c.l || h.c.n)}</div><div class="s">Chart · ${esc(h.c.n)} · 1:${(h.c.s || 0).toLocaleString()}</div></div>`;
6271
return `<div class="sr-item${sel}" data-i="${i}"><div class="t">${esc(h.label)}</div><div class="s">${esc(h.sub)}</div></div>`;
6372
}).join("")
@@ -103,7 +112,8 @@ export class SearchBox {
103112
const h = (this._hits || [])[i];
104113
const map = this._getMap();
105114
if (!h || !map) return;
106-
if (h.type === "feat") map.flyTo({ center: [h.lng, h.lat], zoom: Math.max(map.getZoom(), 14), duration: 800 });
115+
if (h.type === "coord") map.flyTo({ center: [h.lng, h.lat], zoom: Math.max(map.getZoom(), 13), duration: 800 });
116+
else if (h.type === "feat") map.flyTo({ center: [h.lng, h.lat], zoom: Math.max(map.getZoom(), 14), duration: 800 });
107117
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 }); }
108118
const el = this._getResultsEl(); if (el) el.hidden = true;
109119
// Keep the query (and selected highlight) so reopening search returns you to

0 commit comments

Comments
 (0)