Compare commits

..
6 Commits
Author SHA1 Message Date
randy 254e9b4c61 Add a scale bar to the map
CI / deploy (push) Successful in 12s
CI / check (push) Successful in 53s
MapLibre's own ScaleControl, bottom-left (attribution already owns
bottom-right) — no custom NPS-style bar needed, the built-in one covers it.
2026-09-10 22:14:40 +02:00
randy c9561a5489 Add an offline terrain shadow overlay to the Overlays panel
Shades the visible map by whether each point is currently blocked from
the sun by surrounding terrain (shademap.app-style), computed fully
offline via the same Terrarium DEM tiles the Terrain shading layer
already renders, plus this session's sun-position math and Radar's
checkbox+expandable UI shape.

dem.js pulls elevation-at-a-point out on its own since TODO.adoc's
slope readout and solar calculator will need the same lookup.
2026-09-10 21:43:51 +02:00
randy a8d58abaca Move Radar and Terrain shading into the Overlays panel
Both used to be their own separate sidebar icons; neither has
anything to manage beyond on/off, same as Wikipedia already got its
own Overlays entry for. Terrain shading is a plain checkbox item now.
Radar keeps its scrub/forecast controls, but behind an explicit
expand (clicking its name), separate from the checkbox — checking it
on shouldn't force the details open, and expanding to scrub/check the
forecast shouldn't also flip the layer on.

Both controls now build their own <li> (RadarControl.mount(li),
HillshadeControl.mount(li) — the latter moved into overlays.js
entirely, it has no reason to live in map.js once it's not building
its own sidebar icon) rather than a whole sidebar-item + flyout the
way they used to.

