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