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

+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;