Show track properties (distance, elevation gain, duration)
Maptool::TrackStats computes these once, shared by Gpx and Fit so both track feature shapes carry the same properties regardless of source format. Hovering a track line (not clicking — these describe the whole line, not one point) shows them in a popup, same mechanism GPX waypoints already use.
This commit is contained in:
1 parent
fc174d4267
commit
a3c31ace06
5 files changed
+172
-21
No files matched your search
+25
-19
@@ -1,3 +1,5 @@
|
||||
require_relative "track_stats"
|
||||
|
||||
module Maptool
|
||||
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
||||
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
||||
@@ -14,6 +16,7 @@ module Maptool
|
||||
FIELD_POSITION_LAT = 0
|
||||
FIELD_POSITION_LONG = 1
|
||||
FIELD_ALTITUDE = 2
|
||||
FIELD_TIMESTAMP = 253
|
||||
FIELD_ENHANCED_ALTITUDE = 78
|
||||
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
||||
|
||||
@@ -26,16 +29,16 @@ module Maptool
|
||||
end
|
||||
|
||||
def self.to_geojson(path)
|
||||
points = parse(File.binread(path))
|
||||
features = points.empty? ? [] : [track_feature(points)]
|
||||
records = parse(File.binread(path))
|
||||
features = records.empty? ? [] : [track_feature(records.map(&:first), records.map(&:last))]
|
||||
{ "type" => "FeatureCollection", "features" => features }
|
||||
end
|
||||
|
||||
def self.track_feature(points)
|
||||
def self.track_feature(coordinates, times)
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [points] },
|
||||
"properties" => { "kind" => "track", "name" => nil },
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
|
||||
"properties" => { "kind" => "track", "name" => nil }.merge(TrackStats.compute(coordinates, times: times)),
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
@@ -50,7 +53,7 @@ module Maptool
|
||||
|
||||
offset = header_size
|
||||
definitions = {}
|
||||
points = []
|
||||
records = []
|
||||
|
||||
while offset < data_end
|
||||
header = data.getbyte(offset)
|
||||
@@ -59,7 +62,7 @@ module Maptool
|
||||
if header & 0x80 != 0
|
||||
local_type = (header >> 5) & 0x3
|
||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
||||
offset = read_data_message(data, offset, definition, points)
|
||||
offset = read_data_message(data, offset, definition, records)
|
||||
elsif header & 0x40 != 0
|
||||
local_type = header & 0x0F
|
||||
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
||||
@@ -67,11 +70,11 @@ module Maptool
|
||||
else
|
||||
local_type = header & 0x0F
|
||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
||||
offset = read_data_message(data, offset, definition, points)
|
||||
offset = read_data_message(data, offset, definition, records)
|
||||
end
|
||||
end
|
||||
|
||||
points
|
||||
records
|
||||
rescue ArgumentError, NoMethodError => e
|
||||
raise ParseError, e.message
|
||||
end
|
||||
@@ -104,20 +107,20 @@ module Maptool
|
||||
end
|
||||
private_class_method :read_definition
|
||||
|
||||
def self.read_data_message(data, offset, definition, points)
|
||||
def self.read_data_message(data, offset, definition, records)
|
||||
if definition.global_mesg_num == RECORD_MESSAGE_NUM
|
||||
point = extract_point(data, offset, definition)
|
||||
points << point if point
|
||||
record = extract_record(data, offset, definition)
|
||||
records << record if record
|
||||
end
|
||||
offset + definition.total_size
|
||||
end
|
||||
private_class_method :read_data_message
|
||||
|
||||
# Only reads the 3 fields the map needs; every other field in the
|
||||
# message (heart rate, cadence, power, ...) is walked past via its
|
||||
# declared size without being interpreted.
|
||||
def self.extract_point(data, offset, definition)
|
||||
lat = lon = altitude = nil
|
||||
# Only reads the 4 fields the map/its properties need; every other
|
||||
# field in the message (heart rate, cadence, power, ...) is walked past
|
||||
# via its declared size without being interpreted.
|
||||
def self.extract_record(data, offset, definition)
|
||||
lat = lon = altitude = timestamp = nil
|
||||
pos = offset
|
||||
|
||||
definition.fields.each do |field_num, size|
|
||||
@@ -128,6 +131,8 @@ module Maptool
|
||||
lon = unpack_int(data, pos, size, definition.little_endian, signed: true)
|
||||
when FIELD_ALTITUDE, FIELD_ENHANCED_ALTITUDE
|
||||
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
when FIELD_TIMESTAMP
|
||||
timestamp = unpack_int(data, pos, size, definition.little_endian, signed: false)
|
||||
end
|
||||
pos += size
|
||||
end
|
||||
@@ -136,9 +141,10 @@ module Maptool
|
||||
|
||||
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
||||
coordinate << (altitude / 5.0 - 500) if altitude && !invalid?(altitude, 2) && !invalid?(altitude, 4)
|
||||
coordinate
|
||||
time = Time.at(FIT_EPOCH + timestamp).utc if timestamp && !invalid?(timestamp, 4)
|
||||
[coordinate, time]
|
||||
end
|
||||
private_class_method :extract_point
|
||||
private_class_method :extract_record
|
||||
|
||||
UNPACK_FORMATS = {
|
||||
[1, true] => "c", [1, false] => "C",
|
||||
|
||||
+19
-2
@@ -1,4 +1,6 @@
|
||||
require "rexml/document"
|
||||
require "time"
|
||||
require_relative "track_stats"
|
||||
|
||||
module Maptool
|
||||
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
|
||||
@@ -50,20 +52,35 @@ module Maptool
|
||||
|
||||
def self.track_feature(trk)
|
||||
lines = []
|
||||
all_points = []
|
||||
times = []
|
||||
trk.each_element("trkseg") do |seg|
|
||||
points = []
|
||||
seg.each_element("trkpt") { |pt| points << coordinate(pt) }
|
||||
seg.each_element("trkpt") do |pt|
|
||||
point = coordinate(pt)
|
||||
points << point
|
||||
all_points << point
|
||||
times << parse_time(child_text(pt, "time"))
|
||||
end
|
||||
lines << points unless points.empty?
|
||||
end
|
||||
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name") },
|
||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name") }
|
||||
.merge(TrackStats.compute(all_points, times: times)),
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
module Maptool
|
||||
# Distance/elevation-gain/duration for a track, shared by Gpx and Fit so
|
||||
# neither has its own copy of the haversine formula.
|
||||
module TrackStats
|
||||
EARTH_RADIUS_METERS = 6_371_000
|
||||
|
||||
# `coordinates`: array of [lon, lat] or [lon, lat, ele]. `times`: a
|
||||
# parallel array of Time (or nil where unknown) — only its first and
|
||||
# last non-nil entries matter, so gaps in the middle are fine.
|
||||
def self.compute(coordinates, times: [])
|
||||
distance = 0.0
|
||||
elevation_gain = 0.0
|
||||
|
||||
coordinates.each_cons(2) do |(lon1, lat1, ele1), (lon2, lat2, ele2)|
|
||||
distance += haversine(lat1, lon1, lat2, lon2)
|
||||
elevation_gain += ele2 - ele1 if ele1 && ele2 && ele2 > ele1
|
||||
end
|
||||
|
||||
known_times = times.compact
|
||||
duration = known_times.size >= 2 ? (known_times.last - known_times.first).to_i : nil
|
||||
|
||||
{
|
||||
"distance_meters" => distance.round(1),
|
||||
"elevation_gain_meters" => elevation_gain.positive? ? elevation_gain.round(1) : nil,
|
||||
"duration_secs" => duration,
|
||||
"point_count" => coordinates.size,
|
||||
}
|
||||
end
|
||||
|
||||
def self.haversine(lat1, lon1, lat2, lon2)
|
||||
rad = Math::PI / 180
|
||||
dlat = (lat2 - lat1) * rad
|
||||
dlon = (lon2 - lon1) * rad
|
||||
a = (Math.sin(dlat / 2)**2) + (Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * (Math.sin(dlon / 2)**2))
|
||||
2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a))
|
||||
end
|
||||
private_class_method :haversine
|
||||
end
|
||||
end
|
||||
|
||||
if __FILE__ == $0
|
||||
# A right-angle path with a known leg length (via the equirectangular
|
||||
# approximation, accurate at small scale) plus a climb-then-descend
|
||||
# elevation profile, so both distance and elevation-gain have a value to
|
||||
# check against instead of just "did it run".
|
||||
square = [
|
||||
[17.0, 48.0, 100],
|
||||
[17.001, 48.0, 150], # +50m gain
|
||||
[17.001, 48.001, 120], # descent, no gain added
|
||||
]
|
||||
stats = Maptool::TrackStats.compute(square, times: [Time.utc(2026, 1, 1, 0, 0, 0), nil, Time.utc(2026, 1, 1, 0, 10, 0)])
|
||||
|
||||
raise "expected nonzero distance" unless stats["distance_meters"] > 100
|
||||
raise "expected elevation gain 50" unless stats["elevation_gain_meters"] == 50.0
|
||||
raise "expected duration 600s" unless stats["duration_secs"] == 600
|
||||
raise "expected point count 3" unless stats["point_count"] == 3
|
||||
|
||||
flat = Maptool::TrackStats.compute([[17.0, 48.0], [17.0, 48.0]], times: [])
|
||||
raise "expected nil elevation gain without elevation data" unless flat["elevation_gain_meters"].nil?
|
||||
raise "expected nil duration without times" unless flat["duration_secs"].nil?
|
||||
|
||||
single = Maptool::TrackStats.compute([[17.0, 48.0]])
|
||||
raise "expected zero distance for a single point" unless single["distance_meters"] == 0.0
|
||||
|
||||
puts "ok"
|
||||
end
|
||||
Reference in new issue
Block a user