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(`

Looking for trails…

`) .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("
") - : `

No hiking trail found here.

`, - ); + if (relations.length === 0) { + popup.setHTML(`

No hiking trail found here.

`); + 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