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.
198 lines
8.5 KiB
Ruby
198 lines
8.5 KiB
Ruby
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. Three 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 add-locations-to-ways` — a plain Geofabrik PBF's ways only
|
|
# carry bare node *references* (ids), not coordinates; this resolves
|
|
# each way's node ids against the node objects in the same file and
|
|
# rewrites the way with real coordinates attached. Verified against a
|
|
# real extract (Macedonia) that skipping this step silently produces
|
|
# ways with empty x/y (`n123xy`, no numbers) in the OPL output below —
|
|
# not an error, just wrong output, so easy to miss without checking.
|
|
# 3. `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 (now that
|
|
# step 2 actually populated them) 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)
|
|
|
|
located = File.join(dir, "hiking-located.osm.pbf")
|
|
run!(osmium_bin, "add-locations-to-ways", filtered, "-o", located)
|
|
|
|
opl_path = File.join(dir, "hiking.opl")
|
|
run!(osmium_bin, "cat", located, "-f", "opl,locations_on_ways=true,add_metadata=false", "-o", opl_path)
|
|
|
|
parse_opl(File.read(opl_path))
|
|
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
|
|
# 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 << { id: head[1..].to_i, 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 = {
|
|
"id" => relation[:id],
|
|
"name" => tags["name"] || tags["description"],
|
|
"distance_meters" => distance.round(1),
|
|
}
|
|
ROUTE_TAGS.each { |key| properties[key] ||= tags[key] }
|
|
|
|
{
|
|
"type" => "Feature",
|
|
"properties" => properties.compact,
|
|
# 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
|
|
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
|