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
@@ -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
|
matched URL can never break out of the `href="..."` attribute. Every
|
||||||
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
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
|
== Live GNSS position
|
||||||
|
|
||||||
A "GNSS" map control (`public/js/gnss.js`) shows the fix from a local
|
A "GNSS" map control (`public/js/gnss.js`) shows the fix from a local
|
||||||
|
|||||||
+25
-19
@@ -1,3 +1,5 @@
|
|||||||
|
require_relative "track_stats"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
# Converts a Garmin FIT file (binary — see https://developer.garmin.com/fit/)
|
||||||
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
# to a GeoJSON FeatureCollection, in the same shape Gpx.to_geojson produces
|
||||||
@@ -14,6 +16,7 @@ module Maptool
|
|||||||
FIELD_POSITION_LAT = 0
|
FIELD_POSITION_LAT = 0
|
||||||
FIELD_POSITION_LONG = 1
|
FIELD_POSITION_LONG = 1
|
||||||
FIELD_ALTITUDE = 2
|
FIELD_ALTITUDE = 2
|
||||||
|
FIELD_TIMESTAMP = 253
|
||||||
FIELD_ENHANCED_ALTITUDE = 78
|
FIELD_ENHANCED_ALTITUDE = 78
|
||||||
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
||||||
|
|
||||||
@@ -26,16 +29,16 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.to_geojson(path)
|
def self.to_geojson(path)
|
||||||
points = parse(File.binread(path))
|
records = parse(File.binread(path))
|
||||||
features = points.empty? ? [] : [track_feature(points)]
|
features = records.empty? ? [] : [track_feature(records.map(&:first), records.map(&:last))]
|
||||||
{ "type" => "FeatureCollection", "features" => features }
|
{ "type" => "FeatureCollection", "features" => features }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.track_feature(points)
|
def self.track_feature(coordinates, times)
|
||||||
{
|
{
|
||||||
"type" => "Feature",
|
"type" => "Feature",
|
||||||
"geometry" => { "type" => "MultiLineString", "coordinates" => [points] },
|
"geometry" => { "type" => "MultiLineString", "coordinates" => [coordinates] },
|
||||||
"properties" => { "kind" => "track", "name" => nil },
|
"properties" => { "kind" => "track", "name" => nil }.merge(TrackStats.compute(coordinates, times: times)),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
private_class_method :track_feature
|
private_class_method :track_feature
|
||||||
@@ -50,7 +53,7 @@ module Maptool
|
|||||||
|
|
||||||
offset = header_size
|
offset = header_size
|
||||||
definitions = {}
|
definitions = {}
|
||||||
points = []
|
records = []
|
||||||
|
|
||||||
while offset < data_end
|
while offset < data_end
|
||||||
header = data.getbyte(offset)
|
header = data.getbyte(offset)
|
||||||
@@ -59,7 +62,7 @@ module Maptool
|
|||||||
if header & 0x80 != 0
|
if header & 0x80 != 0
|
||||||
local_type = (header >> 5) & 0x3
|
local_type = (header >> 5) & 0x3
|
||||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
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
|
elsif header & 0x40 != 0
|
||||||
local_type = header & 0x0F
|
local_type = header & 0x0F
|
||||||
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
definition, offset = read_definition(data, offset, header & 0x20 != 0)
|
||||||
@@ -67,11 +70,11 @@ module Maptool
|
|||||||
else
|
else
|
||||||
local_type = header & 0x0F
|
local_type = header & 0x0F
|
||||||
definition = definitions[local_type] or raise ParseError, "data message before its definition"
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
points
|
records
|
||||||
rescue ArgumentError, NoMethodError => e
|
rescue ArgumentError, NoMethodError => e
|
||||||
raise ParseError, e.message
|
raise ParseError, e.message
|
||||||
end
|
end
|
||||||
@@ -104,20 +107,20 @@ module Maptool
|
|||||||
end
|
end
|
||||||
private_class_method :read_definition
|
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
|
if definition.global_mesg_num == RECORD_MESSAGE_NUM
|
||||||
point = extract_point(data, offset, definition)
|
record = extract_record(data, offset, definition)
|
||||||
points << point if point
|
records << record if record
|
||||||
end
|
end
|
||||||
offset + definition.total_size
|
offset + definition.total_size
|
||||||
end
|
end
|
||||||
private_class_method :read_data_message
|
private_class_method :read_data_message
|
||||||
|
|
||||||
# Only reads the 3 fields the map needs; every other field in the
|
# Only reads the 4 fields the map/its properties need; every other
|
||||||
# message (heart rate, cadence, power, ...) is walked past via its
|
# field in the message (heart rate, cadence, power, ...) is walked past
|
||||||
# declared size without being interpreted.
|
# via its declared size without being interpreted.
|
||||||
def self.extract_point(data, offset, definition)
|
def self.extract_record(data, offset, definition)
|
||||||
lat = lon = altitude = nil
|
lat = lon = altitude = timestamp = nil
|
||||||
pos = offset
|
pos = offset
|
||||||
|
|
||||||
definition.fields.each do |field_num, size|
|
definition.fields.each do |field_num, size|
|
||||||
@@ -128,6 +131,8 @@ module Maptool
|
|||||||
lon = unpack_int(data, pos, size, definition.little_endian, signed: true)
|
lon = unpack_int(data, pos, size, definition.little_endian, signed: true)
|
||||||
when FIELD_ALTITUDE, FIELD_ENHANCED_ALTITUDE
|
when FIELD_ALTITUDE, FIELD_ENHANCED_ALTITUDE
|
||||||
altitude ||= unpack_int(data, pos, size, definition.little_endian, signed: false)
|
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
|
end
|
||||||
pos += size
|
pos += size
|
||||||
end
|
end
|
||||||
@@ -136,9 +141,10 @@ module Maptool
|
|||||||
|
|
||||||
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
coordinate = [lon * SEMICIRCLE_TO_DEGREES, lat * SEMICIRCLE_TO_DEGREES]
|
||||||
coordinate << (altitude / 5.0 - 500) if altitude && !invalid?(altitude, 2) && !invalid?(altitude, 4)
|
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
|
end
|
||||||
private_class_method :extract_point
|
private_class_method :extract_record
|
||||||
|
|
||||||
UNPACK_FORMATS = {
|
UNPACK_FORMATS = {
|
||||||
[1, true] => "c", [1, false] => "C",
|
[1, true] => "c", [1, false] => "C",
|
||||||
|
|||||||
+19
-2
@@ -1,4 +1,6 @@
|
|||||||
require "rexml/document"
|
require "rexml/document"
|
||||||
|
require "time"
|
||||||
|
require_relative "track_stats"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
|
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
|
||||||
@@ -50,20 +52,35 @@ module Maptool
|
|||||||
|
|
||||||
def self.track_feature(trk)
|
def self.track_feature(trk)
|
||||||
lines = []
|
lines = []
|
||||||
|
all_points = []
|
||||||
|
times = []
|
||||||
trk.each_element("trkseg") do |seg|
|
trk.each_element("trkseg") do |seg|
|
||||||
points = []
|
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?
|
lines << points unless points.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
"type" => "Feature",
|
"type" => "Feature",
|
||||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
"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
|
end
|
||||||
private_class_method :track_feature
|
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)
|
def self.coordinate(point_el)
|
||||||
lat = point_el.attributes["lat"].to_f
|
lat = point_el.attributes["lat"].to_f
|
||||||
lon = point_el.attributes["lon"].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
|
||||||
@@ -304,6 +304,7 @@ export class GpxPanelControl {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this._registerPointInteractivity(`${id}-points`);
|
this._registerPointInteractivity(`${id}-points`);
|
||||||
|
this._registerLineInteractivity(`${id}-line`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registered once per layer id, ever — map.on(type, layerId, fn) is a
|
// 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) {
|
_removeLayers(path) {
|
||||||
const id = sourceId(path);
|
const id = sourceId(path);
|
||||||
[`${id}-line`, `${id}-points`].forEach((layerId) => {
|
[`${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>";
|
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) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|||||||
Reference in new issue
Block a user