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

+14
View File
@@ -51,6 +51,20 @@ overview's polar pan limit, but MapLibre 6's `Map` no longer exposes
`.transform` at all — guarded to skip that (cosmetic) adjustment
instead of throwing when it's absent.
Also vendored under `public/vendor/` — MIT-licensed, attribution
required (`LICENSE` included alongside):
[cols="1,1,2"]
|===
| Library | Version | Source
| maplibre-gl-lanes (offline hiking trails, `public/js/hiking_trails.js`
— trails sharing a path drawn as parallel lanes); its `dist/*.mjs`
build, minus the trailing `sourceMappingURL` comment
| 1.3.0
| https://github.com/c0nsumer/maplibre-gl-lanes
|===
Also vendored under `public/vendor/` — MIT-licensed (© 2013 Panayiotis
Lipiridis), attribution required (this notice satisfies that):
+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);
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Steve Vigneau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff