Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/chartplotter.view.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ export const CHROME = `
<label>Go to scale&nbsp;1:<input id="scale-input" type="text" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="40000"></label>
<button id="scale-go" type="button">Go</button>
</div>
<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>
<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>
<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>
<!-- The NOAA ENC User Agreement modal moved into <chart-library> (it owns the
download flow); the "Terms" link reaches into it. -->
Expand Down
62 changes: 62 additions & 0 deletions web/src/lib/util.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<origin>/#share or
// ?share) — boot() then reconstructs the publisher's scene from /api/share.
export function isShareUrl() {
Expand Down
48 changes: 48 additions & 0 deletions web/src/lib/util.test.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
18 changes: 14 additions & 4 deletions web/src/plugins/search-box.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 = [];
Expand All @@ -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 `<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>`;
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>`;
return `<div class="sr-item${sel}" data-i="${i}"><div class="t">${esc(h.label)}</div><div class="s">${esc(h.sub)}</div></div>`;
}).join("")
Expand Down Expand Up @@ -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
Expand Down