import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { OVERLAYS_ICON, WIKI_ICON, WILDERNESS_ICON, MAP_MARKER_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js"; import { RadarControl } from "/js/radar.js"; import { ShadowControl } from "/js/shadow.js"; import { ContourControl } from "/js/contour.js"; import { HikingTrailsControl } from "/js/hiking_trails.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"; const HILLSHADE_ID = "hillshade-dem"; const COLOR_RELIEF_ID = "elevation-tint"; // MapLibre's own default light ({anchor:"viewport", intensity:0.5, // position:[1.15,210,30]}) sits the sun almost overhead (polar 30°) — fine // for flat 2D, but it barely rakes across 3D terrain at all, which is // most of why real relief still looked flat/washed out even once the mesh // itself was raised. Lower polar angle = lower sun = longer, more visible // slope shadows; azimuth 315 is the classic NW hillshade convention. // anchor:"map" (not "viewport") keeps the sun fixed to geography rather // than spinning with the camera as you look around a tilted view. const TERRAIN_LIGHT = { anchor: "map", color: "#fff", intensity: 0.6, position: [1.5, 315, 65] }; const DEFAULT_LIGHT = { anchor: "viewport", color: "#fff", intensity: 0.5, position: [1.15, 210, 30] }; // Hypsometric tint reusing this app's own palette (wood_a's green at // valley floor, the road casing brown through the mid-slopes) rather than // a generic rainbow ramp, so it reads as part of the same map rather than // a bolted-on overlay. Stops picked for Central European terrain — the // Tatras top out around 2600m — not a general-purpose world ramp. const ELEVATION_COLOR_RAMP = [ "interpolate", ["linear"], ["raster-value"], 0, "#c3e8ab", 400, "#dcd08f", 900, "#c9a66b", 1600, "#a89a86", 2600, "#f9f6ef", ]; // Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived) // elevation tiles OpenStreetMap's own hillshade layer and many other free // tools use. Only reachable online; while the offline basemap is active this // is skipped in favor of a local terrain.pmtiles if one's been generated // (see docs/development.adoc), same optional-local-file story as // offline.pmtiles itself. // Exported: dem.js's elevation lookups (shadow.js's raymarch) hit the same // online tile source, rather than duplicating the URL template. export const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"]; // A plain on/off Overlays item — there's nothing to choose, just whether // the shading shows, unlike Radar's expandable scrub/forecast. class HillshadeControl { constructor(map) { this._map = map; this._active = false; this._offlineAvailable = false; // setStyle() (switching Online/Offline) tears down all custom // sources/layers — put hillshade back if it was on. map.on("style.load", () => { if (this._active) this._addLayer(); this.syncTerrain(this._map.getPitch() > 0); }); } mount(li) { li.innerHTML = ` `; this._checkbox = li.querySelector(".overlays-panel__toggle"); this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked)); } setOfflineAvailable(available) { this._offlineAvailable = available; } _toggle(active) { this._active = active; // Clear first: setTerrain may still be pointing at HILLSHADE_ID from // before, and removing that source out from under it is worth avoiding // even for the instant before syncTerrain below re-evaluates it. if (this._map.getTerrain()) this._map.setTerrain(null); if (active) this._addLayer(); else this._removeLayer(); this.syncTerrain(this._map.getPitch() > 0); } // Real terrain displacement (map.setTerrain), not just the flat // hillshade/color-relief paint above — called both when this checkbox // changes and when the 3D pitch toggle changes (map.js's // PitchToggleControl, via OverlaysControl.syncTerrain below), since // showing raised terrain needs both "tilted enough to see it" and "the // DEM source this checkbox controls is actually loaded". syncTerrain(pitched) { // setLight/setTerrain throw if the style isn't loaded yet — called // synchronously from map.js's setOverlaysControl during page init, // before the first style.load; that listener re-invokes this once it // actually is loaded, so bailing here just skips the premature call. if (!this._map.isStyleLoaded()) return; const terrainOn = pitched && this._active && this._map.getSource(HILLSHADE_ID); if (terrainOn) { this._map.setTerrain({ source: HILLSHADE_ID, exaggeration: 3 }); } else if (this._map.getTerrain()) { this._map.setTerrain(null); } this._map.setLight(terrainOn ? TERRAIN_LIGHT : DEFAULT_LIGHT); // The raster hillshade layer fakes relief by shading a flat texture — // once real 3D geometry is doing that job (with its own lit shading // for free), the raster version just looks like a blurry, low-res // texture pasted on top of it. It's the only way to suggest relief in // flat 2D though, so only hide it once real terrain is actually up. // The color-relief elevation tint stays regardless — that's // colour-by-height information, not a shading trick. if (this._map.getLayer(HILLSHADE_ID)) { this._map.setLayoutProperty(HILLSHADE_ID, "visibility", terrainOn ? "none" : "visible"); } } _addLayer() { if (this._map.getSource(HILLSHADE_ID)) return; // terrain.pmtiles is built from Mapterhorn (mapterhorn.com — see // lib/maptool/pmtiles_builder.rb) whether generated in-app or by hand // (docs/development.adoc), so this credits them regardless of how it // got there — their own site lists the full per-region source // provenance (CC-BY-4.0 and similar), not practical to reproduce here. const source = this._offlineAvailable ? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium", attribution: "Terrain: Mapterhorn", } : { type: "raster-dem", tiles: ONLINE_TERRAIN_TILES, tileSize: 256, encoding: "terrarium", maxzoom: 15, attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM", }; this._map.addSource(HILLSHADE_ID, source); // Elevation tint under, hillshade on top: the tint sets the base color // per elevation band, hillshade multiplies slope-shading over it — the // combined "characteristic relief look" the TODO asked for, not two // independent layers. this._map.addLayer({ id: COLOR_RELIEF_ID, type: "color-relief", source: HILLSHADE_ID, paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 }, }); this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } }); } _removeLayer() { if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID); if (this._map.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID); if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID); } } // A sidebar side panel (same shell as LiveTrackPanelControl/#livetrack-panel) // listing live map layers that aren't a base map or a Tracks & Points file — // each one refetched/toggled live rather than something to add/remove/ // reorder, so this is a plain checklist, its items built here (like // PluginsControl's own list) rather than as static markup. Radar and // Terrain shading used to be their own separate sidebar icons; both moved // here since neither has anything to "manage," just on/off (Radar's own // scrub/forecast controls live behind expanding its item, not a factor in // where it's mounted). export class OverlaysControl { constructor(map) { this._map = map; this._hillshade = new HillshadeControl(map); this._radar = new RadarControl(map); this._shadow = new ShadowControl(map); this._contour = new ContourControl(map); this._hikingTrails = new HikingTrailsControl(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. Hillshade/Radar // handle their own the same way, from their own constructors above. 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)); const list = this._panel.querySelector(".overlays-panel__list"); list.innerHTML = `
  • `; const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children; this._hillshade.mount(hillshadeItem); this._mountWiki(wikiItem); this._radar.mount(radarItem); this._shadow.mount(shadowItem); this._contour.mount(contourItem); this._hikingTrails.mount(hikingTrailsItem); 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); } setHillshadeOfflineAvailable(available) { this._hillshade.setOfflineAvailable(available); } // Called by map.js's PitchToggleControl whenever the 3D pitch toggle // changes, so raised terrain follows the camera tilt rather than needing // the "Terrain shading" checkbox re-clicked to notice. syncTerrain(pitched) { this._hillshade.syncTerrain(pitched); } setShadowOfflineAvailable(available) { this._shadow.setOfflineAvailable(available); } _mountWiki(li) { li.innerHTML = ` `; this._wikiItem = li; this._wikiToggle = li.querySelector(".overlays-panel__toggle"); this._wikiToggle.addEventListener("change", (event) => { if (event.target.checked) this._enableWikiLayer(); else this._disableWikiLayer(); }); } // 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 the other overlays are still usable with no kiwix-serve // configured at all. setWikiAvailable(available) { this._wikiToggle.disabled = !available; if (!available) this._wikiItem.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(""); }