Link POI popups to offline Wikipedia via kiwix-serve
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s

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:
randy committed 2026-09-09 13:44:37 +02:00
1 parent 7cd3171998
commit 24ac5931f8
13 files changed
+150 -9

No files matched your search

+5
View File
@@ -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
View File
@@ -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
View File
@@ -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