GPX tracks were silently failing to render for large real-world files: point_extensions used REXML's "extensions//*" XPath, and the // descendant axis costs low-single-digit milliseconds per call regardless of subtree size. Run once per point, that's negligible on small files but ~30s on a real 8000-point track — long enough to hit a proxy timeout in production, so the file just never appeared with no error. Replaced with plain recursive child traversal (~40x faster on the same file, confirmed 29.9s -> 3.16s); test_parsing_many_points_with_extensions_stays_fast guards against reintroducing it. Also adds support for <rte> routes (not just <trk> tracks and <wpt> waypoints), sharing the track/route conversion logic via a new line_feature helper. Routes get their own "route" kind and a dashed ROUTE_ICON in the file tree so the type is visible at a glance.
166 lines
5.9 KiB
Ruby
166 lines
5.9 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)
|
|
{
|
|
"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"),
|
|
},
|
|
}
|
|
end
|
|
private_class_method :waypoint_feature
|
|
|
|
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
|