Add EV charging discovery feature (disabled by flag)
- 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>
This commit is contained in:
142
SportsTime/Core/EVChargingFeature.md
Normal file
142
SportsTime/Core/EVChargingFeature.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# EV Charging Feature
|
||||
|
||||
## Overview
|
||||
|
||||
When `FeatureFlags.enableEVCharging = true`, the app discovers EV charging stations along planned trip routes using Apple's MapKit APIs.
|
||||
|
||||
## Feature Flag
|
||||
|
||||
```swift
|
||||
// SportsTime/Core/FeatureFlags.swift
|
||||
enum FeatureFlags {
|
||||
static let enableEVCharging = false // Set to true to enable
|
||||
}
|
||||
```
|
||||
|
||||
## API Calls
|
||||
|
||||
### 1. MKDirections (Route Calculation)
|
||||
|
||||
**Purpose:** Get actual driving route polylines between stops
|
||||
|
||||
**When called:** After trip planning completes, for each travel segment
|
||||
|
||||
**Call pattern:**
|
||||
- 1 call per travel segment (city-to-city)
|
||||
- A 5-city trip = 4 MKDirections calls
|
||||
|
||||
**Response includes:**
|
||||
- Route polyline (used for EV charger search)
|
||||
- Actual driving distance (replaces Haversine estimate)
|
||||
- Expected travel time
|
||||
|
||||
### 2. MKLocalPointsOfInterestRequest (EV Charger Search)
|
||||
|
||||
**Purpose:** Find EV charging stations near sample points along the route
|
||||
|
||||
**When called:** For each sample point along the route polyline
|
||||
|
||||
**Call pattern:**
|
||||
- Routes are sampled every 100 miles
|
||||
- Each sample point triggers 1 POI search
|
||||
- 5-mile search radius around each point
|
||||
|
||||
**Example:**
|
||||
| Route Distance | Sample Points | POI Searches |
|
||||
|----------------|---------------|--------------|
|
||||
| 100 miles | 2 (start+end) | 2 calls |
|
||||
| 300 miles | 4 | 4 calls |
|
||||
| 500 miles | 6 | 6 calls |
|
||||
|
||||
## Total API Overhead
|
||||
|
||||
### Per Travel Segment
|
||||
```
|
||||
1 MKDirections call + (distance_miles / 100) MKLocalSearch calls
|
||||
```
|
||||
|
||||
### Example: Boston → New York → Philadelphia → Washington DC
|
||||
|
||||
| Segment | Distance | MKDirections | POI Searches | Total |
|
||||
|---------|----------|--------------|--------------|-------|
|
||||
| BOS→NYC | 215 mi | 1 | 3 | 4 |
|
||||
| NYC→PHL | 95 mi | 1 | 2 | 3 |
|
||||
| PHL→DC | 140 mi | 1 | 2 | 3 |
|
||||
| **Total** | | **3** | **7** | **10 calls** |
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Latency Added to Trip Planning
|
||||
|
||||
| Component | Time per Call | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| MKDirections | 200-500ms | Network round-trip |
|
||||
| MKLocalSearch (POI) | 100-300ms | Per sample point |
|
||||
|
||||
**Typical overhead:** 2-5 seconds added to trip planning
|
||||
|
||||
### Parallel Processing
|
||||
|
||||
- All travel segments are enriched in parallel (using Swift TaskGroup)
|
||||
- POI searches within a segment are sequential (to avoid rate limiting)
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Apple MapKit Limits
|
||||
|
||||
- MapKit has daily quotas based on your Apple Developer account
|
||||
- Free tier: ~25,000 service requests/day
|
||||
- Paid tier: Higher limits available
|
||||
|
||||
### Our Mitigations
|
||||
|
||||
1. **Feature is opt-in** - Only runs when user enables "EV Charging Needed"
|
||||
2. **Reasonable sampling** - 100-mile intervals, not every mile
|
||||
3. **Deduplication** - Chargers appearing in multiple searches are deduplicated
|
||||
4. **Fail gracefully** - If POI search fails, trip planning continues without chargers
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User enables "EV Charging Needed" toggle
|
||||
↓
|
||||
Trip planning runs (sync, Haversine estimates)
|
||||
↓
|
||||
ItineraryBuilder.enrichWithEVChargers() called
|
||||
↓
|
||||
For each travel segment (in parallel):
|
||||
├── MKDirections.calculate() → Get polyline
|
||||
└── EVChargingService.findChargersAlongRoute()
|
||||
├── Sample polyline every 100 miles
|
||||
├── MKLocalPointsOfInterestRequest per sample
|
||||
├── Filter to .evCharger category
|
||||
└── Deduplicate results
|
||||
↓
|
||||
TravelSegment.evChargingStops populated
|
||||
↓
|
||||
TripDetailView shows expandable charger list
|
||||
```
|
||||
|
||||
## Files Involved
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Core/FeatureFlags.swift` | Feature toggle |
|
||||
| `Core/Services/EVChargingService.swift` | POI search logic |
|
||||
| `Planning/Engine/ItineraryBuilder.swift` | Enrichment orchestration |
|
||||
| `Features/Trip/ViewModels/TripCreationViewModel.swift` | Triggers enrichment |
|
||||
| `Features/Trip/Views/TripDetailView.swift` | Displays chargers |
|
||||
| `Core/Models/Domain/TravelSegment.swift` | EVChargingStop model |
|
||||
|
||||
## Testing Notes
|
||||
|
||||
1. **Simulator limitations** - MapKit POI results may be limited in simulator
|
||||
2. **Test on device** - Real EV charger data requires physical device
|
||||
3. **Monitor console** - Look for `[ItineraryBuilder]` log messages showing charger counts
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- Cache EV charger results to reduce API calls on re-planning
|
||||
- Allow configurable sample interval (50mi for shorter range EVs)
|
||||
- Show chargers on the map view
|
||||
- Filter by charger network (Tesla, Electrify America, etc.)
|
||||
14
SportsTime/Core/FeatureFlags.swift
Normal file
14
SportsTime/Core/FeatureFlags.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// FeatureFlags.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Feature toggles for enabling/disabling features during development.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FeatureFlags {
|
||||
/// Enable EV charging station discovery along routes.
|
||||
/// When enabled, trip planning will search for EV chargers using MapKit.
|
||||
static let enableEVCharging = false
|
||||
}
|
||||
183
SportsTime/Core/Services/EVChargingService.swift
Normal file
183
SportsTime/Core/Services/EVChargingService.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user