Show track properties (distance, elevation gain, duration)
CI / check (push) Successful in 38s
CI / deploy (push) Successful in 17s

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:
randy committed 2026-09-07 12:38:30 +02:00
1 parent fc174d4267
commit a3c31ace06
5 files changed
+172 -21

No files matched your search

+25 -19
View File
@@ -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
View File
@@ -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
+66
View File
@@ -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