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;
|
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 {
|
.offline-data-panel__body {
|
||||||
padding: 8px 12px;
|
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 { WALKING_ICON } from "/js/icons.js";
|
||||||
import { firstSymbolLayerId } from "/js/radar.js";
|
import { firstSymbolLayerId } from "/js/radar.js";
|
||||||
import { escapeHtml } from "/js/gpx.js";
|
import { escapeHtml } from "/js/gpx.js";
|
||||||
@@ -101,11 +100,15 @@ export class HikingTrailsControl {
|
|||||||
|
|
||||||
map.on("click", (event) => this._handleClick(event));
|
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/
|
// setStyle() (switching Online/Offline) tears down all custom sources/
|
||||||
// layers — put whatever was showing back, same as every other
|
// layers — put whatever was showing back, same as every other
|
||||||
// Overlays control. The highlight is transient (tied to whichever
|
// Overlays control. The highlight is transient (tied to whichever
|
||||||
// popup's still open, if any) — simpler to just drop it here than try
|
// trail is showing in the panel, if any) — simpler to just drop it
|
||||||
// to carry it across a style swap.
|
// here than try to carry it across a style swap.
|
||||||
map.on("style.load", () => {
|
map.on("style.load", () => {
|
||||||
if (this._active) this._addOnlineLayer();
|
if (this._active) this._addOnlineLayer();
|
||||||
for (const code of this._selectedCountries) this._addOfflineLayer(code);
|
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
|
// The local vector layers have real features to hit-test directly — no
|
||||||
// network round-trip, so this resolves instantly. A genuine miss (empty
|
// network round-trip, so this resolves instantly. A genuine miss (empty
|
||||||
// result) shows no popup at all rather than a "not found" message, same
|
// result) just closes an already-open panel rather than showing a "not
|
||||||
// as clicking empty space anywhere else on the map.
|
// found" message, same as clicking empty space anywhere else on the map.
|
||||||
_handleOfflineClick(event) {
|
_handleOfflineClick(event) {
|
||||||
const layers = [...this._selectedCountries].map(offlineLayerId).filter((id) => this._map.getLayer(id));
|
const layers = [...this._selectedCountries].map(offlineLayerId).filter((id) => this._map.getLayer(id));
|
||||||
if (layers.length === 0) return;
|
if (layers.length === 0) return;
|
||||||
|
|
||||||
const features = this._map.queryRenderedFeatures(event.point, { layers });
|
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 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>");
|
const html = matched.map((feature) => routeHtml(feature.properties)).join("<hr>");
|
||||||
this._setHighlight(matched.map((feature) => ({ geometry: feature.geometry, color: feature.properties.color })));
|
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._showPanel(html);
|
||||||
this._trackPopup(popup);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapLibre's own closeOnClick (default true) means a click on a *new*
|
_showPanel(html) {
|
||||||
// trail both opens its popup and, in the same event dispatch, closes
|
this._panelBody.innerHTML = html;
|
||||||
// whichever popup was already open — firing *that* popup's "close"
|
this._panel.hidden = false;
|
||||||
// *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.
|
_hidePanel() {
|
||||||
_trackPopup(popup) {
|
if (this._panel.hidden) return;
|
||||||
this._activePopup = popup;
|
this._panel.hidden = true;
|
||||||
popup.on("close", () => {
|
this._clearHighlight();
|
||||||
if (this._activePopup === popup) this._clearHighlight();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The raster tiles have no queryable features (they're pixels), so
|
// The raster tiles have no queryable features (they're pixels), so
|
||||||
@@ -227,29 +230,27 @@ export class HikingTrailsControl {
|
|||||||
// pixel-thick) rendered line.
|
// pixel-thick) rendered line.
|
||||||
async _handleOnlineClick(event) {
|
async _handleOnlineClick(event) {
|
||||||
const { lng, lat } = event.lngLat;
|
const { lng, lat } = event.lngLat;
|
||||||
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
|
this._showPanel(`<p class="gpx-popup__category">Looking for trails…</p>`);
|
||||||
.setLngLat(event.lngLat)
|
// A newer click's request replaces this one while Overpass is still
|
||||||
.setHTML(`<p class="gpx-popup__category">Looking for trails…</p>`)
|
// answering — same "in-flight response arrives after it's already
|
||||||
.addTo(this._map);
|
// stale" guard the old Popup-identity check gave, just keyed off a
|
||||||
this._trackPopup(popup);
|
// fresh token each click instead of a fresh Popup instance.
|
||||||
|
const request = (this._onlineRequest = {});
|
||||||
|
|
||||||
let relations;
|
let relations;
|
||||||
try {
|
try {
|
||||||
relations = await fetchNearbyRoutes(lng, lat);
|
relations = await fetchNearbyRoutes(lng, lat);
|
||||||
} catch {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this._onlineRequest !== request) return;
|
||||||
|
|
||||||
if (relations.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
popup.setHTML(relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>"));
|
this._panelBody.innerHTML = 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._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) })));
|
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
|
// Adapts a raw Overpass relation into the same shape offline GeoJSON
|
||||||
// features' properties already carry (lib/maptool/hiking_trails.rb's own
|
// features' properties already carry (lib/maptool/hiking_trails.rb's own
|
||||||
// ROUTE_TAGS + distance_meters + id) — one popup builder below serves
|
// ROUTE_TAGS + distance_meters + id) — one HTML builder (routeHtml) below
|
||||||
// both instead of two near-identical ones.
|
// serves both instead of two near-identical ones.
|
||||||
function overpassRouteInfo(relation) {
|
function overpassRouteInfo(relation) {
|
||||||
const tags = relation.tags || {};
|
const tags = relation.tags || {};
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
<div class="profile-panel__charts"></div>
|
<div class="profile-panel__charts"></div>
|
||||||
</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 id="basemap-panel" class="basemap-panel" hidden>
|
||||||
<div class="basemap-panel__header">
|
<div class="basemap-panel__header">
|
||||||
<strong>Base map</strong>
|
<strong>Base map</strong>
|
||||||
|
|||||||
Reference in new issue
Block a user