diff --git a/public/css/style.css b/public/css/style.css index 5d92969..3271029 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -397,6 +397,68 @@ html, body { visibility: visible; } +/* Docked detail panel for a clicked hiking trail — replaces a map-anchored + Popup (which routinely covered up the very trail line it described) with + a fixed side panel, same "click info moves off the map" idea + track_profile.js already established for clicking a GPX track line + (TrackProfilePanel). .trail-panel__body gets the exact padding + .maplibregl-popup-content used (15px 10px) so routeHtml()'s own output — + built for that popup, including its header's canceling negative margin — + renders here unchanged; no separate panel-flavored template needed. */ +.trail-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 300px; + max-width: 90vw; + background: #fff; + border-left: 1px solid #ddd; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + z-index: 10; +} + +.trail-panel__body { + height: 100%; + box-sizing: border-box; + overflow-y: auto; + padding: 15px 10px; + font-size: 13px; +} + +.trail-panel__close { + position: absolute; + top: 6px; + right: 6px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.85); + font-size: 18px; + line-height: 1; + padding: 2px 7px; + cursor: pointer; +} + +/* First breakpoint in this file. Below it the same panel docks to the + bottom edge instead of the right one — a fixed-width side panel would + otherwise eat most of a phone-width map, the exact "popup dominates the + screen" problem this panel was meant to fix in the first place. */ +@media (max-width: 640px) { + .trail-panel { + top: auto; + left: 0; + right: 0; + bottom: 0; + width: auto; + max-width: none; + max-height: 50vh; + border-left: none; + border-top: 1px solid #ddd; + border-radius: 8px 8px 0 0; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); + } +} + .offline-data-panel__body { padding: 8px 12px; } diff --git a/public/js/hiking_trails.js b/public/js/hiking_trails.js index df9226b..455c159 100644 --- a/public/js/hiking_trails.js +++ b/public/js/hiking_trails.js @@ -1,4 +1,3 @@ -import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { WALKING_ICON } from "/js/icons.js"; import { firstSymbolLayerId } from "/js/radar.js"; import { escapeHtml } from "/js/gpx.js"; @@ -101,11 +100,15 @@ export class HikingTrailsControl { map.on("click", (event) => this._handleClick(event)); + this._panel = document.getElementById("trail-panel"); + this._panelBody = this._panel.querySelector(".trail-panel__body"); + this._panel.querySelector(".trail-panel__close").addEventListener("click", () => this._hidePanel()); + // setStyle() (switching Online/Offline) tears down all custom sources/ // layers — put whatever was showing back, same as every other // Overlays control. The highlight is transient (tied to whichever - // popup's still open, if any) — simpler to just drop it here than try - // to carry it across a style swap. + // trail is showing in the panel, if any) — simpler to just drop it + // here than try to carry it across a style swap. map.on("style.load", () => { if (this._active) this._addOnlineLayer(); for (const code of this._selectedCountries) this._addOfflineLayer(code); @@ -191,33 +194,33 @@ export class HikingTrailsControl { // The local vector layers have real features to hit-test directly — no // network round-trip, so this resolves instantly. A genuine miss (empty - // result) shows no popup at all rather than a "not found" message, same - // as clicking empty space anywhere else on the map. + // result) just closes an already-open panel rather than showing a "not + // found" message, same as clicking empty space anywhere else on the map. _handleOfflineClick(event) { const layers = [...this._selectedCountries].map(offlineLayerId).filter((id) => this._map.getLayer(id)); if (layers.length === 0) return; const features = this._map.queryRenderedFeatures(event.point, { layers }); - if (features.length === 0) return; + if (features.length === 0) { + this._hidePanel(); + return; + } const matched = features.filter((feature, i) => features.findIndex((f) => f.properties.id === feature.properties.id) === i); const html = matched.map((feature) => routeHtml(feature.properties)).join("
"); this._setHighlight(matched.map((feature) => ({ geometry: feature.geometry, color: feature.properties.color }))); - const popup = new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(html).addTo(this._map); - this._trackPopup(popup); + this._showPanel(html); } - // MapLibre's own closeOnClick (default true) means a click on a *new* - // trail both opens its popup and, in the same event dispatch, closes - // whichever popup was already open — firing *that* popup's "close" - // *after* this method already set the highlight for the new one. Without - // this guard, the stale popup's close handler would wipe the highlight - // that was just set a moment earlier for the popup replacing it. - _trackPopup(popup) { - this._activePopup = popup; - popup.on("close", () => { - if (this._activePopup === popup) this._clearHighlight(); - }); + _showPanel(html) { + this._panelBody.innerHTML = html; + this._panel.hidden = false; + } + + _hidePanel() { + if (this._panel.hidden) return; + this._panel.hidden = true; + this._clearHighlight(); } // The raster tiles have no queryable features (they're pixels), so @@ -227,30 +230,28 @@ export class HikingTrailsControl { // pixel-thick) rendered line. async _handleOnlineClick(event) { const { lng, lat } = event.lngLat; - const popup = new Popup({ closeButton: true, maxWidth: "260px" }) - .setLngLat(event.lngLat) - .setHTML(`

Looking for trails…

`) - .addTo(this._map); - this._trackPopup(popup); + this._showPanel(`

Looking for trails…

`); + // A newer click's request replaces this one while Overpass is still + // answering — same "in-flight response arrives after it's already + // stale" guard the old Popup-identity check gave, just keyed off a + // fresh token each click instead of a fresh Popup instance. + const request = (this._onlineRequest = {}); let relations; try { relations = await fetchNearbyRoutes(lng, lat); } catch { - popup.setHTML(`

Could not reach Overpass.

`); + if (this._onlineRequest === request) this._panelBody.innerHTML = `

Could not reach Overpass.

`; return; } + if (this._onlineRequest !== request) return; if (relations.length === 0) { - popup.setHTML(`

No hiking trail found here.

`); + this._panelBody.innerHTML = `

No hiking trail found here.

`; return; } - popup.setHTML(relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("
")); - // A newer click may have replaced this popup while Overpass was still - // answering — don't paint a highlight for a selection that's already - // stale. - if (this._activePopup === popup) - this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) }))); + this._panelBody.innerHTML = relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("
"); + this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) }))); } _addOnlineLayer() { @@ -389,8 +390,8 @@ function routeGeometry(relation) { // Adapts a raw Overpass relation into the same shape offline GeoJSON // features' properties already carry (lib/maptool/hiking_trails.rb's own -// ROUTE_TAGS + distance_meters + id) — one popup builder below serves -// both instead of two near-identical ones. +// ROUTE_TAGS + distance_meters + id) — one HTML builder (routeHtml) below +// serves both instead of two near-identical ones. function overpassRouteInfo(relation) { const tags = relation.tags || {}; return { diff --git a/views/index.erb b/views/index.erb index 2d9a5b7..ce17493 100644 --- a/views/index.erb +++ b/views/index.erb @@ -31,6 +31,11 @@
+ +