// // 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() // 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..= 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 } } }