diff --git a/public/js/hiking_trails.js b/public/js/hiking_trails.js
index 9c4f745..9d9312d 100644
--- a/public/js/hiking_trails.js
+++ b/public/js/hiking_trails.js
@@ -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("
");
- 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("
");
+ 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(``)
.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("
")
- : ``,
- );
+ if (relations.length === 0) {
+ popup.setHTML(``);
+ 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(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