Show clicked hiking trail info in a docked panel, not a popup
CI / check (push) Successful in 22s
CI / deploy (push) Successful in 12s

A map-anchored Popup on a trail click routinely overlapped the very
line it described. Follows the same click -> docked panel pattern
gpx.js already uses for a track line click (TrackProfilePanel) rather
than inventing a new one: a fixed #trail-panel docks to the right edge
on desktop and the bottom edge on narrow viewports (this file's first
@media breakpoint), so the trail stays fully visible either way.

routeHtml()'s own markup is reused unchanged for the panel body —
.trail-panel__body's padding matches .maplibregl-popup-content's own
(15px 10px) so the header's canceling negative margin still lines up.
The popup-identity staleness guard for the async Overpass lookup is
replaced with a plain request token, since there's no longer a fresh
Popup instance to key off of.
This commit is contained in:
randy committed 2026-09-15 12:52:54 +02:00
1 parent eada464fb3
commit 9edc2d9534
3 files changed
+101 -33

No files matched your search

+62
View File
@@ -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;
}
+34 -33
View File
@@ -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("<hr>");
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,29 +230,27 @@ 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(`<p class="gpx-popup__category">Looking for trails…</p>`)
.addTo(this._map);
this._trackPopup(popup);
this._showPanel(`<p class="gpx-popup__category">Looking for trails…</p>`);
// 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(`<p class="gpx-popup__category">Could not reach Overpass.</p>`);
if (this._onlineRequest === request) this._panelBody.innerHTML = `<p class="gpx-popup__category">Could not reach Overpass.</p>`;
return;
}
if (this._onlineRequest !== request) return;
if (relations.length === 0) {
popup.setHTML(`<p class="gpx-popup__category">No hiking trail found here.</p>`);
this._panelBody.innerHTML = `<p class="gpx-popup__category">No hiking trail found here.</p>`;
return;
}
popup.setHTML(relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>"));
// 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._panelBody.innerHTML = relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>");
this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) })));
}
@@ -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 {
+5
View File
@@ -31,6 +31,11 @@
<div class="profile-panel__charts"></div>
</div>
<div id="trail-panel" class="trail-panel" hidden>
<button type="button" class="trail-panel__close" aria-label="Close">&times;</button>
<div class="trail-panel__body"></div>
</div>
<div id="basemap-panel" class="basemap-panel" hidden>
<div class="basemap-panel__header">
<strong>Base map</strong>