import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { WALKING_ICON } from "/js/icons.js"; import { firstSymbolLayerId } from "/js/radar.js"; import { escapeHtml } from "/js/gpx.js"; import { lineLength, formatDistance } from "/js/measure.js"; const SOURCE_ID = "hiking-trails"; const LAYER_ID = "hiking-trails-line"; // Overpass has permissive CORS (verified: Access-Control-Allow-Origin: *), // so this queries it directly from the browser — no server-side proxy // needed, same "public API, no key" story as the Nominatim search already // hits from here. One query per click (not per pan/move like the // Wikipedia layer's live refetch), so this stays well within Overpass's // shared rate limits. // // Three independent public mirrors (also CORS-open, verified), raced with // Promise.any rather than called one at a time — the shared free instances // vary a lot in load from one moment to the next, and a multi-second wait // on a single one was the main complaint. Whichever answers first wins; // the others are simply left to finish and get GC'd, not worth the extra // AbortController plumbing for a single one-off per-click request. const OVERPASS_APIS = [ "https://overpass-api.de/api/interpreter", "https://overpass.kumi.systems/api/interpreter", "https://overpass.private.coffee/api/interpreter", ]; const CLICK_RADIUS_M = 40; // OSM colour tag values are free text (untrusted) — only used as a CSS // background if they look like an actual color (hex or a plain word), // never interpolated unchecked into a style attribute. const SAFE_COLOR = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$/; // Pre-rendered raster tiles, not vector — waymarkedtrails.org already // colors each route by its OSM `colour` tag (the KST/KČT-style paint-blaze // colors used in Slovakia/Czechia), falling back to plain red where a route // has none, so there's no styling to do here ourselves. Online only, same // "quietly renders nothing without a network" story as the Wikipedia/Radar/ // Contour lines overlays — an offline copy (baked into offline.pmtiles) is // a TODO for once there's a route planner to pair it with, see TODO.adoc. const TILE_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png"; const ATTRIBUTION = 'Trails © Waymarked Trails (CC-BY-SA), map data © OpenStreetMap contributors'; const OPACITY = 0.85; // A plain on/off Overlays item, same shape as ContourControl — one static // raster overlay, no scrub/forecast controls to expand into. export class HikingTrailsControl { constructor(map) { this._map = map; this._active = false; this._clickHandler = (event) => this._handleClick(event); // setStyle() (switching Online/Offline) tears down all custom sources/ // layers — put this back if it was on, same as every other Overlays // control. map.on("style.load", () => { if (this._active) this._addLayer(); }); } mount(li) { li.innerHTML = ` `; this._checkbox = li.querySelector(".overlays-panel__toggle"); this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked)); } _toggle(active) { this._active = active; if (active) { this._addLayer(); this._map.on("click", this._clickHandler); } else { this._removeLayer(); this._map.off("click", this._clickHandler); } } // The raster tiles above 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 _handleClick(event) { const { lng, lat } = event.lngLat; const popup = new Popup({ closeButton: true, maxWidth: "260px" }) .setLngLat(event.lngLat) .setHTML(`
Looking for trails…
`) .addTo(this._map); let relations; try { relations = await fetchNearbyRoutes(lng, lat); } catch { popup.setHTML(`Could not reach Overpass.
`); return; } popup.setHTML( relations.length > 0 ? relations.map(routeHtml).join("No hiking trail found here.
`, ); } _addLayer() { if (this._map.getSource(SOURCE_ID)) return; this._map.addSource(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: LAYER_ID, type: "raster", source: SOURCE_ID, paint: { "raster-opacity": OPACITY } }, firstSymbolLayerId(this._map), ); } _removeLayer() { if (this._map.getLayer(LAYER_ID)) this._map.removeLayer(LAYER_ID); if (this._map.getSource(SOURCE_ID)) this._map.removeSource(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. 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); } function routeHtml(relation) { const tags = relation.tags || {}; const name = tags.name || tags.description || "Hiking trail"; const swatch = SAFE_COLOR.test(tags.colour || "") ? `` : ""; const meta = [formatDistance(routeDistance(relation))]; if (tags.ref) meta.push(`#${tags.ref}`); if (tags.network) meta.push(tags.network); if (tags.operator) meta.push(escapeHtml(tags.operator)); const parts = [ `${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 (tags.name && tags.description) parts.push(`${escapeHtml(tags.description)}
`); parts.push( ``, ); return parts.join(""); }