Distance confidence - Every distance surface takes the figure AND its "Approx." marker from one FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget, Live Activity, watch, Siri), so a surface can never mark one figure approximate while showing a routed one. Radius filters and Closest ordering test that same displayed distance, so no row can read a distance outside the radius that filtered it (the reported "14.6 mi inside a 10-mile list"). Accurate distances (Settings -> Distances, default off) - Off: the browse layer serves the straight line, marked Approx., for every row, so two installs agree to the metre and the app makes no route requests at all. - On: routed distances for the whole search area, unmarked, served only from a COMPLETE set anchored within 600 m — a partial fill reverts every row at once rather than mixing road and straight-line bases. - Best Value maths sits deliberately outside the toggle: it keeps using measured road routes and its own confidence marking, so money figures never move with the switch. - Fills run only while the toggle is on and the app is frontmost; capacity raised to 600 stations per pass; completeness tolerates pins Apple Maps has no route for, which otherwise left an area permanently incomplete and the whole list stuck on estimates. Performance (the reported slowdown switching to Closest / launching with it) - FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per Closest sort. Memoised, with the setter keeping it in step. - Both keyed sorts decorate their keys — one distance per station, carried as an index instead of copying whole stations into the decorated array — and the comparison baseline plus per-station journeys now come from ONE pass instead of two full scans per tab. The Stations tab also reuses its single sorted list for the count and the TOP badge instead of sorting the country three times. - Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per list, Cheapest 6 ms, Best Value 11 ms. - DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where devicectl eats a leading-dash argument. Tests: 198, 0 failures. Device and simulator Debug builds green.
221 lines
11 KiB
Swift
221 lines
11 KiB
Swift
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
|
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
|
// show real driving distance instead of straight-line haversine.
|
|
//
|
|
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
|
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
|
//
|
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
|
// moved `moveThresholdMeters` from where the cache was built. In
|
|
// "Accurate distances" mode a pass routes the WHOLE search area (up to
|
|
// `fillCapacity` stations) so the browse layer can serve routed distances for
|
|
// every row at once; the mode must also be ON for any pass to run at all.
|
|
|
|
import Foundation
|
|
import MapKit
|
|
import UIKit
|
|
import WidgetKit
|
|
|
|
enum RoadDistanceService {
|
|
/// Upper bound on stations routed per pass. Accurate-distances mode routes the
|
|
/// whole search area in ONE pass, because a 12-per-pass budget could never
|
|
/// make a list fully routed — that is exactly why two devices (and two
|
|
/// radii) disagreed about the same station. Capped so a pathological dataset
|
|
/// can't turn one pass into thousands of route requests; a capped pass stays
|
|
/// incomplete, and the browse layer keeps showing straight-line + "Approx."
|
|
/// until a later pass finishes the job. Measured on device: ~3.2 route calls
|
|
/// per second, so 600 stations (1,200 calls) is ~6 min at the very worst.
|
|
static let fillCapacity = 600
|
|
/// How many Worth-the-Drive candidates may claim slots in a pass. Those
|
|
/// rows are ranked by NET SAVING and are typically FURTHER out than the
|
|
/// nearest stations — routing only the nearest stations (the old behaviour)
|
|
/// meant they never got a measured route and stayed on straight-line
|
|
/// estimates forever.
|
|
static let priorityCandidates = 6
|
|
/// Only route stations within this straight-line radius (km). Covers the
|
|
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
|
static let maxRadiusKM: Double = 25
|
|
/// Don't route again more often than this (minutes).
|
|
static let throttleMinutes: Double = 10
|
|
/// Recompute when the user moves more than this (metres) from the last
|
|
/// source location.
|
|
static let moveThresholdMeters: Double = 400
|
|
|
|
/// Refreshes the cached road distances for the in-radius stations around
|
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
|
///
|
|
/// - Parameter priorityStations: stations to route BEFORE the nearest ones
|
|
/// (the Worth-the-Drive candidates and the comparison baseline), so the
|
|
/// numbers the user actually reads are road-routed rather than estimated.
|
|
static func refreshIfNeeded(stations: [FuelStation],
|
|
lat: Double,
|
|
lng: Double,
|
|
prioritising priorityStations: [FuelStation] = []) async {
|
|
// "Accurate distances" OFF (the default) means the browse layer never
|
|
// serves a routed value, so any routing would be pure cost with nothing
|
|
// visible to show for it: no pass, no requests, no widget reloads.
|
|
guard FuelStore.accurateDistancesEnabled else { return }
|
|
// Only fill while the app is on screen. The browse layer is the only
|
|
// consumer, so a background fill would spend radio and battery routing a
|
|
// position nothing is displaying — worst case, a long drive.
|
|
guard await appIsActive() else { return }
|
|
guard await refreshGate.begin() else { return }
|
|
defer { Task { await refreshGate.end() } }
|
|
|
|
guard !stations.isEmpty else { return }
|
|
|
|
let existing = FuelStore.loadRoadDistances()
|
|
// Entries can only be merged when they were routed from (near enough)
|
|
// this same origin — the read path rejects anything else anyway.
|
|
let originCompatible = existing.map {
|
|
haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters
|
|
} ?? false
|
|
|
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
|
// `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook).
|
|
#if DEBUG
|
|
let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1"
|
|
#else
|
|
let forcePass = false
|
|
#endif
|
|
if !forcePass, let cache = existing {
|
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Candidates: Worth-the-Drive priorities first, then the nearest
|
|
// stations. Stations that already have a route from this origin are
|
|
// skipped so the per-pass budget goes on the ones still missing one.
|
|
var candidates: [FuelStation] = []
|
|
var seen = Set<String>()
|
|
func consider(_ station: FuelStation) {
|
|
guard candidates.count < fillCapacity else { return }
|
|
guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return }
|
|
guard seen.insert(station.id).inserted else { return }
|
|
guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return }
|
|
candidates.append(station)
|
|
}
|
|
for station in priorityStations.prefix(priorityCandidates) { consider(station) }
|
|
let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
|
for station in nearest {
|
|
if candidates.count >= fillCapacity { break }
|
|
consider(station)
|
|
}
|
|
|
|
guard !candidates.isEmpty else { return }
|
|
|
|
let passStart = Date()
|
|
|
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
|
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
|
var routedCount = 0
|
|
for station in candidates {
|
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
|
async let outward = roadMeters(from: origin, to: dest)
|
|
async let back = roadMeters(from: dest, to: origin)
|
|
if let meters = await outward {
|
|
routedCount += 1
|
|
// Store the exact pin that was routed so the display layer can
|
|
// refuse to serve this value if the station later appears with
|
|
// a different coordinate (corrected pin / other data source).
|
|
let returnMeters = await back
|
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
|
lat: station.lat,
|
|
lng: station.lng,
|
|
returnMeters: returnMeters)
|
|
}
|
|
}
|
|
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
|
|
return
|
|
}
|
|
|
|
// A pass is complete when nothing in the search area is left UNRESOLVED:
|
|
// every station either has a route from this anchor, or was attempted in
|
|
// THIS pass and Apple Maps returned none. A few pins genuinely have no
|
|
// route (offshore, deleted forecourt, bad snapshot) and one of those must
|
|
// never leave the area permanently incomplete — that would pin the whole
|
|
// list on straight-line estimates forever. Those stations show their
|
|
// straight line, marked Approx., and the radius filter uses that same
|
|
// distance, so nothing is ever out of step with what a row displays.
|
|
let attempted = Set(candidates.map(\.id))
|
|
let capped = candidates.count >= fillCapacity
|
|
let unresolved = capped || stations.contains { station in
|
|
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
|
|
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
|
|
&& !attempted.contains(station.id)
|
|
}
|
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries,
|
|
complete: !unresolved)
|
|
#if DEBUG
|
|
// `FB-ROUTE` prints what a pass actually cost (stations, route calls,
|
|
// wall-clock, unroutable pins, whether the set is now complete) — the
|
|
// evidence for any "how taxing is routing?" answer, and how a bigger pass
|
|
// gets scaled from measurement instead of guesswork.
|
|
print("FB-ROUTE pass stations=\(candidates.count) calls=\(candidates.count * 2) "
|
|
+ "elapsed=\(String(format: "%.2f", Date().timeIntervalSince(passStart)))s "
|
|
+ "cached=\(entries.count) failed=\(candidates.count - routedCount) "
|
|
+ "complete=\(!unresolved)")
|
|
#endif
|
|
// Wake the widgets so the new road distances surface immediately, and
|
|
// tell the app so its rows re-read the distance they are showing.
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
await MainActor.run {
|
|
NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil)
|
|
}
|
|
}
|
|
|
|
private static let refreshGate = RoadDistanceRefreshGate()
|
|
|
|
/// Accurate mode fills only while the app is on screen: the browse layer is
|
|
/// the only consumer of routed distances, so a background fill would spend
|
|
/// radio and battery routing a position nothing is looking at.
|
|
private static func appIsActive() async -> Bool {
|
|
await MainActor.run { UIApplication.shared.applicationState == .active }
|
|
}
|
|
|
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
|
let request = MKDirections.Request()
|
|
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
|
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
|
request.transportType = .automobile
|
|
request.requestsAlternateRoutes = false
|
|
do {
|
|
let response = try await MKDirections(request: request).calculate()
|
|
return response.routes.first?.distance
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Straight-line haversine distance between two coordinates, in metres.
|
|
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
|
let r = 6371000.0
|
|
let dLat = (lat2 - lat1) * .pi / 180
|
|
let dLng = (lng2 - lng1) * .pi / 180
|
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
|
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
|
sin(dLng / 2) * sin(dLng / 2)
|
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
}
|
|
}
|
|
|
|
private actor RoadDistanceRefreshGate {
|
|
private var isRefreshing = false
|
|
|
|
func begin() -> Bool {
|
|
guard !isRefreshing else { return false }
|
|
isRefreshing = true
|
|
return true
|
|
}
|
|
|
|
func end() {
|
|
isRefreshing = false
|
|
}
|
|
}
|