Replace the per-popup "Nearby on Wikipedia" link with a live Wikipedia layer

The old link only ever surfaced one nearby article at a time, buried
in whatever POI's popup you happened to open, and only for uploaded
files with a category. Replaced it with a standalone "Wikipedia"
toggle under the renamed "Tracks & Points" panel: enabling it adds a
live map layer that refetches GET /api/kiwix/nearby on every pan/zoom
and plots every nearby article as its own point.

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

Clicking a point opens a small popup linking to the article (proxied
through the existing /kiwix/* passthrough). New WIKI_ICON (NPS's
bookstore-22.svg) marks these, kept out of POI_ICONS since that set
doubles as notes.js's icon-picker option list and this fixed-glyph
layer has no per-feature category to key off of.
This commit is contained in:
randy committed 2026-09-10 20:45:41 +02:00
1 parent 47e3f9c7e8
commit 787df5478d
9 files changed
+354 -14

No files matched your search

+15
View File
@@ -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
+153 -12
View File
@@ -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 = `<button type="button" class="sidebar-icon" title="Tracks" aria-label="Tracks">${GPX_ICON}</button>`;
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Tracks &amp; Points" aria-label="Tracks &amp; Points">${GPX_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon");
this._icon.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] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
@@ -1139,10 +1274,6 @@ 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>`,
@@ -1151,6 +1282,16 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
}
// 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 = [`<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("");
}
// properties come straight from an uploaded GPX/FIT file — untrusted input.
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
const parts = [];
+18
View File
@@ -353,6 +353,24 @@ Object.assign(POI_ICONS, {
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 = `
<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"/>