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`; }