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
+21
-15
@@ -373,21 +373,6 @@ html, body {
|
|||||||
color: #666;
|
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 {
|
.gpx-tree {
|
||||||
/* flex:1 (not just overflow-y) so the tree fills the rest of the panel
|
/* 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
|
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);
|
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 {
|
.livetrack-panel__list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
+7
-161
@@ -11,7 +11,6 @@ import {
|
|||||||
MAP_MARKER_ICON,
|
MAP_MARKER_ICON,
|
||||||
POI_ICONS,
|
POI_ICONS,
|
||||||
PARK4NIGHT_TAGS,
|
PARK4NIGHT_TAGS,
|
||||||
WIKI_ICON,
|
|
||||||
} from "/js/icons.js";
|
} from "/js/icons.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js";
|
||||||
import { formatActivityType } from "/js/track_profile.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 MAP_MARKER_SIZE = 0.55;
|
||||||
const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors
|
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
|
// 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
|
// 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.
|
// 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._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._hiddenCategories = new Set(); // category names toggled off in the legend
|
||||||
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
|
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
|
// setStyle() (switching the Online/Offline base map) tears down all
|
||||||
// custom sources/layers/images — re-register the category glyphs before
|
// custom sources/layers/images — re-register the category glyphs before
|
||||||
@@ -68,7 +57,6 @@ export class GpxPanelControl {
|
|||||||
map.on("style.load", () => {
|
map.on("style.load", () => {
|
||||||
this._iconsReady = this._registerCategoryGlyphs();
|
this._iconsReady = this._registerCategoryGlyphs();
|
||||||
this._visible.forEach((path) => this._addLayers(path));
|
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
|
// 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) {
|
mount(sidebar) {
|
||||||
this._container = document.createElement("div");
|
this._container = document.createElement("div");
|
||||||
this._container.className = "sidebar-item";
|
this._container.className = "sidebar-item";
|
||||||
@@ -148,12 +125,6 @@ export class GpxPanelControl {
|
|||||||
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
||||||
this._legendContainer = this._panel.querySelector(".gpx-legend");
|
this._legendContainer = this._panel.querySelector(".gpx-legend");
|
||||||
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
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="new-folder"]').innerHTML = NEW_FOLDER_ICON;
|
||||||
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
||||||
if (!isLoggedIn()) {
|
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) {
|
async _uploadFiles(fileList, folder) {
|
||||||
// .zip: Garmin's bulk/FIT export format — the server expands it into
|
// .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
|
// 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
|
// fixed square size (the source SVGs have no explicit width/height, just a
|
||||||
// viewBox, so one is injected — otherwise an <img> falls back to the CSS
|
// viewBox, so one is injected — otherwise an <img> falls back to the CSS
|
||||||
// default replaced-element size instead of the viewBox's aspect).
|
// 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
|
// 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
|
// app get away without it — those are inlined via innerHTML, where the
|
||||||
// HTML parser assigns the SVG namespace from context, but a standalone
|
// 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}`;
|
return `${BADGE_IMAGE_PREFIX}${color}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,21 +1103,6 @@ function gpxFileUrl(path) {
|
|||||||
return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`;
|
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 —
|
// properties come straight from an uploaded GPX file — untrusted input —
|
||||||
// so every value is escaped before landing in the popup's HTML.
|
// 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
|
// 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("") : "<em>No details</em>";
|
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.
|
// properties come straight from an uploaded GPX/FIT file — untrusted input.
|
||||||
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -1314,7 +1160,7 @@ function formatDuration(totalSeconds) {
|
|||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
export function escapeHtml(text) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ export const LAYERS_ICON = `
|
|||||||
<path d="M2 14l10 6 10-6"/>
|
<path d="M2 14l10 6 10-6"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<circle cx="9" cy="12" r="6"/>
|
||||||
|
<circle cx="15" cy="12" r="6"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
export const HILLSHADE_ICON = `
|
export const HILLSHADE_ICON = `
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||||
<path d="M2 18 9 7l3.5 5.5L15 9l7 9z"/>
|
<path d="M2 18 9 7l3.5 5.5L15 9l7 9z"/>
|
||||||
|
|||||||
+5
-2
@@ -5,7 +5,8 @@ import {
|
|||||||
addProtocol,
|
addProtocol,
|
||||||
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
import { RadarControl } from "/js/radar.js";
|
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 { GnssControl } from "/js/gnss.js";
|
||||||
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
||||||
import { TrackProfilePanel } from "/js/track_profile.js";
|
import { TrackProfilePanel } from "/js/track_profile.js";
|
||||||
@@ -556,6 +557,8 @@ profilePanel.mount();
|
|||||||
|
|
||||||
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`);
|
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`);
|
||||||
new GpxPanelControl(map, profilePanel).mount(sidebar);
|
new GpxPanelControl(map, profilePanel).mount(sidebar);
|
||||||
|
const overlaysControl = new OverlaysControl(map);
|
||||||
|
overlaysControl.mount(sidebar);
|
||||||
const hillshadeControl = new HillshadeControl(map);
|
const hillshadeControl = new HillshadeControl(map);
|
||||||
hillshadeControl.mount(sidebar);
|
hillshadeControl.mount(sidebar);
|
||||||
// Running a plugin needs a login (POST /api/plugins/:name/run) — same
|
// Running a plugin needs a login (POST /api/plugins/:name/run) — same
|
||||||
@@ -615,7 +618,7 @@ fetch("/api/terrain-tiles")
|
|||||||
|
|
||||||
fetch("/api/kiwix")
|
fetch("/api/kiwix")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then(setKiwixConfig)
|
.then(({ available }) => overlaysControl.setWikiAvailable(available))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Alt+click anywhere shows every rendered feature at that point — base map
|
// Alt+click anywhere shows every rendered feature at that point — base map
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
}
|
||||||
+19
-9
@@ -58,19 +58,29 @@
|
|||||||
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.geojson,.zip" multiple hidden>
|
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.geojson,.zip" multiple hidden>
|
||||||
<p class="gpx-upload__status" hidden></p>
|
<p class="gpx-upload__status" hidden></p>
|
||||||
|
|
||||||
<!-- Not a real file in the tree below: a live layer, refetched from the
|
|
||||||
offline Wikipedia ZIM (via kiwix-serve) for whatever's on screen as
|
|
||||||
the map pans/zooms, not a saved .geojson. Hidden entirely when no
|
|
||||||
kiwix-serve is configured (see gpx.js's mount()). -->
|
|
||||||
<label class="gpx-panel__wiki" hidden>
|
|
||||||
<input type="checkbox" class="gpx-panel__wiki-toggle">
|
|
||||||
<span>Wikipedia</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="gpx-tree"></div>
|
<div class="gpx-tree"></div>
|
||||||
<div class="gpx-legend" hidden></div>
|
<div class="gpx-legend" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="overlays-panel" class="basemap-panel" hidden>
|
||||||
|
<div class="basemap-panel__header">
|
||||||
|
<strong>Overlays</strong>
|
||||||
|
<button type="button" class="basemap-panel__close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<!-- Each item is a live layer refetched from its own source as the map
|
||||||
|
moves, not a file to add/remove/reorder — a plain on/off list, no
|
||||||
|
upload/new-folder actions the way Tracks & Points has. Wikipedia is
|
||||||
|
the first; see public/js/overlays.js for how one of these works. -->
|
||||||
|
<ul class="overlays-panel__list">
|
||||||
|
<li class="overlays-panel__item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" class="overlays-panel__wiki-toggle">
|
||||||
|
<span>Wikipedia</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="livetrack-panel" class="basemap-panel" hidden>
|
<div id="livetrack-panel" class="basemap-panel" hidden>
|
||||||
<div class="basemap-panel__header">
|
<div class="basemap-panel__header">
|
||||||
<strong>LiveTrack sessions</strong>
|
<strong>LiveTrack sessions</strong>
|
||||||
|
|||||||
Reference in new issue
Block a user