Add a Locus Pro-style toolbar readout: DMS coords + sunrise/sunset

The topbar's coordinate display was plain decimal degrees ("48.14611,
17.17722") — switched to DMS (48°08'46"N 17°10'38"E), the more
GPS-unit-like format Locus Pro itself uses, and made it click-to-copy
(plain decimal, since that's what pastes cleanly into another map/GPS
app — not the DMS text actually shown).

Added sunrise/sunset + a countdown to whichever's next, for the same
map-center point the coordinates already describe. Vendored SunCalc
(BSD-2-Clause, github.com/mourner/suncalc) rather than hand-rolling
the astronomical math — small, dependency-free, and correctness here
has real pitfalls (equation of time, refraction, polar day/night) a
first-pass reimplementation would risk getting subtly wrong.

Also added a test asserting every public/js/*.js module has a
versioned importmap entry in layout.erb — caught that overlays.js
(from the previous commit) was missing one, which would have quietly
skipped ASSET_VERSION's cache-busting for that file after every
deploy.
This commit is contained in:
randy committed 2026-09-10 21:08:02 +02:00
1 parent c5b4a9ffb2
commit dce5fd87da
8 files changed
+696 -3

No files matched your search

+33 -1
View File
@@ -15,6 +15,7 @@ import { NotesControl } from "/js/notes.js";
import { PluginsControl } from "/js/plugins.js";
import { LocateControl } from "/js/locate.js";
import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js";
import { formatCoords, formatSunInfo } from "/js/sun.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { isLoggedIn } from "/js/auth.js";
import HomeButton from "/vendor/npmap-home-button/home-button.esm.js";
@@ -540,11 +541,42 @@ map.once("style.load", () => {
const coordsLabel = document.querySelector(".topbar__coords");
const updateCoords = () => {
const { lng, lat } = map.getCenter();
coordsLabel.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
coordsLabel.textContent = formatCoords(lat, lng);
};
updateCoords();
map.on("move", updateCoords);
// Plain decimal degrees, not the DMS text actually shown — that's what
// pastes cleanly into another map/GPS app, which is the whole point of
// copying a coordinate in the first place.
coordsLabel.title = "Click to copy coordinates";
coordsLabel.addEventListener("click", async () => {
const { lng, lat } = map.getCenter();
try {
await navigator.clipboard.writeText(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
} catch {
return; // clipboard API unavailable (e.g. an insecure context) — nothing to fall back to
}
coordsLabel.textContent = "Copied!";
setTimeout(updateCoords, 1000);
});
// Sunrise/sunset for the same center point the coords above already show —
// on moveend (not every "move" frame, unlike updateCoords: the sun position
// barely moves between two nearby spots, not worth recomputing constantly)
// plus a periodic tick so the "Xh Ym to sunset" countdown keeps counting
// down even while the map sits still.
const sunLabel = document.querySelector(".topbar__sun");
const updateSun = () => {
const { lng, lat } = map.getCenter();
const info = formatSunInfo(lat, lng);
sunLabel.textContent = info ?? "";
sunLabel.hidden = info === null;
};
updateSun();
map.on("moveend", updateSun);
setInterval(updateSun, 60000);
const sidebar = document.getElementById("sidebar");
// Grouped into the TODO's planned 4-module menu (Explorer / Offline hub /
+66
View File
@@ -0,0 +1,66 @@
import { getTimes } from "/vendor/suncalc/suncalc.js";
// A Locus Pro-style toolbar readout: DMS coordinates (plain decimal degrees
// read less like a "GPS unit" and are harder to scan at a glance) plus
// sunrise/sunset and a countdown to whichever comes next — both computed
// for the map's current center, the same point the coordinates already
// describe, not a GNSS fix (this app's coordinate readout was never tied
// to having one).
function toDMS(value, positiveLabel, negativeLabel) {
const label = value >= 0 ? positiveLabel : negativeLabel;
const abs = Math.abs(value);
const degrees = Math.floor(abs);
const minutesFull = (abs - degrees) * 60;
let minutes = Math.floor(minutesFull);
let seconds = Math.round((minutesFull - minutes) * 60);
// A seconds value that rounds up to 60 has to carry into minutes (and
// from there, in the rare case, into degrees) rather than print "60".
if (seconds === 60) {
seconds = 0;
minutes += 1;
}
const carriedDegrees = minutes === 60 ? degrees + 1 : degrees;
const carriedMinutes = minutes === 60 ? 0 : minutes;
return `${carriedDegrees}°${String(carriedMinutes).padStart(2, "0")}'${String(seconds).padStart(2, "0")}"${label}`;
}
export function formatCoords(lat, lng) {
return `${toDMS(lat, "N", "S")} ${toDMS(lng, "E", "W")}`;
}
function formatTime(date) {
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function formatCountdown(ms) {
const totalMinutes = Math.max(0, Math.round(ms / 60000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
const DAY_MS = 24 * 60 * 60 * 1000;
// null when there's no sunrise/sunset to show at all — getTimes returns
// null (not Invalid Date) for both on a polar day/night, per its own
// comment in suncalc.js — the caller just hides the readout in that case
// rather than showing something misleading.
export function formatSunInfo(lat, lng, now = new Date()) {
const today = getTimes(now, lat, lng);
if (today.sunrise === null || today.sunset === null) return null;
let next;
if (now < today.sunrise) {
next = { label: "sunrise", at: today.sunrise };
} else if (now < today.sunset) {
next = { label: "sunset", at: today.sunset };
} else {
// Already past today's sunset — the next event is tomorrow's sunrise.
const tomorrow = getTimes(new Date(now.getTime() + DAY_MS), lat, lng);
next = tomorrow.sunrise === null ? null : { label: "sunrise", at: tomorrow.sunrise };
}
const countdown = next ? ` · ${formatCountdown(next.at - now)} to ${next.label}` : "";
return `${formatTime(today.sunrise)}–${formatTime(today.sunset)}${countdown}`;
}