Draw a LiveTrack session's planned course alongside the actual track
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:
1 parent
8eada07869
commit
3106f8c103
4 files changed
+66
-21
No files matched your search
@@ -743,24 +743,36 @@ 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",
|
||||||
{
|
geometry: { type: "Point", coordinates: coordinates.last },
|
||||||
type: "Feature",
|
# Merges the session summary in too — the point layer's hover
|
||||||
geometry: { type: "Point", coordinates: coordinates.last },
|
# popup (public/js/livetrack.js) reads it straight off here
|
||||||
# Merges the session summary in too — the point layer's hover
|
# rather than needing a second fetch against /sessions.
|
||||||
# popup (public/js/livetrack.js) reads it straight off here
|
properties: summary.merge(
|
||||||
# rather than needing a second fetch against /sessions.
|
"time" => last["dateTime"],
|
||||||
properties: summary.merge(
|
"speed_meters_per_sec" => last["speedMetersPerSec"],
|
||||||
"time" => last["dateTime"],
|
"heart_rate_beats_per_min" => last["heartRateBeatsPerMin"]
|
||||||
"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
|
end
|
||||||
|
|
||||||
get "/api/gnss/position" do
|
get "/api/gnss/position" do
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user