Stations past the nearest-12 cutoff silently fell back to straight-line, which explained the remaining app-vs-Apple-Maps gaps (e.g. 3.5 straight-line vs 5.9 road). Raise the per-pass cap to 40 and route every station within a 25 km straight-line radius (covers the 15 mi max search window) so any station the widget/Live Activity/list can show gets a real road distance. Also harden the new cache tests against persistent keychain state across invocations (reset in setUp) so they pass deterministically.
94 lines
4.5 KiB
Swift
94 lines
4.5 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: Double] = [:]
|
|
for station in nearest {
|
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
|
if let meters = await roadMeters(from: origin, to: dest) {
|
|
entries[station.id] = meters
|
|
}
|
|
}
|
|
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(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))
|
|
}
|
|
}
|