// 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. Raised from 12 so stations past the old nearest-12 cutoff /// still get real road distances instead of a straight-line fallback. static let candidatesPerPass = 40 /// 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. static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async { guard !stations.isEmpty else { return } // Throttle: keep cached values when fresh and the user hasn't moved far. if let cache = FuelStore.loadRoadDistances() { let elapsed = Date().timeIntervalSince1970 - cache.updatedAt let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng) if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters { return } } // Candidate stations: the nearest-by-straight-line subset that the UI // could actually display, capped so a pass stays bounded. let nearest = stations .sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } .prefix(candidatesPerPass) .filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) var entries: [String: CachedRoadDistance] = [:] for station in nearest { let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) if let meters = await roadMeters(from: origin, to: dest) { // 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). entries[station.id] = CachedRoadDistance(meters: meters, lat: station.lat, lng: station.lng) } } guard !entries.isEmpty else { return } FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries) // Wake the widgets so the new road distances surface immediately. WidgetCenter.shared.reloadAllTimelines() } /// 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(location: CLLocation(latitude: from.latitude, longitude: from.longitude), address: nil) request.destination = MKMapItem(location: CLLocation(latitude: to.latitude, longitude: to.longitude), address: nil) 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)) } }