Prototype: extract offline hiking trails from the Geofabrik PBF we already have
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 13s

No custom Planetiler profile needed after all — Maptool::HikingTrails
runs osmium tags-filter r/route=hiking against the same .osm.pbf
PmtilesBuilder already downloads per country for offline.pmtiles, then
osmium cat -f opl (locations_on_ways=true) to get member-way geometry
back as plain text, no separate node-id lookup table to build.

No general OSM tool stitches a route relation into one line (routes
are a semantic grouping, not a geometry type) — parse_opl does that
walk itself, same as the Overpass-based click handler already does
client-side. Kept pure/testable apart from the two osmium subprocess
calls, mirroring OverpassPois.fetch vs .to_geojson.

rake hiking:trails[pbf,out] to try it by hand against a real cached
extract. Not wired into the actual offline build or rendering yet —
still just a prototype, per TODO.adoc.
This commit is contained in:
randy committed 2026-09-13 09:18:50 +02:00
1 parent cc3d8ca5f3
commit 714795d52b
4 files changed
+281 -1

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
require "open3"
require "tmpdir"
require "json"
require_relative "track_stats"
module Maptool
# Prototype: extracts route=hiking relations (name/colour/network/operator,
# full member-way geometry) straight out of a Geofabrik .osm.pbf — the same
# file PmtilesBuilder already downloads per country for offline.pmtiles, so
# this needs no separate download or third-party service. Two osmium
# subprocess steps do the heavy lifting (osmium is already a server
# dependency, see PmtilesBuilder/docs/development.adoc):
#
# 1. `osmium tags-filter FILE r/route=hiking -o hiking.osm.pbf` — pulls
# out just the hiking relations *and* (by default, no -R) every way/
# node they reference, so the output is small and self-contained.
# 2. `osmium cat hiking.osm.pbf -f opl,locations_on_ways=true,add_metadata=false`
# — OPL is a plain-text, one-object-per-line format (see
# https://osmcode.org/opl-file-format/); locations_on_ways embeds each
# way's node coordinates directly in its own line, so no separate
# node-id -> [lon,lat] table needs building, just two passes over text.
#
# No general OSM tool stitches a route relation's member ways into one
# line for you (routes are a semantic grouping, not a geometry type) —
# every hiking-trail renderer, including this app's own Overpass-based
# click handler (public/js/hiking_trails.js) and waymarkedtrails.org
# itself, does this same member-way walk by hand.
module HikingTrails
class ExtractError < StandardError; end
ROUTE_TAGS = %w[name description ref colour network operator].freeze
def self.osmium_bin
ENV.fetch("OSMIUM_BIN", "osmium")
end
# pbf_path: a Geofabrik-style .osm.pbf, e.g. one of
# PmtilesBuilder.cached_extracts' own files. Returns a GeoJSON
# FeatureCollection (MultiLineString per relation, one sub-line per
# member way — not one big concatenated LineString, since a relation's
# ways aren't always contiguous/ordered and jumping across a gap would
# draw a spurious straight connector).
def self.extract(pbf_path)
Dir.mktmpdir("maptool-hiking-trails") do |dir|
filtered = File.join(dir, "hiking.osm.pbf")
run!(osmium_bin, "tags-filter", pbf_path, "r/route=hiking", "-o", filtered)
opl_path = File.join(dir, "hiking.opl")
run!(osmium_bin, "cat", filtered, "-f", "opl,locations_on_ways=true,add_metadata=false", "-o", opl_path)
parse_opl(File.read(opl_path))
end
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
# separate from OverpassPois.fetch.
def self.parse_opl(text)
ways = {} # way id -> [[lon, lat], ...]
relations = [] # { tags:, members: [{type:, id:}, ...] }
text.each_line do |line|
fields = line.chomp.split(" ")
head = fields.first
next unless head
case head[0]
when "w"
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"
end
end
features = relations.filter_map { |relation| relation_feature(relation, ways) }
{ "type" => "FeatureCollection", "features" => features }
end
def self.relation_feature(relation, ways)
lines = relation[:members]
.select { |member| member[:type] == "w" }
.filter_map { |member| ways[member[:id]] }
.select { |coords| coords.size >= 2 }
return nil if lines.empty?
distance = lines.sum { |coords| TrackStats.compute(coords)["distance_meters"] }
tags = relation[:tags]
properties = {
"name" => tags["name"] || tags["description"],
"distance_meters" => distance.round(1),
}
ROUTE_TAGS.each { |key| properties[key] ||= tags[key] }
{
"type" => "Feature",
"properties" => properties.compact,
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
}
end
private_class_method :relation_feature
def self.field(fields, letter)
fields.find { |f| f.start_with?(letter) }
end
private_class_method :field
def self.tags_field(fields)
raw = field(fields, "T")&.delete_prefix("T") || ""
return {} if raw.empty?
raw.split(",").to_h { |pair| pair.split("=", 2).map { |part| unescape(part.to_s) } }
end
private_class_method :tags_field
def self.members_field(fields)
raw = field(fields, "M")&.delete_prefix("M") || ""
return [] if raw.empty?
raw.split(",").map do |member|
{ type: member[0], id: member[1..].split("@", 2).first.to_i }
end
end
private_class_method :members_field
# "n123x14.123y49.456,n124x14.130y49.460,..." -> [[14.123, 49.456], ...].
# A member id with no embedded location (shouldn't happen with
# locations_on_ways=true and -R omitted, but a way with a dangling/
# missing node reference is possible in real-world data) is skipped
# rather than raising — one bad node shouldn't drop the whole route.
def self.way_coordinates(fields)
raw = field(fields, "N")&.delete_prefix("N") || ""
return [] if raw.empty?
raw.split(",").filter_map do |node_ref|
match = node_ref.match(/\An\d+x(?<lon>-?[\d.]+)y(?<lat>-?[\d.]+)\z/)
[match[:lon].to_f, match[:lat].to_f] if match
end
end
private_class_method :way_coordinates
# OPL escapes space/newline/comma/equals/@/% as %<hex>% (see
# https://osmcode.org/opl-file-format/#_encoding) — only tag keys/
# values and usernames can contain them; the field-letter prefixes and
# id/comma/@ separators we split on above are never themselves escaped.
def self.unescape(str)
str.gsub(/%([0-9a-fA-F]{1,6})%/) { [Regexp.last_match(1).to_i(16)].pack("U") }
end
private_class_method :unescape
def self.run!(*args)
stdout, stderr, status = Open3.capture3(*args)
raise ExtractError, "#{args.first} exited with status #{status.exitstatus}: #{[stdout, stderr].map(&:strip).reject(&:empty?).join("\n")}" unless status.success?
rescue Errno::ENOENT, Errno::EACCES => e
raise ExtractError, "could not run '#{args.first}' (#{e.message}) — is osmium-tool installed and on PATH?"
end
private_class_method :run!
end
end