Show clicked hiking trail info in a docked panel, not a popup
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:
1 parent
eada464fb3
commit
9edc2d9534
3 files changed
+101
-33
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
Reference in new issue
Block a user