432 lines
19 KiB
JavaScript
432 lines
19 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";
|
|
|
|
// 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 whenever no
|
|
// offline extract is available at all — same "quietly renders nothing
|
|
// without a network" story as the Wikipedia/Radar/Contour lines overlays.
|
|
const ONLINE_SOURCE_ID = "hiking-trails-online";
|
|
const ONLINE_LAYER_ID = "hiking-trails-online-line";
|
|
const TILE_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png";
|
|
const ATTRIBUTION =
|
|
'Trails © <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data © OpenStreetMap contributors';
|
|
const OPACITY = 0.85;
|
|
|
|
// Offline: one real vector line layer per country the viewer actually
|
|
// picks, 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_hiking_trails)
|
|
// — no network needed, and no waiting on Overpass for click details either
|
|
// (queried locally via queryRenderedFeatures instead, see _handleClick
|
|
// below). One file per country rather than one combined file: a viewer
|
|
// covering a dozen countries' worth of offline map data rarely wants all
|
|
// of their hiking trails loaded at once, so this is opt-in per country
|
|
// instead.
|
|
function offlineSourceId(code) {
|
|
return `hiking-trails-offline-${code}`;
|
|
}
|
|
function offlineLayerId(code) {
|
|
return `hiking-trails-offline-${code}-line`;
|
|
}
|
|
function offlineUrl(code) {
|
|
return `/tiles/hiking_trails/${code}.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 highlight outline for whichever route(s) the last click matched — drawn
|
|
// as its own separate source/layer (real geometry, from either the offline
|
|
// vector feature or the online Overpass response) rather than a paint-
|
|
// property change on the base layer, since the online raster tiles have no
|
|
// paintable features to highlight at all. Same "yellow outline on a picked
|
|
// route" treatment the earlier SVG prototype visualization used.
|
|
const HIGHLIGHT_SOURCE_ID = "hiking-trails-highlight";
|
|
const HIGHLIGHT_LAYER_ID = "hiking-trails-highlight-line";
|
|
|
|
// An Overlays panel item — a plain on/off checkbox (raster overlay) until
|
|
// offline extracts exist for at least one country, at which point it
|
|
// becomes an expandable per-country checklist instead (same expand shape
|
|
// as RadarControl), since a viewer with a dozen countries' worth of
|
|
// offline map data rarely wants all of their hiking trails loaded, let
|
|
// alone parsed by the browser, at once.
|
|
export class HikingTrailsControl {
|
|
constructor(map) {
|
|
this._map = map;
|
|
this._active = false; // the online-mode checkbox's own state
|
|
this._offlineCountries = []; // codes with an extract available on the server
|
|
this._selectedCountries = new Set(); // codes currently shown, offline mode
|
|
|
|
map.on("click", (event) => this._handleClick(event));
|
|
|
|
// setStyle() (switching Online/Offline) tears down all custom sources/
|
|
// layers — put whatever was showing back, same as every other
|
|
// Overlays control. The highlight is transient (tied to whichever
|
|
// popup's still open, if any) — simpler to just drop it here than try
|
|
// to carry it across a style swap.
|
|
map.on("style.load", () => {
|
|
if (this._active) this._addOnlineLayer();
|
|
for (const code of this._selectedCountries) this._addOfflineLayer(code);
|
|
this._clearHighlight();
|
|
});
|
|
}
|
|
|
|
mount(li) {
|
|
li.innerHTML = `
|
|
<div class="overlays-panel__row">
|
|
<input type="checkbox" class="overlays-panel__toggle">
|
|
<span class="overlays-panel__icon">${WALKING_ICON}</span>
|
|
<button type="button" class="overlays-panel__name">Hiking trails</button>
|
|
</div>
|
|
<div class="overlays-panel__details" hidden>
|
|
<ul class="hiking-trail-countries"></ul>
|
|
</div>
|
|
`;
|
|
this._checkbox = li.querySelector(".overlays-panel__toggle");
|
|
this._nameButton = li.querySelector(".overlays-panel__name");
|
|
this._details = li.querySelector(".overlays-panel__details");
|
|
this._countryList = li.querySelector(".hiking-trail-countries");
|
|
|
|
this._checkbox.addEventListener("change", (event) => this._toggleOnline(event.target.checked));
|
|
this._nameButton.addEventListener("click", () => {
|
|
if (this._offlineCountries.length === 0) return; // nothing to expand into yet
|
|
const expanding = this._details.hidden;
|
|
this._details.hidden = !expanding;
|
|
this._nameButton.setAttribute("aria-expanded", String(expanding));
|
|
});
|
|
}
|
|
|
|
// Called once map.js's GET /api/hiking-trails check resolves. Switches
|
|
// this item from "online raster, plain on/off" to "offline vector,
|
|
// pick your countries" — the two are mutually exclusive rather than
|
|
// both offered at once, since once a real local extract exists there's
|
|
// no reason to still fall back to a third-party tile server for any of
|
|
// it.
|
|
setOfflineCountries(countries) {
|
|
this._offlineCountries = countries;
|
|
if (countries.length === 0) return;
|
|
|
|
if (this._active) this._toggleOnline(false);
|
|
this._checkbox.hidden = true;
|
|
|
|
this._countryList.innerHTML = countries
|
|
.map(
|
|
(code) => `
|
|
<li class="hiking-trail-countries__item">
|
|
<label>
|
|
<input type="checkbox" class="hiking-trail-countries__toggle" value="${code}">
|
|
${code}
|
|
</label>
|
|
</li>
|
|
`,
|
|
)
|
|
.join("");
|
|
this._countryList.querySelectorAll(".hiking-trail-countries__toggle").forEach((input) => {
|
|
input.addEventListener("change", (event) => this._toggleCountry(event.target.value, event.target.checked));
|
|
});
|
|
}
|
|
|
|
_toggleOnline(active) {
|
|
this._active = active;
|
|
if (active) this._addOnlineLayer();
|
|
else this._removeOnlineLayer();
|
|
}
|
|
|
|
_toggleCountry(code, active) {
|
|
if (active) {
|
|
this._selectedCountries.add(code);
|
|
this._addOfflineLayer(code);
|
|
} else {
|
|
this._selectedCountries.delete(code);
|
|
this._removeOfflineLayer(code);
|
|
}
|
|
}
|
|
|
|
_handleClick(event) {
|
|
if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
|
|
else if (this._active) this._handleOnlineClick(event);
|
|
}
|
|
|
|
// The local vector layers have 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 layers = [...this._selectedCountries].map(offlineLayerId).filter((id) => this._map.getLayer(id));
|
|
if (layers.length === 0) return;
|
|
|
|
const features = this._map.queryRenderedFeatures(event.point, { layers });
|
|
if (features.length === 0) return;
|
|
|
|
const matched = features.filter((feature, i) => features.findIndex((f) => f.properties.id === feature.properties.id) === i);
|
|
const html = matched.map((feature) => routeHtml(feature.properties)).join("<hr>");
|
|
this._setHighlight(matched.map((feature) => ({ geometry: feature.geometry, color: feature.properties.color })));
|
|
const popup = new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(html).addTo(this._map);
|
|
this._trackPopup(popup);
|
|
}
|
|
|
|
// MapLibre's own closeOnClick (default true) means a click on a *new*
|
|
// trail both opens its popup and, in the same event dispatch, closes
|
|
// whichever popup was already open — firing *that* popup's "close"
|
|
// *after* this method already set the highlight for the new one. Without
|
|
// this guard, the stale popup's close handler would wipe the highlight
|
|
// that was just set a moment earlier for the popup replacing it.
|
|
_trackPopup(popup) {
|
|
this._activePopup = popup;
|
|
popup.on("close", () => {
|
|
if (this._activePopup === popup) this._clearHighlight();
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
this._trackPopup(popup);
|
|
|
|
let relations;
|
|
try {
|
|
relations = await fetchNearbyRoutes(lng, lat);
|
|
} catch {
|
|
popup.setHTML(`<p class="gpx-popup__category">Could not reach Overpass.</p>`);
|
|
return;
|
|
}
|
|
|
|
if (relations.length === 0) {
|
|
popup.setHTML(`<p class="gpx-popup__category">No hiking trail found here.</p>`);
|
|
return;
|
|
}
|
|
popup.setHTML(relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>"));
|
|
// A newer click may have replaced this popup while Overpass was still
|
|
// answering — don't paint a highlight for a selection that's already
|
|
// stale.
|
|
if (this._activePopup === popup)
|
|
this._setHighlight(relations.map((relation) => ({ geometry: routeGeometry(relation), color: normalizedTrailColor(relation.tags?.colour) })));
|
|
}
|
|
|
|
_addOnlineLayer() {
|
|
if (this._map.getSource(ONLINE_SOURCE_ID)) return;
|
|
|
|
this._map.addSource(ONLINE_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: ONLINE_LAYER_ID, type: "raster", source: ONLINE_SOURCE_ID, paint: { "raster-opacity": OPACITY } },
|
|
firstSymbolLayerId(this._map),
|
|
);
|
|
}
|
|
|
|
_removeOnlineLayer() {
|
|
if (this._map.getLayer(ONLINE_LAYER_ID)) this._map.removeLayer(ONLINE_LAYER_ID);
|
|
if (this._map.getSource(ONLINE_SOURCE_ID)) this._map.removeSource(ONLINE_SOURCE_ID);
|
|
}
|
|
|
|
async _addOfflineLayer(code) {
|
|
if (this._map.getSource(offlineSourceId(code))) return;
|
|
|
|
let geojson;
|
|
try {
|
|
geojson = await (await fetch(offlineUrl(code))).json();
|
|
} catch {
|
|
return; // degrade quietly, same story as every other optional layer here
|
|
}
|
|
// Toggled back off, or the base map switched, while this was fetching.
|
|
if (!this._selectedCountries.has(code) || this._map.getSource(offlineSourceId(code))) return;
|
|
|
|
// 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(offlineSourceId(code), { type: "geojson", data: geojson });
|
|
this._map.addLayer(
|
|
{
|
|
id: offlineLayerId(code),
|
|
type: "line",
|
|
source: offlineSourceId(code),
|
|
layout: { "line-cap": "round", "line-join": "round" },
|
|
paint: { "line-color": ["get", "color"], "line-width": 2 },
|
|
},
|
|
firstSymbolLayerId(this._map),
|
|
);
|
|
}
|
|
|
|
_removeOfflineLayer(code) {
|
|
if (this._map.getLayer(offlineLayerId(code))) this._map.removeLayer(offlineLayerId(code));
|
|
if (this._map.getSource(offlineSourceId(code))) this._map.removeSource(offlineSourceId(code));
|
|
}
|
|
|
|
// features: an array of { geometry, color } — geometry a GeoJSON
|
|
// LineString or MultiLineString (mixed is fine), color the trail's own
|
|
// (already-normalized) colour — everything the last click matched, drawn
|
|
// as one highlight layer (a wider outline in the trail's own color,
|
|
// rather than a fixed color that would repaint it) on top of the base
|
|
// trail rendering.
|
|
_setHighlight(features) {
|
|
const data = {
|
|
type: "FeatureCollection",
|
|
features: features.map(({ geometry, color }) => ({ type: "Feature", properties: { color }, geometry })),
|
|
};
|
|
const source = this._map.getSource(HIGHLIGHT_SOURCE_ID);
|
|
if (source) {
|
|
source.setData(data);
|
|
return;
|
|
}
|
|
this._map.addSource(HIGHLIGHT_SOURCE_ID, { type: "geojson", data });
|
|
this._map.addLayer({
|
|
id: HIGHLIGHT_LAYER_ID,
|
|
type: "line",
|
|
source: HIGHLIGHT_SOURCE_ID,
|
|
layout: { "line-cap": "round", "line-join": "round" },
|
|
paint: { "line-color": ["get", "color"], "line-width": 5, "line-opacity": 0.9 },
|
|
});
|
|
}
|
|
|
|
_clearHighlight() {
|
|
if (this._map.getLayer(HIGHLIGHT_LAYER_ID)) this._map.removeLayer(HIGHLIGHT_LAYER_ID);
|
|
if (this._map.getSource(HIGHLIGHT_SOURCE_ID)) this._map.removeSource(HIGHLIGHT_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);
|
|
}
|
|
|
|
// Same member-way walk as routeDistance above, collecting coordinates
|
|
// instead of summing them — the online click highlight's geometry source,
|
|
// since there's no vector feature to read it back from the way the offline
|
|
// layer's own queryRenderedFeatures result already carries one.
|
|
function routeGeometry(relation) {
|
|
const lines = (relation.members || [])
|
|
.filter((member) => member.type === "way" && member.geometry)
|
|
.map((member) => member.geometry.map((point) => [point.lon, point.lat]));
|
|
return { type: "MultiLineString", coordinates: lines };
|
|
}
|
|
|
|
// 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("");
|
|
}
|