`;
this._checkbox = li.querySelector(".overlays-panel__toggle");
this._nameButton = li.querySelector(".overlays-panel__name");
this._details = li.querySelector(".overlays-panel__details");
this._countryList = li.querySelector(".hiking-trail-countries");
this._checkbox.addEventListener("change", (event) => this._toggleOnline(event.target.checked));
this._nameButton.addEventListener("click", () => {
if (this._offlineCountries.length === 0) return; // nothing to expand into yet
const expanding = this._details.hidden;
this._details.hidden = !expanding;
this._nameButton.setAttribute("aria-expanded", String(expanding));
});
}
// Called once map.js's GET /api/hiking-trails check resolves. Switches
// this item from "online raster, plain on/off" to "offline vector,
// pick your countries" — the two are mutually exclusive rather than
// both offered at once, since once a real local extract exists there's
// no reason to still fall back to a third-party tile server for any of
// it.
setOfflineCountries(countries) {
this._offlineCountries = countries;
if (countries.length === 0) return;
if (this._active) this._toggleOnline(false);
this._checkbox.hidden = true;
this._countryList.innerHTML = countries
.map(
(code) => `
`,
)
.join("");
this._countryList.querySelectorAll(".hiking-trail-countries__toggle").forEach((input) => {
input.addEventListener("change", (event) => this._toggleCountry(event.target.value, event.target.checked));
});
}
_toggleOnline(active) {
this._active = active;
if (active) this._addOnlineLayer();
else this._removeOnlineLayer();
}
_toggleCountry(code, active) {
if (active) {
this._selectedCountries.add(code);
this._addOfflineLayer(code);
} else {
this._selectedCountries.delete(code);
this._removeOfflineLayer(code);
}
}
_handleClick(event) {
if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
else if (this._active) this._handleOnlineClick(event);
}
// The local vector layers have real features to hit-test directly — no
// network round-trip, so this resolves instantly. A genuine miss (empty
// result) shows no popup at all rather than a "not found" message, same
// as clicking empty space anywhere else on the map.
_handleOfflineClick(event) {
const layers = [...this._selectedCountries].map(offlineLayerId).filter((id) => this._map.getLayer(id));
if (layers.length === 0) return;
const features = this._map.queryRenderedFeatures(event.point, { layers });
if (features.length === 0) return;
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
// "what trail is this" is answered with a one-off Overpass query instead
// of a MapLibre feature hit-test — an `around` search rather than an
// exact point match, since a click rarely lands exactly on the (already
// pixel-thick) rendered line.
async _handleOnlineClick(event) {
const { lng, lat } = event.lngLat;
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
.setLngLat(event.lngLat)
.setHTML(`
`);
return;
}
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() {
if (this._map.getSource(ONLINE_SOURCE_ID)) return;
this._map.addSource(ONLINE_SOURCE_ID, {
type: "raster",
tiles: [TILE_URL],
tileSize: 256,
maxzoom: 17,
attribution: ATTRIBUTION,
});
// Below labels (same firstSymbolLayerId spot contour.js/radar.js use),
// so place names/POI icons stay readable on top of the trail lines.
this._map.addLayer(
{ id: ONLINE_LAYER_ID, type: "raster", source: ONLINE_SOURCE_ID, paint: { "raster-opacity": OPACITY } },
firstSymbolLayerId(this._map),
);
}
_removeOnlineLayer() {
if (this._map.getLayer(ONLINE_LAYER_ID)) this._map.removeLayer(ONLINE_LAYER_ID);
if (this._map.getSource(ONLINE_SOURCE_ID)) this._map.removeSource(ONLINE_SOURCE_ID);
}
async _addOfflineLayer(code) {
if (this._map.getSource(offlineSourceId(code))) return;
let geojson;
try {
geojson = await (await fetch(offlineUrl(code))).json();
} catch {
return; // degrade quietly, same story as every other optional layer here
}
// Toggled back off, or the base map switched, while this was fetching.
if (!this._selectedCountries.has(code) || this._map.getSource(offlineSourceId(code))) return;
// Normalized once here (rather than a MapLibre expression in the paint
// property below) so an untrustworthy `colour` tag value never reaches
// a style property directly — same reasoning gpx.js's own
// feature.properties.color ||= TRACK_COLOR normalization gives.
for (const feature of geojson.features) {
feature.properties.color = normalizedTrailColor(feature.properties.colour);
}
this._map.addSource(offlineSourceId(code), { type: "geojson", data: geojson });
this._map.addLayer(
{
id: offlineLayerId(code),
type: "line",
source: offlineSourceId(code),
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": ["get", "color"], "line-width": 2 },
},
firstSymbolLayerId(this._map),
);
}
_removeOfflineLayer(code) {
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...)
// walks backward from those to their parent relations — the same
// find-the-way-then-its-relation shape as clicking a route on
// waymarkedtrails.org's own site. `out geom` inlines each member way's
// full coordinate list right on the relation, so no separate stitching
// step is needed to get real geometry back.
async function queryOverpass(url, query) {
const response = await fetch(url, { method: "POST", body: `data=${encodeURIComponent(query)}` });
if (!response.ok) throw new Error(`overpass ${response.status}`);
return response.json();
}
async function fetchNearbyRoutes(lng, lat) {
const query = `[out:json][timeout:15];way(around:${CLICK_RADIUS_M},${lat},${lng})->.w;rel(bw.w)["route"="hiking"];out geom;`;
const data = await Promise.any(OVERPASS_APIS.map((url) => queryOverpass(url, query)));
return (data.elements || []).filter((element) => element.type === "relation");
}
// OSM doesn't tag a route's length directly — summed from each member
// way's own geometry instead, same haversine-sum measure.js's own Measure
// tool uses for a hand-drawn line. Shared endpoints between adjacent ways
// get walked twice, same as how a real hike over those ways would. Mirrors
// Maptool::HikingTrails.relation_feature's own distance calc server-side
// (lib/maptool/hiking_trails.rb) — this is the online, no-server-file path.
function routeDistance(relation) {
return (relation.members || [])
.filter((member) => member.type === "way" && member.geometry)
.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
// both instead of two near-identical ones.
function overpassRouteInfo(relation) {
const tags = relation.tags || {};
return {
id: relation.id,
name: tags.name,
description: tags.description,
colour: tags.colour,
network: tags.network,
operator: tags.operator,
ref: tags.ref,
distance_meters: routeDistance(relation),
};
}
function routeHtml(info) {
const name = info.name || info.description || "Hiking trail";
const swatch = SAFE_COLOR.test(info.colour || "")
? ``
: "";
const meta = [formatDistance(info.distance_meters || 0)];
if (info.ref) meta.push(`#${info.ref}`);
if (info.network) meta.push(info.network);
if (info.operator) meta.push(escapeHtml(info.operator));
const parts = [
`
${escapeHtml(name)}${swatch}
`,
`
${meta.filter(Boolean).join(" · ")}
`,
];
// description already used as the title above when there's no name — only
// shown again here as its own line if there both is a name *and* a
// description to add, so it's never rendered twice for the common
// no-name case.
if (info.name && info.description) parts.push(`