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