Files
Sportstime/SportsTime/Core/Services/EVChargingService.swift

193 lines
6.3 KiB
Swift

//
// EVChargingService.swift
// SportsTime
//
import Foundation
import CoreLocation
import MapKit
actor EVChargingService {
static let shared = EVChargingService()
private init() {}
// MARK: - Public API
/// Find EV chargers along a route polyline
/// - Parameters:
/// - polyline: The route polyline from MKDirections
/// - searchRadiusMiles: Radius to search around each sample point (default 5 miles)
/// - intervalMiles: Distance between sample points along route (default 100 miles)
/// - Returns: Array of EV charging stops along the route, deduplicated and sorted by distance from start
func findChargersAlongRoute(
polyline: MKPolyline,
searchRadiusMiles: Double = 5.0,
intervalMiles: Double = 100.0
) async throws -> [EVChargingStop] {
let samplePoints = samplePoints(from: polyline, intervalMiles: intervalMiles)
guard !samplePoints.isEmpty else { return [] }
var allChargers: [EVChargingStop] = []
var seenIds = Set<String>() // For deduplication by name+coordinate
for point in samplePoints {
do {
let mapItems = try await searchChargers(near: point, radiusMiles: searchRadiusMiles)
for item in mapItems {
let charger = mapItemToChargingStop(item)
let uniqueKey = "\(charger.name)-\(charger.location.coordinate?.latitude ?? 0)"
if !seenIds.contains(uniqueKey) {
seenIds.insert(uniqueKey)
allChargers.append(charger)
}
}
} catch {
// Continue with other points if one search fails
continue
}
}
return allChargers
}
// MARK: - Private Helpers
/// Sample points along a polyline at given intervals
private func samplePoints(
from polyline: MKPolyline,
intervalMiles: Double
) -> [CLLocationCoordinate2D] {
let pointCount = polyline.pointCount
guard pointCount > 1 else { return [] }
let points = polyline.points()
var samples: [CLLocationCoordinate2D] = []
var accumulatedDistance: Double = 0
let intervalMeters = intervalMiles * 1609.34 // Convert miles to meters
// Always include start point
samples.append(points[0].coordinate)
for i in 1..<pointCount {
let prev = CLLocation(
latitude: points[i - 1].coordinate.latitude,
longitude: points[i - 1].coordinate.longitude
)
let current = CLLocation(
latitude: points[i].coordinate.latitude,
longitude: points[i].coordinate.longitude
)
accumulatedDistance += current.distance(from: prev)
if accumulatedDistance >= intervalMeters {
samples.append(points[i].coordinate)
accumulatedDistance = 0
}
}
// Always include end point if different from last sample
let lastPoint = points[pointCount - 1].coordinate
if let lastSample = samples.last {
let distance = CLLocation(latitude: lastPoint.latitude, longitude: lastPoint.longitude)
.distance(from: CLLocation(latitude: lastSample.latitude, longitude: lastSample.longitude))
if distance > 1000 { // More than 1km away
samples.append(lastPoint)
}
}
return samples
}
/// Search for EV chargers near a coordinate
private func searchChargers(
near coordinate: CLLocationCoordinate2D,
radiusMiles: Double
) async throws -> [MKMapItem] {
let radiusMeters = radiusMiles * 1609.34
let request = MKLocalPointsOfInterestRequest(
center: coordinate,
radius: radiusMeters
)
request.pointOfInterestFilter = MKPointOfInterestFilter(including: [.evCharger])
let search = MKLocalSearch(request: request)
let response = try await search.start()
return response.mapItems
}
/// Convert an MKMapItem to an EVChargingStop
private func mapItemToChargingStop(_ item: MKMapItem) -> EVChargingStop {
let name = item.name ?? "EV Charger"
let chargerType = detectChargerType(from: name)
let estimatedChargeTime = estimateChargeTime(for: chargerType)
let address = formattedAddress(for: item)
let coordinate = coordinate(for: item)
return EVChargingStop(
name: name,
location: LocationInput(
name: name,
coordinate: coordinate,
address: address
),
chargerType: chargerType,
estimatedChargeTime: estimatedChargeTime
)
}
private func formattedAddress(for item: MKMapItem) -> String? {
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
return cityContext
}
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
return shortAddress
}
return item.address?.fullAddress
}
private func coordinate(for item: MKMapItem) -> CLLocationCoordinate2D {
item.location.coordinate
}
/// Detect charger type from name using heuristics
private func detectChargerType(from name: String) -> ChargerType {
let lowercased = name.lowercased()
if lowercased.contains("tesla") || lowercased.contains("supercharger") {
return .supercharger
}
if lowercased.contains("dc fast") ||
lowercased.contains("dcfc") ||
lowercased.contains("ccs") ||
lowercased.contains("chademo") ||
lowercased.contains("electrify america") ||
lowercased.contains("evgo") {
return .dcFast
}
// Default to Level 2 for ChargePoint, Blink, and others
return .level2
}
/// Estimate charge time based on charger type
private func estimateChargeTime(for chargerType: ChargerType) -> TimeInterval {
switch chargerType {
case .supercharger:
return 25 * 60 // 25 minutes
case .dcFast:
return 30 * 60 // 30 minutes
case .level2:
return 120 * 60 // 2 hours
}
}
}