Add activity type and a click-to-open track profile panel
Track properties gain activity_type (FIT: sport enum from the session/sport message, Garmin's own table; GPX: <trk><type> passed through as-is) alongside the existing distance/elevation/duration. Clicking a track line now opens a bottom panel with a chart per metric the track actually has (elevation always; speed/cadence/heart rate only when present — FIT's record fields or GPX's device extensions). TrackStats.build_profile computes cumulative distance the same way for both formats so they're directly comparable. One pointermove listener drives a synchronized crosshair across every chart, a single tooltip listing all series, and a map marker at the hovered point's position — hovering the chart shows where on the track that moment was.
This commit is contained in:
1 parent
cd4eef342b
commit
105651f005
14 files changed
+655
-42
No files matched your search
+16
-5
@@ -1,6 +1,7 @@
|
||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON } from "/js/icons.js";
|
||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||
import { formatActivityType } from "/js/track_profile.js";
|
||||
|
||||
const TREE_URL = "/api/gpx/tree";
|
||||
const UPLOAD_URL = "/api/gpx";
|
||||
@@ -18,8 +19,9 @@ const MOVE_MIME = "application/x-gpx-path";
|
||||
// and reorganize by dragging), and toggling their tracks/waypoints on and
|
||||
// off the map.
|
||||
export class GpxPanelControl {
|
||||
constructor(map) {
|
||||
constructor(map, profilePanel) {
|
||||
this._map = map;
|
||||
this._profilePanel = profilePanel;
|
||||
this._visible = new Set(); // file paths currently shown on the map
|
||||
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
||||
|
||||
@@ -331,9 +333,12 @@ 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.
|
||||
// Hover shows a quick summary (unlike a waypoint popup, which is
|
||||
// click-triggered — a track's summary describes the whole line, not
|
||||
// whatever point along it happens to get hovered, so there's no single
|
||||
// right anchor point worth waiting for a click on). Click opens the full
|
||||
// profile panel, which does need the click gesture to avoid flashing
|
||||
// open on every casual hover.
|
||||
_registerLineInteractivity(layerId) {
|
||||
if (this._interactiveLayers.has(layerId)) return;
|
||||
this._interactiveLayers.add(layerId);
|
||||
@@ -353,6 +358,10 @@ export class GpxPanelControl {
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
});
|
||||
this._map.on("click", layerId, (event) => {
|
||||
const feature = event.features[0];
|
||||
if (feature) this._profilePanel?.show(feature.properties);
|
||||
});
|
||||
}
|
||||
|
||||
_removeLayers(path) {
|
||||
@@ -488,15 +497,17 @@ function popupHtml({ name, description, time }) {
|
||||
}
|
||||
|
||||
// properties come straight from an uploaded GPX/FIT file — untrusted input.
|
||||
function trackPopupHtml({ name, distance_meters, elevation_gain_meters, duration_secs }) {
|
||||
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
||||
const parts = [];
|
||||
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
||||
|
||||
const stats = [];
|
||||
if (activity_type) stats.push(escapeHtml(formatActivityType(activity_type)));
|
||||
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>`);
|
||||
parts.push(`<p class="gpx-popup__time">Click for the full profile</p>`);
|
||||
|
||||
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user