Highlight the clicked trail(s) on the map, both online and offline
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s

The real app had no visual feedback for which line a click actually
matched, unlike the earlier SVG prototype's hover highlight — just a
popup. Draws the matched route's real geometry (from the offline
vector feature, or reassembled from the online Overpass response's own
member-way geometry) as a separate yellow highlight layer, cleared
when the popup closes.

Guarded against a stale popup's closeOnClick "close" event clearing a
highlight that a newer click already set in the same event dispatch —
tracks which popup is currently "active" and only clears on its own
close, not a predecessor's delayed one.
This commit is contained in:
randy committed 2026-09-13 19:28:51 +02:00
1 parent 4c95927231
commit ca7772dd72
1 file changed
+80 -12
+80 -12
View File
@@ -77,6 +77,15 @@ function normalizedTrailColor(raw) {
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : DEFAULT_TRAIL_COLOR;
}
// A highlight outline for whichever route(s) the last click matched — drawn
// as its own separate source/layer (real geometry, from either the offline
// vector feature or the online Overpass response) rather than a paint-
// property change on the base layer, since the online raster tiles have no
// paintable features to highlight at all. Same "yellow outline on a picked
// route" treatment the earlier SVG prototype visualization used.
const HIGHLIGHT_SOURCE_ID = "hiking-trails-highlight";
const HIGHLIGHT_LAYER_ID = "hiking-trails-highlight-line";
// An Overlays panel item — a plain on/off checkbox (raster overlay) until
// offline extracts exist for at least one country, at which point it
// becomes an expandable per-country checklist instead (same expand shape
@@ -94,10 +103,13 @@ export class HikingTrailsControl {
// setStyle() (switching Online/Offline) tears down all custom sources/
// layers — put whatever was showing back, same as every other
// Overlays control.
// 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.
map.on("style.load", () => {
if (this._active) this._addOnlineLayer();
for (const code of this._selectedCountries) this._addOfflineLayer(code);
this._clearHighlight();
});
}
@@ -188,12 +200,24 @@ export class HikingTrailsControl {
const features = this._map.queryRenderedFeatures(event.point, { layers });
if (features.length === 0) return;
const seen = new Set();
const html = features
.filter((feature) => !seen.has(feature.properties.id) && seen.add(feature.properties.id))
.map((feature) => routeHtml(feature.properties))
.join("<hr>");
new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(html).addTo(this._map);
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) => feature.geometry));
const popup = new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(html).addTo(this._map);
this._trackPopup(popup);
}
// 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();
});
}
// The raster tiles have no queryable features (they're pixels), so
@@ -207,6 +231,7 @@ export class HikingTrailsControl {
.setLngLat(event.lngLat)
.setHTML(`<p class="gpx-popup__category">Looking for trails…</p>`)
.addTo(this._map);
this._trackPopup(popup);
let relations;
try {
@@ -216,11 +241,15 @@ export class HikingTrailsControl {
return;
}
popup.setHTML(
relations.length > 0
? relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>")
: `<p class="gpx-popup__category">No hiking trail found here.</p>`,
);
if (relations.length === 0) {
popup.setHTML(`<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._setHighlight(relations.map(routeGeometry));
}
_addOnlineLayer() {
@@ -283,6 +312,34 @@ export class HikingTrailsControl {
if (this._map.getLayer(offlineLayerId(code))) this._map.removeLayer(offlineLayerId(code));
if (this._map.getSource(offlineSourceId(code))) this._map.removeSource(offlineSourceId(code));
}
// geometries: an array of GeoJSON geometry objects (LineString or
// MultiLineString, mixed is fine) — everything the last click matched,
// drawn as one highlight layer on top of the base trail rendering.
_setHighlight(geometries) {
const data = {
type: "FeatureCollection",
features: geometries.map((geometry) => ({ type: "Feature", properties: {}, geometry })),
};
const source = this._map.getSource(HIGHLIGHT_SOURCE_ID);
if (source) {
source.setData(data);
return;
}
this._map.addSource(HIGHLIGHT_SOURCE_ID, { type: "geojson", data });
this._map.addLayer({
id: HIGHLIGHT_LAYER_ID,
type: "line",
source: HIGHLIGHT_SOURCE_ID,
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#fff176", "line-width": 5, "line-opacity": 0.9 },
});
}
_clearHighlight() {
if (this._map.getLayer(HIGHLIGHT_LAYER_ID)) this._map.removeLayer(HIGHLIGHT_LAYER_ID);
if (this._map.getSource(HIGHLIGHT_SOURCE_ID)) this._map.removeSource(HIGHLIGHT_SOURCE_ID);
}
}
// way(around:...) finds candidate ways near the click, then rel(bw...)
@@ -315,6 +372,17 @@ function routeDistance(relation) {
.reduce((total, member) => total + lineLength(member.geometry.map((point) => [point.lon, point.lat])), 0);
}
// Same member-way walk as routeDistance above, collecting coordinates
// instead of summing them — the online click highlight's geometry source,
// since there's no vector feature to read it back from the way the offline
// layer's own queryRenderedFeatures result already carries one.
function routeGeometry(relation) {
const lines = (relation.members || [])
.filter((member) => member.type === "way" && member.geometry)
.map((member) => member.geometry.map((point) => [point.lon, point.lat]));
return { type: "MultiLineString", coordinates: lines };
}
// 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