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"; // 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. Used whenever no // offline extract is available at all — same "quietly renders nothing // without a network" story as the Wikipedia/Radar/Contour lines overlays. const ONLINE_SOURCE_ID = "hiking-trails-online"; const ONLINE_LAYER_ID = "hiking-trails-online-line"; 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; // Offline: one real vector line layer per country the viewer actually // picks, GeoJSON generated at build time by Maptool::HikingTrails straight // from the same Geofabrik .osm.pbf offline.pmtiles itself is built from // (see lib/maptool/hiking_trails.rb, PmtilesBuilder.extract_hiking_trails) // — no network needed, and no waiting on Overpass for click details either // (queried locally via queryRenderedFeatures instead, see _handleClick // below). One file per country rather than one combined file: a viewer // covering a dozen countries' worth of offline map data rarely wants all // of their hiking trails loaded at once, so this is opt-in per country // instead. function offlineSourceId(code) { return `hiking-trails-offline-${code}`; } function offlineLayerId(code) { return `hiking-trails-offline-${code}-line`; } function offlineUrl(code) { return `/tiles/hiking_trails/${code}.geojson`; } // waymarkedtrails.org's own fallback for an untagged route. const DEFAULT_TRAIL_COLOR = "#c1272d"; // Real-world `colour` values seen in production data (see the 2026-09-13 // extraction against SK.osm.pbf) — anything outside this set (typos like // "yelllow", multi-value combos like "blue, yellow", stray hex codes) falls // back to DEFAULT_TRAIL_COLOR rather than being trusted as a CSS value // straight from OSM tag text. const KNOWN_TRAIL_COLORS = new Set(["red", "yellow", "blue", "green", "orange", "purple", "brown", "black", "white", "grey", "gray", "darkblue"]); function normalizedTrailColor(raw) { if (!raw) return DEFAULT_TRAIL_COLOR; const trimmed = raw.trim(); if (KNOWN_TRAIL_COLORS.has(trimmed.toLowerCase())) return trimmed.toLowerCase(); 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 // as RadarControl), since a viewer with a dozen countries' worth of // offline map data rarely wants all of their hiking trails loaded, let // alone parsed by the browser, at once. export class HikingTrailsControl { constructor(map) { this._map = map; this._active = false; // the online-mode checkbox's own state this._offlineCountries = []; // codes with an extract available on the server this._selectedCountries = new Set(); // codes currently shown, offline mode map.on("click", (event) => this._handleClick(event)); this._panel = document.getElementById("trail-panel"); this._panelBody = this._panel.querySelector(".trail-panel__body"); this._panel.querySelector(".trail-panel__close").addEventListener("click", () => this._hidePanel()); // setStyle() (switching Online/Offline) tears down all custom sources/ // layers — put whatever was showing back, same as every other // Overlays control. The highlight is transient (tied to whichever // trail is showing in the panel, 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(); }); } mount(li) { li.innerHTML = `
${WALKING_ICON}
`; 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) just closes an already-open panel rather than showing 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) { this._hidePanel(); 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) => ({ geometry: feature.geometry, color: feature.properties.color }))); this._showPanel(html); } _showPanel(html) { this._panelBody.innerHTML = html; this._panel.hidden = false; } _hidePanel() { if (this._panel.hidden) return; this._panel.hidden = true; 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; this._showPanel(`

    Looking for trails…

    `); // A newer click's request replaces this one while Overpass is still // answering — same "in-flight response arrives after it's already // stale" guard the old Popup-identity check gave, just keyed off a // fresh token each click instead of a fresh Popup instance. const request = (this._onlineRequest = {}); let relations; try { relations = await fetchNearbyRoutes(lng, lat); } catch { if (this._onlineRequest === request) this._panelBody.innerHTML = `

    Could not reach Overpass.

    `; return; } if (this._onlineRequest !== request) return; if (relations.length === 0) { this._panelBody.innerHTML = `

    No hiking trail found here.

    `; return; } this._panelBody.innerHTML = relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("
    "); this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) }))); } _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)); } // features: an array of { geometry, color } — geometry a GeoJSON // LineString or MultiLineString (mixed is fine), color the trail's own // (already-normalized) colour — everything the last click matched, drawn // as one highlight layer (a wider outline in the trail's own color, // rather than a fixed color that would repaint it) on top of the base // trail rendering. _setHighlight(features) { const data = { type: "FeatureCollection", features: features.map(({ geometry, color }) => ({ type: "Feature", properties: { color }, 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": ["get", "color"], "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 HTML builder (routeHtml) 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(`

    ${escapeHtml(info.description)}

    `); parts.push( ``, ); return parts.join(""); }