From 6f1577f957d763fb39572e5ab1e4f01f10cc7252 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Sat, 12 Sep 2026 23:32:56 +0200 Subject: [PATCH] Click a hiking trail to see its name, color, network and distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Waymarked Trails raster overlay has no queryable features (just pixels), so this answers "what trail is this" with a one-off Overpass query on click instead: way(around:40m) finds candidate ways, then rel(bw) walks backward to their parent route=hiking relations. Overpass has permissive CORS, so this runs straight from the browser, no server proxy needed — verified against a real KST route in the Tatras. Route length isn't tagged directly, so it's summed from each member way's own geometry (measure.js's existing haversine lineLength, now exported and reused rather than duplicated). The colour tag is only ever used as a CSS background after matching a strict hex/word regex — untrusted free text otherwise. --- public/css/style.css | 8 ++++ public/js/hiking_trails.js | 96 +++++++++++++++++++++++++++++++++++++- public/js/measure.js | 7 ++- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 3efe76e..bcb0278 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1354,6 +1354,14 @@ html, body { color: #fff; } +.hiking-trail-popup__swatch { + flex-shrink: 0; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.6); +} + .gpx-popup__header-icon { flex-shrink: 0; width: 18px; diff --git a/public/js/hiking_trails.js b/public/js/hiking_trails.js index e1201bd..86940e8 100644 --- a/public/js/hiking_trails.js +++ b/public/js/hiking_trails.js @@ -1,9 +1,25 @@ +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. +const OVERPASS_API = "https://overpass-api.de/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 @@ -22,6 +38,7 @@ 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 @@ -45,8 +62,40 @@ export class HikingTrailsControl { _toggle(active) { this._active = active; - if (active) this._addLayer(); - else this._removeLayer(); + 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() { @@ -72,3 +121,46 @@ export class HikingTrailsControl { 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 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 response = await fetch(OVERPASS_API, { + method: "POST", + body: `data=${encodeURIComponent(query)}`, + }); + if (!response.ok) throw new Error(`overpass ${response.status}`); + const data = await response.json(); + 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.ref || "Hiking trail"; + const swatch = SAFE_COLOR.test(tags.colour || "") + ? `` + : ""; + const meta = [formatDistance(routeDistance(relation))]; + if (tags.network) meta.push(tags.network); + if (tags.operator) meta.push(escapeHtml(tags.operator)); + + return [ + `
${escapeHtml(name)}${swatch}
`, + `

${meta.filter(Boolean).join(" · ")}

`, + ].join(""); +} diff --git a/public/js/measure.js b/public/js/measure.js index 29e10ec..e0fa3c6 100644 --- a/public/js/measure.js +++ b/public/js/measure.js @@ -317,7 +317,9 @@ function haversine([lng1, lat1], [lng2, lat2]) { return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a)); } -function lineLength(points) { +// Exported — also used by hiking_trails.js to total up an OSM route +// relation's member ways into one distance, same haversine-sum approach. +export function lineLength(points) { let total = 0; for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]); return total; @@ -347,7 +349,8 @@ function polygonArea(points) { return Math.abs((area * EARTH_RADIUS_M * EARTH_RADIUS_M) / 2); } -function formatDistance(meters) { +// Exported — also used by hiking_trails.js's route-details popup. +export function formatDistance(meters) { if (meters === 0) return ""; return meters >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${Math.round(meters)} m`; }