Also gave each Overlays item a leading icon: Wikipedia and Terrain
shading reuse existing NPS pictograms (WIKI_ICON, and WILDERNESS_ICON
newly exported from icons.js's forest entry — NPS has no dedicated
mountain/terrain glyph, wilderness-22.svg is the closest honest
match). Radar's is hand-drawn (a rain cloud) since NPS's set has
nothing weather-related to reuse.
2026-09-10 21:23:51 +02:00
randy dce5fd87da 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.
2026-09-10 21:08:02 +02:00
randy c5b4a9ffb2 Move the Wikipedia layer into its own "Overlays" panel
Wikipedia doesn't belong inside "Tracks & Points" — that panel is for
files you add/remove/reorder, while Wikipedia is a live layer with
nothing to manage, just on/off. Gave it its own sidebar panel
(#overlays-panel, same shell as LiveTrackPanelControl) instead, sized
to hold more such checklist-only layers later without a redesign.

Moved all the wiki layer logic out of GpxPanelControl into a new
OverlaysControl (public/js/overlays.js). svgToImageData/badgeImageId/
escapeHtml are now exported from gpx.js so it doesn't have to
reimplement them — the same three helpers a second overlay would also
need.
2026-09-10 20:54:40 +02:00
randy 787df5478d Replace the per-popup "Nearby on Wikipedia" link with a live Wikipedia layer
The old link only ever surfaced one nearby article at a time, buried
in whatever POI's popup you happened to open, and only for uploaded
files with a category. Replaced it with a standalone "Wikipedia"
toggle under the renamed "Tracks & Points" panel: enabling it adds a
live map layer that refetches GET /api/kiwix/nearby on every pan/zoom
and plots every nearby article as its own point.

kiwix-serve's own geo search never echoes back a result's coordinates
(confirmed live against the real production ZIM, both its HTML and
?format=xml output) — it only filters by distance from a point. So
Maptool::Kiwix.nearby fetches each matching article's own page too and
reads its <meta name="geo.position"> tag, which every geotagged
Wikipedia article carries. Tried parallelizing those per-article
fetches with threads first; measured no improvement against the real
ZIM (kiwix-serve appears to serialize requests regardless), so it
stayed sequential — ~1.6s for a 30-result page, fine for a
refetch-on-pan-end layer.

Clicking a point opens a small popup linking to the article (proxied
through the existing /kiwix/* passthrough). New WIKI_ICON (NPS's
bookstore-22.svg) marks these, kept out of POI_ICONS since that set
doubles as notes.js's icon-picker option list and this fixed-glyph
layer has no per-feature category to key off of.
2026-09-10 20:45:41 +02:00
18 changed files with 1716 additions and 172 deletions

No files matched your search

+2 -1
View File
@@ -16,7 +16,8 @@ independence from connectivity.
* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed * [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed
** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story ** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
** [x] A private plugin's own dependencies now have somewhere to live too: `data/plugins/` is under the persistent `data/` bind mount (survives a redeploy, unlike `build/`), and `docs/deployment.adoc` documents the convention ** [x] A private plugin's own dependencies now have somewhere to live too: `data/plugins/` is under the persistent `data/` bind mount (survives a redeploy, unlike `build/`), and `docs/deployment.adoc` documents the convention
* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link to kiwix-serve's own geo search (`GET /kiwix/search?latitude=&longitude=&distance=`), proxied through this app (`GET /kiwix/*` in app.rb) rather than a direct link to kiwix-serve — production sits behind a reverse proxy that only forwards to this app, so no address exists for a browser to reach kiwix-serve at directly; kiwix-serve runs with `--urlRootLocation=/kiwix` so its own emitted links already match. `KIWIX_URL` + `GET /api/kiwix` reachability check, same optional-integration pattern as offline.pmtiles/terrain.pmtiles; `docker-compose.yaml` now runs a `kiwix` service, ZIM not included (see docs/development.adoc for where to get one) * [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — proxied through this app (`GET /kiwix/*` in app.rb) rather than a direct link to kiwix-serve — production sits behind a reverse proxy that only forwards to this app, so no address exists for a browser to reach kiwix-serve at directly; kiwix-serve runs with `--urlRootLocation=/kiwix` so its own emitted links already match. `KIWIX_URL` + `GET /api/kiwix` reachability check, same optional-integration pattern as offline.pmtiles/terrain.pmtiles; `docker-compose.yaml` now runs a `kiwix` service, ZIM not included (see docs/development.adoc for where to get one)
** [x] Replaced the old per-popup "Nearby on Wikipedia" link (one POI at a time, easy to miss) with a standalone "Wikipedia" layer under Tracks & Points — a live `GET /api/kiwix/nearby` refetch on every pan/zoom, not a saved file. kiwix-serve's own geo search never echoes back a result's coordinates, so `Maptool::Kiwix.nearby` fetches each matching article's own page too and reads its `<meta name="geo.position">` tag — verified against the real production ZIM (~1.6s for a 30-result page; tried parallelizing those fetches with threads, no measured gain, kept sequential)
* [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline
** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb ** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb
+13
View File
@@ -238,6 +238,19 @@ module Maptool
json available: reachable, url: (reachable ? "/kiwix" : "") json available: reachable, url: (reachable ? "/kiwix" : "")
end end
# Backs the "Wikipedia" map layer (public/js/gpx.js) — replaces the old
# per-popup "Nearby on Wikipedia" link, which only ever pointed at one
# POI at a time. Read-only and login-free, same as the tree/plugin
# listing routes below: viewing the map needs no account, only writing
# to it does.
get "/api/kiwix/nearby" do
halt 404 if KIWIX_URL.empty?
bbox = %w[south west north east].map { |key| params[key].to_s }
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
json Kiwix.nearby(KIWIX_URL, bbox.map(&:to_f))
end
# Proxies kiwix-serve's own search UI and content pages through this # Proxies kiwix-serve's own search UI and content pages through this
# app instead of linking the browser to it directly (see KIWIX_URL) — # app instead of linking the browser to it directly (see KIWIX_URL) —
# kiwix-serve is started with --urlRootLocation=/kiwix # kiwix-serve is started with --urlRootLocation=/kiwix
+85
View File
@@ -1,4 +1,5 @@
require "net/http" require "net/http"
require "rexml/document"
module Maptool module Maptool
# A reachability check plus a passthrough proxy for an external # A reachability check plus a passthrough proxy for an external
@@ -10,6 +11,13 @@ module Maptool
# *this* app, leaving no address a browser could reach kiwix-serve at # *this* app, leaving no address a browser could reach kiwix-serve at
# directly. GET /kiwix/* in app.rb calls .proxy below instead. # directly. GET /kiwix/* in app.rb calls .proxy below instead.
module Kiwix module Kiwix
# <meta name="geo.position" content="LAT;LON"> — a standard tag
# Wikipedia's own MediaWiki geo extension puts on every geotagged
# article's page (confirmed live against a real ZIM), semicolon rather
# than comma-separated. Far simpler to match than the human-facing
# "48°08′46″N 17°10′38″E / 48.14611°N 17.17722°E" markup the article
# body itself renders (DMS *and* decimal, in varying wrapper markup).
GEO_POSITION_PATTERN = /<meta name="geo\.position" content="(-?[\d.]+);(-?[\d.]+)"/
def self.reachable?(url, open_timeout: 1, read_timeout: 2) def self.reachable?(url, open_timeout: 1, read_timeout: 2)
return false if url.to_s.strip.empty? return false if url.to_s.strip.empty?
@@ -41,5 +49,82 @@ module Maptool
rescue StandardError rescue StandardError
nil nil
end end
# bbox: [south, west, north, east] — the "Wikipedia" map layer
# (public/js/gpx.js) refetches this on every pan/zoom for whatever's
# currently on screen. kiwix-serve's own geo search only *filters* by
# distance from a point, it doesn't echo each result's own coordinates
# back (confirmed live: neither its HTML nor its ?format=xml output
# carries one) — so every matching article's page is fetched too, and
# its coordinates read from GEO_POSITION_PATTERN above. Cheap since
# kiwix-serve serves its ZIM locally with no network round trip, unlike
# this same fetch-every-result shape would be against live Wikipedia.
def self.nearby(base_url, bbox, limit: 30, open_timeout: 2, read_timeout: 10)
south, west, north, east = bbox
lat = (south + north) / 2.0
lng = (west + east) / 2.0
radius_m = (haversine_km(lat, lng, north, east) * 1000).round
query = URI.encode_www_form(pattern: "", latitude: lat, longitude: lng, distance: radius_m, pageLength: limit, format: "xml")
response = proxy(base_url, "/kiwix/search", query, open_timeout: open_timeout, read_timeout: read_timeout)
return empty_collection unless response&.code == "200"
# One request per result — tried running these in parallel threads
# first (each is blocking I/O, so MRI's GIL releases during the
# actual wait), but measured no improvement against the real
# production ZIM (kiwix-serve itself appears to serialize incoming
# requests regardless), so plain sequential stayed. ~1.6s for a
# 30-result page on that box — fine for a refetch-on-pan-end layer,
# not for anything tighter; lower `limit` first if that's ever not
# enough headroom, before reaching for something more involved.
features = parse_search_results(response.body).filter_map { |result| point_feature(base_url, result, open_timeout, read_timeout) }
{ "type" => "FeatureCollection", "features" => features }
end
def self.empty_collection
{ "type" => "FeatureCollection", "features" => [] }
end
private_class_method :empty_collection
def self.parse_search_results(xml)
doc = REXML::Document.new(xml)
(doc.root&.get_elements("channel/item") || []).filter_map do |item|
title = item.elements["title"]&.text
link = item.elements["link"]&.text
{ title: title, link: link } if title && link
end
rescue REXML::ParseException
[]
end
private_class_method :parse_search_results
# result[:link] arrives already percent-encoded, straight out of
# kiwix-serve's own XML (e.g. "/kiwix/content/wikipedia/%C5%A0tadi%C3%B3n_Rapid")
# — decoded first so .proxy's own escaping (built for Sinatra's
# already-*decoded* splat, see its own comment above) doesn't double-
# encode the "%" and 404 the request.
def self.point_feature(base_url, result, open_timeout, read_timeout)
response = proxy(base_url, URI::RFC2396_PARSER.unescape(result[:link]), nil, open_timeout: open_timeout, read_timeout: read_timeout)
return nil unless response&.code == "200"
match = response.body.match(GEO_POSITION_PATTERN)
return nil unless match
{
"type" => "Feature",
"properties" => { "name" => result[:title], "link" => result[:link] },
"geometry" => { "type" => "Point", "coordinates" => [match[2].to_f, match[1].to_f] },
}
end
private_class_method :point_feature
def self.haversine_km(lat1, lng1, lat2, lng2)
earth_radius_km = 6371.0
dlat = (lat2 - lat1) * Math::PI / 180
dlng = (lng2 - lng1) * Math::PI / 180
a = (Math.sin(dlat / 2)**2) + (Math.cos(lat1 * Math::PI / 180) * Math.cos(lat2 * Math::PI / 180) * (Math.sin(dlng / 2)**2))
2 * earth_radius_km * Math.asin(Math.sqrt(a))
end
private_class_method :haversine_km
end end
end end
+103 -1
View File
@@ -27,13 +27,32 @@ html, body {
color: #fff; color: #fff;
} }
.topbar__coords { .topbar__center {
margin: 0 auto; margin: 0 auto;
display: flex;
align-items: center;
gap: 14px;
}
.topbar__coords,
.topbar__sun {
font-size: 12px; font-size: 12px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: #bbb; color: #bbb;
} }
.topbar__coords {
cursor: pointer;
}
.topbar__coords:hover {
color: #fff;
}
.topbar__sun[hidden] {
display: none;
}
.topbar__offline-status { .topbar__offline-status {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -223,6 +242,27 @@ html, body {
font-size: 11px; font-size: 11px;
} }
.shadow-control__time {
display: flex;
align-items: center;
gap: 6px;
}
.shadow-control__slider {
width: 110px;
}
.shadow-control__clock {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.shadow-control__hint {
margin: 6px 0 0;
color: #888;
font-size: 11px;
}
.basemap-panel { .basemap-panel {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -1174,6 +1214,68 @@ html, body {
box-shadow: 0 0 0 2px rgba(42, 157, 143, 0.4); box-shadow: 0 0 0 2px rgba(42, 157, 143, 0.4);
} }
.overlays-panel__list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 8px 12px;
}
.overlays-panel__item label {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
border-radius: 4px;
cursor: pointer;
}
.overlays-panel__item label:hover {
background: #f0f0f0;
}
.overlays-panel__icon {
display: flex;
width: 16px;
height: 16px;
flex-shrink: 0;
color: #555;
}
.overlays-panel__icon svg {
width: 100%;
height: 100%;
}
.overlays-panel__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
}
.overlays-panel__name {
border: none;
background: none;
padding: 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.overlays-panel__name:hover {
text-decoration: underline;
}
.overlays-panel__details {
margin: 0 4px 6px 26px;
padding-top: 6px;
border-top: 1px solid #eee;
font-size: 12px;
}
.livetrack-panel__list { .livetrack-panel__list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+72
View File
@@ -0,0 +1,72 @@
// Elevation lookups against the same Terrarium-encoded terrain tiles
// overlays.js's HillshadeControl already renders — pulled into its own file
// since TODO.adoc lists two more near-future features (slope readout, solar
// calculator) that will need the same "elevation at a point" primitive,
// shadow.js's raymarch being the first real consumer.
import { ONLINE_TERRAIN_TILES } from "/js/overlays.js";
const DEM_ZOOM = 10; // fixed lookup zoom, independent of the map's own display zoom
const TILE_SIZE = 256;
const tileCache = new Map(); // "z/x/y" -> Promise<Int16Array|null>, so concurrent lookups on the same not-yet-fetched tile share one fetch
let offlinePmtiles = null;
function lngLatToTile(lng, lat, zoom) {
const n = 2 ** zoom;
const x = ((lng + 180) / 360) * n;
const latRad = (lat * Math.PI) / 180;
const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
const tileX = Math.floor(x);
const tileY = Math.floor(y);
return {
tileX,
tileY,
px: Math.min(TILE_SIZE - 1, Math.floor((x - tileX) * TILE_SIZE)),
py: Math.min(TILE_SIZE - 1, Math.floor((y - tileY) * TILE_SIZE)),
};
}
async function decodeTerrarium(pngBytes) {
const bitmap = await createImageBitmap(new Blob([pngBytes], { type: "image/png" }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const elevations = new Int16Array(bitmap.width * bitmap.height);
for (let i = 0; i < elevations.length; i++) {
elevations[i] = data[i * 4] * 256 + data[i * 4 + 1] + data[i * 4 + 2] / 256 - 32768;
}
return elevations;
}
async function fetchTileBytes(z, x, y, offlineAvailable) {
if (offlineAvailable) {
if (!offlinePmtiles) offlinePmtiles = new window.pmtiles.PMTiles(`${window.location.origin}/tiles/terrain.pmtiles`);
const result = await offlinePmtiles.getZxy(z, x, y);
return result ? new Uint8Array(result.data) : null;
}
const url = ONLINE_TERRAIN_TILES[0].replace("{z}", z).replace("{x}", x).replace("{y}", y);
const response = await fetch(url);
return response.ok ? new Uint8Array(await response.arrayBuffer()) : null;
}
function fetchTile(z, x, y, offlineAvailable) {
const key = `${z}/${x}/${y}`;
let cached = tileCache.get(key);
if (cached) return cached;
const promise = fetchTileBytes(z, x, y, offlineAvailable)
.then((bytes) => (bytes ? decodeTerrarium(bytes) : null))
.catch(() => null);
tileCache.set(key, promise);
return promise;
}
// -> Promise<number|null> — null on a fetch/decode failure, or a point
// falling outside a partial offline extract's zoom range.
export async function elevationAt(lng, lat, offlineAvailable) {
const { tileX, tileY, px, py } = lngLatToTile(lng, lat, DEM_ZOOM);
const elevations = await fetchTile(DEM_ZOOM, tileX, tileY, offlineAvailable);
return elevations ? elevations[py * TILE_SIZE + px] : null;
}
+12 -25
View File
@@ -117,7 +117,7 @@ export class GpxPanelControl {
mount(sidebar) { mount(sidebar) {
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.className = "sidebar-item"; this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Tracks" aria-label="Tracks">${GPX_ICON}</button>`; this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Tracks &amp; Points" aria-label="Tracks &amp; Points">${GPX_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon"); this._icon = this._container.querySelector(".sidebar-icon");
this._icon.addEventListener("click", () => this._togglePanel()); this._icon.addEventListener("click", () => this._togglePanel());
@@ -713,7 +713,7 @@ export class GpxPanelControl {
const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn(); const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn();
const popup = new Popup({ closeButton: true, maxWidth: "260px" }) const popup = new Popup({ closeButton: true, maxWidth: "260px" })
.setLngLat(event.lngLat) .setLngLat(event.lngLat)
.setHTML(popupHtml(feature.properties, { editable, lngLat: event.lngLat })) .setHTML(popupHtml(feature.properties, { editable }))
.addTo(this._map); .addTo(this._map);
if (editable) this._wireNoteActions(popup, path, feature); if (editable) this._wireNoteActions(popup, path, feature);
}); });
@@ -765,7 +765,7 @@ export class GpxPanelControl {
const bodyInput = content.querySelector(".gpx-popup__edit-body"); const bodyInput = content.querySelector(".gpx-popup__edit-body");
titleInput.focus(); titleInput.focus();
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => { content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
content.innerHTML = popupHtml(feature.properties, { editable: true, lngLat: popup.getLngLat() }); content.innerHTML = popupHtml(feature.properties, { editable: true });
this._wireNoteActions(popup, path, feature); this._wireNoteActions(popup, path, feature);
}); });
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => { content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
@@ -1060,7 +1060,11 @@ function uploadFile(formData, onUploadComplete) {
// fixed square size (the source SVGs have no explicit width/height, just a // fixed square size (the source SVGs have no explicit width/height, just a
// viewBox, so one is injected — otherwise an <img> falls back to the CSS // viewBox, so one is injected — otherwise an <img> falls back to the CSS
// default replaced-element size instead of the viewBox's aspect). // default replaced-element size instead of the viewBox's aspect).
async function svgToImageData(svgMarkup, size) { // Exported: overlays.js's own live layers (Wikipedia, more to come per its
// own doc comment) need the same SVG-to-map-image/badge-id/escaping
// helpers this file already has, and reimplementing them there would just
// be the same three functions typed twice.
export async function svgToImageData(svgMarkup, size) {
// xmlns is required here even though the other hand-drawn icons in this // xmlns is required here even though the other hand-drawn icons in this
// app get away without it — those are inlined via innerHTML, where the // app get away without it — those are inlined via innerHTML, where the
// HTML parser assigns the SVG namespace from context, but a standalone // HTML parser assigns the SVG namespace from context, but a standalone
@@ -1083,7 +1087,7 @@ async function svgToImageData(svgMarkup, size) {
} }
} }
function badgeImageId(color) { export function badgeImageId(color) {
return `${BADGE_IMAGE_PREFIX}${color}`; return `${BADGE_IMAGE_PREFIX}${color}`;
} }
@@ -1099,26 +1103,13 @@ function gpxFileUrl(path) {
return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`; return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`;
} }
// Set once from map.js after its one-time GET /api/kiwix check (same
// fetch-once-at-load story as the offline-tiles/terrain-tiles checks) —
// module-level rather than threaded through every popupHtml call, since
// it's the same answer for the whole page's lifetime.
let kiwixConfig = { available: false, url: "" };
export function setKiwixConfig(config) {
kiwixConfig = config;
}
// properties come straight from an uploaded GPX file — untrusted input — // properties come straight from an uploaded GPX file — untrusted input —
// so every value is escaped before landing in the popup's HTML. // so every value is escaped before landing in the popup's HTML.
// editable (only ever true for a "Note" feature the viewer is logged in // editable (only ever true for a "Note" feature the viewer is logged in
// for — see _registerPointInteractivity) adds Edit/Delete buttons; the // for — see _registerPointInteractivity) adds Edit/Delete buttons; the
// click handling for them lives in _wireNoteActions, not here, since this // click handling for them lives in _wireNoteActions, not here, since this
// only builds markup, it has no map/store access of its own. lngLat (the // only builds markup, it has no map/store access of its own.
// popup's own anchor point) drives the "Nearby on Wikipedia" link — kiwix- function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) {
// serve's own geo search, linked to directly rather than proxied/embedded
// (it already has a full search UI of its own, see docs/development.adoc).
function popupHtml({ name, description, time, category, photos, icon }, { editable = false, lngLat = null } = {}) {
const parts = []; const parts = [];
if (name) { if (name) {
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : ""; const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
@@ -1139,10 +1130,6 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`); parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
} }
} }
if (kiwixConfig.available && lngLat) {
const wikiUrl = `${kiwixConfig.url}/search?pattern=&latitude=${lngLat.lat}&longitude=${lngLat.lng}&distance=2000`;
parts.push(`<p class="gpx-popup__wiki"><a href="${escapeHtml(wikiUrl)}" target="_blank" rel="noopener noreferrer">Nearby on Wikipedia →</a></p>`);
}
if (editable) { if (editable) {
parts.push( parts.push(
`<div class="gpx-popup__note-actions"><button type="button" class="gpx-popup__edit">Edit</button><button type="button" class="gpx-popup__delete">Delete</button></div>`, `<div class="gpx-popup__note-actions"><button type="button" class="gpx-popup__edit">Edit</button><button type="button" class="gpx-popup__delete">Delete</button></div>`,
@@ -1173,7 +1160,7 @@ function formatDuration(totalSeconds) {
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
} }
function escapeHtml(text) { export function escapeHtml(text) {
const div = document.createElement("div"); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
+54 -10
View File
@@ -7,10 +7,14 @@ export const LAYERS_ICON = `
<path d="M2 14l10 6 10-6"/> <path d="M2 14l10 6 10-6"/>
</svg>`; </svg>`;
export const HILLSHADE_ICON = ` // Two overlapping circles — distinct from LAYERS_ICON's stacked-planes
// look (that one picks a *base* map; this one is for the sidebar flyout
// toggling live overlay layers, e.g. Wikipedia, on top of whichever base
// map is showing) while still reading as "layers combining."
export const OVERLAYS_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M2 18 9 7l3.5 5.5L15 9l7 9z"/> <circle cx="9" cy="12" r="6"/>
<path d="M2 18h20"/> <circle cx="15" cy="12" r="6"/>
</svg>`; </svg>`;
export const NOTE_PIN_ICON = ` export const NOTE_PIN_ICON = `
@@ -26,11 +30,6 @@ export const GPX_ICON = `
<path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5"/> <path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5"/>
</svg>`; </svg>`;
export const RADAR_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M7 18a4 4 0 1 1 .7-7.94A5 5 0 0 1 17 9a4 4 0 0 1 0 8H7z"/>
</svg>`;
export const GNSS_ICON = ` export const GNSS_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="12" cy="12" r="5.5"/> <circle cx="12" cy="12" r="5.5"/>
@@ -141,6 +140,34 @@ export const MAP_MARKER_ICON = `
// tinted at render time, see gpx.js's _registerCategoryGlyphs) so only the // tinted at render time, see gpx.js's _registerCategoryGlyphs) so only the
// alpha shape matters, not the actual `currentColor` value. // alpha shape matters, not the actual `currentColor` value.
// src/wilderness-22.svg — exported (unlike BICYCLE_TRAIL_ICON below, this
// one's also used outside this file) as the Overlays panel's Terrain
// shading glyph (overlays.js): the closest honest match in NPS's set for
// "mountains/terrain" — it has no weather or elevation-specific pictogram.
export const WILDERNESS_ICON = `
<svg viewBox="0 0 22 22" fill="currentColor"><polygon points="18,18 13,15 17,15 13,11 16,11 12,7 15,7 12,4 14,4 11,0 8,4 10,4 7,7 10,7 6,11 9,11 5,15 9,15 4,18 10,18 10,22 12,22 12,18 "/></svg>`;
// Hand-drawn, not NPS-sourced — that set has no weather/precipitation
// pictogram to reuse for the Overlays panel's Radar item, unlike Wikipedia
// (WIKI_ICON) and Terrain shading (WILDERNESS_ICON) above.
export const RADAR_CLOUD_ICON = `
<svg viewBox="0 0 22 22" fill="currentColor">
<path d="M17,10.1c-0.4-3-2.9-5.3-6-5.3c-2.5,0-4.7,1.5-5.6,3.8C3.4,8.9,1,11.1,1,13.9C1,16.7,3.3,19,6.1,19h10.3
c2.5,0,4.6-2,4.6-4.5C21,12.2,19.3,10.4,17,10.1z"/>
<rect x="6" y="20" width="1.6" height="2"/>
<rect x="10.2" y="20" width="1.6" height="2"/>
<rect x="14.4" y="20" width="1.6" height="2"/>
</svg>`;
// Hand-drawn, not NPS-sourced — same story as RADAR_CLOUD_ICON above, no
// terrain-shadow/sun-behind-a-ridge pictogram in that set either. Used as
// the Overlays panel's Terrain shadow glyph (shadow.js via overlays.js).
export const TERRAIN_SHADOW_ICON = `
<svg viewBox="0 0 22 22" fill="currentColor">
<circle cx="16" cy="6" r="3"/>
<polygon points="0,19 6,9 9,13 13,6 22,19"/>
</svg>`;
// src/bicycle-trail-22.svg — pulled out to its own constant since it's also // src/bicycle-trail-22.svg — pulled out to its own constant since it's also
// used unrasterized, inline, as PARK4NIGHT_TAGS.vtt below. // used unrasterized, inline, as PARK4NIGHT_TAGS.vtt below.
const BICYCLE_TRAIL_ICON = ` const BICYCLE_TRAIL_ICON = `
@@ -203,8 +230,7 @@ export const POI_ICONS = {
</svg>`, </svg>`,
// src/wilderness-22.svg // src/wilderness-22.svg
forest: ` forest: WILDERNESS_ICON,
<svg viewBox="0 0 22 22" fill="currentColor"><polygon points="18,18 13,15 17,15 13,11 16,11 12,7 15,7 12,4 14,4 11,0 8,4 10,4 7,7 10,7 6,11 9,11 5,15 9,15 4,18 10,18 10,22 12,22 12,18 "/></svg>`,
// src/bicycle-trail-22.svg // src/bicycle-trail-22.svg
sport_cycling: BICYCLE_TRAIL_ICON, sport_cycling: BICYCLE_TRAIL_ICON,
@@ -353,6 +379,24 @@ Object.assign(POI_ICONS, {
shop_convenience: STORE_ICON, shop_convenience: STORE_ICON,
}); });
// src/bookstore-22.svg (NPS symbol library, same source/license as
// POI_ICONS above) — used as the fixed marker glyph for the "Wikipedia"
// map layer (gpx.js), not part of POI_ICONS itself: that set is keyed by
// OSM/p4n2gpx category and doubles as the note icon-picker's option list
// (notes.js), neither of which applies here — every point on this layer
// gets the same one glyph, there's no per-feature category to key it by.
export const WIKI_ICON = `
<svg viewBox="0 0 22 22" fill="currentColor">
<path d="M16.6,20.7c-0.6,0-0.9-0.4-0.9-0.9c0-0.5,0.3-0.9,0.9-0.9c0.3,0,0.5-0.2,0.5-0.5
S16.9,18,16.6,18c-1.1,0-1.9,0.8-1.9,1.9c0,1,0.7,1.9,1.9,1.9c0.3,0,0.5-0.2,0.5-0.5S16.9,20.7,16.6,20.7L16.6,20.7z"/>
<path d="M5,18c0,0,0,0,0,0c0,3.1,0.5,4,2.7,4l8.9,0c0.3,0,0.5-0.2,0.5-0.5
c0-0.3-0.2-0.5-0.5-0.5H8c-1.5,0-2-0.5-2-3c0,0,0,0,0-0.1c0-0.3-0.2-0.5-0.5-0.5S5,17.7,5,18L5,18z"/>
<path d="M5,8c0-0.6,5-5,5-5h5h1c0.6,0,1,0.4,1,1v14c0,0.6-0.4,1-1,1H6c-0.6,0-1-0.4-1-1
V8z M9,4L6,18c0,0,10,0,10,0c0,0,0-14,0-14C16,4,9,4,9,4z"/>
<path d="M5,6.8c0-0.5,0.4-1.3,0.8-1.6l6.4-4.7C12.7,0.2,13,0.4,13,1v10
c0,0.6-0.3,1.7-0.7,2.1l-6.5,5.3C5.4,18.8,5,18.6,5,18V6.8z"/>
</svg>`;
export const LIVETRACK_ICON = ` export const LIVETRACK_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/> <path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
+45 -103
View File
@@ -2,10 +2,11 @@ import {
Map as MaplibreMap, Map as MaplibreMap,
NavigationControl, NavigationControl,
Popup, Popup,
ScaleControl,
addProtocol, addProtocol,
} from "/vendor/maplibre-gl/maplibre-gl.mjs"; } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { RadarControl } from "/js/radar.js"; import { GpxPanelControl } from "/js/gpx.js";
import { GpxPanelControl, setKiwixConfig } from "/js/gpx.js"; import { OverlaysControl } from "/js/overlays.js";
import { GnssControl } from "/js/gnss.js"; import { GnssControl } from "/js/gnss.js";
import { LiveTrackPanelControl } from "/js/livetrack.js"; import { LiveTrackPanelControl } from "/js/livetrack.js";
import { TrackProfilePanel } from "/js/track_profile.js"; import { TrackProfilePanel } from "/js/track_profile.js";
@@ -13,7 +14,8 @@ import { MeasureControl } from "/js/measure.js";
import { NotesControl } from "/js/notes.js"; import { NotesControl } from "/js/notes.js";
import { PluginsControl } from "/js/plugins.js"; import { PluginsControl } from "/js/plugins.js";
import { LocateControl } from "/js/locate.js"; import { LocateControl } from "/js/locate.js";
import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js"; import { LAYERS_ICON } from "/js/icons.js";
import { formatCoords, formatSunInfo } from "/js/sun.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { isLoggedIn } from "/js/auth.js"; import { isLoggedIn } from "/js/auth.js";
import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js";
@@ -308,100 +310,6 @@ class LayerToggleControl {
} }
} }
const HILLSHADE_ID = "hillshade-dem";
const COLOR_RELIEF_ID = "elevation-tint";
// Hypsometric tint reusing this app's own palette (wood_a's green at
// valley floor, the road casing brown through the mid-slopes) rather than
// a generic rainbow ramp, so it reads as part of the same map rather than
// a bolted-on overlay. Stops picked for Central European terrain — the
// Tatras top out around 2600m — not a general-purpose world ramp.
const ELEVATION_COLOR_RAMP = [
"interpolate",
["linear"],
["raster-value"],
0, "#c3e8ab",
400, "#dcd08f",
900, "#c9a66b",
1600, "#a89a86",
2600, "#f9f6ef",
];
// Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived)
// elevation tiles OpenStreetMap's own hillshade layer and many other free
// tools use. Only reachable online; while the offline basemap is active this
// is skipped in favor of a local terrain.pmtiles if one's been generated
// (see docs/development.adoc), same optional-local-file story as
// offline.pmtiles itself.
const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"];
// A simple on/off sidebar toggle, not a panel — there's nothing to choose,
// just whether the shading shows.
class HillshadeControl {
constructor(map) {
this._map = map;
this._active = false;
this._offlineAvailable = false;
// setStyle() (switching Online/Offline) tears down all custom
// sources/layers — put hillshade back if it was on.
map.on("style.load", () => {
if (this._active) this._addLayer();
});
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Terrain shading" aria-label="Terrain shading" aria-pressed="false">${HILLSHADE_ICON}</button>`;
this._button = this._container.querySelector(".sidebar-icon");
this._button.addEventListener("click", () => this._toggle());
sidebar.appendChild(this._container);
}
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
_toggle() {
this._active = !this._active;
this._button.classList.toggle("active", this._active);
this._button.setAttribute("aria-pressed", String(this._active));
if (this._active) this._addLayer();
else this._removeLayer();
}
_addLayer() {
if (this._map.getSource(HILLSHADE_ID)) return;
const source = this._offlineAvailable
? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" }
: {
type: "raster-dem",
tiles: ONLINE_TERRAIN_TILES,
tileSize: 256,
encoding: "terrarium",
maxzoom: 15,
attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM",
};
this._map.addSource(HILLSHADE_ID, source);
// Elevation tint under, hillshade on top: the tint sets the base color
// per elevation band, hillshade multiplies slope-shading over it — the
// combined "characteristic relief look" the TODO asked for, not two
// independent layers.
this._map.addLayer({
id: COLOR_RELIEF_ID,
type: "color-relief",
source: HILLSHADE_ID,
paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 },
});
this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } });
}
_removeLayer() {
if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID);
if (this._map.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID);
if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID);
}
}
// A small map-corner button (grouped with Home/zoom, see below) that's a // A small map-corner button (grouped with Home/zoom, see below) that's a
// second entry point to the same panel LayerToggleControl already manages // second entry point to the same panel LayerToggleControl already manages
// in the page's own sidebar — matching the National Park Service's own // in the page's own sidebar — matching the National Park Service's own
@@ -520,6 +428,7 @@ const gnssControl = new GnssControl(map);
// the "GNSS" sidebar icon actually turns it on — not just whenever a // the "GNSS" sidebar icon actually turns it on — not just whenever a
// receiver happens to have a fix. See locate.js. // receiver happens to have a fix. See locate.js.
map.addControl(new LocateControl(gnssControl), "top-left"); map.addControl(new LocateControl(gnssControl), "top-left");
map.addControl(new ScaleControl({ unit: "metric" }), "bottom-left");
// A small inset map showing where the current view sits in context — NPS's // A small inset map showing where the current view sits in context — NPS's
// own overview plugin (docs/nps-resources.adoc's "overview" entry, still on // own overview plugin (docs/nps-resources.adoc's "overview" entry, still on
@@ -539,11 +448,42 @@ map.once("style.load", () => {
const coordsLabel = document.querySelector(".topbar__coords"); const coordsLabel = document.querySelector(".topbar__coords");
const updateCoords = () => { const updateCoords = () => {
const { lng, lat } = map.getCenter(); const { lng, lat } = map.getCenter();
coordsLabel.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; coordsLabel.textContent = formatCoords(lat, lng);
}; };
updateCoords(); updateCoords();
map.on("move", 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"); const sidebar = document.getElementById("sidebar");
// Grouped into the TODO's planned 4-module menu (Explorer / Offline hub / // Grouped into the TODO's planned 4-module menu (Explorer / Offline hub /
@@ -556,8 +496,8 @@ profilePanel.mount();
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`); sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`);
new GpxPanelControl(map, profilePanel).mount(sidebar); new GpxPanelControl(map, profilePanel).mount(sidebar);
const hillshadeControl = new HillshadeControl(map); const overlaysControl = new OverlaysControl(map);
hillshadeControl.mount(sidebar); overlaysControl.mount(sidebar);
// Running a plugin needs a login (POST /api/plugins/:name/run) — same // Running a plugin needs a login (POST /api/plugins/:name/run) — same
// "hide the write-only affordance" story as NotesControl above. // "hide the write-only affordance" story as NotesControl above.
if (isLoggedIn()) new PluginsControl(map).mount(sidebar); if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
@@ -565,7 +505,6 @@ if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`); sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
layerToggle.mount(sidebar); layerToggle.mount(sidebar);
layerToggle.setActive("online"); layerToggle.setActive("online");
new RadarControl(map).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`); sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
gnssControl.mount(sidebar); gnssControl.mount(sidebar);
@@ -610,12 +549,15 @@ fetch("/api/offline-tiles")
fetch("/api/terrain-tiles") fetch("/api/terrain-tiles")
.then((response) => response.json()) .then((response) => response.json())
.then(({ available }) => hillshadeControl.setOfflineAvailable(available)) .then(({ available }) => {
overlaysControl.setHillshadeOfflineAvailable(available);
overlaysControl.setShadowOfflineAvailable(available);
})
.catch(() => {}); .catch(() => {});
fetch("/api/kiwix") fetch("/api/kiwix")
.then((response) => response.json()) .then((response) => response.json())
.then(setKiwixConfig) .then(({ available }) => overlaysControl.setWikiAvailable(available))
.catch(() => {}); .catch(() => {});
// Alt+click anywhere shows every rendered feature at that point — base map // Alt+click anywhere shows every rendered feature at that point — base map
+332
View File
@@ -0,0 +1,332 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { OVERLAYS_ICON, WIKI_ICON, WILDERNESS_ICON, MAP_MARKER_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js";
import { RadarControl } from "/js/radar.js";
import { ShadowControl } from "/js/shadow.js";
const ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
const ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below
const MARKER_SIZE = 0.55;
const WIKI_SOURCE_ID = "wiki-points";
const WIKI_ICON_ID = "wiki-icon";
const WIKI_BADGE_COLOR = "#795548";
const HILLSHADE_ID = "hillshade-dem";
const COLOR_RELIEF_ID = "elevation-tint";
// Hypsometric tint reusing this app's own palette (wood_a's green at
// valley floor, the road casing brown through the mid-slopes) rather than
// a generic rainbow ramp, so it reads as part of the same map rather than
// a bolted-on overlay. Stops picked for Central European terrain — the
// Tatras top out around 2600m — not a general-purpose world ramp.
const ELEVATION_COLOR_RAMP = [
"interpolate",
["linear"],
["raster-value"],
0, "#c3e8ab",
400, "#dcd08f",
900, "#c9a66b",
1600, "#a89a86",
2600, "#f9f6ef",
];
// Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived)
// elevation tiles OpenStreetMap's own hillshade layer and many other free
// tools use. Only reachable online; while the offline basemap is active this
// is skipped in favor of a local terrain.pmtiles if one's been generated
// (see docs/development.adoc), same optional-local-file story as
// offline.pmtiles itself.
// Exported: dem.js's elevation lookups (shadow.js's raymarch) hit the same
// online tile source, rather than duplicating the URL template.
export const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"];
// A plain on/off Overlays item — there's nothing to choose, just whether
// the shading shows, unlike Radar's expandable scrub/forecast.
class HillshadeControl {
constructor(map) {
this._map = map;
this._active = false;
this._offlineAvailable = false;
// setStyle() (switching Online/Offline) tears down all custom
// sources/layers — put hillshade back if it was on.
map.on("style.load", () => {
if (this._active) this._addLayer();
});
}
mount(li) {
li.innerHTML = `
<label>
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${WILDERNESS_ICON}</span>
<span>Terrain shading</span>
</label>
`;
this._checkbox = li.querySelector(".overlays-panel__toggle");
this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked));
}
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
_toggle(active) {
this._active = active;
if (active) this._addLayer();
else this._removeLayer();
}
_addLayer() {
if (this._map.getSource(HILLSHADE_ID)) return;
const source = this._offlineAvailable
? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" }
: {
type: "raster-dem",
tiles: ONLINE_TERRAIN_TILES,
tileSize: 256,
encoding: "terrarium",
maxzoom: 15,
attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM",
};
this._map.addSource(HILLSHADE_ID, source);
// Elevation tint under, hillshade on top: the tint sets the base color
// per elevation band, hillshade multiplies slope-shading over it — the
// combined "characteristic relief look" the TODO asked for, not two
// independent layers.
this._map.addLayer({
id: COLOR_RELIEF_ID,
type: "color-relief",
source: HILLSHADE_ID,
paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 },
});
this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } });
}
_removeLayer() {
if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID);
if (this._map.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID);
if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID);
}
}
// A sidebar side panel (same shell as LiveTrackPanelControl/#livetrack-panel)
// listing live map layers that aren't a base map or a Tracks & Points file —
// each one refetched/toggled live rather than something to add/remove/
// reorder, so this is a plain checklist, its items built here (like
// PluginsControl's own list) rather than as static markup. Radar and
// Terrain shading used to be their own separate sidebar icons; both moved
// here since neither has anything to "manage," just on/off (Radar's own
// scrub/forecast controls live behind expanding its item, not a factor in
// where it's mounted).
export class OverlaysControl {
constructor(map) {
this._map = map;
this._hillshade = new HillshadeControl(map);
this._radar = new RadarControl(map);
this._shadow = new ShadowControl(map);
this._wikiEnabled = false; // the checkbox's own state
this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled
this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded
this._interactivityRegistered = false; // guards against double-registering the click/hover handlers below across enable/disable cycles
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources/layers/images — put the wiki layer back afterwards,
// same as GpxPanelControl does for visible GPX files. Hillshade/Radar
// handle their own the same way, from their own constructors above.
map.on("style.load", () => {
if (this._wikiEnabled) this._addWikiLayers().then(() => this._refreshWikiLayer());
});
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Overlays" aria-label="Overlays">${OVERLAYS_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon");
this._icon.addEventListener("click", () => this._togglePanel());
this._panel = document.getElementById("overlays-panel");
this._panel
.querySelector(".basemap-panel__close")
.addEventListener("click", () => this._togglePanel(false));
const list = this._panel.querySelector(".overlays-panel__list");
list.innerHTML = `
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
`;
const [hillshadeItem, wikiItem, radarItem, shadowItem] = list.children;
this._hillshade.mount(hillshadeItem);
this._mountWiki(wikiItem);
this._radar.mount(radarItem);
this._shadow.mount(shadowItem);
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
sidebar.appendChild(this._container);
}
_togglePanel(force) {
const open = force === undefined ? this._panel.hidden : force;
this._panel.hidden = !open;
this._icon.classList.toggle("active", open);
if (open) notifySidePanelOpen(this._panel);
}
setHillshadeOfflineAvailable(available) {
this._hillshade.setOfflineAvailable(available);
}
setShadowOfflineAvailable(available) {
this._shadow.setOfflineAvailable(available);
}
_mountWiki(li) {
li.innerHTML = `
<label>
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${WIKI_ICON}</span>
<span>Wikipedia</span>
</label>
`;
this._wikiItem = li;
this._wikiToggle = li.querySelector(".overlays-panel__toggle");
this._wikiToggle.addEventListener("change", (event) => {
if (event.target.checked) this._enableWikiLayer();
else this._disableWikiLayer();
});
}
// Called once map.js's one-time GET /api/kiwix check resolves — disables
// (rather than hides) the checkbox rather than the whole panel/icon,
// since the other overlays are still usable with no kiwix-serve
// configured at all.
setWikiAvailable(available) {
this._wikiToggle.disabled = !available;
if (!available) this._wikiItem.title = "No kiwix-serve configured";
}
async _enableWikiLayer() {
this._wikiEnabled = true;
await this._addWikiLayers();
this._wikiMoveHandler = () => this._refreshWikiLayer();
this._map.on("moveend", this._wikiMoveHandler);
this._refreshWikiLayer();
}
_disableWikiLayer() {
this._wikiEnabled = false;
if (this._wikiMoveHandler) {
this._map.off("moveend", this._wikiMoveHandler);
this._wikiMoveHandler = null;
}
this._removeWikiLayers();
}
async _registerWikiIcon() {
if (this._map.hasImage(WIKI_ICON_ID)) return;
const whiteSvg = WIKI_ICON.replace(/currentColor/g, "#fff");
const imageData = await svgToImageData(whiteSvg, ICON_IMAGE_SIZE);
if (!this._map.hasImage(WIKI_ICON_ID)) this._map.addImage(WIKI_ICON_ID, imageData);
}
// Same one-image-per-color story as gpx.js's own _registerBadgeColors,
// just for this layer's single fixed color rather than a set of them
// discovered from a file.
async _registerWikiBadge() {
const id = badgeImageId(WIKI_BADGE_COLOR);
if (this._map.hasImage(id)) return;
const coloredSvg = MAP_MARKER_ICON.replace(/currentColor/g, WIKI_BADGE_COLOR);
const imageData = await svgToImageData(coloredSvg, ICON_IMAGE_SIZE);
if (!this._map.hasImage(id)) this._map.addImage(id, imageData);
}
async _addWikiLayers() {
if (this._map.getSource(WIKI_SOURCE_ID)) return;
await Promise.all([this._registerWikiIcon(), this._registerWikiBadge()]);
if (this._map.getSource(WIKI_SOURCE_ID)) return; // style.load raced this while awaiting
this._map.addSource(WIKI_SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
this._map.addLayer({
id: `${WIKI_SOURCE_ID}-badge`,
type: "symbol",
source: WIKI_SOURCE_ID,
layout: {
"icon-image": badgeImageId(WIKI_BADGE_COLOR),
"icon-size": MARKER_SIZE,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
this._map.addLayer({
id: `${WIKI_SOURCE_ID}-icon`,
type: "symbol",
source: WIKI_SOURCE_ID,
layout: { "icon-image": WIKI_ICON_ID, "icon-size": ICON_SIZE, "icon-allow-overlap": true, "icon-ignore-placement": true },
});
this._registerWikiInteractivity(`${WIKI_SOURCE_ID}-badge`);
}
_removeWikiLayers() {
[`${WIKI_SOURCE_ID}-badge`, `${WIKI_SOURCE_ID}-icon`].forEach((layerId) => {
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
});
if (this._map.getSource(WIKI_SOURCE_ID)) this._map.removeSource(WIKI_SOURCE_ID);
}
_registerWikiInteractivity(layerId) {
if (this._interactivityRegistered) return;
this._interactivityRegistered = true;
this._map.on("click", layerId, (event) => {
const feature = event.features[0];
if (!feature) return;
new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(wikiPopupHtml(feature.properties)).addTo(this._map);
});
this._map.on("mouseenter", layerId, () => {
this._map.getCanvas().style.cursor = "pointer";
});
this._map.on("mouseleave", layerId, () => {
this._map.getCanvas().style.cursor = "";
});
}
// seq guards against an out-of-order response — a fast second pan's
// fetch resolving before a slower first one's — overwriting newer data
// with stale data; not a full abort-in-flight setup, just the ordering
// guarantee this actually needs.
async _refreshWikiLayer() {
const seq = ++this._wikiRequestSeq;
const bounds = this._map.getBounds();
const params = new URLSearchParams({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
});
let geojson;
try {
geojson = await (await fetch(`/api/kiwix/nearby?${params}`)).json();
} catch {
return;
}
if (seq !== this._wikiRequestSeq) return;
const source = this._map.getSource(WIKI_SOURCE_ID);
if (source) source.setData(geojson);
}
}
// properties come from GET /api/kiwix/nearby — kiwix-serve's own ZIM
// content, not an uploaded file, but still untrusted-by-convention like
// every other popup builder in this app (escaped all the same). link
// already carries the /kiwix prefix this app proxies (lib/maptool/kiwix.rb).
function wikiPopupHtml({ name, link }) {
const parts = [`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong></div>`];
parts.push(`<p class="gpx-popup__wiki"><a href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Read on Wikipedia →</a></p>`);
return parts.join("");
}
+41 -30
View File
@@ -1,4 +1,4 @@
import { RADAR_ICON } from "/js/icons.js"; import { RADAR_CLOUD_ICON } from "/js/icons.js";
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
@@ -39,10 +39,10 @@ const OPACITY = 0.7;
const ATTRIBUTION = const ATTRIBUTION =
'Weather data &copy; <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>'; 'Weather data &copy; <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
// A sidebar icon that fetches RainViewer's recent precipitation frames on // An Overlays panel item (see overlays.js) that fetches RainViewer's recent
// demand and animates them as a raster overlay, with play/pause and a scrub // precipitation frames on demand and animates them as a raster overlay,
// slider in a flyout. No API key needed — see docs/architecture.adoc for // with play/pause and a scrub slider revealed by expanding the item. No API
// RainViewer's usage terms. // key needed — see docs/architecture.adoc for RainViewer's usage terms.
export class RadarControl { export class RadarControl {
constructor(map) { constructor(map) {
this._map = map; this._map = map;
@@ -59,12 +59,19 @@ export class RadarControl {
}); });
} }
mount(sidebar) { // li: the <li class="overlays-panel__item"> this item owns (see
this._container = document.createElement("div"); // overlays.js) — built here rather than in the static markup since this
this._container.className = "sidebar-item"; // control is the only thing that knows its own internal structure
this._container.innerHTML = ` // (playback/forecast), same as before when it built its own sidebar
<button type="button" class="sidebar-icon" title="Radar" aria-label="Radar">${RADAR_ICON}</button> // flyout.
<div class="sidebar-flyout" hidden> mount(li) {
li.innerHTML = `
<div class="overlays-panel__row">
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${RADAR_CLOUD_ICON}</span>
<button type="button" class="overlays-panel__name">Radar</button>
</div>
<div class="overlays-panel__details" hidden>
<div class="radar-control__playback"> <div class="radar-control__playback">
<button type="button" class="radar-control__play" aria-label="Play">&#9654;</button> <button type="button" class="radar-control__play" aria-label="Play">&#9654;</button>
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0"> <input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
@@ -74,27 +81,34 @@ export class RadarControl {
</div> </div>
`; `;
this._icon = this._container.querySelector(".sidebar-icon"); this._checkbox = li.querySelector(".overlays-panel__toggle");
this._flyout = this._container.querySelector(".sidebar-flyout"); this._nameButton = li.querySelector(".overlays-panel__name");
this._playback = this._container.querySelector(".radar-control__playback"); this._details = li.querySelector(".overlays-panel__details");
this._playButton = this._container.querySelector(".radar-control__play"); this._playback = li.querySelector(".radar-control__playback");
this._scrub = this._container.querySelector(".radar-control__scrub"); this._playButton = li.querySelector(".radar-control__play");
this._time = this._container.querySelector(".radar-control__time"); this._scrub = li.querySelector(".radar-control__scrub");
this._forecastEl = this._container.querySelector(".radar-control__forecast"); this._time = li.querySelector(".radar-control__time");
this._forecastEl = li.querySelector(".radar-control__forecast");
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled)); this._checkbox.addEventListener("change", (event) => this._setEnabled(event.target.checked));
// Separate from the checkbox on purpose — expanding to see the scrub/
// forecast shouldn't also flip the layer on, and turning the layer on
// shouldn't force the details open (a viewer who never cares to scrub
// history/see the forecast keeps a one-line item either way).
this._nameButton.addEventListener("click", () => {
const expanding = this._details.hidden;
this._details.hidden = !expanding;
this._nameButton.setAttribute("aria-expanded", String(expanding));
});
this._playButton.addEventListener("click", () => this._togglePlayback()); this._playButton.addEventListener("click", () => this._togglePlayback());
this._scrub.addEventListener("input", () => { this._scrub.addEventListener("input", () => {
this._stopAnimation(); this._stopAnimation();
this._showFrame(Number(this._scrub.value)); this._showFrame(Number(this._scrub.value));
}); });
sidebar.appendChild(this._container);
} }
async _setEnabled(enabled) { async _setEnabled(enabled) {
this._enabled = enabled; this._enabled = enabled;
this._icon.classList.toggle("active", enabled);
if (!enabled) { if (!enabled) {
this._stopAnimation(); this._stopAnimation();
this._removeLayers(); this._removeLayers();
@@ -104,15 +118,15 @@ export class RadarControl {
return; return;
} }
this._icon.disabled = true; this._checkbox.disabled = true;
// Independent fetches: RainViewer being unreachable shouldn't hide a // Independent fetches: RainViewer being unreachable shouldn't hide a
// forecast that Open-Meteo still answered, or vice versa — especially // forecast that Open-Meteo still answered, or vice versa — especially
// since only the forecast has a cached fallback to fall back to. // since only the forecast has a cached fallback to fall back to.
const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]); const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]);
this._icon.disabled = false; this._checkbox.disabled = false;
// The icon may have been clicked again (disabling) while the fetches // The checkbox may have been unchecked again (disabling) while the
// were in flight. // fetches were in flight.
if (!this._enabled) return; if (!this._enabled) return;
const frames = framesResult.status === "fulfilled" ? framesResult.value : []; const frames = framesResult.status === "fulfilled" ? framesResult.value : [];
@@ -123,7 +137,7 @@ export class RadarControl {
// own failure. // own failure.
if (frames.length === 0 && !forecast) { if (frames.length === 0 && !forecast) {
this._enabled = false; this._enabled = false;
this._icon.classList.remove("active"); this._checkbox.checked = false;
} else if (frames.length > 0) { } else if (frames.length > 0) {
this._frames = frames; this._frames = frames;
this._frameIndex = frames.length - 1; this._frameIndex = frames.length - 1;
@@ -252,9 +266,6 @@ export class RadarControl {
} }
_updateFrameUI() { _updateFrameUI() {
// Shown whenever there's radar frames, a forecast, or both — either
// one on its own is still worth showing the flyout for.
this._flyout.hidden = !this._enabled;
this._playback.hidden = this._frames.length === 0; this._playback.hidden = this._frames.length === 0;
this._scrub.max = String(Math.max(0, this._frames.length - 1)); this._scrub.max = String(Math.max(0, this._frames.length - 1));
this._scrub.value = String(this._frameIndex); this._scrub.value = String(this._frameIndex);
+237
View File
@@ -0,0 +1,237 @@
import { getPosition } from "/vendor/suncalc/suncalc.js";
import { elevationAt } from "/js/dem.js";
import { TERRAIN_SHADOW_ICON } from "/js/icons.js";
const MIN_ZOOM = 11; // below this, a screen is roughly the same scale as the 40km raymarch — "one screen, one shadow-casting neighborhood" stops holding
const GRID_COLS = 96;
const GRID_ROWS = 64;
const RAYMARCH_STEPS = 16;
const RAYMARCH_START_M = 150;
const RAYMARCH_MAX_M = 40000;
const RAYMARCH_RATIO = (RAYMARCH_MAX_M / RAYMARCH_START_M) ** (1 / (RAYMARCH_STEPS - 1)); // geometric spacing: dense near the point, coarse far out
const EARTH_RADIUS_M = 6371000;
const SHADOW_RGBA = [20, 20, 50, Math.round(0.45 * 255)];
const SLIDER_DEBOUNCE_MS = 300;
const IMAGE_SOURCE_ID = "terrain-shadow";
// An Overlays panel item (see overlays.js) shading the parts of the visible
// map currently blocked from the sun by surrounding terrain — offline
// equivalent of shademap.app, built on the same Terrarium DEM tiles
// HillshadeControl already renders (via dem.js) and the same sun-position
// math the toolbar readout uses (public/js/sun.js). No Web Worker: a
// recompute is dozens of small awaited fetches (mostly cache hits after the
// first pan), not sustained sync CPU work, so it already yields to the
// event loop on its own between grid rows without blocking a pan/zoom.
export class ShadowControl {
constructor(map) {
this._map = map;
this._enabled = false;
this._offlineAvailable = false;
this._seq = 0; // bumped on every recompute/disable so a superseded in-flight one (a fast second pan, or turning the layer off mid-compute) discards its result instead of overwriting newer data
this._moveHandler = null;
this._sliderMinutes = currentMinutesOfDay();
this._sliderTimer = null;
// setStyle() (switching Online/Offline) tears down all custom sources/
// layers — recompute puts the shadow layer back if it was on, same as
// every other Overlays control.
map.on("style.load", () => {
if (this._enabled) this._recompute();
});
}
mount(li) {
li.innerHTML = `
<div class="overlays-panel__row">
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${TERRAIN_SHADOW_ICON}</span>
<button type="button" class="overlays-panel__name">Terrain shadow</button>
</div>
<div class="overlays-panel__details" hidden>
<div class="shadow-control__time">
<input type="range" class="shadow-control__slider" min="0" max="1439" step="5" value="${this._sliderMinutes}">
<span class="shadow-control__clock">${formatMinutes(this._sliderMinutes)}</span>
</div>
<p class="shadow-control__hint" hidden>Zoom in further to compute terrain shadow.</p>
</div>
`;
this._checkbox = li.querySelector(".overlays-panel__toggle");
this._nameButton = li.querySelector(".overlays-panel__name");
this._details = li.querySelector(".overlays-panel__details");
this._slider = li.querySelector(".shadow-control__slider");
this._clock = li.querySelector(".shadow-control__clock");
this._hint = li.querySelector(".shadow-control__hint");
this._checkbox.addEventListener("change", (event) => this._setEnabled(event.target.checked));
this._nameButton.addEventListener("click", () => {
const expanding = this._details.hidden;
this._details.hidden = !expanding;
this._nameButton.setAttribute("aria-expanded", String(expanding));
});
this._slider.addEventListener("input", () => {
this._sliderMinutes = Number(this._slider.value);
this._clock.textContent = formatMinutes(this._sliderMinutes);
clearTimeout(this._sliderTimer);
this._sliderTimer = setTimeout(() => this._recompute(), SLIDER_DEBOUNCE_MS);
});
}
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
_setEnabled(enabled) {
this._enabled = enabled;
if (enabled) {
this._moveHandler = () => this._recompute();
this._map.on("moveend", this._moveHandler);
this._recompute();
return;
}
this._seq++;
if (this._moveHandler) {
this._map.off("moveend", this._moveHandler);
this._moveHandler = null;
}
this._hint.hidden = true;
this._removeLayer();
}
async _recompute() {
const seq = ++this._seq;
if (this._map.getZoom() < MIN_ZOOM) {
this._hint.hidden = false;
this._removeLayer();
return;
}
this._hint.hidden = true;
const bounds = this._map.getBounds();
const center = this._map.getCenter();
const date = this._selectedDate();
// One sun position for the whole viewport — azimuth/altitude vary
// negligibly across it next to the raymarch's own 150m-step coarseness.
const sun = getPosition(date, center.lat, center.lng);
const west = bounds.getWest();
const east = bounds.getEast();
const south = bounds.getSouth();
const north = bounds.getNorth();
const canvas = document.createElement("canvas");
canvas.width = GRID_COLS;
canvas.height = GRID_ROWS;
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(GRID_COLS, GRID_ROWS);
if (sun.altitude <= 0) {
// Sun's below the horizon everywhere in view — no point raymarching.
fillShadow(imageData);
} else {
for (let row = 0; row < GRID_ROWS; row++) {
const lat = north - ((row + 0.5) / GRID_ROWS) * (north - south);
const rowWork = [];
for (let col = 0; col < GRID_COLS; col++) {
const lng = west + ((col + 0.5) / GRID_COLS) * (east - west);
rowWork.push(
this._isShadowed(lng, lat, sun.azimuth, sun.altitude).then((shadowed) => setPixel(imageData, col, row, shadowed)),
);
}
await Promise.all(rowWork); // yields once per row, so a fast pan/zoom's newer recompute can cut in
if (seq !== this._seq) return;
}
}
ctx.putImageData(imageData, 0, 0);
if (seq !== this._seq) return;
this._publish(canvas.toDataURL(), bounds);
}
async _isShadowed(lng, lat, sunAzimuth, sunAltitude) {
const pointElev = await elevationAt(lng, lat, this._offlineAvailable);
if (pointElev === null) return false; // no DEM data here — render sunlit rather than guess
let dist = RAYMARCH_START_M;
const samples = [];
for (let i = 0; i < RAYMARCH_STEPS; i++) {
samples.push(this._horizonSample(lng, lat, pointElev, sunAzimuth, dist));
dist *= RAYMARCH_RATIO;
}
const angles = (await Promise.all(samples)).filter((a) => a !== null);
const horizonMax = angles.length ? Math.max(...angles) : -90;
return sunAltitude <= horizonMax;
}
async _horizonSample(lng, lat, pointElev, sunAzimuth, dist) {
const sample = destinationPoint(lng, lat, sunAzimuth, dist);
const sampleElev = await elevationAt(sample.lng, sample.lat, this._offlineAvailable);
if (sampleElev === null) return null;
const curvatureDrop = (dist * dist) / (2 * EARTH_RADIUS_M);
return (Math.atan2(sampleElev - pointElev - curvatureDrop, dist) * 180) / Math.PI;
}
_selectedDate() {
const date = new Date();
date.setHours(0, this._sliderMinutes, 0, 0);
return date;
}
// First image-source usage in this app — addSource/addLayer once, then
// updateImage on every later recompute rather than remove+re-add.
_publish(dataUrl, bounds) {
const coordinates = [
[bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()],
[bounds.getWest(), bounds.getSouth()],
];
const source = this._map.getSource(IMAGE_SOURCE_ID);
if (source) {
source.updateImage({ url: dataUrl, coordinates });
} else {
this._map.addSource(IMAGE_SOURCE_ID, { type: "image", url: dataUrl, coordinates });
this._map.addLayer({ id: IMAGE_SOURCE_ID, type: "raster", source: IMAGE_SOURCE_ID });
}
}
_removeLayer() {
if (this._map.getLayer(IMAGE_SOURCE_ID)) this._map.removeLayer(IMAGE_SOURCE_ID);
if (this._map.getSource(IMAGE_SOURCE_ID)) this._map.removeSource(IMAGE_SOURCE_ID);
}
}
// Flat-earth destination point (bearing/distance -> lng/lat) — fine under
// 40km, and nothing in this codebase already does lng/lat offset math.
function destinationPoint(lng, lat, bearingDeg, distanceM) {
const bearing = (bearingDeg * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const dLat = (distanceM * Math.cos(bearing)) / EARTH_RADIUS_M;
const dLng = (distanceM * Math.sin(bearing)) / (EARTH_RADIUS_M * Math.cos(latRad));
return { lng: lng + (dLng * 180) / Math.PI, lat: lat + (dLat * 180) / Math.PI };
}
function setPixel(imageData, col, row, shadowed) {
if (!shadowed) return; // ImageData starts fully transparent — nothing to do for a sunlit cell
const i = (row * imageData.width + col) * 4;
imageData.data.set(SHADOW_RGBA, i);
}
function fillShadow(imageData) {
for (let i = 0; i < imageData.data.length; i += 4) imageData.data.set(SHADOW_RGBA, i);
}
function formatMinutes(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function currentMinutesOfDay() {
const now = new Date();
return now.getHours() * 60 + now.getMinutes();
}
+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}`;
}
+22
View File
@@ -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.
+533
View File
@@ -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;
}
+20
View File
@@ -18,6 +18,22 @@ class AppTest < Minitest::Test
assert_includes response.body, "livetrack-panel" assert_includes response.body, "livetrack-panel"
end 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 def test_offline_tiles_reports_unavailable_when_no_pmtiles_present
response = request.get("/api/offline-tiles") response = request.get("/api/offline-tiles")
assert_equal({ "available" => false }, json_body(response)) assert_equal({ "available" => false }, json_body(response))
@@ -32,6 +48,10 @@ class AppTest < Minitest::Test
assert_equal 404, request.get("/kiwix/search").status assert_equal 404, request.get("/kiwix/search").status
end end
def test_kiwix_nearby_404s_when_no_url_configured
assert_equal 404, request.get("/api/kiwix/nearby", params: { south: "48", west: "17", north: "48.1", east: "17.1" }).status
end
def test_gnss_position_unavailable_with_no_fix def test_gnss_position_unavailable_with_no_fix
response = request.get("/api/gnss/position", **logged_in_env) response = request.get("/api/gnss/position", **logged_in_env)
assert_equal({ "available" => false }, json_body(response)) assert_equal({ "available" => false }, json_body(response))
+54
View File
@@ -70,4 +70,58 @@ class KiwixTest < Minitest::Test
assert_equal "GET /kiwix/content/wikipedia/Dlh%C3%A9_diely HTTP/1.1\r\n", request_line assert_equal "GET /kiwix/content/wikipedia/Dlh%C3%A9_diely HTTP/1.1\r\n", request_line
assert_equal "200", response.code assert_equal "200", response.code
end end
# kiwix-serve's own geo search never echoes back a result's coordinates
# (verified live against a real ZIM) — .nearby fetches each matching
# article's page too and reads its <meta name="geo.position"> tag, so
# this stub server has to answer both the search request and each
# article-page request that follows, in turn.
def test_nearby_builds_a_feature_per_geotagged_search_result
server = TCPServer.new("127.0.0.1", 0)
port = server.addr[1]
search_xml = <<~XML
<?xml version="1.0" encoding="UTF-8"?>
<rss><channel>
<item><title>Foo</title><link>/kiwix/content/wikipedia/Foo</link></item>
<item><title>Bar</title><link>/kiwix/content/wikipedia/Bar</link></item>
</channel></rss>
XML
thread = Thread.new do
# 3, not 2: .nearby fetches every search result's own page to look
# for coordinates, including Bar's — which turns out to have none
# and gets filtered out, but the request still happens. Leaving this
# at 2 the first time round left that third connection's request
# unaccepted, and it hung for the full default read_timeout instead
# of failing fast.
3.times do
client = server.accept
request_line = client.gets
body =
if request_line.include?("/kiwix/search")
search_xml
elsif request_line.include?("/Foo")
'<meta name="geo.position" content="48.1;17.2">'
else
"no coordinates here"
end
client.print("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
client.close
end
end
geojson = Maptool::Kiwix.nearby("http://127.0.0.1:#{port}", [48.0, 17.0, 48.2, 17.3])
thread.join
server.close
assert_equal 1, geojson["features"].size, "Bar has no geo.position tag and must be skipped, not crash"
feature = geojson["features"].first
assert_equal "Foo", feature["properties"]["name"]
assert_equal [17.2, 48.1], feature["geometry"]["coordinates"]
end
def test_nearby_returns_an_empty_collection_when_the_search_itself_fails
geojson = Maptool::Kiwix.nearby("http://127.0.0.1:1", [48.0, 17.0, 48.2, 17.3], open_timeout: 1)
assert_equal({ "type" => "FeatureCollection", "features" => [] }, geojson)
end
end end
+21 -2
View File
@@ -1,7 +1,10 @@
<div id="app"> <div id="app">
<header class="topbar"> <header class="topbar">
<span class="topbar__brand" title="rev <%= revision %>">MapTool</span> <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> <span class="topbar__offline-status" hidden></span>
<div class="topbar__auth"> <div class="topbar__auth">
<% if logged_in %> <% if logged_in %>
@@ -47,7 +50,7 @@
<div id="gpx-panel" class="gpx-panel" hidden> <div id="gpx-panel" class="gpx-panel" hidden>
<div class="gpx-panel__header"> <div class="gpx-panel__header">
<strong>Tracks</strong> <strong>Tracks &amp; Points</strong>
<div class="gpx-panel__actions"> <div class="gpx-panel__actions">
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button> <button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT/GeoJSON file" aria-label="Upload GPX/FIT/GeoJSON file"></button> <button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT/GeoJSON file" aria-label="Upload GPX/FIT/GeoJSON file"></button>
@@ -62,6 +65,22 @@
<div class="gpx-legend" hidden></div> <div class="gpx-legend" hidden></div>
</div> </div>
<div id="overlays-panel" class="basemap-panel" hidden>
<div class="basemap-panel__header">
<strong>Overlays</strong>
<button type="button" class="basemap-panel__close" aria-label="Close">&times;</button>
</div>
<!-- Each item is a live layer refetched/toggled from its own source as
the map moves, not a file to add/remove/reorder — a plain on/off
list, no upload/new-folder actions the way Tracks & Points has.
Populated by OverlaysControl#mount (public/js/overlays.js), not
here — Radar's item needs its own scrub/forecast markup built by
RadarControl itself (radar.js), so all three items go through the
same "control owns its own <li>" path rather than half static
markup, half dynamic. -->
<ul class="overlays-panel__list"></ul>
</div>
<div id="livetrack-panel" class="basemap-panel" hidden> <div id="livetrack-panel" class="basemap-panel" hidden>
<div class="basemap-panel__header"> <div class="basemap-panel__header">
<strong>LiveTrack sessions</strong> <strong>LiveTrack sessions</strong>
+4
View File
@@ -31,6 +31,7 @@
{ {
"imports": { "imports": {
"/js/auth.js": "/js/auth.js?v=<%= ASSET_VERSION %>", "/js/auth.js": "/js/auth.js?v=<%= ASSET_VERSION %>",
"/js/dem.js": "/js/dem.js?v=<%= ASSET_VERSION %>",
"/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>", "/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>",
"/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>", "/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>",
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>", "/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
@@ -38,9 +39,12 @@
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>", "/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
"/js/measure.js": "/js/measure.js?v=<%= ASSET_VERSION %>", "/js/measure.js": "/js/measure.js?v=<%= ASSET_VERSION %>",
"/js/notes.js": "/js/notes.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/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>", "/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
"/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>", "/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>",
"/js/shadow.js": "/js/shadow.js?v=<%= ASSET_VERSION %>",
"/js/sun.js": "/js/sun.js?v=<%= ASSET_VERSION %>",
"/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>" "/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"
} }
} }