Link POI popups to offline Wikipedia via kiwix-serve
lib/maptool/kiwix.rb: a small reachability check for an external kiwix-serve instance. GET /api/kiwix reports it (same optional- integration pattern as offline-tiles/terrain-tiles); the frontend adds a "Nearby on Wikipedia" link to a point's popup pointing straight at kiwix-serve's own geo search (GET /search?latitude=&longitude= &distance=, confirmed from libkiwix's actual source rather than guessed) — linked to directly, not proxied/embedded, since it already has a full search UI of its own. docker-compose.yaml: new `kiwix` service (ghcr.io/kiwix/kiwix-serve) on the default network, KIWIX_URL wired to reach it by service name. The ZIM itself isn't part of the repo (large, self-contained archive) — data/kiwix/ gitignored same as the *.pmtiles files, setup steps in docs/development.adoc. No kiwix-serve/ZIM available here to test end-to-end — verified the app side against a stand-in local HTTP server (GET /api/kiwix flips available correctly) and confirmed the generated popup link matches the real geo-search query shape. docker-compose.yaml validated with `docker-compose config`. Full test suite: 118 runs, 0 failures.
This commit is contained in:
1 parent
7cd3171998
commit
24ac5931f8
13 files changed
+150
-9
No files matched your search
@@ -12,6 +12,11 @@
|
||||
# see docs/development.adoc)
|
||||
/data/*.pmtiles
|
||||
|
||||
# Offline Wikipedia ZIM for kiwix-serve (also large, also local-only —
|
||||
# same story, see docs/development.adoc)
|
||||
/data/kiwix/*
|
||||
!/data/kiwix/.gitkeep
|
||||
|
||||
# User-uploaded GPX files
|
||||
/data/gpx/*
|
||||
!/data/gpx/.gitkeep
|
||||
|
||||
@@ -14,7 +14,7 @@ independence from connectivity.
|
||||
* [ ] Contour lines from OSM for hikes and mountain passes
|
||||
* [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload, not called by the running app itself
|
||||
* [ ] Import Park4Night / iOverlander / Freecampsites data
|
||||
* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates
|
||||
* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link straight to kiwix-serve's own geo search (`GET /search?latitude=&longitude=&distance=`, confirmed from its actual source, `libkiwix/internalServer.cpp`), not proxied/embedded — it already has a full search UI. `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). Not live-tested against a real ZIM (none available here) — verified via a stand-in local HTTP server + real popup-link generation
|
||||
* [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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ require_relative "lib/maptool/gpx_store"
|
||||
require_relative "lib/maptool/gpsd_client"
|
||||
require_relative "lib/maptool/livetrack_store"
|
||||
require_relative "lib/maptool/livetrack_client"
|
||||
require_relative "lib/maptool/kiwix"
|
||||
|
||||
module Maptool
|
||||
class App < Sinatra::Base
|
||||
@@ -69,6 +70,12 @@ module Maptool
|
||||
# OAuth2 secrets: fine outside production, nothing works without it set.
|
||||
LIVETRACK_WEBHOOK_SECRET = ENV.fetch("LIVETRACK_WEBHOOK_SECRET", "")
|
||||
|
||||
# An external kiwix-serve instance (see docs/development.adoc), not
|
||||
# something this app runs itself — blank disables the "Nearby on
|
||||
# Wikipedia" popup link the same way the other optional-integration
|
||||
# blanks above do.
|
||||
KIWIX_URL = ENV.fetch("KIWIX_URL", "")
|
||||
|
||||
# Login delegates to the Gitea instance the user already has an account
|
||||
# on, rather than maintaining a local username/password — see
|
||||
# docs/architecture.adoc. Only users listed in ALLOWED_GITEA_USERS may
|
||||
@@ -199,6 +206,13 @@ module Maptool
|
||||
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
||||
end
|
||||
|
||||
# Pinged once per page load (public/js/map.js) — url is handed back so
|
||||
# the frontend can build the geo-search link itself; this server never
|
||||
# proxies to kiwix-serve, just says whether it's there.
|
||||
get "/api/kiwix" do
|
||||
json available: Kiwix.reachable?(KIWIX_URL), url: KIWIX_URL
|
||||
end
|
||||
|
||||
get "/api/gpx/tree" do
|
||||
json GPX_STORE.tree
|
||||
end
|
||||
|
||||
Whitespace-only changes.
@@ -21,6 +21,13 @@ services:
|
||||
# Optional: blank disables the LiveTrack webhook endpoint rather than
|
||||
# failing to start, since it's not needed for the app's core function.
|
||||
LIVETRACK_WEBHOOK_SECRET: ${LIVETRACK_WEBHOOK_SECRET:-}
|
||||
# The kiwix service below, reached by its compose service name (both
|
||||
# are on the same default compose network) — port matches its own
|
||||
# --port=8080 in the command below. Not made conditional on the ZIM
|
||||
# actually being present: GET /api/kiwix pings it and just reports
|
||||
# unavailable if the container isn't up/reachable, same story as
|
||||
# every other optional-local-file feature (offline.pmtiles, etc).
|
||||
KIWIX_URL: "http://kiwix:8080"
|
||||
volumes:
|
||||
# Uploaded GPX files and the offline PMTiles extract — kept outside
|
||||
# the image so a rebuild/redeploy doesn't wipe them (see
|
||||
@@ -43,3 +50,19 @@ services:
|
||||
restart: unless-stopped
|
||||
network_mode: "service:maptool"
|
||||
command: ["ruby", "bin/gnss_simulator.rb"]
|
||||
|
||||
# Serves an offline Wikipedia ZIM for the popup's "Nearby on Wikipedia"
|
||||
# link (see docs/development.adoc for where to get one and drop it in —
|
||||
# same "not part of the repo, place it under ./data locally" story as
|
||||
# offline.pmtiles/terrain.pmtiles). On the default compose network (not
|
||||
# network_mode: "service:maptool" — unlike gnss-simulator this doesn't
|
||||
# need a hardcoded localhost port, KIWIX_URL above reaches it by this
|
||||
# service's own name), so maptool and kiwix stay independent containers.
|
||||
# Keeps restarting (harmlessly) if data/kiwix/wikipedia.zim isn't there
|
||||
# yet — GET /api/kiwix just reports unavailable until it comes up.
|
||||
kiwix:
|
||||
image: ghcr.io/kiwix/kiwix-serve:latest
|
||||
restart: unless-stopped
|
||||
command: ["--port=8080", "/data/wikipedia.zim"]
|
||||
volumes:
|
||||
- ./data/kiwix:/data
|
||||
@@ -35,9 +35,11 @@ expect this to flip the other way, `/srv/docker` symlinked to
|
||||
below) — `Dockerfile`, `docker-compose.yaml`, application code.
|
||||
`podman compose` reads the same `docker-compose.yaml` — no
|
||||
Podman-specific compose file needed.
|
||||
* `data/`, *not* part of that sync — uploaded GPX files and
|
||||
`offline.pmtiles`. Persisted via a bind mount (`./data:/app/data` in
|
||||
`docker-compose.yaml`), so a redeploy never touches it.
|
||||
* `data/`, *not* part of that sync — uploaded GPX files,
|
||||
`offline.pmtiles`, `terrain.pmtiles`, and `kiwix/wikipedia.zim` (see
|
||||
`docs/development.adoc` for each). Persisted via a bind mount
|
||||
(`./data:/app/data` in `docker-compose.yaml`), so a redeploy never
|
||||
touches it.
|
||||
|
||||
== The native deploy runner
|
||||
|
||||
|
||||
@@ -72,6 +72,32 @@ something the running app calls itself — drop the output into
|
||||
mapped as a way/relation, e.g. a supermarket traced as a building
|
||||
outline rather than a single point).
|
||||
|
||||
== Offline Wikipedia (kiwix-serve)
|
||||
|
||||
A point popup's "Nearby on Wikipedia" link needs
|
||||
https://github.com/kiwix/kiwix-tools[kiwix-serve] running with a
|
||||
Wikipedia https://library.kiwix.org[ZIM] loaded — set `KIWIX_URL` to
|
||||
wherever it's reachable (`docker-compose.yaml` already runs a `kiwix`
|
||||
service pointed at `data/kiwix/wikipedia.zim` and wires `KIWIX_URL` to
|
||||
it; blank/unset disables the link, same as every other optional
|
||||
integration here). Not part of the repo — a ZIM is a large,
|
||||
self-contained archive, obtain one locally:
|
||||
|
||||
* https://library.kiwix.org/#lang=eng&category=wikipedia[Kiwix's own
|
||||
library] has pre-built Wikipedia ZIMs — a `_maxi` variant per
|
||||
language/region includes images, `_nopic` is much smaller if
|
||||
browsing images offline doesn't matter here.
|
||||
* Download it to `data/kiwix/wikipedia.zim` (the exact filename
|
||||
`docker-compose.yaml`'s `kiwix` service expects — rename after
|
||||
downloading, Kiwix's own filenames are much more specific).
|
||||
|
||||
Once it's there, `docker compose up -d kiwix` (or a full restart)
|
||||
picks it up — `GET /api/kiwix` reports `available: true` and the
|
||||
"Nearby on Wikipedia" link appears. kiwix-serve's own geo search
|
||||
(`GET /search?pattern=&latitude=&longitude=&distance=`,
|
||||
`lib/maptool/kiwix.rb`'s docstring) is linked to directly, not
|
||||
proxied/embedded — it already has a full search UI of its own.
|
||||
|
||||
== GNSS position (gpsd)
|
||||
|
||||
The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
require "net/http"
|
||||
|
||||
module Maptool
|
||||
# A thin reachability check for an external kiwix-serve instance serving
|
||||
# an offline Wikipedia ZIM (see docs/development.adoc for how to set one
|
||||
# up) — the app itself never talks to it beyond this ping. Linking a POI
|
||||
# popup to a geo search there (public/js/gpx.js) is a plain browser <a
|
||||
# href> straight to kiwix-serve, not something this server proxies —
|
||||
# kiwix-serve already has its own full search UI, no reason to rebuild it.
|
||||
module Kiwix
|
||||
def self.reachable?(url, open_timeout: 1, read_timeout: 2)
|
||||
return false if url.to_s.strip.empty?
|
||||
|
||||
uri = URI(url)
|
||||
Net::HTTP.start(uri.host, uri.port, open_timeout: open_timeout, read_timeout: read_timeout) { |http| http.head("/") }
|
||||
true
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1008,6 +1008,11 @@ html, body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gpx-popup__wiki {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gpx-popup__category {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
|
||||
+21
-4
@@ -702,7 +702,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 }))
|
||||
.setHTML(popupHtml(feature.properties, { editable, lngLat: event.lngLat }))
|
||||
.addTo(this._map);
|
||||
if (editable) this._wireNoteActions(popup, path, feature);
|
||||
});
|
||||
@@ -754,7 +754,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 });
|
||||
content.innerHTML = popupHtml(feature.properties, { editable: true, lngLat: popup.getLngLat() });
|
||||
this._wireNoteActions(popup, path, feature);
|
||||
});
|
||||
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
|
||||
@@ -1077,13 +1077,26 @@ function gpxFileUrl(path) {
|
||||
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 —
|
||||
// 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
|
||||
// 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.
|
||||
function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) {
|
||||
// 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 } = {}) {
|
||||
const parts = [];
|
||||
if (name) {
|
||||
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
|
||||
@@ -1104,6 +1117,10 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab
|
||||
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) {
|
||||
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>`,
|
||||
|
||||
+6
-1
@@ -5,7 +5,7 @@ import {
|
||||
addProtocol,
|
||||
} 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 { GnssControl } from "/js/gnss.js";
|
||||
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
||||
import { TrackProfilePanel } from "/js/track_profile.js";
|
||||
@@ -609,6 +609,11 @@ fetch("/api/terrain-tiles")
|
||||
.then(({ available }) => hillshadeControl.setOfflineAvailable(available))
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/kiwix")
|
||||
.then((response) => response.json())
|
||||
.then(setKiwixConfig)
|
||||
.catch(() => {});
|
||||
|
||||
// Alt+click anywhere shows every rendered feature at that point — base map
|
||||
// (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it —
|
||||
// with its raw properties, inspired by NPMap4's infoboxControl (a generic
|
||||
|
||||
@@ -23,6 +23,11 @@ class AppTest < Minitest::Test
|
||||
assert_equal({ "available" => false }, json_body(response))
|
||||
end
|
||||
|
||||
def test_kiwix_reports_unavailable_when_no_url_configured
|
||||
response = request.get("/api/kiwix")
|
||||
assert_equal({ "available" => false, "url" => "" }, json_body(response))
|
||||
end
|
||||
|
||||
def test_gnss_position_unavailable_with_no_fix
|
||||
response = request.get("/api/gnss/position", **logged_in_env)
|
||||
assert_equal({ "available" => false }, json_body(response))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/kiwix"
|
||||
|
||||
# No real network calls — a blank URL and a guaranteed-local connection
|
||||
# refusal are both fully offline/deterministic; simulating a genuine
|
||||
# reachable kiwix-serve would need spinning up a real HTTP server for what
|
||||
# is, at heart, a two-line Net::HTTP wrapper — not worth it, same
|
||||
# restraint LiveTrackClientTest already applies to its own network path.
|
||||
class KiwixTest < Minitest::Test
|
||||
def test_reachable_is_false_for_a_blank_url
|
||||
refute Maptool::Kiwix.reachable?("")
|
||||
refute Maptool::Kiwix.reachable?(nil)
|
||||
end
|
||||
|
||||
def test_reachable_is_false_when_nothing_is_listening
|
||||
refute Maptool::Kiwix.reachable?("http://127.0.0.1:1", open_timeout: 1)
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user