Draw a LiveTrack session's planned course alongside the actual track
CI / check (push) Successful in 19s
CI / deploy (push) Successful in 13s

Garmin's own LiveTrack page already shows both when the activity was
started from a saved course — the session payload carries the course's
geoPoints right alongside the track points (LiveTrackClient found it
nested under the same React Query cache the session/points come from).

Rendered as a second, dashed LineString distinct from the actual
track's own line ("kind": "actual" vs "planned" on each GeoJSON
feature) — muted gray rather than matching Garmin's own purple-for-
planned, since this app's actual track is already purple and reusing
it would make the two indistinguishable.
This commit is contained in:
randy committed 2026-09-20 16:54:24 +02:00
1 parent 8eada07869
commit 3106f8c103
4 files changed
+66 -21

No files matched your search

+9 -2
View File
@@ -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