Move the Wikipedia layer into its own "Overlays" panel
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.
This commit is contained in:
1 parent
787df5478d
commit
c5b4a9ffb2
6 files changed
+257
-187
No files matched your search
@@ -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 = `<button type="button" class="sidebar-icon" title="Overlays" aria-label="Overlays">${OVERLAYS_ICON}</button>`;
|
||||
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 = [`<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("");
|
||||
}
|
||||
Reference in new issue
Block a user