Wire offline hiking trails into the real build, API, and renderer
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 12s

data/hiking_trails.geojson now generates automatically as the last
step of every offline.pmtiles build (PmtilesBuilder.extract_hiking_trails,
best-effort — a failure here doesn't fail the whole build), merged
across all selected countries with cross-border dedup by OSM relation
id (HikingTrails.extract_merged). Served at GET /tiles/hiking_trails.geojson
and GET /api/hiking-trails, fetched client-side only once the overlay
is actually toggled on.

hiking_trails.js now prefers this local file whenever it exists,
regardless of which base map (online/offline) is active — same
"prefer local when available" story as HillshadeControl already uses
for terrain. Offline click hit-tests the real vector layer directly
(queryRenderedFeatures) instead of querying Overpass — instant, no
network. Coordinates are rounded to 5dp to keep the combined file
smaller; colour tag values are normalized against a known-safe set
before ever reaching a style paint property.

Verified extract_merged (new: combines multiple countries' extracts,
dedupes overlapping relations) against two real cached country PBFs
on brick directly, not just fixtures.
This commit is contained in:
randy committed 2026-09-13 10:31:59 +02:00
1 parent b35ace9734
commit 079ac37787
11 files changed
+370 -25

No files matched your search

+28 -2
View File
@@ -63,6 +63,22 @@ 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
@@ -81,7 +97,7 @@ module Maptool
ways[head[1..].to_i] = way_coordinates(fields)
when "r"
tags = tags_field(fields)
relations << { tags: tags, members: members_field(fields) } if tags["route"] == "hiking"
relations << { id: head[1..].to_i, tags: tags, members: members_field(fields) } if tags["route"] == "hiking"
end
end
@@ -99,6 +115,7 @@ module Maptool
distance = lines.sum { |coords| TrackStats.compute(coords)["distance_meters"] }
tags = relation[:tags]
properties = {
"id" => relation[:id],
"name" => tags["name"] || tags["description"],
"distance_meters" => distance.round(1),
}
@@ -107,11 +124,20 @@ module Maptool
{
"type" => "Feature",
"properties" => properties.compact,
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
# Rounded to 5 decimal places (~1m) — full OSM float precision
# (~1cm) is pointless for a rendered line and roughly doubles the
# size of every coordinate pair once this many-country GeoJSON is
# actually served to a browser.
"geometry" => { "type" => "MultiLineString", "coordinates" => round_coordinates(lines) },
}
end
private_class_method :relation_feature
def self.round_coordinates(lines)
lines.map { |coords| coords.map { |lon, lat| [lon.round(5), lat.round(5)] } }
end
private_class_method :round_coordinates
def self.field(fields, letter)
fields.find { |f| f.start_with?(letter) }
end
+27
View File
@@ -5,6 +5,7 @@ require "json"
require "tmpdir"
require "fileutils"
require "etc"
require_relative "hiking_trails"
module Maptool
# Regenerates data/offline.pmtiles and data/terrain.pmtiles for the
@@ -289,12 +290,37 @@ module Maptool
run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}", "--download", "--threads=#{Etc.nprocessors}")
File.rename(tmp_path, File.join(data_dir, "offline.pmtiles"))
File.write(offline_meta_path(data_dir), JSON.generate(sources: sources.map { |s| s.slice("code", "slug", "last_modified") }))
report_step.call("extracting hiking trails")
extract_hiking_trails(sources, data_dir)
ensure
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
end
private_class_method :extract_offline
# 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.
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}"
end
private_class_method :extract_hiking_trails
def self.hiking_trails_path(data_dir)
File.join(data_dir, "hiking_trails.geojson")
end
# Renders a report_step string like "fetching PL (3/4) — done: SK, CZ —
# pending: HU" — so GET .../build-status shows not just "it's running"
# but which of the current build's sources is in flight and which are
@@ -442,6 +468,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))
cache_dir = geofabrik_cache_dir(data_dir)
FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir)
end