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 + HTML @@ -70,6 +71,15 @@ class LiveTrackClientTest < Minitest::Test assert Maptool::LiveTrackClient.summarize(result)["active"] 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 points = @client.send(:search, @client.send(:parse_payloads, "")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } assert_nil points