Highlight the clicked trail(s) on the map, both online and offline
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:
1 parent
4c95927231
commit
ca7772dd72
1 file changed
+80
-12
+80
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user