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
|
`.transform` at all — guarded to skip that (cosmetic) adjustment
|
||||||
instead of throwing when it's absent.
|
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
|
Also vendored under `public/vendor/` — MIT-licensed (© 2013 Panayiotis
|
||||||
Lipiridis), attribution required (this notice satisfies that):
|
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 { firstSymbolLayerId } from "/js/radar.js";
|
||||||
import { escapeHtml } from "/js/gpx.js";
|
import { escapeHtml } from "/js/gpx.js";
|
||||||
import { lineLength, formatDistance } from "/js/measure.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: *),
|
// Overpass has permissive CORS (verified: Access-Control-Allow-Origin: *),
|
||||||
// so this queries it directly from the browser — no server-side proxy
|
// 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';
|
'Trails © <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data © OpenStreetMap contributors';
|
||||||
const OPACITY = 0.85;
|
const OPACITY = 0.85;
|
||||||
|
|
||||||
// Offline: one real vector line layer per country the viewer actually
|
// Offline: real vector trails for each country the viewer actually picks,
|
||||||
// picks, GeoJSON generated at build time by Maptool::HikingTrails straight
|
// GeoJSON generated at build time by Maptool::HikingTrails straight
|
||||||
// from the same Geofabrik .osm.pbf offline.pmtiles itself is built from
|
// from the same Geofabrik .osm.pbf offline.pmtiles itself is built from
|
||||||
// (see lib/maptool/hiking_trails.rb, PmtilesBuilder.extract_hiking_trails)
|
// (see lib/maptool/hiking_trails.rb, PmtilesBuilder.extract_hiking_trails)
|
||||||
// — no network needed, and no waiting on Overpass for click details either
|
// — 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
|
// below). One file per country rather than one combined file: a viewer
|
||||||
// covering a dozen countries' worth of offline map data rarely wants all
|
// 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
|
// of their hiking trails loaded at once, so this is opt-in per country
|
||||||
// instead.
|
// instead.
|
||||||
function offlineSourceId(code) {
|
//
|
||||||
return `hiking-trails-offline-${code}`;
|
// 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
|
||||||
function offlineLayerId(code) {
|
// otherwise one line drawn over and over, only the topmost color visible.
|
||||||
return `hiking-trails-offline-${code}-line`;
|
// 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) {
|
function offlineUrl(code) {
|
||||||
return `/tiles/hiking_trails/${code}.geojson`;
|
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;
|
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
|
// Online mode's highlight outline for whichever route(s) the last click
|
||||||
// as its own separate source/layer (real geometry, from either the offline
|
// matched — drawn as its own separate source/layer (real geometry, from the
|
||||||
// vector feature or the online Overpass response) rather than a paint-
|
// Overpass response), since the raster tiles have no paintable features to
|
||||||
// property change on the base layer, since the online raster tiles have no
|
// highlight at all. Offline lanes use LaneLayer.setHighlight instead. Same "yellow outline on a picked
|
||||||
// paintable features to highlight at all. Same "yellow outline on a picked
|
|
||||||
// route" treatment the earlier SVG prototype visualization used.
|
// route" treatment the earlier SVG prototype visualization used.
|
||||||
const HIGHLIGHT_SOURCE_ID = "hiking-trails-highlight";
|
const HIGHLIGHT_SOURCE_ID = "hiking-trails-highlight";
|
||||||
const HIGHLIGHT_LAYER_ID = "hiking-trails-highlight-line";
|
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._active = false; // the online-mode checkbox's own state
|
||||||
this._offlineCountries = []; // codes with an extract available on the server
|
this._offlineCountries = []; // codes with an extract available on the server
|
||||||
this._selectedCountries = new Set(); // codes currently shown, offline mode
|
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));
|
map.on("click", (event) => this._handleClick(event));
|
||||||
|
|
||||||
@@ -111,8 +120,8 @@ export class HikingTrailsControl {
|
|||||||
// here than try to carry it across a style swap.
|
// here than try to carry it across a style swap.
|
||||||
map.on("style.load", () => {
|
map.on("style.load", () => {
|
||||||
if (this._active) this._addOnlineLayer();
|
if (this._active) this._addOnlineLayer();
|
||||||
for (const code of this._selectedCountries) this._addOfflineLayer(code);
|
|
||||||
this._clearHighlight();
|
this._clearHighlight();
|
||||||
|
if (this._selectedCountries.size > 0) this._rebuildLanes();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,14 +186,56 @@ export class HikingTrailsControl {
|
|||||||
else this._removeOnlineLayer();
|
else this._removeOnlineLayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
_toggleCountry(code, active) {
|
async _toggleCountry(code, active) {
|
||||||
if (active) {
|
if (!active) {
|
||||||
this._selectedCountries.add(code);
|
|
||||||
this._addOfflineLayer(code);
|
|
||||||
} else {
|
|
||||||
this._selectedCountries.delete(code);
|
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) {
|
_handleClick(event) {
|
||||||
@@ -192,24 +243,20 @@ export class HikingTrailsControl {
|
|||||||
else if (this._active) this._handleOnlineClick(event);
|
else if (this._active) this._handleOnlineClick(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The local vector layers have real features to hit-test directly — no
|
// The lanes have real geometry to hit-test directly — no network
|
||||||
// network round-trip, so this resolves instantly. A genuine miss (empty
|
// round-trip, so this resolves instantly. Each route has its own lane, so
|
||||||
// result) just closes an already-open panel rather than showing a "not
|
// 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.
|
// found" message, same as clicking empty space anywhere else on the map.
|
||||||
_handleOfflineClick(event) {
|
_handleOfflineClick(event) {
|
||||||
const layers = [...this._selectedCountries].map(offlineLayerId).filter((id) => this._map.getLayer(id));
|
const hit = this._lanes?.queryLane(event.point, 8);
|
||||||
if (layers.length === 0) return;
|
const info = hit && this._routeInfo.get(String(hit.route));
|
||||||
|
if (!info) {
|
||||||
const features = this._map.queryRenderedFeatures(event.point, { layers });
|
|
||||||
if (features.length === 0) {
|
|
||||||
this._hidePanel();
|
this._hidePanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._lanes.setHighlight(hit.route);
|
||||||
const matched = features.filter((feature, i) => features.findIndex((f) => f.properties.id === feature.properties.id) === i);
|
this._showPanel(routeHtml(info));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_showPanel(html) {
|
_showPanel(html) {
|
||||||
@@ -277,44 +324,6 @@ export class HikingTrailsControl {
|
|||||||
if (this._map.getSource(ONLINE_SOURCE_ID)) this._map.removeSource(ONLINE_SOURCE_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
|
// features: an array of { geometry, color } — geometry a GeoJSON
|
||||||
// LineString or MultiLineString (mixed is fine), color the trail's own
|
// LineString or MultiLineString (mixed is fine), color the trail's own
|
||||||
// (already-normalized) colour — everything the last click matched, drawn
|
// (already-normalized) colour — everything the last click matched, drawn
|
||||||
@@ -342,6 +351,7 @@ export class HikingTrailsControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_clearHighlight() {
|
_clearHighlight() {
|
||||||
|
this._lanes?.setHighlight(null);
|
||||||
if (this._map.getLayer(HIGHLIGHT_LAYER_ID)) this._map.removeLayer(HIGHLIGHT_LAYER_ID);
|
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);
|
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