Show session summary on hover over its LiveTrack marker
CI / check (push) Successful in 55s
CI / deploy (push) Successful in 23s

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:
randy committed 2026-09-06 22:32:44 +02:00
1 parent 0ee98d8f07
commit 55607aece9
2 files changed
+76 -6

No files matched your search

+10 -6
View File
@@ -258,7 +258,8 @@ module Maptool
rescue LiveTrackClient::FetchError => e
halt 502, json(error: "could not fetch track: #{e.message}")
end
LIVETRACK_STORE.set_summary(session["id"], LiveTrackClient.summarize(result))
summary = LiveTrackClient.summarize(result)
LIVETRACK_STORE.set_summary(session["id"], summary)
points = result.points
coordinates = points.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
@@ -272,11 +273,14 @@ module Maptool
{
type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last },
properties: {
time: last["dateTime"],
speedMetersPerSec: last["speedMetersPerSec"],
heartRateBeatsPerMin: last["heartRateBeatsPerMin"],
},
# Merges the session summary in too — the point layer's hover
# popup (public/js/livetrack.js) reads it straight off here
# rather than needing a second fetch against /sessions.
properties: summary.merge(
"time" => last["dateTime"],
"speed_meters_per_sec" => last["speedMetersPerSec"],
"heart_rate_beats_per_min" => last["heartRateBeatsPerMin"]
),
},
]
)
+66
View File
@@ -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));