diff --git a/public/css/style.css b/public/css/style.css index b3fc4e9..09d79af 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1324,6 +1324,17 @@ html, body { 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 { display: flex; align-items: center; diff --git a/public/js/map.js b/public/js/map.js index dce4127..1da1aae 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -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({ container: "map", 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. map.addControl(new NavigationControl({ showCompass: false }), "top-left"); map.addControl(new ZoomLevelControl(), "top-left"); +const pitchToggle = new PitchToggleControl(); +map.addControl(pitchToggle, "top-left"); map.addControl(new MeasureControl(), "top-left"); // 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 @@ -487,6 +548,7 @@ sidebar.insertAdjacentHTML("beforeend", `