Show session summary on hover over its LiveTrack marker
The track endpoint now merges the session summary into the marker's GeoJSON properties (already computed for storage, just wasn't passed through), and the marker gets the same hover-popup treatment GPX points already have.
This commit is contained in:
1 parent
0ee98d8f07
commit
55607aece9
2 files changed
+76
-6
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
import { LIVETRACK_ICON } from "/js/icons.js";
|
||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||
|
||||
@@ -14,6 +15,8 @@ export class LiveTrackPanelControl {
|
||||
this._map = map;
|
||||
this._activeSessionId = null;
|
||||
this._geojson = null;
|
||||
this._hoverPopup = null;
|
||||
this._pointInteractivityRegistered = false;
|
||||
|
||||
// setStyle() (switching the Online/Offline base map) tears down all
|
||||
// custom sources and layers — put the track back afterwards, same as
|
||||
@@ -145,6 +148,7 @@ export class LiveTrackPanelControl {
|
||||
"circle-stroke-width": 1.5,
|
||||
},
|
||||
});
|
||||
this._registerPointInteractivity();
|
||||
|
||||
if (!fit) return;
|
||||
|
||||
@@ -167,9 +171,37 @@ export class LiveTrackPanelControl {
|
||||
);
|
||||
}
|
||||
|
||||
// Registered once, ever — map.on(type, layerId, fn) is a delegated
|
||||
// listener that lives on the map itself and keeps matching that layer id
|
||||
// after setStyle() removes and (via _showTrack) re-adds it, so
|
||||
// re-registering on every re-add would stack duplicate popups (same
|
||||
// reasoning as GpxPanelControl's own point interactivity).
|
||||
_registerPointInteractivity() {
|
||||
if (this._pointInteractivityRegistered) return;
|
||||
this._pointInteractivityRegistered = true;
|
||||
|
||||
const layerId = `${TRACK_SOURCE}-point`;
|
||||
this._map.on("mouseenter", layerId, (event) => {
|
||||
this._map.getCanvas().style.cursor = "pointer";
|
||||
const feature = event.features[0];
|
||||
if (!feature) return;
|
||||
this._hoverPopup = new Popup({ closeButton: false, closeOnClick: false, offset: 10 })
|
||||
.setLngLat(event.lngLat)
|
||||
.setHTML(popupHtml(feature.properties))
|
||||
.addTo(this._map);
|
||||
});
|
||||
this._map.on("mouseleave", layerId, () => {
|
||||
this._map.getCanvas().style.cursor = "";
|
||||
this._hoverPopup?.remove();
|
||||
this._hoverPopup = null;
|
||||
});
|
||||
}
|
||||
|
||||
_removeTrack() {
|
||||
this._activeSessionId = null;
|
||||
this._geojson = null;
|
||||
this._hoverPopup?.remove();
|
||||
this._hoverPopup = null;
|
||||
[`${TRACK_SOURCE}-line`, `${TRACK_SOURCE}-point`].forEach((layerId) => {
|
||||
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
|
||||
});
|
||||
@@ -177,6 +209,40 @@ export class LiveTrackPanelControl {
|
||||
}
|
||||
}
|
||||
|
||||
// Properties come straight off the GeoJSON the server built from Garmin
|
||||
// data — session_name in particular is user-supplied (whatever the LiveTrack
|
||||
// session was named on the watch/app) — so escape before it lands in HTML,
|
||||
// same as GpxPanelControl's popups do for GPX file contents.
|
||||
function popupHtml({ session_name, activity_type, distance_meters, duration_secs, time, speed_meters_per_sec, heart_rate_beats_per_min }) {
|
||||
const parts = [`<strong>${escapeHtml(session_name || "LiveTrack session")}</strong>`];
|
||||
|
||||
const stats = [];
|
||||
if (activity_type) stats.push(escapeHtml(titleCase(activity_type)));
|
||||
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
|
||||
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
|
||||
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
|
||||
|
||||
const latest = [];
|
||||
if (typeof speed_meters_per_sec === "number") latest.push(`${(speed_meters_per_sec * 3.6).toFixed(1)} km/h`);
|
||||
if (typeof heart_rate_beats_per_min === "number") latest.push(`${heart_rate_beats_per_min} bpm`);
|
||||
if (latest.length > 0) parts.push(`<p>${latest.join(" · ")}</p>`);
|
||||
|
||||
if (time) {
|
||||
const parsed = new Date(time);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function summaryLabel({ session_name, activity_type, distance_meters, duration_secs }) {
|
||||
const parts = [];
|
||||
if (activity_type) parts.push(titleCase(activity_type));
|
||||
|
||||
Reference in new issue
Block a user