Draw offline hiking trails sharing a path as parallel lanes
CI / check (push) Successful in 22s
CI / deploy (push) Successful in 12s

Several marked routes running down one path drew as a single line, only
the topmost color visible. maplibre-gl-lanes puts each route in its own
lane; OSM relations already share exact vertices, which is all it needs.
One LaneLayer across every picked country; click hit-testing and the
highlight move to queryLane/setHighlight.
This commit is contained in:
randy committed 2026-10-09 21:46:05 +02:00
1 parent 9c80a20f3e
commit ede21454a2
4 files changed
+3674 -72

No files matched your search

+82 -72
View File
@@ -2,6 +2,7 @@ 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";
import { buildLineGraph, orderLanesAsync, LaneLayer } from "/vendor/maplibre-gl-lanes/maplibre-gl-lanes.mjs";
// Overpass has permissive CORS (verified: Access-Control-Allow-Origin: *),
// so this queries it directly from the browser — no server-side proxy
@@ -40,22 +41,28 @@ 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: one real vector line layer per country the viewer actually
// picks, GeoJSON generated at build time by Maptool::HikingTrails straight
// Offline: real vector trails for each 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
// (hit-tested locally via LaneLayer.queryLane 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`;
}
//
// Drawn with maplibre-gl-lanes rather than a plain line layer: a stretch
// of path several marked routes share (very common with KST blazes) is
// otherwise one line drawn over and over, only the topmost color visible.
// Lanes puts each route in its own parallel lane instead. It needs routes
// sharing a path to share exact vertices, which OSM relations over shared
// ways already do. One LaneLayer over every picked country, not one per
// country, so lanes at a border stay ordered together. Doesn't drape over
// 3D terrain (sinks under raised ground, warns on the console) — a known
// limitation of the library, not worth working around for an overlay.
const LANES_LAYER_ID = "hiking-trails-lanes";
const LANE_SIZES = () => ({ spacing: 3.5, width: 3, casingWidth: 0.5 });
function offlineUrl(code) {
return `/tiles/hiking_trails/${code}.geojson`;
}
@@ -76,11 +83,10 @@ function normalizedTrailColor(raw) {
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
// Online mode's highlight outline for whichever route(s) the last click
// matched — drawn as its own separate source/layer (real geometry, from the
// Overpass response), since the raster tiles have no paintable features to
// highlight at all. Offline lanes use LaneLayer.setHighlight instead. 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";
@@ -97,6 +103,9 @@ export class HikingTrailsControl {
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
this._trailData = new Map(); // code -> fetched GeoJSON, kept across toggles/style swaps
this._routeInfo = new Map(); // String(relation id) -> feature properties, for the panel
this._lanes = null; // the current LaneLayer, null when no country is shown
map.on("click", (event) => this._handleClick(event));
@@ -111,8 +120,8 @@ export class HikingTrailsControl {
// 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();
if (this._selectedCountries.size > 0) this._rebuildLanes();
});
}
@@ -177,14 +186,56 @@ export class HikingTrailsControl {
else this._removeOnlineLayer();
}
_toggleCountry(code, active) {
if (active) {
this._selectedCountries.add(code);
this._addOfflineLayer(code);
} else {
async _toggleCountry(code, active) {
if (!active) {
this._selectedCountries.delete(code);
this._removeOfflineLayer(code);
this._rebuildLanes();
return;
}
this._selectedCountries.add(code);
if (!this._trailData.has(code)) {
let geojson;
try {
geojson = await (await fetch(offlineUrl(code))).json();
} catch {
return; // degrade quietly, same story as every other optional layer here
}
// Normalized once here so an untrustworthy `colour` tag value never
// reaches the layer's color 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._trailData.set(code, geojson);
if (!this._selectedCountries.has(code)) return; // toggled back off while fetching
}
this._rebuildLanes();
}
// Rebuilt from scratch on every country toggle and style swap (setStyle
// drops custom layers too) — lane order depends on every route drawn, so
// there's no cheaper incremental path. Ordering runs in a worker; a newer
// rebuild started meanwhile wins, same token guard as _handleOnlineClick.
async _rebuildLanes() {
const build = (this._lanesBuild = {});
const features = [...this._selectedCountries].flatMap((code) => this._trailData.get(code)?.features || []);
let graph = null;
if (features.length > 0) {
graph = buildLineGraph(features, { routeProperty: "id" });
await orderLanesAsync(graph);
}
if (this._lanesBuild !== build) return;
this._hidePanel();
if (this._map.getLayer(LANES_LAYER_ID)) this._map.removeLayer(LANES_LAYER_ID);
this._lanes = null;
this._routeInfo = new Map(features.map((feature) => [String(feature.properties.id), feature.properties]));
if (!graph) return;
this._lanes = new LaneLayer({ id: LANES_LAYER_ID, graph, sizes: LANE_SIZES, casingColor: "rgba(255, 255, 255, 0.8)" });
// 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(this._lanes, firstSymbolLayerId(this._map));
}
_handleClick(event) {
@@ -192,24 +243,20 @@ export class HikingTrailsControl {
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) just closes an already-open panel rather than showing a "not
// The lanes have real geometry to hit-test directly — no network
// round-trip, so this resolves instantly. Each route has its own lane, so
// a click picks exactly one, highlighted by the layer itself. A genuine
// miss just closes an already-open panel rather than showing 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) {
const hit = this._lanes?.queryLane(event.point, 8);
const info = hit && this._routeInfo.get(String(hit.route));
if (!info) {
this._hidePanel();
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 })));
this._showPanel(html);
this._lanes.setHighlight(hit.route);
this._showPanel(routeHtml(info));
}
_showPanel(html) {
@@ -277,44 +324,6 @@ export class HikingTrailsControl {
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
@@ -342,6 +351,7 @@ export class HikingTrailsControl {
}
_clearHighlight() {
this._lanes?.setHighlight(null);
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);
}