// 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() 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 } }