Split offline hiking trails per country, add a picker instead of one big file
data/hiking_trails.geojson is now data/hiking_trails/<CODE>.geojson — one file per country instead of a merge, generated per-source (PmtilesBuilder.extract_hiking_trails), cached the same way geofabrik_cache is (skipped once a country's own extract is already newer than its source .osm.pbf, so growing the selection by one country only re-extracts that one). GET /api/hiking-trails now lists available country codes instead of a single file's status. The Overlays panel's "Hiking trails" item becomes an expandable per-country checklist once any offline extract exists (same shape as Radar's own expand), replacing the plain on/off checkbox — nobody covering a dozen countries' worth of offline map data needs all of their hiking trails loaded and parsed at once, so each ticked country fetches its own GeoJSON independently instead. HikingTrails.extract_merged (server-side multi-country merge, no longer needed now that the client picks per country) removed again. Verified the new extract_hiking_trails + caching skip live on brick against a real cached country PBF.
This commit is contained in:
1 parent
079ac37787
commit
c6e4a455b6
12 files changed
+300
-184
No files matched your search
+149
-88
@@ -4,9 +4,6 @@ import { firstSymbolLayerId } from "/js/radar.js";
|
||||
import { escapeHtml } from "/js/gpx.js";
|
||||
import { lineLength, formatDistance } from "/js/measure.js";
|
||||
|
||||
const SOURCE_ID = "hiking-trails";
|
||||
const LAYER_ID = "hiking-trails-line";
|
||||
|
||||
// Overpass has permissive CORS (verified: Access-Control-Allow-Origin: *),
|
||||
// so this queries it directly from the browser — no server-side proxy
|
||||
// needed, same "public API, no key" story as the Nominatim search already
|
||||
@@ -34,22 +31,36 @@ 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. 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.
|
||||
// has none, so there's no styling to do here ourselves. Used whenever no
|
||||
// offline extract is available at all — same "quietly renders nothing
|
||||
// without a network" story as the Wikipedia/Radar/Contour lines overlays.
|
||||
const ONLINE_SOURCE_ID = "hiking-trails-online";
|
||||
const ONLINE_LAYER_ID = "hiking-trails-online-line";
|
||||
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";
|
||||
// Offline: one real vector line layer per 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
|
||||
// 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`;
|
||||
}
|
||||
function offlineUrl(code) {
|
||||
return `/tiles/hiking_trails/${code}.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
|
||||
@@ -66,68 +77,115 @@ function normalizedTrailColor(raw) {
|
||||
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.
|
||||
// An Overlays panel item — a plain on/off checkbox (raster overlay) until
|
||||
// offline extracts exist for at least one country, at which point it
|
||||
// becomes an expandable per-country checklist instead (same expand shape
|
||||
// as RadarControl), since a viewer with a dozen countries' worth of
|
||||
// offline map data rarely wants all of their hiking trails loaded, let
|
||||
// alone parsed by the browser, at once.
|
||||
export class HikingTrailsControl {
|
||||
constructor(map) {
|
||||
this._map = map;
|
||||
this._active = false;
|
||||
this._offlineAvailable = false;
|
||||
this._clickHandler = (event) => this._handleClick(event);
|
||||
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
|
||||
|
||||
map.on("click", (event) => this._handleClick(event));
|
||||
|
||||
// setStyle() (switching Online/Offline) tears down all custom sources/
|
||||
// layers — put this back if it was on, same as every other Overlays
|
||||
// control.
|
||||
// layers — put whatever was showing back, same as every other
|
||||
// Overlays control.
|
||||
map.on("style.load", () => {
|
||||
if (this._active) this._addLayer();
|
||||
if (this._active) this._addOnlineLayer();
|
||||
for (const code of this._selectedCountries) this._addOfflineLayer(code);
|
||||
});
|
||||
}
|
||||
|
||||
// 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>
|
||||
<div class="overlays-panel__row">
|
||||
<input type="checkbox" class="overlays-panel__toggle">
|
||||
<span class="overlays-panel__icon">${WALKING_ICON}</span>
|
||||
<span>Hiking trails</span>
|
||||
</label>
|
||||
<button type="button" class="overlays-panel__name">Hiking trails</button>
|
||||
</div>
|
||||
<div class="overlays-panel__details" hidden>
|
||||
<ul class="hiking-trail-countries"></ul>
|
||||
</div>
|
||||
`;
|
||||
this._checkbox = li.querySelector(".overlays-panel__toggle");
|
||||
this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked));
|
||||
this._nameButton = li.querySelector(".overlays-panel__name");
|
||||
this._details = li.querySelector(".overlays-panel__details");
|
||||
this._countryList = li.querySelector(".hiking-trail-countries");
|
||||
|
||||
this._checkbox.addEventListener("change", (event) => this._toggleOnline(event.target.checked));
|
||||
this._nameButton.addEventListener("click", () => {
|
||||
if (this._offlineCountries.length === 0) return; // nothing to expand into yet
|
||||
const expanding = this._details.hidden;
|
||||
this._details.hidden = !expanding;
|
||||
this._nameButton.setAttribute("aria-expanded", String(expanding));
|
||||
});
|
||||
}
|
||||
|
||||
_toggle(active) {
|
||||
// Called once map.js's GET /api/hiking-trails check resolves. Switches
|
||||
// this item from "online raster, plain on/off" to "offline vector,
|
||||
// pick your countries" — the two are mutually exclusive rather than
|
||||
// both offered at once, since once a real local extract exists there's
|
||||
// no reason to still fall back to a third-party tile server for any of
|
||||
// it.
|
||||
setOfflineCountries(countries) {
|
||||
this._offlineCountries = countries;
|
||||
if (countries.length === 0) return;
|
||||
|
||||
if (this._active) this._toggleOnline(false);
|
||||
this._checkbox.hidden = true;
|
||||
|
||||
this._countryList.innerHTML = countries
|
||||
.map(
|
||||
(code) => `
|
||||
<li class="hiking-trail-countries__item">
|
||||
<label>
|
||||
<input type="checkbox" class="hiking-trail-countries__toggle" value="${code}">
|
||||
${code}
|
||||
</label>
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
this._countryList.querySelectorAll(".hiking-trail-countries__toggle").forEach((input) => {
|
||||
input.addEventListener("change", (event) => this._toggleCountry(event.target.value, event.target.checked));
|
||||
});
|
||||
}
|
||||
|
||||
_toggleOnline(active) {
|
||||
this._active = active;
|
||||
if (active) this._addOnlineLayer();
|
||||
else this._removeOnlineLayer();
|
||||
}
|
||||
|
||||
_toggleCountry(code, active) {
|
||||
if (active) {
|
||||
this._addLayer();
|
||||
this._map.on("click", this._clickHandler);
|
||||
this._selectedCountries.add(code);
|
||||
this._addOfflineLayer(code);
|
||||
} else {
|
||||
this._removeLayer();
|
||||
this._map.off("click", this._clickHandler);
|
||||
this._selectedCountries.delete(code);
|
||||
this._removeOfflineLayer(code);
|
||||
}
|
||||
}
|
||||
|
||||
_handleClick(event) {
|
||||
if (this._offlineAvailable) {
|
||||
this._handleOfflineClick(event);
|
||||
} else {
|
||||
this._handleOnlineClick(event);
|
||||
}
|
||||
if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
|
||||
else if (this._active) this._handleOnlineClick(event);
|
||||
}
|
||||
|
||||
// The local vector layer has real features to hit-test directly — no
|
||||
// The local vector layers have 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] });
|
||||
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) return;
|
||||
|
||||
const seen = new Set();
|
||||
@@ -165,45 +223,10 @@ export class HikingTrailsControl {
|
||||
);
|
||||
}
|
||||
|
||||
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, {
|
||||
if (this._map.getSource(ONLINE_SOURCE_ID)) return;
|
||||
|
||||
this._map.addSource(ONLINE_SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: [TILE_URL],
|
||||
tileSize: 256,
|
||||
@@ -213,14 +236,52 @@ export class HikingTrailsControl {
|
||||
// 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(
|
||||
{ id: LAYER_ID, type: "raster", source: SOURCE_ID, paint: { "raster-opacity": OPACITY } },
|
||||
{ id: ONLINE_LAYER_ID, type: "raster", source: ONLINE_SOURCE_ID, paint: { "raster-opacity": OPACITY } },
|
||||
firstSymbolLayerId(this._map),
|
||||
);
|
||||
}
|
||||
|
||||
_removeLayer() {
|
||||
if (this._map.getLayer(LAYER_ID)) this._map.removeLayer(LAYER_ID);
|
||||
if (this._map.getSource(SOURCE_ID)) this._map.removeSource(SOURCE_ID);
|
||||
_removeOnlineLayer() {
|
||||
if (this._map.getLayer(ONLINE_LAYER_ID)) this._map.removeLayer(ONLINE_LAYER_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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user