diff --git a/TODO.adoc b/TODO.adoc index 04558f3..abf11fc 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -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] 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] 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 `` 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 the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb diff --git a/app.rb b/app.rb index d669b13..9bd7ab8 100644 --- a/app.rb +++ b/app.rb @@ -238,6 +238,19 @@ module Maptool json available: reachable, url: (reachable ? "/kiwix" : "") 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 # app instead of linking the browser to it directly (see KIWIX_URL) — # kiwix-serve is started with --urlRootLocation=/kiwix diff --git a/lib/maptool/kiwix.rb b/lib/maptool/kiwix.rb index 5c10891..335cc57 100644 --- a/lib/maptool/kiwix.rb +++ b/lib/maptool/kiwix.rb @@ -1,4 +1,5 @@ require "net/http" +require "rexml/document" module Maptool # 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 # directly. GET /kiwix/* in app.rb calls .proxy below instead. module Kiwix + # — 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 = / "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 diff --git a/public/css/style.css b/public/css/style.css index 4b60119..8abd8e1 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -373,6 +373,21 @@ html, body { color: #666; } +.gpx-panel__wiki { + display: flex; + align-items: center; + gap: 6px; + margin: 0 12px 8px; + padding-bottom: 8px; + border-bottom: 1px solid #e5e5e5; + font-size: 13px; + cursor: pointer; +} + +.gpx-panel__wiki[hidden] { + display: none; +} + .gpx-tree { /* flex:1 (not just overflow-y) so the tree fills the rest of the panel even when short/empty — otherwise the blank space below it wouldn't diff --git a/public/js/gpx.js b/public/js/gpx.js index 24214f2..4346fc2 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -11,6 +11,7 @@ import { MAP_MARKER_ICON, POI_ICONS, PARK4NIGHT_TAGS, + WIKI_ICON, } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js"; import { formatActivityType } from "/js/track_profile.js"; @@ -27,6 +28,13 @@ const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker b const MAP_MARKER_SIZE = 0.55; const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors +// The "Wikipedia" layer (a live GET /api/kiwix/nearby refetch on every +// pan/zoom, not a file in the tree — see _enableWikiLayer) — one fixed +// icon/color for every point on it, unlike a POI file's per-category ones. +const WIKI_SOURCE_ID = "wiki-points"; +const WIKI_ICON_ID = "wiki-icon"; +const WIKI_BADGE_COLOR = "#795548"; + // Custom drag payload MIME type for moving an already-uploaded file between // folders, as opposed to an OS file drag (type "Files") for uploading a new // one — both land on the same drop targets, told apart by this. @@ -46,6 +54,9 @@ export class GpxPanelControl { this._linesByPath = new Map(); // path -> its lines/polygons FeatureCollection, same reason (a polygon/line note also carries a category) this._hiddenCategories = new Set(); // category names toggled off in the legend this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any + this._wikiEnabled = false; // the "Wikipedia" layer's checkbox 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 // setStyle() (switching the Online/Offline base map) tears down all // custom sources/layers/images — re-register the category glyphs before @@ -57,6 +68,7 @@ export class GpxPanelControl { map.on("style.load", () => { this._iconsReady = this._registerCategoryGlyphs(); this._visible.forEach((path) => this._addLayers(path)); + if (this._wikiEnabled) this._addWikiLayers().then(() => this._refreshWikiLayer()); }); // notes.js writes directly into the same GPX store (a hand-drawn note @@ -114,10 +126,21 @@ export class GpxPanelControl { ); } + // Same registration as _registerCategoryGlyphs above, for the one fixed + // glyph the "Wikipedia" layer uses — kept separate from POI_ICONS since + // that set is keyed by OSM/p4n2gpx category and doubles as notes.js's + // icon-picker option list, neither of which applies to this layer. + async _registerWikiIcon() { + if (this._map.hasImage(WIKI_ICON_ID)) return; + const whiteSvg = WIKI_ICON.replace(/currentColor/g, "#fff"); + const imageData = await svgToImageData(whiteSvg, POI_ICON_IMAGE_SIZE); + if (!this._map.hasImage(WIKI_ICON_ID)) this._map.addImage(WIKI_ICON_ID, imageData); + } + mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; - this._container.innerHTML = ``; + this._container.innerHTML = ``; this._icon = this._container.querySelector(".sidebar-icon"); this._icon.addEventListener("click", () => this._togglePanel()); @@ -125,6 +148,12 @@ export class GpxPanelControl { this._treeContainer = this._panel.querySelector(".gpx-tree"); this._legendContainer = this._panel.querySelector(".gpx-legend"); this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); + wikiToggleLabel = this._panel.querySelector(".gpx-panel__wiki"); + wikiToggleLabel.hidden = !kiwixConfig.available; + wikiToggleLabel.querySelector(".gpx-panel__wiki-toggle").addEventListener("change", (event) => { + if (event.target.checked) this._enableWikiLayer(); + else this._disableWikiLayer(); + }); this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON; this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON; if (!isLoggedIn()) { @@ -713,7 +742,7 @@ export class GpxPanelControl { const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn(); const popup = new Popup({ closeButton: true, maxWidth: "260px" }) .setLngLat(event.lngLat) - .setHTML(popupHtml(feature.properties, { editable, lngLat: event.lngLat })) + .setHTML(popupHtml(feature.properties, { editable })) .addTo(this._map); if (editable) this._wireNoteActions(popup, path, feature); }); @@ -765,7 +794,7 @@ export class GpxPanelControl { const bodyInput = content.querySelector(".gpx-popup__edit-body"); titleInput.focus(); 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); }); content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => { @@ -924,6 +953,110 @@ export class GpxPanelControl { } } + // The "Wikipedia" layer's checkbox toggle (see mount()) — not a file in + // _visible/the tree, so it gets its own small add/remove pair rather than + // going through _addLayers/_removeLayers, which are built around a + // GpxStore path. Refetches on every pan/zoom instead of once for a fixed + // area (contrast the import plugins' "Run for this area" button): cheap + // to do since GET /api/kiwix/nearby only ever talks to kiwix-serve's own + // local ZIM, none of the plugins' shared/rate-limited API concerns apply. + 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 _addWikiLayers() { + if (this._map.getSource(WIKI_SOURCE_ID)) return; + await Promise.all([this._registerWikiIcon(), this._registerBadgeColors([WIKI_BADGE_COLOR])]); + 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": MAP_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": POI_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); + } + + // Not _registerPointInteractivity: that one's built around a GpxStore + // path for Note edit/delete, neither of which a wiki article has — + // simpler to keep this layer's own small click/hover pair separate than + // to bend the shared one around a case it was never meant to cover. + _registerWikiInteractivity(layerId) { + if (this._interactiveLayers.has(layerId)) return; + this._interactiveLayers.add(layerId); + + 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); + } + async _uploadFiles(fileList, folder) { // .zip: Garmin's bulk/FIT export format — the server expands it into // its .gpx/.fit/.geojson entries, so it's accepted here even though the @@ -1104,9 +1237,14 @@ function gpxFileUrl(path) { // 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: "" }; +// Set once GpxPanelControl#mount runs — updated here too since map.js's +// GET /api/kiwix check resolves after mount() already ran, so the panel +// markup exists but its availability isn't known yet at that point. +let wikiToggleLabel = null; export function setKiwixConfig(config) { kiwixConfig = config; + if (wikiToggleLabel) wikiToggleLabel.hidden = !config.available; } // properties come straight from an uploaded GPX file — untrusted input — @@ -1114,11 +1252,8 @@ export function setKiwixConfig(config) { // editable (only ever true for a "Note" feature the viewer is logged in // for — see _registerPointInteractivity) adds Edit/Delete buttons; the // 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 -// popup's own anchor point) drives the "Nearby on Wikipedia" link — kiwix- -// 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 } = {}) { +// only builds markup, it has no map/store access of its own. +function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) { const parts = []; if (name) { const glyph = icon && POI_ICONS[icon] ? `` : ""; @@ -1139,10 +1274,6 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab parts.push(`
${escapeHtml(parsed.toLocaleString())}
`); } } - if (kiwixConfig.available && lngLat) { - const wikiUrl = `${kiwixConfig.url}/search?pattern=&latitude=${lngLat.lat}&longitude=${lngLat.lng}&distance=2000`; - parts.push(``); - } if (editable) { parts.push( ``, @@ -1151,6 +1282,16 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab return parts.length > 0 ? parts.join("") : "No details"; } +// 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 here (escaped all the same). link already +// carries the /kiwix prefix this app proxies (see lib/maptool/kiwix.rb). +function wikiPopupHtml({ name, link }) { + const parts = [`