Mapterhorn (mapterhorn.com, Protomaps-affiliated) publishes pre-built Terrarium-encoded terrain as PMTiles: a global planet.pmtiles (z0-12) plus ~450 regional chunks (z13+). Same pmtiles extract --bbox=... mechanism offline.pmtiles already uses, just against their files — confirmed live against their real manifest and the documented Interlaken example. Replaces fetching every individual tile ourselves (even after this session's earlier concurrency fix, still tens of minutes for a real region) with 1-3 extract calls plus a merge when the bbox spans more than one chunk: seconds, not minutes. Removes CONCURRENT_TILE_FETCHES, write_terrarium_mbtiles, tile_range, lng/lat_to_tile_x/y, fetch_terrarium_tile, and the sqlite3 gem entirely — none of it is needed once the tiles arrive pre-packaged. dem.js's point-elevation lookup (shadow.js's terrain-shadow overlay) hardcoded a 256px PNG tile assumption that Mapterhorn's 512px WebP tiles would have silently broken — now reads the actually-decoded tile's own size and mime type instead of assuming either.
347 lines
14 KiB
JavaScript
347 lines
14 KiB
JavaScript
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";
|
|
|
|
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";
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
mount(li) {
|
|
li.innerHTML = `
|
|
<label>
|
|
<input type="checkbox" class="overlays-panel__toggle">
|
|
<span class="overlays-panel__icon">${WILDERNESS_ICON}</span>
|
|
<span>Terrain shading</span>
|
|
</label>
|
|
`;
|
|
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;
|
|
if (active) this._addLayer();
|
|
else this._removeLayer();
|
|
}
|
|
|
|
_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._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 = `<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));
|
|
|
|
const list = this._panel.querySelector(".overlays-panel__list");
|
|
list.innerHTML = `
|
|
<li class="overlays-panel__item"></li>
|
|
<li class="overlays-panel__item"></li>
|
|
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
|
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
|
<li class="overlays-panel__item"></li>
|
|
`;
|
|
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem] = list.children;
|
|
this._hillshade.mount(hillshadeItem);
|
|
this._mountWiki(wikiItem);
|
|
this._radar.mount(radarItem);
|
|
this._shadow.mount(shadowItem);
|
|
this._contour.mount(contourItem);
|
|
|
|
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);
|
|
}
|
|
|
|
setShadowOfflineAvailable(available) {
|
|
this._shadow.setOfflineAvailable(available);
|
|
}
|
|
|
|
_mountWiki(li) {
|
|
li.innerHTML = `
|
|
<label>
|
|
<input type="checkbox" class="overlays-panel__toggle">
|
|
<span class="overlays-panel__icon">${WIKI_ICON}</span>
|
|
<span>Wikipedia</span>
|
|
</label>
|
|
`;
|
|
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 = [`<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("");
|
|
}
|