Split offline hiking trails per country, add a picker instead of one big file
CI / check (push) Successful in 21s
CI / deploy (push) Successful in 11s

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:
randy committed 2026-09-13 11:17:05 +02:00
1 parent 079ac37787
commit c6e4a455b6
12 files changed
+300 -184

No files matched your search

+1 -2
View File
@@ -13,8 +13,7 @@ independence from connectivity.
** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes
* [x] Hiking trail overlay — Waymarked Trails raster tiles online (`public/js/hiking_trails.js`), colored per-route by the OSM `colour` tag they already render (the KST/KČT paint-blaze colors in Slovakia/Czechia, plain red elsewhere). Click queries a one-off Overpass `around` search (racing 3 public mirrors) for the route's name/colour/network/operator/distance
** [x] Offline copy wired in for real, no custom Planetiler profile needed — `Maptool::HikingTrails` (`lib/maptool/hiking_trails.rb`) runs `osmium tags-filter r/route=hiking` + `add-locations-to-ways` + `cat -f opl` against the *same* per-country Geofabrik `.osm.pbf` already downloaded for `offline.pmtiles`, no new download. Generated automatically as the last step of every offline build (`PmtilesBuilder.extract_hiking_trails`, best-effort — a failure here doesn't fail the whole build) into `data/hiking_trails.geojson`, served at `GET /tiles/hiking_trails.geojson` only once the overlay's actually toggled on. Offline click uses a local `queryRenderedFeatures` hit-test instead of Overpass — instant, no network. See docs/development.adoc
*** [ ] File size for a many-country selection could get large (Slovakia alone: ~3000 routes) — coordinates are rounded to 5dp but there's no real tiling/simplification yet; revisit if a big selection makes the browser fetch noticeably slow
** [x] Offline copy wired in for real, no custom Planetiler profile needed — `Maptool::HikingTrails` (`lib/maptool/hiking_trails.rb`) runs `osmium tags-filter r/route=hiking` + `add-locations-to-ways` + `cat -f opl` against the *same* per-country Geofabrik `.osm.pbf` already downloaded for `offline.pmtiles`, no new download. One `data/hiking_trails/<CODE>.geojson` per country (not one combined file), generated as the last step of every offline build and cached like `geofabrik_cache` (skipped once already newer than its source PBF) — a many-country selection no longer means one giant file, since `GET /api/hiking-trails` lists available countries and the Overlays panel's own checklist only fetches `GET /tiles/hiking_trails/<CODE>.geojson` for whichever ones get ticked. Offline click uses a local `queryRenderedFeatures` hit-test instead of Overpass — instant, no network. See docs/development.adoc
* [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query). Each category now carries a real `POI_ICONS` glyph and color (not p4n2gpx's plain red marker) plus a popup description built from opening_hours/operator/website tags
* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed
** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
+18 -15
View File
@@ -62,7 +62,6 @@ module Maptool
OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles")
TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.pmtiles")
HIKING_TRAILS_PATH = PmtilesBuilder.hiking_trails_path(DATA_DIR)
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
@@ -361,24 +360,28 @@ module Maptool
# PmtilesBuilder.extract_hiking_trails) rather than its own separate
# build — it's derived from the exact same per-country .osm.pbf cache
# offline.pmtiles itself already downloads, so there's nothing of its
# own to trigger or delete; DELETE /api/offline-tiles removes this
# file too. No `meta` argument here (unlike offline/terrain above) —
# parsing the whole GeoJSON just to report a feature count would cost
# real time on every page load's status check for what's a multi-MB
# file across several countries; size/mtime alone is enough to know
# whether it's there.
# own to trigger; DELETE /api/offline-tiles removes it too. One file
# per country (not one combined file) so a viewer only ever fetches
# the country they actually pick in the Hiking trails overlay's own
# checklist (public/js/hiking_trails.js), not every selected country's
# trails at once — `countries` lists which ones exist to build that
# checklist from.
get "/api/hiking-trails" do
json pmtiles_file_status(HIKING_TRAILS_PATH, nil)
json countries: PmtilesBuilder.hiking_trails_countries(DATA_DIR)
end
# Fetched client-side only when the Hiking trails overlay is actually
# toggled on (public/js/hiking_trails.js), not on page load — same
# "on demand" story as the online raster tiles it replaces once this
# is available.
get "/tiles/hiking_trails.geojson" do
halt 404 unless File.exist?(HIKING_TRAILS_PATH)
# Fetched client-side only for a country the viewer actually ticks in
# the Hiking trails checklist, not on page load. :code is constrained
# to exactly what hiking_trails_countries can ever return (two
# uppercase letters) — otherwise this would accept an arbitrary
# ".."-laden path segment.
get "/tiles/hiking_trails/:code.geojson" do
halt 404 unless params[:code].match?(/\A[A-Z]{2}\z/)
send_file HIKING_TRAILS_PATH, type: "application/geo+json"
path = PmtilesBuilder.hiking_trails_country_path(DATA_DIR, params[:code])
halt 404 unless File.exist?(path)
send_file path, type: "application/geo+json"
end
# Pinged once per page load (public/js/map.js) — url is a same-origin
+29 -18
View File
@@ -106,11 +106,12 @@ size rather than assuming one, so this isn't a problem either way.
The sidebar's "Hiking trails" overlay works online out of the box
(waymarkedtrails.org raster tiles, `public/js/hiking_trails.js`) —
clicking a trail fires a one-off Overpass query for its name/colour/
network/distance. For it to work offline too, `data/hiking_trails.geojson`
is generated automatically as the last step of every `offline.pmtiles`
build (`Maptool::HikingTrails`, `lib/maptool/hiking_trails.rb`), straight
from the *same* per-country `.osm.pbf` files already downloaded for
`offline.pmtiles` itself — no separate download, no third-party service:
network/distance. For it to work offline too, one `data/hiking_trails/
<CODE>.geojson` per country is generated automatically as the last step
of every `offline.pmtiles` build (`Maptool::HikingTrails`,
`lib/maptool/hiking_trails.rb`), straight from the *same* per-country
`.osm.pbf` files already downloaded for `offline.pmtiles` itself — no
separate download, no third-party service:
[source,shell]
----
@@ -130,20 +131,30 @@ tool stitches a route relation's member ways into one line for you
waymarkedtrails.org and this app's own online Overpass click handler
already do.
Best-effort: a hiking-trails extraction problem on one country's PBF
logs a warning and is skipped rather than failing the whole
`offline.pmtiles` build over what's a bonus overlay, not the core map
(`PmtilesBuilder.extract_hiking_trails`). Coordinates are rounded to 5
decimal places (~1m, plenty for a rendered line) to keep the combined
file smaller — for a many-country selection it can still be a sizeable
GeoJSON (Slovakia alone: ~3000 routes, ~30MB unrounded), fetched
client-side only once the overlay is actually toggled on, not on page
load. `rake hiking:trails[pbf,out]` runs the extraction by hand against
one `.osm.pbf` for testing.
Best-effort per country: an extraction problem on one country's PBF logs
a warning and is skipped rather than failing the whole `offline.pmtiles`
build, or even the other countries in the same selection, over what's a
bonus overlay, not the core map (`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
region selection by one country only extracts that one. Coordinates are
rounded to 5 decimal places (~1m, plenty for a rendered line) — Slovakia
alone is ~3000 routes, ~30MB unrounded.
`DELETE /api/offline-tiles` removes this file along with `offline.pmtiles`
and the Geofabrik cache — there's no separate delete/rebuild route for it,
since it's not an independent build.
One file per country rather than one combined file: `GET /api/hiking-trails`
lists which country codes have an extract (`countries: [...]`), and the
Overlays panel's "Hiking trails" item turns that into a checklist —
each ticked country fetches its own `GET /tiles/hiking_trails/<CODE>.geojson`
only then, not on page load and not for countries never ticked. Offline
click hit-tests the loaded vector layer(s) directly
(`queryRenderedFeatures`) instead of querying Overpass, so it resolves
instantly with no network at all. `rake hiking:trails[pbf,out]` runs the
extraction by hand against one `.osm.pbf` for testing.
`DELETE /api/offline-tiles` removes the whole `data/hiking_trails/`
directory along with `offline.pmtiles` and the Geofabrik cache — there's
no separate delete/rebuild route for it, since it's not an independent
build.
== In-app regeneration
-16
View File
@@ -63,22 +63,6 @@ module Maptool
end
end
# Runs extract() over one .osm.pbf per selected country and combines the
# results into a single FeatureCollection — a route relation crossing a
# border (an E-path, say) shows up once per country whose extract it
# was pulled from, deduplicated here by OSM relation id (properties.id)
# rather than left to render as overlapping duplicate lines.
# extract: same injectable-seam reasoning as PmtilesBuilder's own
# download_extract:/fetch_manifest: — lets a test swap out the real
# (osmium-shelling-out) implementation for a canned one.
def self.extract_merged(pbf_paths, extract: method(:extract))
seen = {}
pbf_paths.each do |pbf_path|
extract.call(pbf_path)["features"].each { |feature| seen[feature["properties"]["id"]] ||= feature }
end
{ "type" => "FeatureCollection", "features" => seen.values }
end
# Pure — no subprocess — so it's cheaply testable against a canned OPL
# fixture, same "keep the real logic testable without the real tool
# installed" reasoning OverpassPois.to_geojson already gives for staying
+45 -16
View File
@@ -299,26 +299,55 @@ module Maptool
end
private_class_method :extract_offline
# Runs over the same per-country .osm.pbf files sources above already
# One GeoJSON per country (data/hiking_trails/<CODE>.geojson), not one
# merged file — the frontend only ever needs to show a country at a
# time the viewer actually picks (see HikingTrailsControl's country
# checklist), and a combined multi-country file was needlessly making
# every viewer fetch every selected country's trails regardless.
#
# Runs over the same per-country .osm.pbf files `sources` above already
# downloaded/cached for offline.pmtiles itself — no separate download.
# Best-effort: a hiking-trails extraction problem (a malformed PBF
# region, osmium hiccup on one particular file) shouldn't fail the
# whole offline.pmtiles build over what's a bonus overlay, not the
# core map — logged into the sidecar meta's own "error" field instead,
# so it's visible without taking build_offline itself down.
# Cached the same way geofabrik_cache is: skipped for a country whose
# extract is already newer than its source .osm.pbf, so growing the
# region selection by one country only extracts that one, not every
# already-covered country all over again.
#
# Best-effort per country: an extraction problem (a malformed PBF
# region, an osmium hiccup) shouldn't fail the whole offline.pmtiles
# build, or even the other countries in this same selection, over
# what's a bonus overlay, not the core map.
def self.extract_hiking_trails(sources, data_dir)
geojson = HikingTrails.extract_merged(sources.map { |s| s["path"] })
tmp_path = "#{hiking_trails_path(data_dir)}.tmp"
File.write(tmp_path, JSON.generate(geojson))
File.rename(tmp_path, hiking_trails_path(data_dir))
rescue HikingTrails::ExtractError => e
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
warn "hiking trails extraction failed, skipping: #{e.message}"
FileUtils.mkdir_p(hiking_trails_dir(data_dir))
sources.each do |source|
out_path = hiking_trails_country_path(data_dir, source["code"])
next if File.exist?(out_path) && File.mtime(out_path) >= File.mtime(source["path"])
tmp_path = "#{out_path}.tmp"
begin
File.write(tmp_path, JSON.generate(HikingTrails.extract(source["path"])))
File.rename(tmp_path, out_path)
rescue HikingTrails::ExtractError => e
File.delete(tmp_path) if File.exist?(tmp_path)
warn "hiking trails extraction failed for #{source["code"]}, skipping: #{e.message}"
end
end
end
private_class_method :extract_hiking_trails
def self.hiking_trails_path(data_dir)
File.join(data_dir, "hiking_trails.geojson")
def self.hiking_trails_dir(data_dir)
File.join(data_dir, "hiking_trails")
end
def self.hiking_trails_country_path(data_dir, code)
File.join(hiking_trails_dir(data_dir), "#{code}.geojson")
end
# Country codes with an already-extracted offline hiking-trails file,
# sorted — not necessarily the same as the *current* region selection
# (accumulates like geofabrik_cache does; a country deselected after
# being built once is left alone rather than swept up here).
def self.hiking_trails_countries(data_dir)
Dir.glob(File.join(hiking_trails_dir(data_dir), "*.geojson")).map { |path| File.basename(path, ".geojson") }.sort
end
# Renders a report_step string like "fetching PL (3/4) — done: SK, CZ —
@@ -468,7 +497,7 @@ module Maptool
File.delete(File.join(data_dir, "offline.pmtiles")) if File.exist?(File.join(data_dir, "offline.pmtiles"))
File.delete(offline_meta_path(data_dir)) if File.exist?(offline_meta_path(data_dir))
File.delete(hiking_trails_path(data_dir)) if File.exist?(hiking_trails_path(data_dir))
FileUtils.remove_entry(hiking_trails_dir(data_dir)) if Dir.exist?(hiking_trails_dir(data_dir))
cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
end
+16
View File
@@ -1579,6 +1579,22 @@ html, body {
font-size: 12px;
}
.hiking-trail-countries {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
list-style: none;
margin: 0;
padding: 0;
}
.hiking-trail-countries__item label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.livetrack-panel__list {
flex: 1;
overflow-y: auto;
+149 -88
View File
@@ -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 &copy; <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data &copy; 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));
}
}
+1 -1
View File
@@ -609,7 +609,7 @@ fetch("/api/terrain-tiles")
fetch("/api/hiking-trails")
.then((response) => response.json())
.then(({ available }) => overlaysControl.setHikingTrailsOfflineAvailable(available))
.then(({ countries }) => overlaysControl.setHikingTrailsOfflineCountries(countries))
.catch(() => {});
fetch("/api/kiwix")
+3 -3
View File
@@ -219,7 +219,7 @@ export class OverlaysControl {
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item"></li>
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
`;
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children;
this._hillshade.mount(hillshadeItem);
@@ -245,8 +245,8 @@ export class OverlaysControl {
this._hillshade.setOfflineAvailable(available);
}
setHikingTrailsOfflineAvailable(available) {
this._hikingTrails.setOfflineAvailable(available);
setHikingTrailsOfflineCountries(countries) {
this._hikingTrails.setOfflineCountries(countries);
}
// Called by map.js's PitchToggleControl whenever the 3D pitch toggle
+8 -4
View File
@@ -39,13 +39,17 @@ class AppTest < Minitest::Test
assert_equal({ "available" => false }, json_body(response))
end
def test_hiking_trails_reports_unavailable_when_no_geojson_present
def test_hiking_trails_reports_no_countries_when_none_extracted_yet
response = request.get("/api/hiking-trails")
assert_equal({ "available" => false }, json_body(response))
assert_equal({ "countries" => [] }, json_body(response))
end
def test_hiking_trails_file_404s_when_no_geojson_present
assert_equal 404, request.get("/tiles/hiking_trails.geojson").status
def test_hiking_trails_file_404s_for_a_country_with_no_extract
assert_equal 404, request.get("/tiles/hiking_trails/SK.geojson").status
end
def test_hiking_trails_file_404s_for_a_malformed_country_code
assert_equal 404, request.get("/tiles/hiking_trails/../../etc/passwd.geojson").status
end
def test_kiwix_reports_unavailable_when_no_url_configured
-15
View File
@@ -111,21 +111,6 @@ class HikingTrailsTest < Minitest::Test
assert_equal [[[20.08765, 49.17912], [20.088, 49.18]]], coords
end
def test_extract_merged_deduplicates_by_relation_id_across_files
feature = ->(id) {
{ "type" => "Feature", "properties" => { "id" => id, "name" => "Trail #{id}" }, "geometry" => { "type" => "MultiLineString", "coordinates" => [] } }
}
# Relation 1 shows up in both "country" files (a cross-border trail);
# relation 2 only in the second.
per_file = { "at.osm.pbf" => [feature.call(1)], "cz.osm.pbf" => [feature.call(1), feature.call(2)] }
extract = ->(pbf_path) { { "type" => "FeatureCollection", "features" => per_file.fetch(pbf_path) } }
merged = Maptool::HikingTrails.extract_merged(%w[at.osm.pbf cz.osm.pbf], extract: extract)
ids = merged["features"].map { |f| f["properties"]["id"] }.sort
assert_equal [1, 2], ids
end
def test_parse_opl_ignores_a_dangling_member_reference_but_keeps_the_rest
opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
+30 -6
View File
@@ -216,20 +216,44 @@ class PmtilesBuilderTest < Minitest::Test
)
end
def test_build_offline_also_writes_a_hiking_trails_geojson
def test_build_offline_also_writes_a_per_country_hiking_trails_geojson
select_regions("SK")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join
# The fake osmium binary doesn't produce real OPL output, just
# confirms the file lands and is valid (empty) GeoJSON — the real
# parsing logic is covered by test_hiking_trails.rb against real OPL
# fixtures.
path = File.join(@data_dir, "hiking_trails.geojson")
assert_path_exists path
# confirms the file lands (one per country) and is valid (empty)
# GeoJSON — the real parsing logic is covered by test_hiking_trails.rb
# against real OPL fixtures.
assert_equal ["SK"], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
path = Maptool::PmtilesBuilder.hiking_trails_country_path(@data_dir, "SK")
assert_equal({ "type" => "FeatureCollection", "features" => [] }, JSON.parse(File.read(path)))
end
def test_build_offline_only_re_extracts_hiking_trails_for_a_newly_added_country
select_regions("SK")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
File.write(@log, "")
select_regions("SK", "AT")
Maptool::PmtilesBuilder.build_offline(
data_dir: @data_dir,
download_extract: fake_download,
fetch_last_modified: ->(_url) { "Wed, 10 Sep 2026 08:00:01 GMT" },
).join
# SK's cached extract is already newer than SK.osm.pbf (untouched
# since the first build), so only AT's hiking trails get (re-)extracted
# — SK.osm.pbf itself is also skipped by cached_extract's own
# Last-Modified check, "osmium merge" runs regardless since two
# countries are now selected.
assert_equal(
["osmium merge", "planetiler --osm-path", "osmium tags-filter", "osmium add-locations-to-ways", "osmium cat"],
log_lines,
)
assert_equal %w[AT SK], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
end
# The actual reasoning: hiking trails are a bonus overlay bolted onto the
# existing offline.pmtiles build, not the core map — an osmium hiccup on
# this one step shouldn't take down an otherwise-successful build.