diff --git a/api/src/handlers/prefetch.ts b/api/src/handlers/prefetch.ts --- a/api/src/handlers/prefetch.ts +++ b/api/src/handlers/prefetch.ts @@ -9,7 +9,7 @@ totalRouteDistanceKm, } from "../geo.ts"; const PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours -const MAX_POINTS = 500; +const MAX_POINTS = 1000; const MAX_DISTANCE_KM = 200; const MAX_SAMPLES = 25; const DEFAULT_INTERVAL_KM = 8; diff --git a/gastrack/ContentView.swift b/gastrack/ContentView.swift --- a/gastrack/ContentView.swift +++ b/gastrack/ContentView.swift @@ -16,6 +16,9 @@ MapStationsView() .tabItem { Label("Map", systemImage: "map") } + PrefetchView() + .tabItem { Label("Route", systemImage: "road.lanes") } + SettingsView() .tabItem { Label("Settings", systemImage: "gear") } } diff --git a/gastrack/Services/StationStore.swift b/gastrack/Services/StationStore.swift --- a/gastrack/Services/StationStore.swift +++ b/gastrack/Services/StationStore.swift @@ -9,7 +9,9 @@ @Published private(set) var byId: [String: Station] = [:] func merge(_ stations: [Station]) { - for s in stations { byId[s.id] = s } + for s in stations where s.prices.contains(where: { $0.formattedPrice != nil }) { + byId[s.id] = s + } } // Filtered + sorted by distance from a coordinate. diff --git a/gastrack/Views/MapStationsView.swift b/gastrack/Views/MapStationsView.swift --- a/gastrack/Views/MapStationsView.swift +++ b/gastrack/Views/MapStationsView.swift @@ -91,27 +91,28 @@ } } } - // Thin markers when zoomed out: one best-price station per grid cell. + // Only thin when markers would actually crowd. Greedy by price so the + // same stations are kept regardless of viewport position. private var displayedStations: [Station] { - guard let region = visibleRegion else { return stations } - let span = max(region.span.latitudeDelta, region.span.longitudeDelta) - guard span > 0.05 else { return stations } + guard stations.count > 15, let region = visibleRegion else { return stations } - let cellSize = span / 6.0 - var best: [String: Station] = [:] - for station in stations { - let col = Int(floor((station.lng - (region.center.longitude - region.span.longitudeDelta / 2)) / cellSize)) - let row = Int(floor((station.lat - (region.center.latitude - region.span.latitudeDelta / 2)) / cellSize)) - let key = "\(row):\(col)" - if let existing = best[key] { - let ep = existing.regularPrice?.numericPrice - let np = station.regularPrice?.numericPrice - if let n = np, ep == nil || n < ep! { best[key] = station } - } else { - best[key] = station + // Minimum separation scales with zoom — roughly one marker-width apart. + let minSep = min(region.span.latitudeDelta, region.span.longitudeDelta) / 8.0 + + // Cheapest first; stations with no price go last. + let sorted = stations.sorted { + ($0.regularPrice?.numericPrice ?? .infinity) < + ($1.regularPrice?.numericPrice ?? .infinity) + } + + var kept: [Station] = [] + for station in sorted { + let crowded = kept.contains { + abs(station.lat - $0.lat) < minSep && abs(station.lng - $0.lng) < minSep } + if !crowded { kept.append(station) } } - return Array(best.values) + return kept } private func markerTint(for station: Station) -> Color { diff --git a/gastrack/Views/PrefetchView.swift b/gastrack/Views/PrefetchView.swift new file mode 100644 --- /dev/null +++ b/gastrack/Views/PrefetchView.swift @@ -0,0 +1,286 @@ +import Combine +import SwiftUI +import MapKit +import CoreLocation + +// MARK: - Completer + +private final class SearchCompleter: NSObject, ObservableObject, MKLocalSearchCompleterDelegate { + @Published var results: [MKLocalSearchCompletion] = [] + private let inner = MKLocalSearchCompleter() + + override init() { + super.init() + inner.delegate = self + inner.resultTypes = [.address, .pointOfInterest] + } + + func query(_ text: String, near region: MKCoordinateRegion?) { + if let r = region { inner.region = r } + inner.queryFragment = text + } + + func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + results = Array(completer.results.prefix(6)) + } + + func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { + results = [] + } +} + +// MARK: - View + +struct PrefetchView: View { + @EnvironmentObject private var api: APIClient + @EnvironmentObject private var store: StationStore + @StateObject private var location = LocationManager.shared + @StateObject private var completer = SearchCompleter() + + enum ActiveField { case from, to } + + @FocusState private var focused: ActiveField? + @State private var fromText = "" + @State private var toText = "" + @State private var fromItem: MKMapItem? // nil = current location + @State private var toItem: MKMapItem? + @State private var route: MKRoute? + @State private var routePosition: MapCameraPosition = .automatic + @State private var isPrefetching = false + @State private var prefetchResult: (stations: Int, samples: Int)? + @State private var errorMsg: String? + + var body: some View { + NavigationStack { + List { + // ── Route inputs ── + Section { + locationRow( + systemImage: "circle.fill", + tint: .blue, + text: $fromText, + placeholder: "Current location", + field: .from + ) + locationRow( + systemImage: "mappin.circle.fill", + tint: .red, + text: $toText, + placeholder: "Destination", + field: .to + ) + } + + // ── Autocomplete suggestions ── + if focused != nil && !completer.results.isEmpty { + Section { + ForEach(completer.results, id: \.self) { c in + Button { pick(c) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(c.title).foregroundStyle(.primary) + if !c.subtitle.isEmpty { + Text(c.subtitle).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + } + + // ── Route preview ── + if let route { + Section { + Map(position: $routePosition) { + MapPolyline(route.polyline) + .stroke(.blue, lineWidth: 3) + } + .frame(height: 180) + .listRowInsets(.init()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + HStack(spacing: 24) { + Label(String(format: "%.0f km", route.distance / 1000), systemImage: "arrow.left.and.right") + Label(formatDuration(route.expectedTravelTime), systemImage: "clock") + } + .font(.callout) + .foregroundStyle(.secondary) + } + } + + // ── Prefetch button ── + Section { + Button { + Task { await prefetch() } + } label: { + HStack { + Spacer() + if isPrefetching { + ProgressView().tint(.white) + Text("Caching stations…").foregroundStyle(.white) + } else { + Label("Prefetch Route", systemImage: "arrow.down.circle.fill") + .foregroundStyle(.white) + } + Spacer() + } + .padding(.vertical, 4) + } + .listRowBackground(toItem != nil && !isPrefetching ? Color.accentColor : Color.secondary) + .disabled(toItem == nil || isPrefetching) + } + + // ── Error / result ── + if let errorMsg { + Section { + Label(errorMsg, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + + if let r = prefetchResult { + Section("Last Prefetch") { + LabeledContent("Stations cached", value: "\(r.stations)") + LabeledContent("New cells fetched", value: "\(r.samples)") + } + } + } + .navigationTitle("Prefetch Route") + .navigationBarTitleDisplayMode(.inline) + } + } + + // MARK: - Row + + @ViewBuilder + private func locationRow( + systemImage: String, + tint: Color, + text: Binding, + placeholder: String, + field: ActiveField + ) -> some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: 24) + + TextField(placeholder, text: text) + .focused($focused, equals: field) + .autocorrectionDisabled() + .textInputAutocapitalization(.words) + .submitLabel(field == .from ? .next : .search) + .onChange(of: text.wrappedValue) { _, new in + if focused == field { + completer.query(new, near: location.location.map { + MKCoordinateRegion(center: $0.coordinate, latitudinalMeters: 500_000, longitudinalMeters: 500_000) + }) + } + if new.isEmpty { + if field == .from { fromItem = nil } + if field == .to { toItem = nil; route = nil } + } + } + .onSubmit { if field == .from { focused = .to } } + + if !text.wrappedValue.isEmpty { + Button { + text.wrappedValue = "" + if field == .from { fromItem = nil } + if field == .to { toItem = nil; route = nil } + completer.results = [] + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Actions + + private func pick(_ completion: MKLocalSearchCompletion) { + let field = focused + focused = nil + completer.results = [] + Task { + let req = MKLocalSearch.Request(completion: completion) + guard let item = try? await MKLocalSearch(request: req).start().mapItems.first else { return } + if field == .from { + fromText = completion.title + fromItem = item + } else { + toText = completion.title + toItem = item + } + await fetchRoute() + } + } + + private func fetchRoute() async { + guard let dest = toItem else { return } + let src = fromItem ?? .forCurrentLocation() + let req = MKDirections.Request() + req.source = src + req.destination = dest + req.transportType = .automobile + guard let r = try? await MKDirections(request: req).calculate().routes.first else { return } + route = r + let rect = r.polyline.boundingMapRect + routePosition = .rect(rect.insetBy(dx: -rect.size.width * 0.15, dy: -rect.size.height * 0.15)) + } + + private func prefetch() async { + guard let dest = toItem else { return } + errorMsg = nil + isPrefetching = true + + let currentRoute: MKRoute + if let r = route { + currentRoute = r + } else { + let req = MKDirections.Request() + req.source = fromItem ?? .forCurrentLocation() + req.destination = dest + req.transportType = .automobile + guard let r = try? await MKDirections(request: req).calculate().routes.first else { + errorMsg = "Could not calculate route" + isPrefetching = false + return + } + currentRoute = r + route = r + } + + let points = extractPoints(from: currentRoute.polyline, maxPoints: 400) + do { + let response = try await api.prefetchRoute(points: points) + store.merge(response.stations) + prefetchResult = (stations: response.count, samples: response.samples) + } catch { + errorMsg = error.localizedDescription + } + isPrefetching = false + } + + // MARK: - Helpers + + private func extractPoints(from polyline: MKPolyline, maxPoints: Int) -> [[Double]] { + let count = polyline.pointCount + let step = Swift.max(1, Int(ceil(Double(count) / Double(maxPoints)))) + var coords = [CLLocationCoordinate2D](repeating: .init(), count: count) + polyline.getCoordinates(&coords, range: NSRange(location: 0, length: count)) + var result: [[Double]] = [] + var i = 0 + while i < count { + result.append([coords[i].latitude, coords[i].longitude]) + i += step + } + return result + } + + private func formatDuration(_ seconds: TimeInterval) -> String { + let h = Int(seconds) / 3600 + let m = (Int(seconds) % 3600) / 60 + return h > 0 ? "\(h)h \(m)m" : "\(m) min" + } +} diff --git a/gastrack/gastrack.xcodeproj/project.pbxproj b/gastrack/gastrack.xcodeproj/project.pbxproj --- a/gastrack/gastrack.xcodeproj/project.pbxproj +++ b/gastrack/gastrack.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 0B5E3B582F75B002005F7F70 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B472F75B002005F7F70 /* KeychainService.swift */; }; 0B5E3B5C2F75B3E9005F7F70 /* EIAService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5B2F75B3E9005F7F70 /* EIAService.swift */; }; 0B5E3B5E2F75C0B6005F7F70 /* gas.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */; }; 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */; }; + 0B5E3B622F75C40F005F7F70 /* PrefetchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -41,6 +42,7 @@ 0B5E3B4E2F75B002005F7F70 /* StationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationRow.swift; sourceTree = ""; }; 0B5E3B5B2F75B3E9005F7F70 /* EIAService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EIAService.swift; sourceTree = ""; }; 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = gas.icon; sourceTree = ""; }; 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationStore.swift; sourceTree = ""; }; + 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -91,6 +93,7 @@ }; 0B5E3B4F2F75B002005F7F70 /* Views */ = { isa = PBXGroup; children = ( + 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */, 0B5E3B4A2F75B002005F7F70 /* MapStationsView.swift */, 0B5E3B4B2F75B002005F7F70 /* NearbyView.swift */, 0B5E3B4C2F75B002005F7F70 /* SettingsView.swift */, @@ -182,6 +185,7 @@ 0B5E3B532F75B002005F7F70 /* StationRow.swift in Sources */, 0B5E3B542F75B002005F7F70 /* StationDetailView.swift in Sources */, 0B5E3B552F75B002005F7F70 /* Station.swift in Sources */, 0B5E3B562F75B002005F7F70 /* NearbyView.swift in Sources */, + 0B5E3B622F75C40F005F7F70 /* PrefetchView.swift in Sources */, 0B5E3B572F75B002005F7F70 /* MapStationsView.swift in Sources */, 0B5E3B582F75B002005F7F70 /* KeychainService.swift in Sources */, 0B5E3B392F75ACD9005F7F70 /* gastrackApp.swift in Sources */,