Files
maptool/public/js/hiking_trails.js
T
randy 079ac37787
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s
Wire offline hiking trails into the real build, API, and renderer
data/hiking_trails.geojson now generates automatically as the last
step of every offline.pmtiles build (PmtilesBuilder.extract_hiking_trails,
best-effort — a failure here doesn't fail the whole build), merged
across all selected countries with cross-border dedup by OSM relation
id (HikingTrails.extract_merged). Served at GET /tiles/hiking_trails.geojson
and GET /api/hiking-trails, fetched client-side only once the overlay
is actually toggled on.

hiking_trails.js now prefers this local file whenever it exists,
regardless of which base map (online/offline) is active — same
"prefer local when available" story as HillshadeControl already uses
for terrain. Offline click hit-tests the real vector layer directly
(queryRenderedFeatures) instead of querying Overpass — instant, no
network. Coordinates are rounded to 5dp to keep the combined file
smaller; colour tag values are normalized against a known-safe set
before ever reaching a style paint property.

Verified extract_merged (new: combines multiple countries' extracts,
dedupes overlapping relations) against two real cached country PBFs
on brick directly, not just fixtures.
2026-09-13 10:31:59 +02:00

299 lines
12 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.
//
// 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 when there's
// no local offline extraction to draw from instead (see _addOfflineLayer
// below) — same "quietly renders nothing without a network" story as the
// Wikipedia/Radar/Contour lines overlays.
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;
// Offline: a real vector line layer, 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_offline) — no network needed at all, and no
// waiting on Overpass for click details either (queried locally via
// queryRenderedFeatures instead, see _handleClick below).
const OFFLINE_GEOJSON_URL = "/tiles/hiking_trails.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;
}
// 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._offlineAvailable = 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();
});
}
// Called once map.js's GET /api/hiking-trails check resolves — same
// "prefer the local file whenever it exists" story as
// OverlaysControl#setHillshadeOfflineAvailable, independent of whether
// the Online or Offline *base map* happens to be selected right now.
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
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);
}
}
_handleClick(event) {
if (this._offlineAvailable) {
this._handleOfflineClick(event);
} else {
this._handleOnlineClick(event);
}
}
// The local vector layer has real features to hit-test directly — no
// network round-trip, so this resolves instantly. A genuine miss (empty
// result) shows no popup at all rather than a "not found" message, same
// as clicking empty space anywhere else on the map.
_handleOfflineClick(event) {
const features = this._map.queryRenderedFeatures(event.point, { layers: [LAYER_ID] });
if (features.length === 0) return;
const seen = new Set();
const html = features
.filter((feature) => !seen.has(feature.properties.id) && seen.add(feature.properties.id))
.map((feature) => routeHtml(feature.properties))
.join("<hr>");
new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(html).addTo(this._map);
}
// The raster tiles 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 _handleOnlineClick(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((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>")
: `<p class="gpx-popup__category">No hiking trail found here.</p>`,
);
}
async _addLayer() {
if (this._map.getSource(SOURCE_ID)) return;
if (this._offlineAvailable) await this._addOfflineLayer();
else this._addOnlineLayer();
}
async _addOfflineLayer() {
let geojson;
try {
geojson = await (await fetch(OFFLINE_GEOJSON_URL)).json();
} catch {
return; // degrade quietly, same story as every other optional layer here
}
if (this._map.getSource(SOURCE_ID)) return; // toggled off and back on again while this was fetching
// Normalized once here (rather than a MapLibre expression in the paint
// property below) so an untrustworthy `colour` tag value never reaches
// a style property directly — same reasoning gpx.js's own
// feature.properties.color ||= TRACK_COLOR normalization gives.
for (const feature of geojson.features) {
feature.properties.color = normalizedTrailColor(feature.properties.colour);
}
this._map.addSource(SOURCE_ID, { type: "geojson", data: geojson });
this._map.addLayer(
{
id: LAYER_ID,
type: "line",
source: SOURCE_ID,
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": ["get", "color"], "line-width": 2 },
},
firstSymbolLayerId(this._map),
);
}
_addOnlineLayer() {
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 queryOverpass(url, query) {
const response = await fetch(url, { method: "POST", body: `data=${encodeURIComponent(query)}` });
if (!response.ok) throw new Error(`overpass ${response.status}`);
return response.json();
}
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 data = await Promise.any(OVERPASS_APIS.map((url) => queryOverpass(url, query)));
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. Mirrors
// Maptool::HikingTrails.relation_feature's own distance calc server-side
// (lib/maptool/hiking_trails.rb) — this is the online, no-server-file path.
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);
}
// Adapts a raw Overpass relation into the same shape offline GeoJSON
// features' properties already carry (lib/maptool/hiking_trails.rb's own
// ROUTE_TAGS + distance_meters + id) — one popup builder below serves
// both instead of two near-identical ones.
function overpassRouteInfo(relation) {
const tags = relation.tags || {};
return {
id: relation.id,
name: tags.name,
description: tags.description,
colour: tags.colour,
network: tags.network,
operator: tags.operator,
ref: tags.ref,
distance_meters: routeDistance(relation),
};
}
function routeHtml(info) {
const name = info.name || info.description || "Hiking trail";
const swatch = SAFE_COLOR.test(info.colour || "")
? `<span class="hiking-trail-popup__swatch" style="background:${info.colour}"></span>`
: "";
const meta = [formatDistance(info.distance_meters || 0)];
if (info.ref) meta.push(`#${info.ref}`);
if (info.network) meta.push(info.network);
if (info.operator) meta.push(escapeHtml(info.operator));
const parts = [
`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong>${swatch}</div>`,
`<p class="gpx-popup__category">${meta.filter(Boolean).join(" · ")}</p>`,
];
// 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(`<p class="gpx-popup__description">${escapeHtml(info.description)}</p>`);
parts.push(
`<p class="gpx-popup__link"><a href="https://www.openstreetmap.org/relation/${info.id}" target="_blank" rel="noopener noreferrer">View on OpenStreetMap →</a></p>`,
);
return parts.join("");
}