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
|
||||
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
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user