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
+53 -8

No files matched your search

+17 -5
View File
@@ -743,10 +743,8 @@ module Maptool
halt 502, json(error: "track has no positions") if coordinates.empty? halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last last = points.last
json( features = [
type: "FeatureCollection", { type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: { "kind" => "actual" } },
features: [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
{ {
type: "Feature", type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last }, geometry: { type: "Point", coordinates: coordinates.last },
@@ -760,7 +758,21 @@ module Maptool
), ),
}, },
] ]
)
# 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 end
get "/api/gnss/position" do get "/api/gnss/position" do
+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" 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 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) def self.fetch(session_id, token)
new.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: # if that part wasn't found even though points were (seen in practice:
# session details are consistently present alongside points, but nothing # session details are consistently present alongside points, but nothing
# here depends on that holding forever). # 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) def fetch(session_id, token)
payloads = parse_payloads(fetch_html(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 raise FetchError, "no track points found (session invalid or expired?)" unless points
session = search(payloads) { |node| node if node["sessionName"] } 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 end
# Distance/duration/activity type come from the last track point (its # Distance/duration/activity type come from the last track point (its
+17 -1
View File
@@ -5,6 +5,11 @@ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const SESSIONS_URL = "/api/livetrack/sessions"; const SESSIONS_URL = "/api/livetrack/sessions";
const TRACK_SOURCE = "livetrack-track"; const TRACK_SOURCE = "livetrack-track";
const TRACK_COLOR = "#7209b7"; 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 // Garmin devices post a new point roughly every 15s (postTrackPointFrequency
// in the session data) — polling twice that often would just re-fetch the // in the session data) — polling twice that often would just re-fetch the
// same 400+KB page for nothing most of the time. // same 400+KB page for nothing most of the time.
@@ -171,11 +176,22 @@ export class LiveTrackPanelControl {
if (this._map.getSource(TRACK_SOURCE)) return; if (this._map.getSource(TRACK_SOURCE)) return;
this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson }); 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({ this._map.addLayer({
id: `${TRACK_SOURCE}-line`, id: `${TRACK_SOURCE}-line`,
type: "line", type: "line",
source: TRACK_SOURCE, source: TRACK_SOURCE,
filter: ["==", ["geometry-type"], "LineString"], filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "kind"], "actual"]],
layout: { "line-cap": "round", "line-join": "round" }, layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 }, paint: { "line-color": TRACK_COLOR, "line-width": 3 },
}); });
+10
View File
@@ -16,6 +16,7 @@ class LiveTrackClientTest < Minitest::Test
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script> <script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
<script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script> <script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script>
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script> <script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script>
<script>self.__next_f.push([1,"11:{\\"courses\\":[{\\"name\\":\\"40 km okruh\\",\\"geoPoints\\":[{\\"position\\":{\\"lat\\":48.05,\\"lon\\":17.35},\\"distanceMeters\\":0},{\\"position\\":{\\"lat\\":48.06,\\"lon\\":17.36},\\"distanceMeters\\":1000}]}]}"])</script>
</body></html> </body></html>
HTML HTML
@@ -70,6 +71,15 @@ class LiveTrackClientTest < Minitest::Test
assert Maptool::LiveTrackClient.summarize(result)["active"] assert Maptool::LiveTrackClient.summarize(result)["active"]
end end
def test_finds_the_planned_course_alongside_the_actual_track
payloads = @client.send(:parse_payloads, FAKE_PAGE)
course = @client.send(:clean, @client.send(:search, payloads) { |n| n["courses"].first if n["courses"].is_a?(Array) && !n["courses"].empty? })
assert_equal "40 km okruh", course["name"]
assert_equal 2, course["geoPoints"].size
assert_in_delta 48.06, course["geoPoints"].last["position"]["lat"]
end
def test_no_track_points_found_in_a_page_without_them def test_no_track_points_found_in_a_page_without_them
points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } points = @client.send(:search, @client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
assert_nil points assert_nil points