diff --git a/README.md b/README.md index a208727..40296e9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A Tesla Model Y style driving dashboard for iPhone: a big speedometer over a liv Apple Map that follows your heading, fed by **your** car's data through the **Tesla Fleet API** with realtime updates over WebSocket. +![Voltpit dashboard: speedometer, gear selector and estimated range over a live map of Kungsholmen, Stockholm, with battery and power cards](docs/images/dashboard.png) + ```mermaid flowchart LR car["Tesla vehicle
(your Model Y)"] diff --git a/backend/src/sources/SimulatorSource.ts b/backend/src/sources/SimulatorSource.ts index 7a7c6df..5fffe15 100644 --- a/backend/src/sources/SimulatorSource.ts +++ b/backend/src/sources/SimulatorSource.ts @@ -23,17 +23,74 @@ export class SimulatorSource implements VehicleSource { private targetMph = 35; private battery = 82; - // A loop around a few blocks (San Francisco). Heading is derived from motion. + // A real driving loop through Kungsholmen, Stockholm, road-snapped via the + // OSRM routing engine so every waypoint sits exactly on the street network. + // Heading is derived from motion; the dense point spacing keeps the + // straight-line interpolation hugging the actual roads instead of cutting + // across blocks. private readonly route: Waypoint[] = [ - { lat: 37.7766, lng: -122.4172 }, - { lat: 37.7793, lng: -122.4131 }, - { lat: 37.7821, lng: -122.4089 }, - { lat: 37.7848, lng: -122.4048 }, - { lat: 37.7861, lng: -122.4006 }, - { lat: 37.784, lng: -122.3964 }, - { lat: 37.7807, lng: -122.3983 }, - { lat: 37.7779, lng: -122.4039 }, - { lat: 37.7758, lng: -122.4101 }, + { lat: 59.332886, lng: 18.029528 }, + { lat: 59.332911, lng: 18.029421 }, + { lat: 59.333366, lng: 18.029718 }, + { lat: 59.333816, lng: 18.027946 }, + { lat: 59.333857, lng: 18.027621 }, + { lat: 59.333941, lng: 18.027829 }, + { lat: 59.334627, lng: 18.028234 }, + { lat: 59.334674, lng: 18.030906 }, + { lat: 59.334501, lng: 18.032073 }, + { lat: 59.33448, lng: 18.032249 }, + { lat: 59.334438, lng: 18.032571 }, + { lat: 59.33425, lng: 18.033999 }, + { lat: 59.334222, lng: 18.034217 }, + { lat: 59.334167, lng: 18.034649 }, + { lat: 59.334045, lng: 18.035576 }, + { lat: 59.334178, lng: 18.035646 }, + { lat: 59.335868, lng: 18.036487 }, + { lat: 59.33641, lng: 18.036753 }, + { lat: 59.336095, lng: 18.038496 }, + { lat: 59.335871, lng: 18.039536 }, + { lat: 59.336095, lng: 18.038496 }, + { lat: 59.33641, lng: 18.036753 }, + { lat: 59.335868, lng: 18.036487 }, + { lat: 59.334178, lng: 18.035646 }, + { lat: 59.334045, lng: 18.035576 }, + { lat: 59.33374, lng: 18.037922 }, + { lat: 59.333701, lng: 18.038228 }, + { lat: 59.333546, lng: 18.039415 }, + { lat: 59.333327, lng: 18.041095 }, + { lat: 59.333031, lng: 18.043382 }, + { lat: 59.332847, lng: 18.044821 }, + { lat: 59.332791, lng: 18.045256 }, + { lat: 59.332615, lng: 18.045202 }, + { lat: 59.331531, lng: 18.044551 }, + { lat: 59.3309, lng: 18.044183 }, + { lat: 59.330418, lng: 18.043943 }, + { lat: 59.329984, lng: 18.043713 }, + { lat: 59.329803, lng: 18.043617 }, + { lat: 59.329075, lng: 18.043218 }, + { lat: 59.329018, lng: 18.043057 }, + { lat: 59.329062, lng: 18.042725 }, + { lat: 59.329235, lng: 18.041428 }, + { lat: 59.32937, lng: 18.040408 }, + { lat: 59.329499, lng: 18.039452 }, + { lat: 59.329685, lng: 18.03808 }, + { lat: 59.329766, lng: 18.037499 }, + { lat: 59.32994, lng: 18.036324 }, + { lat: 59.330307, lng: 18.034694 }, + { lat: 59.330678, lng: 18.032906 }, + { lat: 59.330754, lng: 18.032501 }, + { lat: 59.331082, lng: 18.031082 }, + { lat: 59.331159, lng: 18.030781 }, + { lat: 59.331749, lng: 18.031086 }, + { lat: 59.331926, lng: 18.031186 }, + { lat: 59.332074, lng: 18.031272 }, + { lat: 59.332224, lng: 18.031211 }, + { lat: 59.332169, lng: 18.030796 }, + { lat: 59.332108, lng: 18.028968 }, + { lat: 59.332107, lng: 18.028808 }, + { lat: 59.332236, lng: 18.02867 }, + { lat: 59.332911, lng: 18.029421 }, + { lat: 59.332886, lng: 18.029528 }, ]; constructor( diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000..0391aa1 Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/ios/TeslaDash/Views/DashboardView.swift b/ios/TeslaDash/Views/DashboardView.swift index adc9a12..f6f07db 100644 --- a/ios/TeslaDash/Views/DashboardView.swift +++ b/ios/TeslaDash/Views/DashboardView.swift @@ -209,17 +209,21 @@ struct DashboardView: View { } } - /// Range-style readout at the foot of the pod (battery %, the only - /// energy figure the Fleet API gives us). + /// Estimated-range readout at the foot of the pod. Battery % already lives + /// in the right-hand card, so here we surface the derived driving range + /// instead of repeating the same number. private var rangeReadout: some View { HStack(spacing: 8) { - Image(systemName: "car.fill") + Image(systemName: "road.lanes") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Color(red: 0.45, green: 0.85, blue: 1.0)) - Text(batteryText) + Text(rangeText) .font(.system(size: 20, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + Text("est. range") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.45)) } } @@ -288,6 +292,18 @@ struct DashboardView: View { (stream.state?.batteryLevel ?? 100) <= 15 ? .red : Color(red: 0.45, green: 0.85, blue: 1.0) } + /// Estimated driving range derived from battery level. The Fleet API only + /// gives us a charge percentage, so we scale it against a nominal full + /// range (Model Y Long Range: ~330 mi / 530 km) and render it in the + /// configured unit. + private var rangeText: String { + guard let level = stream.state?.batteryLevel else { return "—" } + let metric = stream.state?.usesMetric ?? false + let fullRange = metric ? 530.0 : 330.0 + let estimate = Int((Double(level) / 100.0 * fullRange).rounded()) + return "\(estimate) \(metric ? "km" : "mi")" + } + private var powerTitle: String { (stream.state?.power ?? 0) < 0 ? "Regen" : "Power" } private var powerText: String { diff --git a/ios/TeslaDash/Views/MapView.swift b/ios/TeslaDash/Views/MapView.swift index d2c0bb4..0dabb29 100644 --- a/ios/TeslaDash/Views/MapView.swift +++ b/ios/TeslaDash/Views/MapView.swift @@ -9,7 +9,7 @@ struct MapView: UIViewRepresentable { var heading: Double? var moving: Bool - private static let defaultCenter = CLLocationCoordinate2D(latitude: 37.7766, longitude: -122.4172) + private static let defaultCenter = CLLocationCoordinate2D(latitude: 59.332886, longitude: 18.029528) // Tight, steep camera so buildings rise up for a street-level 3D feel. private static let cameraDistance: CLLocationDistance = 320 private static let pitch: CGFloat = 60 @@ -55,11 +55,6 @@ struct MapView: UIViewRepresentable { func updateUIView(_ map: MKMapView, context: Context) { guard let coordinate else { return } - // Move the puck to the new position. - if let marker = context.coordinator.marker { - marker.coordinate = coordinate - } - let bearing = moving ? (heading ?? map.camera.heading) : map.camera.heading // Follow the car but keep whatever zoom (camera distance) the driver @@ -70,11 +65,37 @@ struct MapView: UIViewRepresentable { pitch: map.camera.pitch, heading: bearing ) - map.setCamera(camera, animated: true) + + // The backend streams one discrete position per update, but the + // cadence varies by data source: the simulator pushes every ~250 ms, + // the Tesla Fleet API poll lands every ~2.5 s, and Fleet Telemetry is + // sub-second. A fixed glide tuned to one source would hop-and-freeze on + // the others, so the animation duration tracks the measured gap since + // the previous update and spreads the move evenly across it. That turns + // every source into a continuous glide instead of a jump. Clamped so a + // first fix or a long offline gap cannot produce an absurd crawl. + let now = Date() + let gap = context.coordinator.lastUpdate.map { now.timeIntervalSince($0) } ?? 0.3 + context.coordinator.lastUpdate = now + let duration = min(max(gap, 0.15), 2.5) + + // .beginFromCurrentState lets each new update smoothly redirect the + // in-flight motion rather than stopping and restarting it. + UIView.animate( + withDuration: duration, + delay: 0, + options: [.curveLinear, .allowUserInteraction, .beginFromCurrentState] + ) { + context.coordinator.marker?.coordinate = coordinate + map.setCamera(camera, animated: false) + } } final class Coordinator: NSObject, MKMapViewDelegate { var marker: CarAnnotation? + // Timestamp of the previous position update, used to size each glide to + // the real interval between updates regardless of data source. + var lastUpdate: Date? func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { guard annotation is CarAnnotation else { return nil }