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

+17
View File
@@ -233,6 +233,23 @@ assembling any HTML; the URL pattern excludes quote characters, so a
matched URL can never break out of the `href="..."` attribute. Every
other value is HTML-escaped before being handed to `Popup#setHTML`.
Track properties:: Every track feature (both formats) also carries
`distance_meters`, `elevation_gain_meters`, `duration_secs` and
`point_count`, computed once server-side by `Maptool::TrackStats`
(`lib/maptool/track_stats.rb`, shared by `Gpx`/`Fit` so neither
duplicates the haversine formula) rather than in the browser — cheap
either way, but it means a GPX and a FIT file end up with the same
properties shape, so the frontend doesn't need to know which format it
got. Distance sums the haversine distance between consecutive points;
elevation gain sums only the positive deltas (descents don't
subtract); duration is the gap between the first and last known
timestamp, skipping unknown ones in between — `nil` for any of these
where the source data doesn't have enough to compute it (e.g. a GPX
file with no `<time>` elements has `duration_secs: nil`). Hovering a
track line (rather than clicking, unlike a waypoint — these properties
describe the whole line, not whichever point the cursor happens to be
over) shows them in the same popup mechanism.
== Live GNSS position
A "GNSS" map control (`public/js/gnss.js`) shows the fix from a local
+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
+45
View File
@@ -304,6 +304,7 @@ export class GpxPanelControl {
});
this._registerPointInteractivity(`${id}-points`);
this._registerLineInteractivity(`${id}-line`);
}
// Registered once per layer id, ever — map.on(type, layerId, fn) is a
@@ -330,6 +331,30 @@ export class GpxPanelControl {
});
}
// Hover rather than click: unlike a waypoint, a track's properties
// (distance, elevation gain, duration) describe the whole line, not
// whatever point along it happens to get clicked.
_registerLineInteractivity(layerId) {
if (this._interactiveLayers.has(layerId)) return;
this._interactiveLayers.add(layerId);
let popup = null;
this._map.on("mouseenter", layerId, (event) => {
this._map.getCanvas().style.cursor = "pointer";
const feature = event.features[0];
if (!feature) return;
popup = new Popup({ closeButton: false, closeOnClick: false, maxWidth: "260px" })
.setLngLat(event.lngLat)
.setHTML(trackPopupHtml(feature.properties))
.addTo(this._map);
});
this._map.on("mouseleave", layerId, () => {
this._map.getCanvas().style.cursor = "";
popup?.remove();
popup = null;
});
}
_removeLayers(path) {
const id = sourceId(path);
[`${id}-line`, `${id}-points`].forEach((layerId) => {
@@ -462,6 +487,26 @@ function popupHtml({ name, description, time }) {
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
}
// properties come straight from an uploaded GPX/FIT file — untrusted input.
function trackPopupHtml({ name, distance_meters, elevation_gain_meters, duration_secs }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
const stats = [];
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`);
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;