diff --git a/app.rb b/app.rb
index 7a4aae3..6009f41 100644
--- a/app.rb
+++ b/app.rb
@@ -743,24 +743,36 @@ module Maptool
halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last
- json(
- type: "FeatureCollection",
- features: [
- { type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
- {
- type: "Feature",
- geometry: { type: "Point", coordinates: coordinates.last },
- # 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"]
- ),
- },
- ]
- )
+ features = [
+ { type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: { "kind" => "actual" } },
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: coordinates.last },
+ # 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"]
+ ),
+ },
+ ]
+
+ # Only present when the activity was started from a saved Garmin
+ # course (see LiveTrackClient#fetch) — plotted separately from the
+ # actual track ("kind" distinguishes the two LineStrings) since it's
+ # the planned route, not where the person has actually been.
+ course_coordinates = result.course&.[]("geoPoints")&.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
+ if course_coordinates&.any?
+ features << {
+ type: "Feature",
+ geometry: { type: "LineString", coordinates: course_coordinates },
+ properties: { "kind" => "planned", "name" => result.course["name"] },
+ }
+ end
+
+ json(type: "FeatureCollection", features: features)
end
get "/api/gnss/position" do
diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb
index a28b944..c561d34 100644
--- a/lib/maptool/livetrack_client.rb
+++ b/lib/maptool/livetrack_client.rb
@@ -19,7 +19,7 @@ module Maptool
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
- Result = Struct.new(:points, :session, keyword_init: true)
+ Result = Struct.new(:points, :session, :course, keyword_init: true)
def self.fetch(session_id, token)
new.fetch(session_id, token)
@@ -30,6 +30,10 @@ module Maptool
# if that part wasn't found even though points were (seen in practice:
# session details are consistently present alongside points, but nothing
# here depends on that holding forever).
+ # `course`: the planned route (name/geoPoints), or nil — only present
+ # when the activity was actually started from a saved Garmin course
+ # (session["courseIds"] is how the page itself knows to fetch one; a
+ # plain unplanned activity has neither).
def fetch(session_id, token)
payloads = parse_payloads(fetch_html(session_id, token))
@@ -37,8 +41,11 @@ module Maptool
raise FetchError, "no track points found (session invalid or expired?)" unless points
session = search(payloads) { |node| node if node["sessionName"] }
+ # First of possibly several — a LiveTrack session only ever follows
+ # the one course it was started from.
+ course = search(payloads) { |node| node["courses"].first if node["courses"].is_a?(Array) && !node["courses"].empty? }
- Result.new(points: clean(points), session: clean(session))
+ Result.new(points: clean(points), session: clean(session), course: clean(course))
end
# Distance/duration/activity type come from the last track point (its
diff --git a/public/js/livetrack.js b/public/js/livetrack.js
index d1b428a..df7d1e8 100644
--- a/public/js/livetrack.js
+++ b/public/js/livetrack.js
@@ -5,6 +5,11 @@ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const SESSIONS_URL = "/api/livetrack/sessions";
const TRACK_SOURCE = "livetrack-track";
const TRACK_COLOR = "#7209b7";
+// The planned route (only present when the activity was started from a
+// saved Garmin course, see LiveTrackClient#fetch) — dashed and muted so it
+// reads as a reference line, not more recorded data next to the actual
+// track's own color.
+const PLANNED_COLOR = "#888";
// Garmin devices post a new point roughly every 15s (postTrackPointFrequency
// in the session data) — polling twice that often would just re-fetch the
// same 400+KB page for nothing most of the time.
@@ -171,11 +176,22 @@ export class LiveTrackPanelControl {
if (this._map.getSource(TRACK_SOURCE)) return;
this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson });
+ // Added before the actual track's own line layer so it paints
+ // underneath — the recorded track is the thing that actually matters
+ // when the two overlap.
+ this._map.addLayer({
+ id: `${TRACK_SOURCE}-planned`,
+ type: "line",
+ source: TRACK_SOURCE,
+ filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "planned"]],
+ layout: { "line-cap": "round", "line-join": "round" },
+ paint: { "line-color": PLANNED_COLOR, "line-width": 2, "line-dasharray": [2, 2] },
+ });
this._map.addLayer({
id: `${TRACK_SOURCE}-line`,
type: "line",
source: TRACK_SOURCE,
- filter: ["==", ["geometry-type"], "LineString"],
+ filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "actual"]],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
diff --git a/test/test_livetrack_client.rb b/test/test_livetrack_client.rb
index 8c6ffa2..59ec304 100644
--- a/test/test_livetrack_client.rb
+++ b/test/test_livetrack_client.rb
@@ -16,6 +16,7 @@ class LiveTrackClientTest < Minitest::Test
+