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.
This commit is contained in:
1 parent
b35ace9734
commit
079ac37787
11 files changed
+370
-25
No files matched your search
+129
-19
@@ -34,21 +34,45 @@ 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.
|
||||
// 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 © <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data © 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/
|
||||
@@ -59,6 +83,14 @@ export class HikingTrailsControl {
|
||||
});
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -82,12 +114,36 @@ export class HikingTrailsControl {
|
||||
}
|
||||
}
|
||||
|
||||
// The raster tiles above have no queryable features (they're pixels), so
|
||||
_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 _handleClick(event) {
|
||||
async _handleOnlineClick(event) {
|
||||
const { lng, lat } = event.lngLat;
|
||||
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
|
||||
.setLngLat(event.lngLat)
|
||||
@@ -104,14 +160,49 @@ export class HikingTrailsControl {
|
||||
|
||||
popup.setHTML(
|
||||
relations.length > 0
|
||||
? relations.map(routeHtml).join("<hr>")
|
||||
? relations.map((relation) => routeHtml(overpassRouteInfo(relation))).join("<hr>")
|
||||
: `<p class="gpx-popup__category">No hiking trail found here.</p>`,
|
||||
);
|
||||
}
|
||||
|
||||
_addLayer() {
|
||||
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],
|
||||
@@ -154,23 +245,42 @@ async function fetchNearbyRoutes(lng, lat) {
|
||||
// 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.
|
||||
// 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);
|
||||
}
|
||||
|
||||
function routeHtml(relation) {
|
||||
// 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 || {};
|
||||
const name = tags.name || tags.description || "Hiking trail";
|
||||
const swatch = SAFE_COLOR.test(tags.colour || "")
|
||||
? `<span class="hiking-trail-popup__swatch" style="background:${tags.colour}"></span>`
|
||||
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(routeDistance(relation))];
|
||||
if (tags.ref) meta.push(`#${tags.ref}`);
|
||||
if (tags.network) meta.push(tags.network);
|
||||
if (tags.operator) meta.push(escapeHtml(tags.operator));
|
||||
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>`,
|
||||
@@ -180,9 +290,9 @@ function routeHtml(relation) {
|
||||
// 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 (tags.name && tags.description) parts.push(`<p class="gpx-popup__description">${escapeHtml(tags.description)}</p>`);
|
||||
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/${relation.id}" target="_blank" rel="noopener noreferrer">View on OpenStreetMap →</a></p>`,
|
||||
`<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("");
|
||||
}
|
||||
Reference in new issue
Block a user