Draw offline hiking trails sharing a path as parallel lanes
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:
1 parent
9c80a20f3e
commit
ede21454a2
4 files changed
+3674
-72
No files matched your search
@@ -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
@@ -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 © <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
|
||||
// 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
@@ -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.
|
||||
+3557
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user