diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index ade78a5..6c25df5 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -468,7 +468,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -600,7 +600,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -651,7 +651,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c20d2e..8e9fe72 100644 --- a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "6.25.0" } }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "7238c483dca47b5419e42dec1906f6f2d5cd3630", + "version" : "8.58.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/mobile/lib/navigation/camera_controller.dart b/mobile/lib/navigation/camera_controller.dart index 7a7beb0..cc8fb96 100644 --- a/mobile/lib/navigation/camera_controller.dart +++ b/mobile/lib/navigation/camera_controller.dart @@ -9,7 +9,10 @@ class NavigationCameraController extends ChangeNotifier { CameraMode get mode => _mode; double get followZoom => _followZoom; - void onFirstFix() { + /// Transitions awaitingFirstFix → following. Called when nav starts and we + /// already have a cached user location, or (edge case) when the first + /// location update arrives during nav after the session began with no fix. + void onNavStart() { if (_mode != CameraMode.awaitingFirstFix) return; _mode = CameraMode.following; notifyListeners(); diff --git a/mobile/lib/navigation/location_converter.dart b/mobile/lib/navigation/location_converter.dart index e1709d6..8f05996 100644 --- a/mobile/lib/navigation/location_converter.dart +++ b/mobile/lib/navigation/location_converter.dart @@ -1,5 +1,6 @@ import 'package:ferrostar_flutter/ferrostar_flutter.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' as ml; UserLocation positionToUserLocation(Position p) => UserLocation( lat: p.latitude, @@ -9,3 +10,21 @@ UserLocation positionToUserLocation(Position p) => UserLocation( speedMps: p.speed >= 0 ? p.speed : null, timestampMs: p.timestamp.millisecondsSinceEpoch, ); + +UserLocation maplibreToUserLocation(ml.UserLocation l) { + // CLLocation course is -1 when heading is unknown (e.g. user stationary). + // The Swift bridge traps on UInt16(-1.0); drop sentinel + non-finite values. + final b = l.bearing; + final course = (b != null && b.isFinite && b >= 0 && b <= 360) ? b : null; + final s = l.speed; + final speed = (s != null && s.isFinite && s >= 0) ? s : null; + final acc = l.horizontalAccuracy; + return UserLocation( + lat: l.position.latitude, + lng: l.position.longitude, + horizontalAccuracyM: (acc != null && acc.isFinite && acc >= 0) ? acc : 0, + courseDeg: course, + speedMps: speed, + timestampMs: l.timestamp.millisecondsSinceEpoch, + ); +} diff --git a/mobile/lib/providers/user_location_provider.dart b/mobile/lib/providers/user_location_provider.dart new file mode 100644 index 0000000..c5d5937 --- /dev/null +++ b/mobile/lib/providers/user_location_provider.dart @@ -0,0 +1,12 @@ +import 'package:ferrostar_flutter/ferrostar_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Latest user location observed by MapLibre's CLLocationManager (the same +/// source that drives the pulsing blue dot). Written from the map's +/// `onUserLocationUpdated` callback; read when seeding navigation, picking a +/// route origin, or recentering the camera. +/// +/// Decoupled from `Geolocator.getLastKnownPosition()` because that reads the +/// system-wide cache which can lag behind MapLibre's stream and produced the +/// "new route starts 20-30m back" symptom. +final userLocationProvider = StateProvider((ref) => null); diff --git a/mobile/lib/screens/map_screen.dart b/mobile/lib/screens/map_screen.dart index a706ca9..3dd7cac 100644 --- a/mobile/lib/screens/map_screen.dart +++ b/mobile/lib/screens/map_screen.dart @@ -8,10 +8,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation; +import 'package:maplibre_gl/maplibre_gl.dart' as ml show UserLocation; import '../l10n/generated/app_localizations.dart'; import '../models/location.dart'; import '../models/route_state.dart'; +import '../navigation/camera_controller.dart'; import '../navigation/location_converter.dart'; import '../navigation/nav_constants.dart'; import '../providers/brush_provider.dart'; @@ -22,6 +24,7 @@ import '../providers/location_provider.dart'; import '../providers/map_bearing_provider.dart'; import '../providers/rating_overlay_provider.dart'; import '../providers/route_provider.dart'; +import '../providers/user_location_provider.dart'; import '../services/brush_overlay.dart'; import '../services/error_reporter.dart'; import '../services/haptics.dart'; @@ -69,21 +72,9 @@ class _MapScreenState extends ConsumerState { final l10n = AppLocalizations.of(context)!; final notifier = ref.read(routeControllerProvider.notifier); if (ref.read(routeControllerProvider).origin == null) { - Position? pos; - try { - pos = await Geolocator.getLastKnownPosition() ?? - await Geolocator.getCurrentPosition(); - } catch (_) {} + final origin = await _resolveCurrentOriginLocation(l10n); if (!mounted) return; - notifier.setOrigin( - Location( - id: 'gps', - name: l10n.locationCurrent, - label: l10n.locationCurrent, - lng: pos?.longitude ?? 13.4533, - lat: pos?.latitude ?? 52.5065, - ), - ); + notifier.setOrigin(origin); } notifier.setDestination( Location( @@ -194,19 +185,9 @@ class _MapScreenState extends ConsumerState { final l10n = AppLocalizations.of(context)!; final notifier = ref.read(routeControllerProvider.notifier); if (ref.read(routeControllerProvider).origin == null) { - Position? pos; - try { - pos = await Geolocator.getLastKnownPosition() ?? - await Geolocator.getCurrentPosition(); - } catch (_) {} + final origin = await _resolveCurrentOriginLocation(l10n); if (!mounted) return; - notifier.setOrigin(Location( - id: 'gps', - name: l10n.locationCurrent, - label: l10n.locationCurrent, - lng: pos?.longitude ?? 13.4533, - lat: pos?.latitude ?? 52.5065, - )); + notifier.setOrigin(origin); } notifier.setDestination(Location( id: home.id, @@ -223,14 +204,27 @@ class _MapScreenState extends ConsumerState { await _homeMarker.update(controller, home); } - Future _handleBrowseLocationUpdate(double lat, double lng) async { + /// Single entry point for MapLibre's user-location callback. Caches the + /// fix in userLocationProvider (used to seed nav, pick route origins, and + /// fall back recenter when ferrostar hasn't snapped yet), auto-centers in + /// browse mode on first fix, and — edge case — promotes the camera into + /// following mode if nav started before any fix was cached. + Future _onUserLocationUpdated(ml.UserLocation loc) async { + final uloc = maplibreToUserLocation(loc); + ref.read(userLocationProvider.notifier).state = uloc; + if (ref.read(navigationSessionProvider)) { + final cam = ref.read(navigationCameraControllerProvider); + if (cam.mode == CameraMode.awaitingFirstFix) { + await _activateFollowingCamera(uloc); + } + return; + } if (_browseAutocentered) return; - if (ref.read(navigationSessionProvider)) return; final controller = _mapController; if (controller == null) return; _browseAutocentered = true; await controller.animateCamera( - CameraUpdate.newLatLngZoom(LatLng(lat, lng), 16), + CameraUpdate.newLatLngZoom(LatLng(uloc.lat, uloc.lng), 16), ); } @@ -244,13 +238,16 @@ class _MapScreenState extends ConsumerState { } debugPrint('nav: start ${origin.name} -> ${destination.name}'); final service = ref.read(navigationServiceProvider); - // Fetch last-known position synchronously (no GPS wait) so the controller - // has an initial fix and NavigationState emits immediately. - UserLocation? initial; - try { - final pos = await Geolocator.getLastKnownPosition(); - if (pos != null) initial = positionToUserLocation(pos); - } catch (_) {} + // Prefer the live MapLibre fix (same source as the blue dot, written by + // onUserLocationUpdated). Fall back to Geolocator's cache only if MapLibre + // hasn't emitted yet — its cache can lag behind by 20-30m at cycling speed. + UserLocation? initial = ref.read(userLocationProvider); + if (initial == null) { + try { + final pos = await Geolocator.getLastKnownPosition(); + if (pos != null) initial = positionToUserLocation(pos); + } catch (_) {} + } try { await service.start( origin: WaypointInput(lat: origin.lat, lng: origin.lng), @@ -259,6 +256,12 @@ class _MapScreenState extends ConsumerState { initialLocation: initial, ); if (mounted) _speakNav(AppLocalizations.of(context)!.navTtsDeparting); + if (initial != null) { + // We already have a fix — skip awaitingFirstFix entirely. Without this + // the camera waits for ferrostar to emit a `.navigating` state with + // snapped_location, which won't happen until a stream tick arrives. + await _activateFollowingCamera(initial); + } } catch (e, st) { reportError(e, st, context: 'nav.start'); } @@ -296,11 +299,15 @@ class _MapScreenState extends ConsumerState { } } - Future _handleFirstFix(UserLocation loc) async { - debugPrint('nav: first fix'); - AppHaptics.firstFix(); + /// Transitions the camera into following mode for a nav session that has a + /// known starting location. Idempotent: safe to call again on the first + /// onUserLocationUpdated during nav as a fallback for the no-cache edge case. + Future _activateFollowingCamera(UserLocation loc) async { final cam = ref.read(navigationCameraControllerProvider); - cam.onFirstFix(); + if (cam.mode != CameraMode.awaitingFirstFix) return; + debugPrint('nav: activating following camera'); + AppHaptics.firstFix(); + cam.onNavStart(); final controller = _mapController; if (controller == null) return; // Enable tracking first so maplibre drives the camera target, then @@ -340,12 +347,16 @@ class _MapScreenState extends ConsumerState { Future _handleRecenterTap() async { final controller = _mapController; if (controller == null) return; + // Prefer ferrostar's snapped position so the camera lands on the route + // line, not the raw fix. Fall back to the latest MapLibre fix when nav + // hasn't produced a snapped state (e.g. before the first ferrostar tick). final snapped = ref.read(navigationStateProvider).value?.snappedLocation; - if (snapped == null) return; + final loc = snapped ?? ref.read(userLocationProvider); + if (loc == null) return; final cam = ref.read(navigationCameraControllerProvider); cam.onRecenterTapped(); await controller.animateCamera(CameraUpdate.newLatLngZoom( - LatLng(snapped.lat, snapped.lng), cam.followZoom)); + LatLng(loc.lat, loc.lng), cam.followZoom)); if (!mounted) return; await controller .updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass); @@ -359,11 +370,6 @@ class _MapScreenState extends ConsumerState { final nextState = next.value; if (nextState == null) return; - if (prevState?.snappedLocation == null && - nextState.snappedLocation != null) { - _handleFirstFix(nextState.snappedLocation!); - } - if (prevState?.status != TripStatus.complete && nextState.status == TripStatus.complete) { _handleArrival(); @@ -390,26 +396,60 @@ class _MapScreenState extends ConsumerState { } Future _refreshPreviewFromGps() async { - Position? pos; - try { - pos = await Geolocator.getLastKnownPosition() ?? - await Geolocator.getCurrentPosition(); - } catch (e) { - debugPrint('nav: refresh-preview GPS error: $e'); + final cached = ref.read(userLocationProvider); + double? lat = cached?.lat; + double? lng = cached?.lng; + if (lat == null || lng == null) { + try { + final pos = await Geolocator.getLastKnownPosition() ?? + await Geolocator.getCurrentPosition(); + lat = pos.latitude; + lng = pos.longitude; + } catch (e) { + debugPrint('nav: refresh-preview GPS error: $e'); + } } - if (!mounted || pos == null) return; + if (!mounted || lat == null || lng == null) return; final l10n = AppLocalizations.of(context)!; ref.read(routeControllerProvider.notifier).setOrigin( Location( id: 'gps', name: l10n.locationCurrent, label: l10n.locationCurrent, - lat: pos.latitude, - lng: pos.longitude, + lat: lat, + lng: lng, ), ); } + /// Resolves a "current location" Location for use as a route origin. Prefers + /// the cached MapLibre fix (live, written from onUserLocationUpdated), then + /// Geolocator's last-known/current, and finally a Berlin-center fallback. + Future _resolveCurrentOriginLocation(AppLocalizations l10n) async { + final cached = ref.read(userLocationProvider); + if (cached != null) { + return Location( + id: 'gps', + name: l10n.locationCurrent, + label: l10n.locationCurrent, + lat: cached.lat, + lng: cached.lng, + ); + } + Position? pos; + try { + pos = await Geolocator.getLastKnownPosition() ?? + await Geolocator.getCurrentPosition(); + } catch (_) {} + return Location( + id: 'gps', + name: l10n.locationCurrent, + label: l10n.locationCurrent, + lat: pos?.latitude ?? 52.5065, + lng: pos?.longitude ?? 13.4533, + ); + } + Future _probeRatingFeature(LatLng coords) async { final controller = _mapController; if (controller == null) return null; @@ -613,8 +653,7 @@ class _MapScreenState extends ConsumerState { onMapCreated: (controller) { _mapController = controller; }, - onUserLocationUpdated: (loc) => _handleBrowseLocationUpdate( - loc.position.latitude, loc.position.longitude), + onUserLocationUpdated: _onUserLocationUpdated, onStyleLoadedCallback: () async { // Attach rating overlay AFTER style is loaded — MapLibre // silently ignores addGeoJsonSource / addLayer calls diff --git a/mobile/test/navigation/camera_controller_test.dart b/mobile/test/navigation/camera_controller_test.dart index 0804b84..4c0754e 100644 --- a/mobile/test/navigation/camera_controller_test.dart +++ b/mobile/test/navigation/camera_controller_test.dart @@ -9,20 +9,20 @@ void main() { expect(c.followZoom, 17.0); }); - test('onFirstFix transitions awaitingFirstFix -> following', () { + test('onNavStart transitions awaitingFirstFix -> following', () { final c = NavigationCameraController(); - c.onFirstFix(); + c.onNavStart(); expect(c.mode, CameraMode.following); }); - test('onFirstFix is a no-op if already following', () { - final c = NavigationCameraController()..onFirstFix(); - c.onFirstFix(); + test('onNavStart is a no-op if already following', () { + final c = NavigationCameraController()..onNavStart(); + c.onNavStart(); expect(c.mode, CameraMode.following); }); test('onTrackingDismissed transitions following -> free', () { - final c = NavigationCameraController()..onFirstFix(); + final c = NavigationCameraController()..onNavStart(); c.onTrackingDismissed(); expect(c.mode, CameraMode.free); }); @@ -45,7 +45,7 @@ void main() { final c = NavigationCameraController(); c.onZoomChanged(14.0); expect(c.followZoom, 17.0); // awaitingFirstFix: ignored - c.onFirstFix(); + c.onNavStart(); c.onZoomChanged(15.5); expect(c.followZoom, 17.0); // following: ignored c.onTrackingDismissed(); @@ -55,14 +55,14 @@ void main() { test('onRecenterTapped transitions free -> following', () { final c = NavigationCameraController() - ..onFirstFix() + ..onNavStart() ..onTrackingDismissed(); c.onRecenterTapped(); expect(c.mode, CameraMode.following); }); test('onRecenterTapped is a no-op in following', () { - final c = NavigationCameraController()..onFirstFix(); + final c = NavigationCameraController()..onNavStart(); c.onRecenterTapped(); expect(c.mode, CameraMode.following); }); @@ -70,9 +70,9 @@ void main() { test('onArrived transitions any state to arrived', () { for (final setup in [ () => NavigationCameraController(), - () => NavigationCameraController()..onFirstFix(), + () => NavigationCameraController()..onNavStart(), () => NavigationCameraController() - ..onFirstFix() + ..onNavStart() ..onTrackingDismissed(), ]) { final c = setup(); @@ -85,7 +85,7 @@ void main() { final c = NavigationCameraController(); var notifications = 0; c.addListener(() => notifications++); - c.onFirstFix(); + c.onNavStart(); c.onTrackingDismissed(); c.onZoomChanged(14.0); c.onRecenterTapped(); @@ -105,11 +105,11 @@ void main() { expect(notifications, 0); // Real transition: -> following - c.onFirstFix(); + c.onNavStart(); notifications = 0; // No-ops from following - c.onFirstFix(); + c.onNavStart(); c.onZoomChanged(14.0); expect(notifications, 0); diff --git a/mobile/test/navigation/location_converter_test.dart b/mobile/test/navigation/location_converter_test.dart index 8200b3a..2e97e61 100644 --- a/mobile/test/navigation/location_converter_test.dart +++ b/mobile/test/navigation/location_converter_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' as ml; import 'package:beebeebike/navigation/location_converter.dart'; @@ -66,4 +67,44 @@ void main() { expect(result.courseDeg, isNull); expect(result.speedMps, isNull); }); + + ml.UserLocation mlLoc({ + double? bearing, + double? speed, + double? accuracy, + }) => + ml.UserLocation( + position: const ml.LatLng(52.52, 13.405), + altitude: 0, + bearing: bearing, + speed: speed, + horizontalAccuracy: accuracy, + verticalAccuracy: 0, + timestamp: DateTime.fromMillisecondsSinceEpoch(2000), + heading: null, + ); + + test('maplibreToUserLocation drops bearing=-1 (CLLocation unknown sentinel)', + () { + // Swift FFI traps on UInt16(-1.0). The Dart-side guard prevents the value + // from reaching the bridge. + final r = maplibreToUserLocation(mlLoc(bearing: -1, speed: 4.0)); + expect(r.courseDeg, isNull); + expect(r.speedMps, 4.0); + }); + + test('maplibreToUserLocation drops non-finite bearing/speed/accuracy', () { + final r = + maplibreToUserLocation(mlLoc(bearing: double.nan, speed: double.infinity, accuracy: double.nan)); + expect(r.courseDeg, isNull); + expect(r.speedMps, isNull); + expect(r.horizontalAccuracyM, 0); + }); + + test('maplibreToUserLocation passes valid bearing/speed through', () { + final r = maplibreToUserLocation(mlLoc(bearing: 90, speed: 3.0, accuracy: 5)); + expect(r.courseDeg, 90); + expect(r.speedMps, 3.0); + expect(r.horizontalAccuracyM, 5); + }); } diff --git a/mobile/test/screens/map_screen_navigation_test.dart b/mobile/test/screens/map_screen_navigation_test.dart index f91feb1..17f7499 100644 --- a/mobile/test/screens/map_screen_navigation_test.dart +++ b/mobile/test/screens/map_screen_navigation_test.dart @@ -114,15 +114,24 @@ Future _triggerRebuild( } void main() { - testWidgets('recenter FAB hidden initially', (tester) async { + testWidgets('recenter FAB hidden in awaitingFirstFix (pre-first-fix)', + (tester) async { await _pumpNavActive(tester); expect(find.byType(RecenterFab), findsNothing); }); + testWidgets('recenter FAB hidden when camera enters following mode', + (tester) async { + final h = await _pumpNavActive(tester); + h.cam.onNavStart(); + await _triggerRebuild(tester, h.navStream); + expect(find.byType(RecenterFab), findsNothing); + }); + testWidgets('recenter FAB visible when camera enters free mode', (tester) async { final h = await _pumpNavActive(tester); - h.cam.onFirstFix(); + h.cam.onNavStart(); h.cam.onTrackingDismissed(); await _triggerRebuild(tester, h.navStream); expect(find.byType(RecenterFab), findsOneWidget); diff --git a/packages/ferrostar_flutter/ios/ferrostar_flutter/Sources/ferrostar_flutter/Serialization.swift b/packages/ferrostar_flutter/ios/ferrostar_flutter/Sources/ferrostar_flutter/Serialization.swift index ff3bb34..afad7c9 100644 --- a/packages/ferrostar_flutter/ios/ferrostar_flutter/Sources/ferrostar_flutter/Serialization.swift +++ b/packages/ferrostar_flutter/ios/ferrostar_flutter/Sources/ferrostar_flutter/Serialization.swift @@ -13,8 +13,13 @@ enum Serialization { let tsMs = dict["timestamp_ms"] as? Int else { throw SerializationError.missingField("lat/lng/horizontal_accuracy_m/timestamp_ms") } - let course: CourseOverGround? = (dict["course_deg"] as? Double).map { - CourseOverGround(degrees: UInt16($0), accuracy: nil) + // CLLocation course is -1 when heading is unknown; clamp negatives to nil + // and finite >360 values into wrap range. UInt16 init traps on negative or + // out-of-range Double, so this guard is load-bearing. + let course: CourseOverGround? = (dict["course_deg"] as? Double).flatMap { + guard $0.isFinite, $0 >= 0 else { return nil } + let deg = UInt16(min($0.truncatingRemainder(dividingBy: 360), 359)) + return CourseOverGround(degrees: deg, accuracy: nil) } let speed: Speed? = (dict["speed_mps"] as? Double).map { Speed(value: $0, accuracy: nil) @@ -74,10 +79,21 @@ enum Serialization { } static func encodeTripProgress(_ p: TripProgress) -> [String: Any?] { + // Int(Double) traps on NaN/Inf/overflow with EXC_BREAKPOINT — observed in + // prod when ferrostar emits a degenerate first progress tick at session + // start. Coerce to 0 so the Dart side falls back to "—" / loading state. + // Bound at ~31 years of seconds before multiplying — covers any sane + // routing duration and stays well within Int64 after *1000. + let durMs: Int + if p.durationRemaining.isFinite, abs(p.durationRemaining) < 1e9 { + durMs = Int(p.durationRemaining * 1000) + } else { + durMs = 0 + } return [ - "distance_to_next_maneuver_m": p.distanceToNextManeuver, - "distance_remaining_m": p.distanceRemaining, - "duration_remaining_ms": Int(p.durationRemaining * 1000), + "distance_to_next_maneuver_m": p.distanceToNextManeuver.isFinite ? p.distanceToNextManeuver : 0, + "distance_remaining_m": p.distanceRemaining.isFinite ? p.distanceRemaining : 0, + "duration_remaining_ms": durMs, ] }