// 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. Bounded to the // `candidatesPerPass` nearest stations so a pass stays a handful of route calls. import Foundation import MapKit import WidgetKit enum RoadDistanceService { /// Upper bound on stations routed per pass, so a pass stays a bounded set of /// route calls. Worth-the-Drive now asks for outward AND return routes, so /// this must stay conservative; a larger value can make first launch feel /// unresponsive while MapKit drains dozens of route requests. static let candidatesPerPass = 12 /// 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 { 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 < candidatesPerPass 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 >= candidatesPerPass { break } consider(station) } guard !candidates.isEmpty else { return } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:] 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 { // 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 } FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries) // Wake the widgets so the new road distances surface immediately. WidgetCenter.shared.reloadAllTimelines() } private static let refreshGate = RoadDistanceRefreshGate() /// 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 } }