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:
1 parent
c5b4a9ffb2
commit
dce5fd87da
8 files changed
+696
-3
No files matched your search
+20
-1
@@ -27,13 +27,32 @@ html, body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topbar__coords {
|
||||
.topbar__center {
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.topbar__coords,
|
||||
.topbar__sun {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.topbar__coords {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar__coords:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topbar__sun[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar__offline-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
+33
-1
@@ -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 /
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
Copyright (c) 2026, Volodymyr Agafonkin
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are
|
||||
permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of
|
||||
conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list
|
||||
of conditions and the following disclaimer in the documentation and/or other materials
|
||||
provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
|
||||
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
|
||||
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+533
@@ -0,0 +1,533 @@
|
||||
// Vendored from github.com/mourner/suncalc (BSD-2-Clause, see LICENSE in
|
||||
// this directory) — used for public/js/sun.js's sunrise/sunset readout.
|
||||
// Only getTimes (and its own helpers below) is actually used; the
|
||||
// moon-position/-phase/-times exports below it are unused dead weight here,
|
||||
// left in rather than hand-trimmed so this stays a straight, verifiable
|
||||
// copy of the upstream file.
|
||||
|
||||
// shortcuts for easier to read formulas
|
||||
const {PI, sin, cos, tan, asin, atan2: atan, acos, sqrt, abs, round} = Math;
|
||||
const rad = PI / 180;
|
||||
|
||||
// date/time constants and conversions
|
||||
|
||||
const dayMs = 1000 * 60 * 60 * 24;
|
||||
const J1970 = 2440588;
|
||||
const J2000 = 2451545;
|
||||
const earthRadius = 6378.14; // equatorial radius in km, for the Moon's topocentric parallax
|
||||
|
||||
function fromJulian(j) {
|
||||
return new Date((j + 0.5 - J1970) * dayMs);
|
||||
}
|
||||
function toDays(date) {
|
||||
return date.valueOf() / dayMs - 0.5 + J1970 - J2000;
|
||||
}
|
||||
|
||||
// ΔT = TT − UT in seconds (Espenak & Meeus polynomial fits, good ~1900–2150). The Meeus position
|
||||
// series are defined in Terrestrial Time, but SunCalc's input Dates are UT — so the position math
|
||||
// runs on days-since-J2000 shifted by deltaT, while sidereal time stays on UT. ~69 s today;
|
||||
// negligible for the Sun (<0.001°), real for the Moon. d only needs ~month accuracy here (ΔT
|
||||
// changes <1 s/yr), so the decimal year is derived arithmetically from d rather than from the Date.
|
||||
function deltaT(d) {
|
||||
const y = 2000 + d / 365.2425;
|
||||
let t;
|
||||
if (y < 1920) { t = y - 1900; return -2.79 + t * (1.494119 + t * (-0.0598939 + t * (0.0061966 - t * 0.000197))); }
|
||||
if (y < 1941) { t = y - 1920; return 21.20 + t * (0.84493 + t * (-0.076100 + t * 0.0020936)); }
|
||||
if (y < 1961) { t = y - 1950; return 29.07 + t * (0.407 + t * (-1 / 233 + t / 2547)); }
|
||||
if (y < 1986) { t = y - 1975; return 45.45 + t * (1.067 + t * (-1 / 260 - t / 718)); }
|
||||
if (y < 2005) { t = y - 2000; return 63.86 + t * (0.3345 + t * (-0.060374 + t * (0.0017275 + t * (0.000651814 + t * 0.00002373599)))); }
|
||||
if (y < 2050) { t = y - 2000; return 62.92 + t * (0.32217 + t * 0.005589); }
|
||||
t = (y - 1820) / 100; return -20 + 32 * t * t - 0.5628 * (2150 - y);
|
||||
}
|
||||
function toDaysTT(d) {
|
||||
return d + deltaT(d) / 86400;
|
||||
}
|
||||
|
||||
// general calculations for position
|
||||
|
||||
// north-based clockwise azimuth in degrees (0 = N, 90 = E, 180 = S, 270 = W)
|
||||
function azimuth(H, phi, dec) {
|
||||
return (atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)) / rad + 540) % 360;
|
||||
}
|
||||
function altitude(H, phi, dec) {
|
||||
return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H));
|
||||
}
|
||||
|
||||
// Greenwich mean sidereal time, formula 12.4 of Meeus (linear term; sub-arcsec T^2/T^3 dropped)
|
||||
function siderealTime(d, lw) {
|
||||
return rad * (280.46061837 + 360.98564736629 * d) - lw;
|
||||
}
|
||||
|
||||
function astroRefraction(h) {
|
||||
if (h < 0) h = 0; // formula valid for positive altitudes only
|
||||
|
||||
// Meeus 16.4: 1.02 / tan(h + 10.26 / (h + 5.10)), h in degrees, arcmin result — folded into rad
|
||||
return 0.0002967 / tan(h + 0.00312536 / (h + 0.08901179));
|
||||
}
|
||||
|
||||
// general sun calculations
|
||||
|
||||
// Sun's apparent equatorial coordinates, Meeus ch. 25. d = days since J2000 (TT); t = Julian centuries.
|
||||
function sunCoords(d) {
|
||||
const t = d / 36525;
|
||||
const L0 = rad * (280.46646 + t * (36000.76983 + t * 0.0003032)); // 25.2 geometric mean longitude
|
||||
const M = rad * (357.52911 + t * (35999.05029 - t * 0.0001537)); // 25.3 mean anomaly
|
||||
const sinM = sin(M);
|
||||
const cosM = cos(M);
|
||||
const C = rad * ((1.914602 - t * (0.004817 + t * 0.000014)) * sinM + // equation of center
|
||||
(0.019993 - 0.000101 * t) * 2 * sinM * cosM + 0.000289 * sinM * (3 - 4 * sinM * sinM));
|
||||
const Om = rad * (125.04 - 1934.136 * t); // longitude of the ascending node
|
||||
const L = L0 + C - rad * (0.00569 + 0.00478 * sin(Om)); // apparent longitude (nutation + aberration)
|
||||
// 22.2 mean obliquity + 25.8 correction for apparent position
|
||||
const e = rad * (23.439291 - t * (0.0130042 + t * (0.00000016 - t * 0.000000504))) + rad * 0.00256 * cos(Om);
|
||||
|
||||
return {
|
||||
ra: atan(cos(e) * sin(L), cos(L)), // 25.6
|
||||
dec: asin(sin(e) * sin(L)) // 25.7
|
||||
};
|
||||
}
|
||||
|
||||
// calculates sun position for a given date and latitude/longitude
|
||||
export function getPosition(date, lat, lng) {
|
||||
const lw = rad * -lng;
|
||||
const phi = rad * lat;
|
||||
const d = toDays(date);
|
||||
|
||||
const c = sunCoords(toDaysTT(d)); // position series run on Terrestrial Time
|
||||
const H = siderealTime(d, lw) - c.ra; // sidereal time stays on UT
|
||||
const h = altitude(H, phi, c.dec);
|
||||
|
||||
return {
|
||||
azimuth: azimuth(H, phi, c.dec),
|
||||
// apparent (refraction-corrected) altitude in degrees
|
||||
altitude: (h + astroRefraction(h)) / rad
|
||||
};
|
||||
}
|
||||
|
||||
// sun times configuration (angle, morning name, evening name)
|
||||
export const times = [
|
||||
[-0.833, 'sunrise', 'sunset'],
|
||||
[-0.3, 'sunriseEnd', 'sunsetStart'],
|
||||
[-6, 'dawn', 'dusk'],
|
||||
[-12, 'nauticalDawn', 'nauticalDusk'],
|
||||
[-18, 'nightEnd', 'night'],
|
||||
[6, 'goldenHourEnd', 'goldenHour']
|
||||
];
|
||||
|
||||
// adds a custom time to the times config
|
||||
export function addTime(angle, riseName, setName) {
|
||||
times.push([angle, riseName, setName]);
|
||||
}
|
||||
|
||||
// calculations for sun times — Meeus ch.15 (rising, transit, setting), solving the Sun's
|
||||
// local hour angle directly off the same apparent coordinates (sunCoords) and sidereal time
|
||||
// used by getPosition. Day offsets d are in UT days since J2000; the position series run on TT.
|
||||
|
||||
const J0 = 0.0009;
|
||||
|
||||
function observerAngle(height) {
|
||||
return -2.076 * sqrt(height) / 60;
|
||||
}
|
||||
|
||||
// wrap an angle to (-PI, PI]
|
||||
function wrapPi(a) {
|
||||
return a - 2 * PI * round(a / (2 * PI));
|
||||
}
|
||||
|
||||
// refines a transit time so the Sun's local hour angle is zero (Meeus 15.2; dH/dd ~= 2*PI/day,
|
||||
// the sidereal excess and the Sun's own motion cancelling to one solar day).
|
||||
function solarTransit(dt, lw) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const H = wrapPi(siderealTime(dt, lw) - sunCoords(toDaysTT(dt)).ra);
|
||||
dt -= H / (2 * PI);
|
||||
}
|
||||
return dt;
|
||||
}
|
||||
|
||||
// time the Sun reaches altitude h0 on the given side of transit (sign -1 = rise, +1 = set);
|
||||
// starts from the hour angle at transit and converges with Meeus' altitude correction (15.2).
|
||||
function getSetJ(h0, dt, sign, lw, phi, decT) {
|
||||
const cosH0 = (sin(h0) - sin(phi) * sin(decT)) / (cos(phi) * cos(decT));
|
||||
if (cosH0 < -1 || cosH0 > 1) return NaN; // sun stays above / below this altitude all day
|
||||
|
||||
let d = dt + sign * acos(cosH0) / (2 * PI);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const c = sunCoords(toDaysTT(d));
|
||||
const H = wrapPi(siderealTime(d, lw) - c.ra);
|
||||
const h = altitude(H, phi, c.dec);
|
||||
const sinH = cos(phi) * cos(c.dec) * sin(H);
|
||||
if (abs(sinH) < 1e-6) break; // grazing the horizon — correction is ill-conditioned
|
||||
d += (h - h0) / (2 * PI * sinH);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// calculates sun times for a given date, latitude/longitude, and, optionally,
|
||||
// the observer height (in meters) relative to the horizon
|
||||
|
||||
export function getTimes(date, lat, lng, height = 0) {
|
||||
|
||||
const lw = rad * -lng;
|
||||
const phi = rad * lat;
|
||||
const dh = observerAngle(height);
|
||||
// Anchor to the input date's UTC solar day regardless of its time-of-day, killing the historical
|
||||
// "always pass noon" footgun where an early-morning Date returned the previous day's events:
|
||||
// round to that day's noon, offset to the nearest local solar noon, then let solarTransit refine.
|
||||
const d = round(toDays(date) - J0 - lw / (2 * PI));
|
||||
const dt = solarTransit(d + J0 + lw / (2 * PI), lw);
|
||||
const dec = sunCoords(toDaysTT(dt)).dec; // declination at transit, shared by every rise/set solve
|
||||
|
||||
const result = {
|
||||
solarNoon: fromJulian(dt + J2000),
|
||||
nadir: fromJulian(dt + J2000 - 0.5)
|
||||
};
|
||||
|
||||
for (const [angle, riseName, setName] of times) {
|
||||
const h0 = (angle + dh) * rad;
|
||||
const jrise = getSetJ(h0, dt, -1, lw, phi, dec);
|
||||
const jset = getSetJ(h0, dt, 1, lw, phi, dec);
|
||||
|
||||
// a NaN means the Sun never reaches this altitude on this day — report null, not Invalid Date
|
||||
result[riseName] = Number.isNaN(jrise) ? null : fromJulian(jrise + J2000);
|
||||
result[setName] = Number.isNaN(jset) ? null : fromJulian(jset + J2000);
|
||||
}
|
||||
|
||||
// polar day/night: when the Sun never crosses the standard rise/set altitude, flag which side it
|
||||
// stays on by comparing its altitude at solar noon (its daily maximum) against that threshold.
|
||||
if (result.sunrise === null) {
|
||||
const noonAlt = altitude(0, phi, dec);
|
||||
const riseSetAlt = (times[0][0] + dh) * rad;
|
||||
result.alwaysUp = noonAlt > riseSetAlt;
|
||||
result.alwaysDown = noonAlt <= riseSetAlt;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// moon calculations, based on Meeus ch. 47 (truncated ELP-2000/82 series)
|
||||
|
||||
// Nutation in longitude (Δψ) and true obliquity of the ecliptic, both in degrees,
|
||||
// from the abridged series of Meeus ch. 22 (sub-arcsecond, ample for our needs).
|
||||
function nutationObliquity(t) {
|
||||
const om = rad * (125.04452 - 1934.136261 * t); // longitude of the Moon's ascending node
|
||||
const ls = rad * (280.4665 + 36000.7698 * t); // mean longitude of the Sun
|
||||
const lm = rad * (218.3165 + 481267.8813 * t); // mean longitude of the Moon
|
||||
const dpsi = (-17.20 * sin(om) - 1.32 * sin(2 * ls) - 0.23 * sin(2 * lm) + 0.21 * sin(2 * om)) / 3600;
|
||||
const deps = (9.20 * cos(om) + 0.57 * cos(2 * ls) + 0.10 * cos(2 * lm) - 0.09 * cos(2 * om)) / 3600;
|
||||
const eps0 = 23.439291 - t * (0.0130042 + t * (0.00000016 - t * 0.000000504)); // 22.2 mean obliquity
|
||||
return {dpsi, eps: rad * (eps0 + deps)};
|
||||
}
|
||||
|
||||
// Meeus table 47.A — periodic terms for the Moon's longitude (Σl, ×1e-6 deg) and
|
||||
// distance (Σr, ×1e-3 km). Flat rows of 6: D, M, M', F, Σl, Σr.
|
||||
const moonLon = new Int32Array([
|
||||
0, 0, 1, 0, 6288774, -20905355,
|
||||
2, 0, -1, 0, 1274027, -3699111,
|
||||
2, 0, 0, 0, 658314, -2955968,
|
||||
0, 0, 2, 0, 213618, -569925,
|
||||
0, 1, 0, 0, -185116, 48888,
|
||||
0, 0, 0, 2, -114332, -3149,
|
||||
2, 0, -2, 0, 58793, 246158,
|
||||
2, -1, -1, 0, 57066, -152138,
|
||||
2, 0, 1, 0, 53322, -170733,
|
||||
2, -1, 0, 0, 45758, -204586,
|
||||
0, 1, -1, 0, -40923, -129620,
|
||||
1, 0, 0, 0, -34720, 108743,
|
||||
0, 1, 1, 0, -30383, 104755,
|
||||
2, 0, 0, -2, 15327, 10321,
|
||||
0, 0, 1, 2, -12528, 0,
|
||||
0, 0, 1, -2, 10980, 79661,
|
||||
4, 0, -1, 0, 10675, -34782,
|
||||
0, 0, 3, 0, 10034, -23210,
|
||||
4, 0, -2, 0, 8548, -21636,
|
||||
2, 1, -1, 0, -7888, 24208,
|
||||
2, 1, 0, 0, -6766, 30824,
|
||||
1, 0, -1, 0, -5163, -8379,
|
||||
1, 1, 0, 0, 4987, -16675,
|
||||
2, -1, 1, 0, 4036, -12831,
|
||||
2, 0, 2, 0, 3994, -10445,
|
||||
4, 0, 0, 0, 3861, -11650,
|
||||
2, 0, -3, 0, 3665, 14403,
|
||||
0, 1, -2, 0, -2689, -7003,
|
||||
2, 0, -1, 2, -2602, 0,
|
||||
2, -1, -2, 0, 2390, 10056,
|
||||
1, 0, 1, 0, -2348, 6322,
|
||||
2, -2, 0, 0, 2236, -9884,
|
||||
0, 1, 2, 0, -2120, 5751,
|
||||
0, 2, 0, 0, -2069, 0,
|
||||
2, -2, -1, 0, 2048, -4950,
|
||||
2, 0, 1, -2, -1773, 4130,
|
||||
2, 0, 0, 2, -1595, 0,
|
||||
4, -1, -1, 0, 1215, -3958,
|
||||
0, 0, 2, 2, -1110, 0,
|
||||
3, 0, -1, 0, -892, 3258,
|
||||
2, 1, 1, 0, -810, 2616,
|
||||
4, -1, -2, 0, 759, -1897,
|
||||
0, 2, -1, 0, -713, -2117,
|
||||
2, 2, -1, 0, -700, 2354,
|
||||
2, 1, -2, 0, 691, 0,
|
||||
2, -1, 0, -2, 596, 0,
|
||||
4, 0, 1, 0, 549, -1423,
|
||||
0, 0, 4, 0, 537, -1117,
|
||||
4, -1, 0, 0, 520, -1571,
|
||||
1, 0, -2, 0, -487, -1739,
|
||||
2, 1, 0, -2, -399, 0,
|
||||
0, 0, 2, -2, -381, -4421,
|
||||
1, 1, 1, 0, 351, 0,
|
||||
3, 0, -2, 0, -340, 0,
|
||||
4, 0, -3, 0, 330, 0,
|
||||
2, -1, 2, 0, 327, 0,
|
||||
0, 2, 1, 0, -323, 1165,
|
||||
1, 1, -1, 0, 299, 0,
|
||||
2, 0, 3, 0, 294, 0,
|
||||
2, 0, -1, -2, 0, 8752
|
||||
]);
|
||||
|
||||
// Meeus table 47.B — periodic terms for the Moon's latitude (Σb, ×1e-6 deg).
|
||||
// Flat rows of 5: D, M, M', F, Σb.
|
||||
const moonLat = new Int32Array([
|
||||
0, 0, 0, 1, 5128122,
|
||||
0, 0, 1, 1, 280602,
|
||||
0, 0, 1, -1, 277693,
|
||||
2, 0, 0, -1, 173237,
|
||||
2, 0, -1, 1, 55413,
|
||||
2, 0, -1, -1, 46271,
|
||||
2, 0, 0, 1, 32573,
|
||||
0, 0, 2, 1, 17198,
|
||||
2, 0, 1, -1, 9266,
|
||||
0, 0, 2, -1, 8822,
|
||||
2, -1, 0, -1, 8216,
|
||||
2, 0, -2, -1, 4324,
|
||||
2, 0, 1, 1, 4200,
|
||||
2, 1, 0, -1, -3359,
|
||||
2, -1, -1, 1, 2463,
|
||||
2, -1, 0, 1, 2211,
|
||||
2, -1, -1, -1, 2065,
|
||||
0, 1, -1, -1, -1870,
|
||||
4, 0, -1, -1, 1828,
|
||||
0, 1, 0, 1, -1794,
|
||||
0, 0, 0, 3, -1749,
|
||||
0, 1, -1, 1, -1565,
|
||||
1, 0, 0, 1, -1491,
|
||||
0, 1, 1, 1, -1475,
|
||||
0, 1, 1, -1, -1410,
|
||||
0, 1, 0, -1, -1344,
|
||||
1, 0, 0, -1, -1335,
|
||||
0, 0, 3, 1, 1107,
|
||||
4, 0, 0, -1, 1021,
|
||||
4, 0, -1, 1, 833,
|
||||
0, 0, 1, -3, 777,
|
||||
4, 0, -2, 1, 671,
|
||||
2, 0, 0, -3, 607,
|
||||
2, 0, 2, -1, 596,
|
||||
2, -1, 1, -1, 491,
|
||||
2, 0, -2, 1, -451,
|
||||
0, 0, 3, -1, 439,
|
||||
2, 0, 2, 1, 422,
|
||||
2, 0, -3, -1, 421,
|
||||
2, 1, -1, 1, -366,
|
||||
2, 1, 0, 1, -351,
|
||||
4, 0, 0, 1, 331,
|
||||
2, -1, 1, 1, 315,
|
||||
2, -2, 0, -1, 302,
|
||||
0, 0, 1, 3, -283,
|
||||
2, 1, 1, -1, -229,
|
||||
1, 1, 0, -1, 223,
|
||||
1, 1, 0, 1, 223,
|
||||
0, 1, -2, -1, -220,
|
||||
2, 1, -1, -1, -220,
|
||||
1, 0, 1, 1, -185,
|
||||
2, -1, -2, -1, 181,
|
||||
0, 1, 2, 1, -177,
|
||||
4, 0, -2, -1, 176,
|
||||
4, -1, -1, -1, 166,
|
||||
1, 0, 1, -1, -164,
|
||||
4, 0, 1, -1, 132,
|
||||
1, 0, -1, -1, -119,
|
||||
4, -1, 0, -1, 115,
|
||||
2, -2, 0, 1, 107
|
||||
]);
|
||||
|
||||
// geocentric apparent equatorial coordinates of the Moon, Meeus ch. 47. d = days since J2000 (TT).
|
||||
function moonCoords(d) {
|
||||
const t = d / 36525;
|
||||
|
||||
// fundamental arguments (degrees), 47.1–47.6
|
||||
const Lp = 218.3164477 + t * (481267.88123421 + t * (-0.0015786 + t * (1 / 538841 - t / 65194000)));
|
||||
const D = 297.8501921 + t * (445267.1114034 + t * (-0.0018819 + t * (1 / 545868 - t / 113065000)));
|
||||
const M = 357.5291092 + t * (35999.0502909 + t * (-0.0001536 + t / 24490000));
|
||||
const Mp = 134.9633964 + t * (477198.8675055 + t * (0.0087414 + t * (1 / 69699 - t / 14712000)));
|
||||
const F = 93.2720950 + t * (483202.0175233 + t * (-0.0036539 + t * (-1 / 3526000 + t / 863310000)));
|
||||
const A1 = 119.75 + 131.849 * t;
|
||||
const A2 = 53.09 + 479264.290 * t;
|
||||
const A3 = 313.45 + 481266.484 * t;
|
||||
const E = 1 - t * (0.002516 + t * 0.0000074); // eccentricity factor for solar-anomaly terms
|
||||
|
||||
const Dr = rad * D, Mr = rad * M, Mpr = rad * Mp, Fr = rad * F;
|
||||
let sl = 0, sr = 0, sb = 0;
|
||||
for (let i = 0; i < moonLon.length; i += 6) {
|
||||
const m = moonLon[i + 1];
|
||||
const arg = moonLon[i] * Dr + m * Mr + moonLon[i + 2] * Mpr + moonLon[i + 3] * Fr;
|
||||
const f = m === 1 || m === -1 ? E : m === 2 || m === -2 ? E * E : 1;
|
||||
sl += moonLon[i + 4] * f * sin(arg);
|
||||
sr += moonLon[i + 5] * f * cos(arg);
|
||||
}
|
||||
for (let i = 0; i < moonLat.length; i += 5) {
|
||||
const m = moonLat[i + 1];
|
||||
const arg = moonLat[i] * Dr + m * Mr + moonLat[i + 2] * Mpr + moonLat[i + 3] * Fr;
|
||||
const f = m === 1 || m === -1 ? E : m === 2 || m === -2 ? E * E : 1;
|
||||
sb += moonLat[i + 4] * f * sin(arg);
|
||||
}
|
||||
|
||||
// additive terms (Venus, Jupiter and flattening of the Earth), 47 p.342
|
||||
const A1r = rad * A1, Lpr = rad * Lp;
|
||||
sl += 3958 * sin(A1r) + 1962 * sin(Lpr - Fr) + 318 * sin(rad * A2);
|
||||
sb += -2235 * sin(Lpr) + 382 * sin(rad * A3) + 175 * sin(A1r - Fr) + 175 * sin(A1r + Fr) +
|
||||
127 * sin(Lpr - Mpr) - 115 * sin(Lpr + Mpr);
|
||||
|
||||
const {dpsi, eps} = nutationObliquity(t);
|
||||
const l = rad * (Lp + sl / 1e6 + dpsi); // apparent ecliptic longitude
|
||||
const b = rad * (sb / 1e6); // ecliptic latitude
|
||||
|
||||
return {
|
||||
ra: atan(sin(l) * cos(eps) - tan(b) * sin(eps), cos(l)), // 13.3
|
||||
dec: asin(sin(b) * cos(eps) + cos(b) * sin(eps) * sin(l)), // 13.4
|
||||
dist: 385000.56 + sr / 1000 // distance to the Moon in km
|
||||
};
|
||||
}
|
||||
|
||||
export function getMoonPosition(date, lat, lng) {
|
||||
const lw = rad * -lng;
|
||||
const phi = rad * lat;
|
||||
const d = toDays(date);
|
||||
const c = moonCoords(toDaysTT(d)); // position series run on Terrestrial Time
|
||||
const H = siderealTime(d, lw) - c.ra; // sidereal time stays on UT
|
||||
|
||||
// geocentric parallax (Meeus ch.40) lowers the moon along its vertical circle, leaving
|
||||
// azimuth unchanged; sin(parallax) = (Earth radius / distance) * cos(geocentric altitude).
|
||||
const hGeo = altitude(H, phi, c.dec);
|
||||
const h = hGeo - asin(earthRadius / c.dist * cos(hGeo));
|
||||
|
||||
// parallactic angle, Meeus 14.1
|
||||
const pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H));
|
||||
|
||||
return {
|
||||
azimuth: azimuth(H, phi, c.dec),
|
||||
// apparent (refraction-corrected) altitude in degrees
|
||||
altitude: (h + astroRefraction(h)) / rad,
|
||||
distance: c.dist,
|
||||
parallacticAngle: pa / rad
|
||||
};
|
||||
}
|
||||
|
||||
// moon illumination parameters, Meeus ch. 48 (and idlastro's mphase.pro)
|
||||
export function getMoonIllumination(date = new Date()) {
|
||||
const d = toDaysTT(toDays(date));
|
||||
const s = sunCoords(d);
|
||||
const m = moonCoords(d);
|
||||
const sdist = 149598000; // distance from Earth to Sun in km
|
||||
const phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra));
|
||||
const inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi));
|
||||
const angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) -
|
||||
cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra));
|
||||
|
||||
const waxing = angle < 0; // bright limb leads → illuminated fraction is growing (new → full)
|
||||
|
||||
return {
|
||||
// illuminated fraction, 0 (new) → 1 (full); reaches the exact extrema only at perfect
|
||||
// syzygy (eclipses), so a "full" moon typically peaks a hair under 1 — this is correct
|
||||
fraction: (1 + cos(inc)) / 2,
|
||||
// phase, 0 → 1: 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter (waxing for phase < 0.5)
|
||||
phase: 0.5 + 0.5 * inc * (waxing ? -1 : 1) / PI,
|
||||
// position angle of the bright limb, degrees (v2: all angles emitted in degrees) — subtract
|
||||
// getMoonPosition().parallacticAngle, also degrees, to get the zenith-relative tilt
|
||||
angle: angle / rad,
|
||||
waxing // true while the Moon is waxing (new → full), false while waning (full → new)
|
||||
};
|
||||
}
|
||||
|
||||
function hoursLater(date, h) {
|
||||
return new Date(date.valueOf() + h * dayMs / 24);
|
||||
}
|
||||
|
||||
// height of the moon's upper limb above the rise/set horizon (deg): topocentric centre altitude plus
|
||||
// the moon's semidiameter (0.2725 * equatorial horizontal parallax, so it tracks distance: ~0.25° at
|
||||
// apogee, ~0.28° at perigee) plus the residual horizon refraction our model under-bends (~0.09°,
|
||||
// tuned vs USNO). Crossing zero == upper-limb rise/set, the USNO convention.
|
||||
function moonHeight(date, lat, lng) {
|
||||
const p = getMoonPosition(date, lat, lng);
|
||||
return p.altitude + 0.2725 * asin(earthRadius / p.distance) / rad + 0.09;
|
||||
}
|
||||
|
||||
// polish a crossing time (ms): the quadratic sampler's parabola root sits up to ~0.2° off the true
|
||||
// altitude curve, so Newton-refine against the real moonHeight. Two central-difference steps drop the
|
||||
// mean error from ~0.65 to ~0.28 min; more don't help (near grazing dh/dt is small either way).
|
||||
function refineMoonCross(tMs, lat, lng) {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const h = moonHeight(new Date(tMs), lat, lng);
|
||||
const dh = (moonHeight(new Date(tMs + 30000), lat, lng) - moonHeight(new Date(tMs - 30000), lat, lng)) / 60000;
|
||||
tMs -= h / dh; // dh is degrees per ms, so h/dh is ms
|
||||
}
|
||||
return tMs;
|
||||
}
|
||||
|
||||
export function getMoonTimes(date, lat, lng) {
|
||||
// scan the UTC calendar day of the given date; the date is treated as a UTC instant like
|
||||
// everywhere else in the API. For a local-civil-day window, pass a date at local midnight.
|
||||
const t = new Date(date);
|
||||
t.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
let h0 = moonHeight(t, lat, lng);
|
||||
let rise, set, hMax = h0;
|
||||
|
||||
// go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
|
||||
for (let i = 1; i <= 24; i += 2) {
|
||||
const h1 = moonHeight(hoursLater(t, i), lat, lng);
|
||||
const h2 = moonHeight(hoursLater(t, i + 1), lat, lng);
|
||||
hMax = Math.max(hMax, h1, h2);
|
||||
const a = (h0 + h2) / 2 - h1;
|
||||
const b = (h2 - h0) / 2;
|
||||
const xe = -b / (2 * a);
|
||||
const d = b * b - 4 * a * h1;
|
||||
let roots = 0, x1 = 0, x2 = 0;
|
||||
const ye = (a * xe + b) * xe + h1;
|
||||
|
||||
if (d >= 0) {
|
||||
const dx = sqrt(d) / (abs(a) * 2);
|
||||
x1 = xe - dx;
|
||||
x2 = xe + dx;
|
||||
if (abs(x1) <= 1) roots++;
|
||||
if (abs(x2) <= 1) roots++;
|
||||
if (x1 < -1) x1 = x2;
|
||||
}
|
||||
|
||||
if (roots === 1) {
|
||||
if (h0 < 0) rise = i + x1;
|
||||
else set = i + x1;
|
||||
|
||||
} else if (roots === 2) {
|
||||
rise = i + (ye < 0 ? x2 : x1);
|
||||
set = i + (ye < 0 ? x1 : x2);
|
||||
}
|
||||
|
||||
if (rise !== undefined && set !== undefined) break;
|
||||
|
||||
h0 = h2;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
// compare against undefined, not truthiness: a crossing at exactly hour 0 (midnight) is a real
|
||||
// event whose numeric value is 0, which a truthy check would wrongly treat as "no event".
|
||||
if (rise !== undefined) result.rise = new Date(refineMoonCross(hoursLater(t, rise).valueOf(), lat, lng));
|
||||
if (set !== undefined) result.set = new Date(refineMoonCross(hoursLater(t, set).valueOf(), lat, lng));
|
||||
|
||||
// no crossing all day: flag which side the moon stays on by testing its highest sampled height
|
||||
// against the rise/set threshold (already baked into moonHeight), setting both like getTimes does
|
||||
if (rise === undefined && set === undefined) {
|
||||
result.alwaysUp = hMax > 0;
|
||||
result.alwaysDown = hMax <= 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -18,6 +18,22 @@ class AppTest < Minitest::Test
|
||||
assert_includes response.body, "livetrack-panel"
|
||||
end
|
||||
|
||||
# Every public/js/*.js module map.js (or something it imports) can reach
|
||||
# needs its own versioned importmap entry (views/layout.erb) or it skips
|
||||
# ASSET_VERSION's whole cache-busting story — easy to forget when adding a
|
||||
# new module, since the app still works fine locally either way (a plain
|
||||
# unmapped import just resolves straight to the unversioned file).
|
||||
# map.js itself is excluded: it's the one entry point loaded directly by
|
||||
# its own <script> tag, not imported by anything, so it has no importmap
|
||||
# entry of its own by design.
|
||||
def test_every_own_js_module_has_a_versioned_importmap_entry
|
||||
response = request.get("/")
|
||||
modules = Dir.glob(File.join(__dir__, "..", "public", "js", "*.js")).map { |path| File.basename(path) } - ["map.js"]
|
||||
modules.each do |name|
|
||||
assert_match(%r{"/js/#{Regexp.escape(name)}":\s*"/js/#{Regexp.escape(name)}\?v=\d+"}, response.body, "#{name} is missing from the importmap")
|
||||
end
|
||||
end
|
||||
|
||||
def test_offline_tiles_reports_unavailable_when_no_pmtiles_present
|
||||
response = request.get("/api/offline-tiles")
|
||||
assert_equal({ "available" => false }, json_body(response))
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<span class="topbar__brand" title="rev <%= revision %>">MapTool</span>
|
||||
<span class="topbar__coords"></span>
|
||||
<span class="topbar__center">
|
||||
<span class="topbar__coords"></span>
|
||||
<span class="topbar__sun" hidden></span>
|
||||
</span>
|
||||
<span class="topbar__offline-status" hidden></span>
|
||||
<div class="topbar__auth">
|
||||
<% if logged_in %>
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/measure.js": "/js/measure.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/notes.js": "/js/notes.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/overlays.js": "/js/overlays.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/sun.js": "/js/sun.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user