- Create EVChargingService using MapKit POI search for EV chargers - Add ItineraryBuilder.enrichWithEVChargers() for post-planning enrichment - Update TravelSection in TripDetailView with expandable charger list - Add FeatureFlags.enableEVCharging toggle (default: false) - Include EVChargingFeature.md documenting API overhead 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
184 lines
6.1 KiB
Swift
184 lines
6.1 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)
|
|
|
|
var address: String? = nil
|
|
if let placemark = item.placemark as MKPlacemark? {
|
|
var components: [String] = []
|
|
if let city = placemark.locality { components.append(city) }
|
|
if let state = placemark.administrativeArea { components.append(state) }
|
|
address = components.isEmpty ? nil : components.joined(separator: ", ")
|
|
}
|
|
|
|
return EVChargingStop(
|
|
name: name,
|
|
location: LocationInput(
|
|
name: name,
|
|
coordinate: item.placemark.coordinate,
|
|
address: address
|
|
),
|
|
chargerType: chargerType,
|
|
estimatedChargeTime: estimatedChargeTime
|
|
)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|