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"; import { buildLineGraph, orderLanesAsync, LaneLayer } from "/vendor/maplibre-gl-lanes/maplibre-gl-lanes.mjs"; // 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: real vector trails for each 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 // (hit-tested locally via LaneLayer.queryLane 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. // // Drawn with maplibre-gl-lanes rather than a plain line layer: a stretch // of path several marked routes share (very common with KST blazes) is // otherwise one line drawn over and over, only the topmost color visible. // Lanes puts each route in its own parallel lane instead. It needs routes // sharing a path to share exact vertices, which OSM relations over shared // ways already do. One LaneLayer over every picked country, not one per // country, so lanes at a border stay ordered together. Doesn't drape over // 3D terrain (sinks under raised ground, warns on the console) — a known // limitation of the library, not worth working around for an overlay. const LANES_LAYER_ID = "hiking-trails-lanes"; const LANE_SIZES = () => ({ spacing: 3.5, width: 3, casingWidth: 0.5 }); 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; } // Online mode's highlight outline for whichever route(s) the last click // matched — drawn as its own separate source/layer (real geometry, from the // Overpass response), since the raster tiles have no paintable features to // highlight at all. Offline lanes use LaneLayer.setHighlight instead. 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 this._trailData = new Map(); // code -> fetched GeoJSON, kept across toggles/style swaps this._routeInfo = new Map(); // String(relation id) -> feature properties, for the panel this._lanes = null; // the current LaneLayer, null when no country is shown 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(); this._clearHighlight(); if (this._selectedCountries.size > 0) this._rebuildLanes(); }); } mount(li) { li.innerHTML = `
`; 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) => `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("${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(""); }