From c5b4a9ffb2fe554477ecd8c9e3d20dfa35db0fcc Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Thu, 10 Sep 2026 20:54:40 +0200 Subject: [PATCH] Move the Wikipedia layer into its own "Overlays" panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wikipedia doesn't belong inside "Tracks & Points" — that panel is for files you add/remove/reorder, while Wikipedia is a live layer with nothing to manage, just on/off. Gave it its own sidebar panel (#overlays-panel, same shell as LiveTrackPanelControl) instead, sized to hold more such checklist-only layers later without a redesign. Moved all the wiki layer logic out of GpxPanelControl into a new OverlaysControl (public/js/overlays.js). svgToImageData/badgeImageId/ escapeHtml are now exported from gpx.js so it doesn't have to reimplement them — the same three helpers a second overlay would also need. --- public/css/style.css | 36 ++++---- public/js/gpx.js | 168 ++---------------------------------- public/js/icons.js | 10 +++ public/js/map.js | 7 +- public/js/overlays.js | 195 ++++++++++++++++++++++++++++++++++++++++++ views/index.erb | 28 ++++-- 6 files changed, 257 insertions(+), 187 deletions(-) create mode 100644 public/js/overlays.js diff --git a/public/css/style.css b/public/css/style.css index 8abd8e1..cf420b3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -373,21 +373,6 @@ 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 @@ -1189,6 +1174,27 @@ html, body { 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; +} + .livetrack-panel__list { flex: 1; overflow-y: auto; diff --git a/public/js/gpx.js b/public/js/gpx.js index 4346fc2..e0d3938 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -11,7 +11,6 @@ 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"; @@ -28,13 +27,6 @@ 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. @@ -54,9 +46,6 @@ 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 @@ -68,7 +57,6 @@ 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 @@ -126,17 +114,6 @@ 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"; @@ -148,12 +125,6 @@ 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()) { @@ -953,110 +924,6 @@ 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 @@ -1193,7 +1060,11 @@ function uploadFile(formData, onUploadComplete) { // fixed square size (the source SVGs have no explicit width/height, just a // viewBox, so one is injected — otherwise an falls back to the CSS // 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 // app get away without it — those are inlined via innerHTML, where the // HTML parser assigns the SVG namespace from context, but a standalone @@ -1216,7 +1087,7 @@ async function svgToImageData(svgMarkup, size) { } } -function badgeImageId(color) { +export function badgeImageId(color) { return `${BADGE_IMAGE_PREFIX}${color}`; } @@ -1232,21 +1103,6 @@ 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: "" }; -// 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 — // 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 @@ -1282,16 +1138,6 @@ 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 = [`
${escapeHtml(name)}
`]; - parts.push(`

Read on Wikipedia →

`); - 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 = []; @@ -1314,7 +1160,7 @@ function formatDuration(totalSeconds) { return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } -function escapeHtml(text) { +export function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; diff --git a/public/js/icons.js b/public/js/icons.js index 113ba15..eaff0de 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -7,6 +7,16 @@ export const LAYERS_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 = ` + + + +`; + export const HILLSHADE_ICON = ` diff --git a/public/js/map.js b/public/js/map.js index 341635b..854b51a 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -5,7 +5,8 @@ import { addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; -import { GpxPanelControl, setKiwixConfig } from "/js/gpx.js"; +import { GpxPanelControl } from "/js/gpx.js"; +import { OverlaysControl } from "/js/overlays.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; @@ -556,6 +557,8 @@ profilePanel.mount(); sidebar.insertAdjacentHTML("beforeend", ``); new GpxPanelControl(map, profilePanel).mount(sidebar); +const overlaysControl = new OverlaysControl(map); +overlaysControl.mount(sidebar); const hillshadeControl = new HillshadeControl(map); hillshadeControl.mount(sidebar); // Running a plugin needs a login (POST /api/plugins/:name/run) — same @@ -615,7 +618,7 @@ fetch("/api/terrain-tiles") fetch("/api/kiwix") .then((response) => response.json()) - .then(setKiwixConfig) + .then(({ available }) => overlaysControl.setWikiAvailable(available)) .catch(() => {}); // Alt+click anywhere shows every rendered feature at that point — base map diff --git a/public/js/overlays.js b/public/js/overlays.js new file mode 100644 index 0000000..0b0be3e --- /dev/null +++ b/public/js/overlays.js @@ -0,0 +1,195 @@ +import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; +import { OVERLAYS_ICON, WIKI_ICON, MAP_MARKER_ICON } from "/js/icons.js"; +import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; +import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.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"; + +// A sidebar side panel (same shell as LiveTrackPanelControl/#livetrack-panel) +// listing live map layers that aren't a base map, a Tracks & Points file, or +// a fixed on/off toggle (Hillshade) — each one refetched from its own +// source as the map moves, not something to add/remove/reorder the way +// Tracks & Points' file tree is, so this is a plain checklist. Wikipedia is +// the first entry; more can join the same #overlays-panel list later +// without a redesign. +export class OverlaysControl { + constructor(map) { + this._map = 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. + 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 = ``; + 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)); + this._wikiToggle = this._panel.querySelector(".overlays-panel__wiki-toggle"); + this._wikiToggle.addEventListener("change", (event) => { + if (event.target.checked) this._enableWikiLayer(); + else this._disableWikiLayer(); + }); + + 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); + } + + // 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 a future second overlay could still be usable with no kiwix-serve + // configured at all. + setWikiAvailable(available) { + this._wikiToggle.disabled = !available; + if (!available) this._wikiToggle.closest(".overlays-panel__item").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 = [`
${escapeHtml(name)}
`]; + parts.push(`

Read on Wikipedia →

`); + return parts.join(""); +} diff --git a/views/index.erb b/views/index.erb index c8c25f9..fadeb1a 100644 --- a/views/index.erb +++ b/views/index.erb @@ -58,19 +58,29 @@ - - -
+ +