Click a hiking trail to see its name, color, network and distance
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.
This commit is contained in:
1 parent
9c45e7275c
commit
6f1577f957
3 files changed
+107
-4
No files matched your search
@@ -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(`<p class="gpx-popup__category">Looking for trails…</p>`)
|
||||
.addTo(this._map);
|
||||
|
||||
let relations;
|
||||
try {
|
||||
relations = await fetchNearbyRoutes(lng, lat);
|
||||
} catch {
|
||||
popup.setHTML(`<p class="gpx-popup__category">Could not reach Overpass.</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
popup.setHTML(
|
||||
relations.length > 0
|
||||
? relations.map(routeHtml).join("<hr>")
|
||||
: `<p class="gpx-popup__category">No hiking trail found here.</p>`,
|
||||
);
|
||||
}
|
||||
|
||||
_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 || "")
|
||||
? `<span class="hiking-trail-popup__swatch" style="background:${tags.colour}"></span>`
|
||||
: "";
|
||||
const meta = [formatDistance(routeDistance(relation))];
|
||||
if (tags.network) meta.push(tags.network);
|
||||
if (tags.operator) meta.push(escapeHtml(tags.operator));
|
||||
|
||||
return [
|
||||
`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${swatch}</div>`,
|
||||
`<p class="gpx-popup__category">${meta.filter(Boolean).join(" · ")}</p>`,
|
||||
].join("");
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user