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
+299 -183

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) ** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes * [ ] 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] 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 ** [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
*** [ ] 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] 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] 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] 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 ** [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") OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles")
TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.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")) GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json")) 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 # PmtilesBuilder.extract_hiking_trails) rather than its own separate
# build — it's derived from the exact same per-country .osm.pbf cache # build — it's derived from the exact same per-country .osm.pbf cache
# offline.pmtiles itself already downloads, so there's nothing of its # offline.pmtiles itself already downloads, so there's nothing of its
# own to trigger or delete; DELETE /api/offline-tiles removes this # own to trigger; DELETE /api/offline-tiles removes it too. One file
# file too. No `meta` argument here (unlike offline/terrain above) — # per country (not one combined file) so a viewer only ever fetches
# parsing the whole GeoJSON just to report a feature count would cost # the country they actually pick in the Hiking trails overlay's own
# real time on every page load's status check for what's a multi-MB # checklist (public/js/hiking_trails.js), not every selected country's
# file across several countries; size/mtime alone is enough to know # trails at once — `countries` lists which ones exist to build that
# whether it's there. # checklist from.
get "/api/hiking-trails" do get "/api/hiking-trails" do
json pmtiles_file_status(HIKING_TRAILS_PATH, nil) json countries: PmtilesBuilder.hiking_trails_countries(DATA_DIR)
end end
# Fetched client-side only when the Hiking trails overlay is actually # Fetched client-side only for a country the viewer actually ticks in
# toggled on (public/js/hiking_trails.js), not on page load — same # the Hiking trails checklist, not on page load. :code is constrained
# "on demand" story as the online raster tiles it replaces once this # to exactly what hiking_trails_countries can ever return (two
# is available. # uppercase letters) — otherwise this would accept an arbitrary
get "/tiles/hiking_trails.geojson" do # ".."-laden path segment.
halt 404 unless File.exist?(HIKING_TRAILS_PATH) 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 end
# Pinged once per page load (public/js/map.js) — url is a same-origin # 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 The sidebar's "Hiking trails" overlay works online out of the box
(waymarkedtrails.org raster tiles, `public/js/hiking_trails.js`) — (waymarkedtrails.org raster tiles, `public/js/hiking_trails.js`) —
clicking a trail fires a one-off Overpass query for its name/colour/ 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` network/distance. For it to work offline too, one `data/hiking_trails/
is generated automatically as the last step of every `offline.pmtiles` <CODE>.geojson` per country is generated automatically as the last step
build (`Maptool::HikingTrails`, `lib/maptool/hiking_trails.rb`), straight of every `offline.pmtiles` build (`Maptool::HikingTrails`,
from the *same* per-country `.osm.pbf` files already downloaded for `lib/maptool/hiking_trails.rb`), straight from the *same* per-country
`offline.pmtiles` itself — no separate download, no third-party service: `.osm.pbf` files already downloaded for `offline.pmtiles` itself — no
separate download, no third-party service:
[source,shell] [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 waymarkedtrails.org and this app's own online Overpass click handler
already do. already do.
Best-effort: a hiking-trails extraction problem on one country's PBF Best-effort per country: an extraction problem on one country's PBF logs
logs a warning and is skipped rather than failing the whole a warning and is skipped rather than failing the whole `offline.pmtiles`
`offline.pmtiles` build over what's a bonus overlay, not the core map build, or even the other countries in the same selection, over what's a
(`PmtilesBuilder.extract_hiking_trails`). Coordinates are rounded to 5 bonus overlay, not the core map (`PmtilesBuilder.extract_hiking_trails`).
decimal places (~1m, plenty for a rendered line) to keep the combined Cached the same way `geofabrik_cache` is (skipped once a country's own
file smaller — for a many-country selection it can still be a sizeable extract is already newer than its source `.osm.pbf`), so growing the
GeoJSON (Slovakia alone: ~3000 routes, ~30MB unrounded), fetched region selection by one country only extracts that one. Coordinates are
client-side only once the overlay is actually toggled on, not on page rounded to 5 decimal places (~1m, plenty for a rendered line) — Slovakia
load. `rake hiking:trails[pbf,out]` runs the extraction by hand against alone is ~3000 routes, ~30MB unrounded.
one `.osm.pbf` for testing.
`DELETE /api/offline-tiles` removes this file along with `offline.pmtiles` One file per country rather than one combined file: `GET /api/hiking-trails`
and the Geofabrik cache — there's no separate delete/rebuild route for it, lists which country codes have an extract (`countries: [...]`), and the
since it's not an independent build. 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 == In-app regeneration
-16
View File
@@ -63,22 +63,6 @@ module Maptool
end end
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 # Pure — no subprocess — so it's cheaply testable against a canned OPL
# fixture, same "keep the real logic testable without the real tool # fixture, same "keep the real logic testable without the real tool
# installed" reasoning OverpassPois.to_geojson already gives for staying # installed" reasoning OverpassPois.to_geojson already gives for staying
+44 -15
View File
@@ -299,26 +299,55 @@ module Maptool
end end
private_class_method :extract_offline 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. # downloaded/cached for offline.pmtiles itself — no separate download.
# Best-effort: a hiking-trails extraction problem (a malformed PBF # Cached the same way geofabrik_cache is: skipped for a country whose
# region, osmium hiccup on one particular file) shouldn't fail the # extract is already newer than its source .osm.pbf, so growing the
# whole offline.pmtiles build over what's a bonus overlay, not the # region selection by one country only extracts that one, not every
# core map — logged into the sidecar meta's own "error" field instead, # already-covered country all over again.
# so it's visible without taking build_offline itself down. #
# 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) def self.extract_hiking_trails(sources, data_dir)
geojson = HikingTrails.extract_merged(sources.map { |s| s["path"] }) FileUtils.mkdir_p(hiking_trails_dir(data_dir))
tmp_path = "#{hiking_trails_path(data_dir)}.tmp" sources.each do |source|
File.write(tmp_path, JSON.generate(geojson)) out_path = hiking_trails_country_path(data_dir, source["code"])
File.rename(tmp_path, hiking_trails_path(data_dir)) 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 rescue HikingTrails::ExtractError => e
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) File.delete(tmp_path) if File.exist?(tmp_path)
warn "hiking trails extraction failed, skipping: #{e.message}" warn "hiking trails extraction failed for #{source["code"]}, skipping: #{e.message}"
end
end
end end
private_class_method :extract_hiking_trails private_class_method :extract_hiking_trails
def self.hiking_trails_path(data_dir) def self.hiking_trails_dir(data_dir)
File.join(data_dir, "hiking_trails.geojson") 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 end
# Renders a report_step string like "fetching PL (3/4) — done: SK, CZ — # 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(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(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) cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir) FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
end end
+16
View File
@@ -1579,6 +1579,22 @@ html, body {
font-size: 12px; 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 { .livetrack-panel__list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+149 -88
View File
@@ -4,9 +4,6 @@ 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";
const SOURCE_ID = "hiking-trails";
const LAYER_ID = "hiking-trails-line";
// 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
// needed, same "public API, no key" story as the Nominatim search already // 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 // 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 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 // 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 // has none, so there's no styling to do here ourselves. Used whenever no
// no local offline extraction to draw from instead (see _addOfflineLayer // offline extract is available at all — same "quietly renders nothing
// below) — same "quietly renders nothing without a network" story as the // without a network" story as the Wikipedia/Radar/Contour lines overlays.
// 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 TILE_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png";
const ATTRIBUTION = const ATTRIBUTION =
'Trails &copy; <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data &copy; OpenStreetMap contributors'; 'Trails &copy; <a href="https://waymarkedtrails.org" target="_blank">Waymarked Trails</a> (CC-BY-SA), map data &copy; OpenStreetMap contributors';
const OPACITY = 0.85; const OPACITY = 0.85;
// Offline: a real vector line layer, GeoJSON generated at build time by // Offline: one real vector line layer per country the viewer actually
// Maptool::HikingTrails straight from the same Geofabrik .osm.pbf // picks, GeoJSON generated at build time by Maptool::HikingTrails straight
// offline.pmtiles itself is built from (see lib/maptool/hiking_trails.rb, // from the same Geofabrik .osm.pbf offline.pmtiles itself is built from
// PmtilesBuilder.extract_offline) — no network needed at all, and no // (see lib/maptool/hiking_trails.rb, PmtilesBuilder.extract_hiking_trails)
// waiting on Overpass for click details either (queried locally via // — no network needed, and no waiting on Overpass for click details either
// queryRenderedFeatures instead, see _handleClick below). // (queried locally via queryRenderedFeatures instead, see _handleClick
const OFFLINE_GEOJSON_URL = "/tiles/hiking_trails.geojson"; // 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. // waymarkedtrails.org's own fallback for an untagged route.
const DEFAULT_TRAIL_COLOR = "#c1272d"; const DEFAULT_TRAIL_COLOR = "#c1272d";
// Real-world `colour` values seen in production data (see the 2026-09-13 // 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; 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 // An Overlays panel item — a plain on/off checkbox (raster overlay) until
// raster overlay, no scrub/forecast controls to expand into. // 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 { export class HikingTrailsControl {
constructor(map) { constructor(map) {
this._map = map; this._map = map;
this._active = false; this._active = false; // the online-mode checkbox's own state
this._offlineAvailable = false; this._offlineCountries = []; // codes with an extract available on the server
this._clickHandler = (event) => this._handleClick(event); 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/ // setStyle() (switching Online/Offline) tears down all custom sources/
// layers — put this back if it was on, same as every other Overlays // layers — put whatever was showing back, same as every other
// control. // Overlays control.
map.on("style.load", () => { 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) { mount(li) {
li.innerHTML = ` li.innerHTML = `
<label> <div class="overlays-panel__row">
<input type="checkbox" class="overlays-panel__toggle"> <input type="checkbox" class="overlays-panel__toggle">
<span class="overlays-panel__icon">${WALKING_ICON}</span> <span class="overlays-panel__icon">${WALKING_ICON}</span>
<span>Hiking trails</span> <button type="button" class="overlays-panel__name">Hiking trails</button>
</label> </div>
<div class="overlays-panel__details" hidden>
<ul class="hiking-trail-countries"></ul>
</div>
`; `;
this._checkbox = li.querySelector(".overlays-panel__toggle"); 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; this._active = active;
if (active) this._addOnlineLayer();
else this._removeOnlineLayer();
}
_toggleCountry(code, active) {
if (active) { if (active) {
this._addLayer(); this._selectedCountries.add(code);
this._map.on("click", this._clickHandler); this._addOfflineLayer(code);
} else { } else {
this._removeLayer(); this._selectedCountries.delete(code);
this._map.off("click", this._clickHandler); this._removeOfflineLayer(code);
} }
} }
_handleClick(event) { _handleClick(event) {
if (this._offlineAvailable) { if (this._selectedCountries.size > 0) this._handleOfflineClick(event);
this._handleOfflineClick(event); else if (this._active) this._handleOnlineClick(event);
} else {
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 // network round-trip, so this resolves instantly. A genuine miss (empty
// result) shows no popup at all rather than a "not found" message, same // result) shows no popup at all rather than a "not found" message, same
// as clicking empty space anywhere else on the map. // as clicking empty space anywhere else on the map.
_handleOfflineClick(event) { _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; if (features.length === 0) return;
const seen = new Set(); 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() { _addOnlineLayer() {
this._map.addSource(SOURCE_ID, { if (this._map.getSource(ONLINE_SOURCE_ID)) return;
this._map.addSource(ONLINE_SOURCE_ID, {
type: "raster", type: "raster",
tiles: [TILE_URL], tiles: [TILE_URL],
tileSize: 256, tileSize: 256,
@@ -213,14 +236,52 @@ export class HikingTrailsControl {
// Below labels (same firstSymbolLayerId spot contour.js/radar.js use), // Below labels (same firstSymbolLayerId spot contour.js/radar.js use),
// so place names/POI icons stay readable on top of the trail lines. // so place names/POI icons stay readable on top of the trail lines.
this._map.addLayer( 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), firstSymbolLayerId(this._map),
); );
} }
_removeLayer() { _removeOnlineLayer() {
if (this._map.getLayer(LAYER_ID)) this._map.removeLayer(LAYER_ID); if (this._map.getLayer(ONLINE_LAYER_ID)) this._map.removeLayer(ONLINE_LAYER_ID);
if (this._map.getSource(SOURCE_ID)) this._map.removeSource(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));
} }
} }
+1 -1
View File
@@ -609,7 +609,7 @@ fetch("/api/terrain-tiles")
fetch("/api/hiking-trails") fetch("/api/hiking-trails")
.then((response) => response.json()) .then((response) => response.json())
.then(({ available }) => overlaysControl.setHikingTrailsOfflineAvailable(available)) .then(({ countries }) => overlaysControl.setHikingTrailsOfflineCountries(countries))
.catch(() => {}); .catch(() => {});
fetch("/api/kiwix") 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 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"></li> <li class="overlays-panel__item overlays-panel__item--expandable"></li>
`; `;
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children; const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem, hikingTrailsItem] = list.children;
this._hillshade.mount(hillshadeItem); this._hillshade.mount(hillshadeItem);
@@ -245,8 +245,8 @@ export class OverlaysControl {
this._hillshade.setOfflineAvailable(available); this._hillshade.setOfflineAvailable(available);
} }
setHikingTrailsOfflineAvailable(available) { setHikingTrailsOfflineCountries(countries) {
this._hikingTrails.setOfflineAvailable(available); this._hikingTrails.setOfflineCountries(countries);
} }
// Called by map.js's PitchToggleControl whenever the 3D pitch toggle // 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)) assert_equal({ "available" => false }, json_body(response))
end 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") response = request.get("/api/hiking-trails")
assert_equal({ "available" => false }, json_body(response)) assert_equal({ "countries" => [] }, json_body(response))
end end
def test_hiking_trails_file_404s_when_no_geojson_present def test_hiking_trails_file_404s_for_a_country_with_no_extract
assert_equal 404, request.get("/tiles/hiking_trails.geojson").status 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 end
def test_kiwix_reports_unavailable_when_no_url_configured 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 assert_equal [[[20.08765, 49.17912], [20.088, 49.18]]], coords
end 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 def test_parse_opl_ignores_a_dangling_member_reference_but_keeps_the_rest
opl = <<~OPL opl = <<~OPL
w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18 w1 Thighway=path Nn10x20.087y49.179,n11x20.088y49.18
+30 -6
View File
@@ -216,20 +216,44 @@ class PmtilesBuilderTest < Minitest::Test
) )
end 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") select_regions("SK")
thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download) thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download)
thread.join thread.join
# The fake osmium binary doesn't produce real OPL output, just # The fake osmium binary doesn't produce real OPL output, just
# confirms the file lands and is valid (empty) GeoJSON — the real # confirms the file lands (one per country) and is valid (empty)
# parsing logic is covered by test_hiking_trails.rb against real OPL # GeoJSON — the real parsing logic is covered by test_hiking_trails.rb
# fixtures. # against real OPL fixtures.
path = File.join(@data_dir, "hiking_trails.geojson") assert_equal ["SK"], Maptool::PmtilesBuilder.hiking_trails_countries(@data_dir)
assert_path_exists path path = Maptool::PmtilesBuilder.hiking_trails_country_path(@data_dir, "SK")
assert_equal({ "type" => "FeatureCollection", "features" => [] }, JSON.parse(File.read(path))) assert_equal({ "type" => "FeatureCollection", "features" => [] }, JSON.parse(File.read(path)))
end 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 # The actual reasoning: hiking trails are a bonus overlay bolted onto the
# existing offline.pmtiles build, not the core map — an osmium hiccup on # existing offline.pmtiles build, not the core map — an osmium hiccup on
# this one step shouldn't take down an otherwise-successful build. # this one step shouldn't take down an otherwise-successful build.