Prototype: extract offline hiking trails from the Geofabrik PBF we already have
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:
1 parent
cc3d8ca5f3
commit
714795d52b
4 files changed
+281
-1
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user