103 lines
5.2 KiB
Swift
103 lines
5.2 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. 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))
|
|
}
|
|
}
|