Files
maptool/public/js/hiking_trails.js
T
randy 6f1577f957
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
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.
2026-09-12 23:32:56 +02:00

167 lines
6.7 KiB
JavaScript

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
// has none, so there's no styling to do here ourselves. Online only, same
// "quietly renders nothing without a network" story as the Wikipedia/Radar/
// Contour lines overlays — an offline copy (baked into offline.pmtiles) is
// a TODO for once there's a route planner to pair it with, see TODO.adoc.
const TILE_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png";
const ATTRIBUTION =
'Trails &copy; <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data &copy; OpenStreetMap contributors';
const OPACITY = 0.85;
// A plain on/off Overlays item, same shape as ContourControl — one static
// raster overlay, no scrub/forecast controls to expand into.
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
// control.
map.on("style.load", () => {
if (this._active) this._addLayer();
});
}
mount(li) {
li.innerHTML = `
<label>
<input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${WALKING_ICON}</span>
<span>Hiking trails</span>
</label>
`;
this._checkbox = li.querySelector(".overlays-panel__toggle");
this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked));
}
_toggle(active) {
this._active = active;
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() {
if (this._map.getSource(SOURCE_ID)) return;
this._map.addSource(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: LAYER_ID, type: "raster", source: SOURCE_ID, paint: { "raster-opacity": OPACITY } },
firstSymbolLayerId(this._map),
);
}
_removeLayer() {
if (this._map.getLayer(LAYER_ID)) this._map.removeLayer(LAYER_ID);
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("");
}