Compare commits

...
2 Commits
Author SHA1 Message Date
randy cd02b03502 Retry pmtiles extract/merge on failure, not just on the first try
CI / deploy (push) Successful in 12s
CI / check (push) Successful in 39s
2026-09-12 12:50:09 +02:00
randy 5c3151c2f5 Add a 3D pitch toggle, with raised terrain following it 2026-09-12 12:50:09 +02:00
4 changed files with 131 additions and 2 deletions

No files matched your search

+9 -2
View File
@@ -629,9 +629,16 @@ module Maptool
end end
private_class_method :command_output private_class_method :command_output
def self.run_pmtiles!(*args) # extract/merge stream tens of GB over HTTP range requests; retry a
# transient failure (e.g. a mid-transfer HTTP/2 stream reset from the
# remote) a couple times before giving up, rather than forcing a full
# re-download on every hiccup.
def self.run_pmtiles!(*args, attempts: 3)
attempts.times do |i|
stdout, stderr, status = Open3.capture3(pmtiles_bin, *args) stdout, stderr, status = Open3.capture3(pmtiles_bin, *args)
raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" unless status.success? return if status.success?
raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" if i == attempts - 1
end
rescue Errno::ENOENT, Errno::EACCES => e rescue Errno::ENOENT, Errno::EACCES => e
raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc." raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc."
end end
+11
View File
@@ -1324,6 +1324,17 @@ html, body {
user-select: none; user-select: none;
} }
.pitch-toggle-ctrl__button {
width: 29px;
height: 29px;
font-size: 11px;
font-weight: 600;
}
.pitch-toggle-ctrl__button.active {
background: #dbe9ff;
}
.gpx-popup__header { .gpx-popup__header {
display: flex; display: flex;
align-items: center; align-items: center;
+62
View File
@@ -355,6 +355,65 @@ class ZoomLevelControl {
} }
} }
// Tilts the camera between a flat top-down view and a fixed 3D-perspective
// pitch — bearing stays untouched (this map never rotates, see the
// NavigationControl comment below), so this is purely "look at it from an
// angle", not a rotate/compass control. Independent of the "Terrain
// shading" overlay toggle (overlays.js) — that adds a raster-dem hillshade/
// color-relief layer, this just moves the camera; combining both gives a
// real sense of relief but neither depends on the other.
const PITCH_3D = 60;
class PitchToggleControl {
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group pitch-toggle-ctrl";
this._button = document.createElement("button");
this._button.type = "button";
this._button.className = "pitch-toggle-ctrl__button";
this._button.addEventListener("click", () => this._toggle());
this._container.appendChild(this._button);
this._update = () => this._render();
map.on("pitchend", this._update);
this._render();
return this._container;
}
onRemove() {
this._map.off("pitchend", this._update);
this._container.remove();
}
getDefaultPosition() {
return "top-left";
}
// Set once overlaysControl exists (it's built later in this file) —
// lets raised terrain (OverlaysControl.syncTerrain, see overlays.js)
// follow the pitch toggle without this control needing to know anything
// about hillshade/DEM sources itself.
setOverlaysControl(overlaysControl) {
this._overlaysControl = overlaysControl;
this._overlaysControl.syncTerrain(this._map.getPitch() > 0);
}
_toggle() {
this._map.easeTo({ pitch: this._map.getPitch() > 0 ? 0 : PITCH_3D, duration: 300 });
}
_render() {
const is3d = this._map.getPitch() > 0;
this._button.textContent = is3d ? "2D" : "3D";
this._button.title = is3d ? "Switch to 2D view" : "Switch to 3D view";
this._button.setAttribute("aria-label", this._button.title);
this._button.classList.toggle("active", is3d);
this._overlaysControl?.syncTerrain(is3d);
}
}
const map = new MaplibreMap({ const map = new MaplibreMap({
container: "map", container: "map",
style: ONLINE_STYLE, style: ONLINE_STYLE,
@@ -391,6 +450,8 @@ map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left");
// (NavigationControl's default third button) would just be dead weight. // (NavigationControl's default third button) would just be dead weight.
map.addControl(new NavigationControl({ showCompass: false }), "top-left"); map.addControl(new NavigationControl({ showCompass: false }), "top-left");
map.addControl(new ZoomLevelControl(), "top-left"); map.addControl(new ZoomLevelControl(), "top-left");
const pitchToggle = new PitchToggleControl();
map.addControl(pitchToggle, "top-left");
map.addControl(new MeasureControl(), "top-left"); map.addControl(new MeasureControl(), "top-left");
// Writing a note needs a login (POST /api/gpx/notes) — no point adding a // Writing a note needs a login (POST /api/gpx/notes) — no point adding a
// button that can only ever end in a 401 for a logged-out viewer, same // button that can only ever end in a 401 for a logged-out viewer, same
@@ -487,6 +548,7 @@ sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title=
new GpxPanelControl(map, profilePanel).mount(sidebar); new GpxPanelControl(map, profilePanel).mount(sidebar);
const overlaysControl = new OverlaysControl(map); const overlaysControl = new OverlaysControl(map);
overlaysControl.mount(sidebar); overlaysControl.mount(sidebar);
pitchToggle.setOverlaysControl(overlaysControl);
// Running a plugin needs a login (POST /api/plugins/:name/run) — same // Running a plugin needs a login (POST /api/plugins/:name/run) — same
// "hide the write-only affordance" story as NotesControl above. // "hide the write-only affordance" story as NotesControl above.
if (isLoggedIn()) new PluginsControl(map).mount(sidebar); if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
+49
View File
@@ -16,6 +16,16 @@ const WIKI_BADGE_COLOR = "#795548";
const HILLSHADE_ID = "hillshade-dem"; const HILLSHADE_ID = "hillshade-dem";
const COLOR_RELIEF_ID = "elevation-tint"; 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 // 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 // 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 generic rainbow ramp, so it reads as part of the same map rather than
@@ -52,6 +62,7 @@ class HillshadeControl {
// sources/layers — put hillshade back if it was on. // sources/layers — put hillshade back if it was on.
map.on("style.load", () => { map.on("style.load", () => {
if (this._active) this._addLayer(); if (this._active) this._addLayer();
this.syncTerrain(this._map.getPitch() > 0);
}); });
} }
@@ -73,8 +84,39 @@ class HillshadeControl {
_toggle(active) { _toggle(active) {
this._active = 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(); if (active) this._addLayer();
else this._removeLayer(); 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) {
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() { _addLayer() {
@@ -194,6 +236,13 @@ export class OverlaysControl {
this._hillshade.setOfflineAvailable(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) { setShadowOfflineAvailable(available) {
this._shadow.setOfflineAvailable(available); this._shadow.setOfflineAvailable(available);
} }