From 55607aece9d0d7ea457efe195d4b6125e6f6d41b Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Sun, 6 Sep 2026 22:32:44 +0200 Subject: [PATCH] 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. --- app.rb | 16 ++++++---- public/js/livetrack.js | 66 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/app.rb b/app.rb index 6a2c9c3..7ac5335 100644 --- a/app.rb +++ b/app.rb @@ -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"] + ), }, ] ) diff --git a/public/js/livetrack.js b/public/js/livetrack.js index 905aa6b..f0f779e 100644 --- a/public/js/livetrack.js +++ b/public/js/livetrack.js @@ -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 = [`${escapeHtml(session_name || "LiveTrack session")}`]; + + 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(`

${stats.join(" · ")}

`); + + 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(`

${latest.join(" · ")}

`); + + if (time) { + const parsed = new Date(time); + if (!Number.isNaN(parsed.getTime())) { + parts.push(`

${escapeHtml(parsed.toLocaleString())}

`); + } + } + + 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));