Files
maptool/lib/maptool/gpx.rb
T
randy dd0ad6d845 Show waypoint photos from newer p4n2gpx exports
A newer p4n2gpx run (Ruby rewrite, ~/test/mapping/p4n2gpx) started
emitting one plain GPX <link href> per park4night listing photo,
which this app parsed but never displayed. Gpx.waypoint_photos
collects them into a photos property; the waypoint popup renders them
as a row of thumbnails above the description, each opening full-size
in a new tab. Absent on older exports and plain GPS waypoints, so the
row just doesn't render for those.
2026-09-07 22:38:22 +02:00

210 lines
7.8 KiB
Ruby

require "rexml/document"
require "time"
require_relative "track_stats"
module Maptool
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
# become Point features, tracks (<trk>) and routes (<rte>) both become
# MultiLineString features — "kind" (=> "track"/"route") is the only
# difference in shape, so the file tree can show which one a file is.
module Gpx
class ParseError < StandardError; end
# Cheap content-type check for the file tree's icons: a plain substring
# search rather than a full REXML parse, since some uploaded files (e.g.
# a several-thousand-waypoint POI export) are large enough that parsing
# the whole DOM just to answer "does it have any of these tags" would
# make every tree fetch noticeably slower.
def self.kinds(path)
content = File.read(path)
kinds = []
kinds << "waypoint" if content.include?("<wpt")
kinds << "track" if content.include?("<trk")
kinds << "route" if content.include?("<rte")
kinds
end
def self.to_geojson(path)
root = REXML::Document.new(File.read(path)).root
raise ParseError, "not a GPX file" unless root && root.name == "gpx"
features = []
root.each_element("wpt") { |wpt| features << waypoint_feature(wpt) }
root.each_element("trk") { |trk| features << track_feature(trk) }
root.each_element("rte") { |rte| features << route_feature(rte) }
{ "type" => "FeatureCollection", "features" => features }
rescue REXML::ParseException => e
raise ParseError, e.message
end
def self.waypoint_feature(wpt)
ext = waypoint_style(wpt)
{
"type" => "Feature",
"geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) },
"properties" => {
"kind" => "waypoint",
"name" => child_text(wpt, "name"),
"description" => child_text(wpt, "desc"),
"time" => child_text(wpt, "time"),
"category" => child_text(wpt, "type"),
"color" => ext[:color],
"icon" => ext[:icon],
"photos" => waypoint_photos(wpt),
},
}
end
private_class_method :waypoint_feature
# <link> is plain GPX 1.1 (a wpt/trk/rte can carry any number of them),
# not a vendor extension like waypoint_style's <osmand:*> — a newer
# p4n2gpx export started emitting one per park4night listing photo. A
# plain GPS waypoint has none, so this is almost always empty.
def self.waypoint_photos(wpt)
wpt.get_elements("link").filter_map { |link| link.attributes["href"] }
end
private_class_method :waypoint_photos
OSMAND_NAMESPACE = "https://osmand.net"
private_constant :OSMAND_NAMESPACE
# POI exports (park4night via p4n2gpx, OsmAnd) tag each waypoint with a
# category (<type>) and, in a vendor <extensions> block, a per-category
# color and an OsmAnd icon key (e.g. "amenity_parking") — used to
# color/icon-code the map instead of every waypoint rendering as the same
# plain dot. Matched by namespace *URI*, not just local element name
# (unlike point_extensions below, which only cares about local names):
# a p4n2gpx export carries both an <osmand:icon> (a category key we can
# actually render) and a <locus:icon> (a Locus-app-specific drawable
# path) side by side — both strip down to the same local name "icon",
# so matching on that alone picks whichever happens to come last.
def self.waypoint_style(wpt)
values = {}
extensions = wpt.elements["extensions"]
return values unless extensions
extensions.elements.each do |el|
next unless el.namespace == OSMAND_NAMESPACE
case el.name
when "color" then values[:color] = el.text&.strip
when "icon" then values[:icon] = el.text&.strip
end
end
values
end
private_class_method :waypoint_style
def self.track_feature(trk)
lines = []
all_points = []
times = []
extras = []
trk.each_element("trkseg") do |seg|
points = []
seg.each_element("trkpt") do |pt|
point, time, extra = process_point(pt)
points << point
all_points << point
times << time
extras << extra
end
lines << points unless points.empty?
end
line_feature(kind: "track", name: child_text(trk, "name"), activity_type: child_text(trk, "type"),
lines: lines, all_points: all_points, times: times, extras: extras)
end
private_class_method :track_feature
# Structurally simpler than a track: <rte> holds <rtept> directly, no
# <trkseg>-style grouping — a route is one continuous line by
# definition, planned rather than recorded.
def self.route_feature(rte)
points = []
times = []
extras = []
rte.each_element("rtept") do |pt|
point, time, extra = process_point(pt)
points << point
times << time
extras << extra
end
line_feature(kind: "route", name: child_text(rte, "name"), activity_type: nil,
lines: points.empty? ? [] : [points], all_points: points, times: times, extras: extras)
end
private_class_method :route_feature
def self.line_feature(kind:, name:, activity_type:, lines:, all_points:, times:, extras:)
{
"type" => "Feature",
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
"properties" => { "kind" => kind, "name" => name, "activity_type" => activity_type }
.merge(TrackStats.compute(all_points, times: times))
.merge("profile" => TrackStats.build_profile(all_points, times: times, extras: extras)),
}
end
private_class_method :line_feature
def self.process_point(pt)
[coordinate(pt), parse_time(child_text(pt, "time")), point_extensions(pt)]
end
private_class_method :process_point
# Heart rate/cadence/speed live in a device-specific <extensions> block
# (usually Garmin's own TrackPointExtension) — matched by local element
# name only (`Element#name` already strips the namespace prefix), so
# this doesn't care which extension schema a given file actually
# declares, as long as it names its elements the same way Garmin's does.
#
# Walked by hand (elements.each with no argument — plain child
# iteration) rather than the `"extensions//*"` XPath this used
# originally: REXML's `//` (descendant) axis turned out to cost
# milliseconds *per call* regardless of how small the subtree actually
# is, and this runs once per point — fine on a short test file, ~30s
# for an 8000-point real one. Plain traversal is ~40x faster on that
# same file.
def self.point_extensions(point_el)
values = {}
extensions = point_el.elements["extensions"]
collect_extension_values(extensions, values) if extensions
values
end
private_class_method :point_extensions
def self.collect_extension_values(el, values)
el.elements.each do |child|
case child.name
when "hr" then values[:heart_rate_bpm] = child.text&.strip&.to_i
when "cad" then values[:cadence_rpm] = child.text&.strip&.to_i
when "speed" then values[:speed_mps] = child.text&.strip&.to_f
end
collect_extension_values(child, values)
end
end
private_class_method :collect_extension_values
def self.parse_time(text)
text && Time.parse(text)
rescue ArgumentError
nil
end
private_class_method :parse_time
def self.coordinate(point_el)
lat = point_el.attributes["lat"].to_f
lon = point_el.attributes["lon"].to_f
ele = child_text(point_el, "ele")
ele ? [lon, lat, ele.to_f] : [lon, lat]
end
private_class_method :coordinate
def self.child_text(el, name)
el.elements[name]&.text&.strip
end
private_class_method :child_text
end
end