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(""); }