// // MapSelectionView.swift // StikJIT // // Created by Stephen on 11/3/25. // import SwiftUI import MapKit import UIKit import UniformTypeIdentifiers private struct CoordinateSnapshot: Equatable { let latitude: Double let longitude: Double init(_ coordinate: CLLocationCoordinate2D) { latitude = coordinate.latitude longitude = coordinate.longitude } var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } } private struct RouteSearchSelection { let title: String let coordinate: CLLocationCoordinate2D } private enum RouteSearchField { case start case end } private struct RouteSimulationPlan { let displayCoordinates: [CLLocationCoordinate2D] let distance: CLLocationDistance let expectedTravelTime: TimeInterval } private enum RouteSimulationDefaults { static let pathSamplingDistance: CLLocationDistance = 10 static let playbackTickInterval: TimeInterval = 0.5 static let minimumSpeedMetersPerSecond: CLLocationSpeed = 1.0 static let importedRouteFallbackSpeedMetersPerSecond: CLLocationSpeed = 13.4 } private struct RoutePlaybackSample { let coordinate: CLLocationCoordinate2D let delayFromPrevious: TimeInterval } private struct OpenStreetMapWay { let geometry: [CLLocationCoordinate2D] let speedLimitMetersPerSecond: CLLocationSpeed } private enum OpenStreetMapSpeedLimitService { static let endpoint = URL(string: "https://overpass-api.de/api/interpreter")! static let copyrightURL = URL(string: "https://www.openstreetmap.org/copyright")! static let boundingBoxPaddingDegrees = 0.0015 static let nearestWayThreshold: CLLocationDistance = 40 } private struct OverpassResponse: Decodable { let elements: [Element] struct Element: Decodable { let tags: [String: String]? let geometry: [Coordinate]? } struct Coordinate: Decodable { let lat: Double let lon: Double } } private extension MKPolyline { var coordinateArray: [CLLocationCoordinate2D] { var coordinates = [CLLocationCoordinate2D]( repeating: CLLocationCoordinate2D(latitude: 0, longitude: 0), count: pointCount ) getCoordinates(&coordinates, range: NSRange(location: 0, length: pointCount)) return coordinates } } private func interpolateCoordinate( from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D, fraction: Double ) -> CLLocationCoordinate2D { CLLocationCoordinate2D( latitude: start.latitude + ((end.latitude - start.latitude) * fraction), longitude: start.longitude + ((end.longitude - start.longitude) * fraction) ) } private func sampledRouteCoordinates( from coordinates: [CLLocationCoordinate2D], targetDistance: CLLocationDistance ) -> [CLLocationCoordinate2D] { guard coordinates.count > 1 else { return coordinates } var sampled = [coordinates[0]] for (start, end) in zip(coordinates, coordinates.dropFirst()) { let distance = CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) let segmentCount = max(1, Int(ceil(distance / targetDistance))) for index in 1...segmentCount { let point = interpolateCoordinate( from: start, to: end, fraction: Double(index) / Double(segmentCount) ) if sampled.last.map(CoordinateSnapshot.init) != CoordinateSnapshot(point) { sampled.append(point) } } } return sampled } private func midpointCoordinate( from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D ) -> CLLocationCoordinate2D { interpolateCoordinate(from: start, to: end, fraction: 0.5) } private func distanceAlong(_ coordinates: [CLLocationCoordinate2D]) -> CLLocationDistance { zip(coordinates, coordinates.dropFirst()).reduce(0) { total, pair in total + CLLocation(latitude: pair.0.latitude, longitude: pair.0.longitude) .distance(from: CLLocation(latitude: pair.1.latitude, longitude: pair.1.longitude)) } } private func distanceFromPoint( _ point: MKMapPoint, toSegmentFrom start: MKMapPoint, to end: MKMapPoint ) -> CLLocationDistance { let dx = end.x - start.x let dy = end.y - start.y guard dx != 0 || dy != 0 else { return point.distance(to: start) } let projection = max(0, min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / ((dx * dx) + (dy * dy)))) let projectedPoint = MKMapPoint( x: start.x + (dx * projection), y: start.y + (dy * projection) ) return point.distance(to: projectedPoint) } private func parseSpeedLimitMetersPerSecond(from rawValue: String) -> CLLocationSpeed? { let normalized = rawValue .lowercased() .split(separator: ";") .first? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !normalized.isEmpty else { return nil } guard normalized != "none", normalized != "signals", normalized != "implicit", normalized != "walk" else { return nil } let scanner = Scanner(string: normalized) guard let numericValue = scanner.scanDouble() else { return nil } if normalized.contains("mph") { return numericValue * 0.44704 } if normalized.contains("knot") { return numericValue * 0.514444 } return numericValue / 3.6 } private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocationSpeed? { if let maxspeed = tags["maxspeed"], let parsed = parseSpeedLimitMetersPerSecond(from: maxspeed) { return parsed } let directionalValues = [ tags["maxspeed:forward"], tags["maxspeed:backward"] ] .compactMap { $0 } .compactMap(parseSpeedLimitMetersPerSecond(from:)) guard !directionalValues.isEmpty else { return nil } return directionalValues.min() } private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? { guard let first = coordinates.first else { return nil } var minLatitude = first.latitude var maxLatitude = first.latitude var minLongitude = first.longitude var maxLongitude = first.longitude for coordinate in coordinates.dropFirst() { minLatitude = min(minLatitude, coordinate.latitude) maxLatitude = max(maxLatitude, coordinate.latitude) minLongitude = min(minLongitude, coordinate.longitude) maxLongitude = max(maxLongitude, coordinate.longitude) } let padding = OpenStreetMapSpeedLimitService.boundingBoxPaddingDegrees let south = minLatitude - padding let west = minLongitude - padding let north = maxLatitude + padding let east = maxLongitude + padding let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) return """ [out:json][timeout:20]; ( way(\(bbox))[highway][maxspeed]; way(\(bbox))[highway]["maxspeed:forward"]; way(\(bbox))[highway]["maxspeed:backward"]; ); out tags geom; """ } private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) async throws -> [OpenStreetMapWay] { guard let query = overpassQuery(for: coordinates) else { return [] } var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) components?.queryItems = [URLQueryItem(name: "data", value: query)] guard let url = components?.url else { return [] } let (data, response) = try await URLSession.shared.data(from: url) if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { throw NSError( domain: "OpenStreetMapSpeedLimits", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Overpass returned HTTP \(httpResponse.statusCode)."] ) } let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) return decoded.elements.compactMap { element in guard let tags = element.tags, let speedLimit = speedLimitMetersPerSecond(from: tags), let geometry = element.geometry?.map({ CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }), geometry.count > 1 else { return nil } return OpenStreetMapWay( geometry: geometry, speedLimitMetersPerSecond: speedLimit ) } } private func nearestSpeedLimit( forSegmentFrom start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D, using ways: [OpenStreetMapWay] ) -> CLLocationSpeed? { let midpoint = MKMapPoint(midpointCoordinate(from: start, to: end)) var bestMatch: (speed: CLLocationSpeed, distance: CLLocationDistance)? for way in ways { for (wayStart, wayEnd) in zip(way.geometry, way.geometry.dropFirst()) { let candidateDistance = distanceFromPoint( midpoint, toSegmentFrom: MKMapPoint(wayStart), to: MKMapPoint(wayEnd) ) if bestMatch == nil || candidateDistance < bestMatch!.distance { bestMatch = (way.speedLimitMetersPerSecond, candidateDistance) } } } guard let bestMatch, bestMatch.distance <= OpenStreetMapSpeedLimitService.nearestWayThreshold else { return nil } return bestMatch.speed } private func buildPlaybackSamples( from displayCoordinates: [CLLocationCoordinate2D], speedWays: [OpenStreetMapWay], fallbackSpeedMetersPerSecond: CLLocationSpeed ) -> [RoutePlaybackSample] { guard let firstCoordinate = displayCoordinates.first else { return [] } var samples = [RoutePlaybackSample(coordinate: firstCoordinate, delayFromPrevious: 0)] for (start, end) in zip(displayCoordinates, displayCoordinates.dropFirst()) { let segmentDistance = CLLocation(latitude: start.latitude, longitude: start.longitude) .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) guard segmentDistance > 0 else { continue } let speedLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedWays) ?? fallbackSpeedMetersPerSecond let clampedSpeed = max(speedLimit, RouteSimulationDefaults.minimumSpeedMetersPerSecond) let segmentTravelTime = segmentDistance / clampedSpeed let segmentStepCount = max(1, Int(ceil(segmentTravelTime / RouteSimulationDefaults.playbackTickInterval))) let stepDelay = segmentTravelTime / Double(segmentStepCount) for index in 1...segmentStepCount { let coordinate = interpolateCoordinate( from: start, to: end, fraction: Double(index) / Double(segmentStepCount) ) if samples.last.map({ CoordinateSnapshot($0.coordinate) }) != CoordinateSnapshot(coordinate) { samples.append(RoutePlaybackSample(coordinate: coordinate, delayFromPrevious: stepDelay)) } } } return samples } private func prefetchRoutePlaybackSamples( displayCoordinates: [CLLocationCoordinate2D], fallbackSpeedMetersPerSecond: CLLocationSpeed ) async -> [RoutePlaybackSample] { let speedWays = (try? await fetchOpenStreetMapWays(for: displayCoordinates)) ?? [] return buildPlaybackSamples( from: displayCoordinates, speedWays: speedWays, fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond ) } private enum CoordinateImportError: LocalizedError { case emptyFile case noCoordinates var errorDescription: String? { switch self { case .emptyFile: return "The selected file is empty." case .noCoordinates: return "No valid coordinates were found. Use GPX, GeoJSON, JSON, CSV, or plain text with latitude and longitude values." } } } private enum CoordinateImportParser { static let supportedContentTypes: [UTType] = [ .plainText, .commaSeparatedText, .json, .xml, UTType(filenameExtension: "gpx", conformingTo: .xml) ?? .xml, UTType(filenameExtension: "kml", conformingTo: .xml) ?? .xml, UTType(filenameExtension: "geojson", conformingTo: .json) ?? .json ] private enum CoordinateOrder { case latitudeLongitude case longitudeLatitude } static func parse(url: URL) throws -> [CLLocationCoordinate2D] { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) guard !data.isEmpty else { throw CoordinateImportError.emptyFile } let fileExtension = url.pathExtension.lowercased() if fileExtension == "json" || fileExtension == "geojson" { if let coordinates = try? parseJSONCoordinates(from: data), !coordinates.isEmpty { return coordinates } } if fileExtension == "gpx" || fileExtension == "kml" || fileExtension == "xml" { let coordinates = parseXMLCoordinates(from: data) if !coordinates.isEmpty { return coordinates } } if let text = decodedText(from: data) { let coordinates = parseInline(text) if !coordinates.isEmpty { return coordinates } } if let coordinates = try? parseJSONCoordinates(from: data), !coordinates.isEmpty { return coordinates } let coordinates = parseXMLCoordinates(from: data) if !coordinates.isEmpty { return coordinates } throw CoordinateImportError.noCoordinates } static func parseInline(_ text: String) -> [CLLocationCoordinate2D] { sanitized(parseTextCoordinates(from: text)) } private static func decodedText(from data: Data) -> String? { String(data: data, encoding: .utf8) ?? String(data: data, encoding: .utf16) ?? String(data: data, encoding: .ascii) } private static func sanitized(_ coordinates: [CLLocationCoordinate2D]) -> [CLLocationCoordinate2D] { var result: [CLLocationCoordinate2D] = [] for coordinate in coordinates where CLLocationCoordinate2DIsValid(coordinate) { if result.last.map(CoordinateSnapshot.init) == CoordinateSnapshot(coordinate) { continue } result.append(coordinate) } return result } private static func coordinate( first: Double, second: Double, order: CoordinateOrder ) -> CLLocationCoordinate2D? { let preferred: CLLocationCoordinate2D let fallback: CLLocationCoordinate2D switch order { case .latitudeLongitude: preferred = CLLocationCoordinate2D(latitude: first, longitude: second) fallback = CLLocationCoordinate2D(latitude: second, longitude: first) case .longitudeLatitude: preferred = CLLocationCoordinate2D(latitude: second, longitude: first) fallback = CLLocationCoordinate2D(latitude: first, longitude: second) } if CLLocationCoordinate2DIsValid(preferred) { return preferred } if CLLocationCoordinate2DIsValid(fallback) { return fallback } return nil } private static func parseJSONCoordinates(from data: Data) throws -> [CLLocationCoordinate2D] { let object = try JSONSerialization.jsonObject(with: data) return sanitized(coordinates(fromJSONObject: object, order: .latitudeLongitude)) } private static func coordinates( fromJSONObject object: Any, order: CoordinateOrder ) -> [CLLocationCoordinate2D] { if let dictionary = object as? [String: Any] { if let latitude = numberValue(forAnyKey: ["latitude", "lat"], in: dictionary), let longitude = numberValue(forAnyKey: ["longitude", "lon", "lng"], in: dictionary), let coordinate = coordinate(first: latitude, second: longitude, order: .latitudeLongitude) { return [coordinate] } if let geometry = dictionary["geometry"] { return coordinates(fromJSONObject: geometry, order: order) } if let type = dictionary["type"] as? String { let loweredType = type.lowercased() if loweredType == "featurecollection", let features = dictionary["features"] as? [Any] { return features.flatMap { coordinates(fromJSONObject: $0, order: .longitudeLatitude) } } if loweredType == "geometrycollection", let geometries = dictionary["geometries"] as? [Any] { return geometries.flatMap { coordinates(fromJSONObject: $0, order: .longitudeLatitude) } } if let coordinateObject = dictionary["coordinates"] { return coordinates(fromJSONObject: coordinateObject, order: .longitudeLatitude) } } return dictionary.values.flatMap { coordinates(fromJSONObject: $0, order: order) } } if let array = object as? [Any] { if array.count >= 2, let first = numericValue(array[0]), let second = numericValue(array[1]), let coordinate = coordinate(first: first, second: second, order: order) { return [coordinate] } return array.flatMap { coordinates(fromJSONObject: $0, order: order) } } return [] } private static func numericValue(_ value: Any) -> Double? { if let number = value as? NSNumber { return number.doubleValue } if let string = value as? String { return Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) } return nil } private static func numberValue(forAnyKey keys: [String], in dictionary: [String: Any]) -> Double? { let keyedValues = Dictionary(uniqueKeysWithValues: dictionary.map { ($0.key.lowercased(), $0.value) }) for key in keys { if let value = keyedValues[key], let number = numericValue(value) { return number } } return nil } private static func parseXMLCoordinates(from data: Data) -> [CLLocationCoordinate2D] { let collector = XMLCoordinateCollector() let parser = XMLParser(data: data) parser.delegate = collector guard parser.parse() else { return [] } return sanitized(collector.coordinates) } private final class XMLCoordinateCollector: NSObject, XMLParserDelegate { var coordinates: [CLLocationCoordinate2D] = [] private var isCollectingKMLCoordinates = false private var kmlCoordinateBuffer = "" func parser( _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] ) { let name = elementName.lowercased() if ["wpt", "trkpt", "rtept"].contains(name), let latitude = Double(attributeDict["lat"] ?? ""), let longitude = Double(attributeDict["lon"] ?? ""), let coordinate = CoordinateImportParser.coordinate( first: latitude, second: longitude, order: .latitudeLongitude ) { coordinates.append(coordinate) } else if name == "coordinates" { isCollectingKMLCoordinates = true kmlCoordinateBuffer = "" } } func parser(_ parser: XMLParser, foundCharacters string: String) { if isCollectingKMLCoordinates { kmlCoordinateBuffer += string } } func parser( _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String? ) { guard elementName.lowercased() == "coordinates" else { return } coordinates.append(contentsOf: CoordinateImportParser.parseKMLCoordinateText(kmlCoordinateBuffer)) isCollectingKMLCoordinates = false kmlCoordinateBuffer = "" } } private static func parseKMLCoordinateText(_ text: String) -> [CLLocationCoordinate2D] { text .split(whereSeparator: { $0.isWhitespace }) .compactMap { token -> CLLocationCoordinate2D? in let values = token .split(separator: ",") .compactMap { Double($0.trimmingCharacters(in: .whitespacesAndNewlines)) } guard values.count >= 2 else { return nil } return coordinate(first: values[0], second: values[1], order: .longitudeLatitude) } } private static func parseTextCoordinates(from text: String) -> [CLLocationCoordinate2D] { var coordinates: [CLLocationCoordinate2D] = [] var headerIndices: (latitude: Int, longitude: Int)? for line in text.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let fields = splitFields(trimmed) if headerIndices == nil, let detectedHeader = detectHeader(in: fields) { headerIndices = detectedHeader continue } if let headerIndices, fields.indices.contains(headerIndices.latitude), fields.indices.contains(headerIndices.longitude), let latitude = numbers(in: fields[headerIndices.latitude]).first, let longitude = numbers(in: fields[headerIndices.longitude]).first, let coordinate = coordinate(first: latitude, second: longitude, order: .latitudeLongitude) { coordinates.append(coordinate) continue } let values = numbers(in: trimmed) if values.count >= 2, let coordinate = coordinate(first: values[0], second: values[1], order: .latitudeLongitude) { coordinates.append(coordinate) } } return coordinates } private static func splitFields(_ line: String) -> [String] { line .split { character in character == "," || character == ";" || character == "\t" } .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } } private static func detectHeader(in fields: [String]) -> (latitude: Int, longitude: Int)? { let lowered = fields.map { $0.lowercased() } guard let latitude = lowered.firstIndex(where: { $0 == "lat" || $0 == "latitude" }), let longitude = lowered.firstIndex(where: { $0 == "lon" || $0 == "lng" || $0 == "long" || $0 == "longitude" }) else { return nil } return (latitude, longitude) } private static func numbers(in text: String) -> [Double] { let pattern = #"[-+]?(?:\d+(?:\.\d*)?|\.\d+)"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(text.startIndex..? @State private var routeSpeedPrefetchTask: Task? @State private var routePlaybackTask: Task? @State private var isBusy = false @State private var isLoadingRoute = false @State private var isPrefetchingRouteSpeeds = false @State private var isImportingCoordinates = false @State private var showAlert = false @State private var alertTitle = "" @State private var alertMessage = "" @State private var searchText = "" @StateObject private var searchCompleter = LocationSearchCompleter() @State private var showCoordinateImporter = false @State private var showRouteSearch = false @State private var routeStartSelection: RouteSearchSelection? @State private var routeEndSelection: RouteSearchSelection? @State private var routePlan: RouteSimulationPlan? @State private var routePolyline: MKPolyline? @State private var routePlaybackSamples: [RoutePlaybackSample] = [] @State private var routePlaybackCoordinate: CLLocationCoordinate2D? @State private var simulatedCoordinate: CLLocationCoordinate2D? @State private var routeRequestID = UUID() private static let routeDurationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .dropAll return formatter }() // Bookmarks @State private var bookmarks: [LocationBookmark] = [] @State private var showBookmarks = false @State private var showSaveBookmark = false @State private var newBookmarkName = "" private var pairingFilePath: String { PairingFileStore.prepareURL().path() } private var pairingExists: Bool { FileManager.default.fileExists(atPath: pairingFilePath) } private var deviceIP: String { let stored = UserDefaults.standard.string(forKey: "customTargetIP") ?? "" return stored.isEmpty ? "10.7.0.1" : stored } private var routeStartCoordinate: CLLocationCoordinate2D? { routeStartSelection?.coordinate } private var routeEndCoordinate: CLLocationCoordinate2D? { routeEndSelection?.coordinate } private var hasActiveSimulation: Bool { simulatedCoordinate != nil || routePlaybackTask != nil } private var isRouteRunning: Bool { routePlaybackTask != nil } private var hasRouteContext: Bool { routeStartSelection != nil || routeEndSelection != nil || routePlan != nil || isLoadingRoute || isPrefetchingRouteSpeeds || routePlaybackCoordinate != nil } private var routeSummaryText: String? { guard let routePlan else { return nil } let distanceText = Measurement( value: routePlan.distance / 1000, unit: UnitLength.kilometers ).formatted(.measurement(width: .abbreviated, usage: .road)) let durationText = Self.routeDurationFormatter.string(from: routePlan.expectedTravelTime) if let durationText, !durationText.isEmpty { return "\(distanceText) • ETA \(durationText)" } return distanceText } private var routeStatusText: String { if isLoadingRoute { return "Calculating route…" } if isPrefetchingRouteSpeeds { return "Prefetching road speeds…" } if routePlan != nil { return "Route ready." } if routeStartSelection != nil || routeEndSelection != nil { return "Pick both route endpoints to build the drive." } return "Plan a route from the toolbar." } private var routeAttributionLink: some View { Link( "Speed limit data © OpenStreetMap contributors (ODbL)", destination: OpenStreetMapSpeedLimitService.copyrightURL ) .font(.caption2) .foregroundStyle(.secondary) } private var searchResultsListBase: some View { List(searchCompleter.results.prefix(5), id: \.self) { result in Button { selectSearchResult(result) } label: { VStack(alignment: .leading, spacing: 2) { Text(result.title) .font(.subheadline) if !result.subtitle.isEmpty { Text(result.subtitle) .font(.caption) .foregroundStyle(.secondary) } } } } .listStyle(.plain) .frame(maxHeight: 350) .scrollDisabled(true) .padding(.horizontal, 16) .padding(.top, 8) } @ViewBuilder private var searchResultsList: some View { if #available(iOS 26, *) { searchResultsListBase .glassEffect(in: .rect(cornerRadius: 12)) } else { searchResultsListBase } } var body: some View { ZStack(alignment: .bottom) { MapReader { proxy in Map(position: $position) { if hasRouteContext { if let routePolyline { MapPolyline(routePolyline) .stroke(.blue.opacity(0.8), lineWidth: 5) } if let routeStartCoordinate { Marker("Start", coordinate: routeStartCoordinate) .tint(.green) } if let routeEndCoordinate { Marker("End", coordinate: routeEndCoordinate) .tint(.red) } if let routePlaybackCoordinate { Marker("Current", coordinate: routePlaybackCoordinate) .tint(.blue) } } else if let coordinate { Marker("Pin", coordinate: coordinate) .tint(.red) } } .mapStyle(.standard(elevation: .realistic)) .onTapGesture { point in if let loc = proxy.convert(point, from: .local) { applySelection(loc) } } .mapControls { MapCompass() } } .ignoresSafeArea() .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in if let new { position = .region( MKCoordinateRegion( center: new.coordinate, latitudinalMeters: 1000, longitudinalMeters: 1000 ) ) } } VStack(spacing: 0) { if !searchCompleter.results.isEmpty { searchResultsList } Spacer() VStack(spacing: 12) { if isImportingCoordinates { ProgressView("Importing coordinates…") .font(.footnote) } if hasRouteContext { routeControls } else { pinControls } } .padding(.bottom, 24) .padding(.horizontal, 16) .padding(.horizontal, 16) } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button { showBookmarks = true } label: { Image(systemName: "bookmark.fill") } Button { showRouteSearch = true } label: { Image(systemName: "point.topleft.down.curvedto.point.bottomright.up") } .disabled(isBusy || isRouteRunning) Button { showCoordinateImporter = true } label: { Image(systemName: "square.and.arrow.down") } .disabled(isBusy || isRouteRunning || isImportingCoordinates) .accessibilityLabel("Import Coordinates") } ToolbarItem(placement: .topBarTrailing) { TextField("Search location...", text: $searchText) .padding(.leading, 6) .autocorrectionDisabled() .submitLabel(.go) .onChange(of: searchText) { _, newValue in searchCompleter.update(query: newValue) } .onSubmit { applyCoordinatesFromSearchText() } } } .alert(alertTitle, isPresented: $showAlert) { Button("OK", role: .cancel) { } } message: { Text(alertMessage) } .alert("Save Bookmark", isPresented: $showSaveBookmark) { TextField("Name", text: $newBookmarkName) Button("Save") { addBookmark() } Button("Cancel", role: .cancel) { newBookmarkName = "" } } message: { Text("Enter a name for this location.") } .sheet(isPresented: $showBookmarks) { BookmarksView(bookmarks: $bookmarks) { bookmark in applySelection(bookmark.coordinate) showBookmarks = false } onDelete: { offsets in bookmarks.remove(atOffsets: offsets) saveBookmarks() } } .sheet(isPresented: $showRouteSearch) { RouteSearchSheet( initialStart: routeStartSelection, initialEnd: routeEndSelection ) { startSelection, endSelection in routeStartSelection = startSelection routeEndSelection = endSelection refreshRoute() } } .fileImporter( isPresented: $showCoordinateImporter, allowedContentTypes: CoordinateImportParser.supportedContentTypes, allowsMultipleSelection: false ) { result in importCoordinates(result) } .onAppear { loadBookmarks() } .onDisappear { routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil cancelRoutePlayback(resetMarker: true) stopResendLoop() if backgroundTaskID != .invalid { BackgroundLocationManager.shared.requestStop() } endBackgroundTask() } } // MARK: - Bookmarks private func loadBookmarks() { guard let data = UserDefaults.standard.data(forKey: "locationBookmarks"), let decoded = try? JSONDecoder().decode([LocationBookmark].self, from: data) else { return } bookmarks = decoded } private func saveBookmarks() { if let data = try? JSONEncoder().encode(bookmarks) { UserDefaults.standard.set(data, forKey: "locationBookmarks") } } private func addBookmark() { guard let coord = coordinate else { return } let name = newBookmarkName.trimmingCharacters(in: .whitespacesAndNewlines) let bookmark = LocationBookmark( name: name.isEmpty ? String(format: "%.4f, %.4f", coord.latitude, coord.longitude) : name, latitude: coord.latitude, longitude: coord.longitude ) bookmarks.append(bookmark) saveBookmarks() newBookmarkName = "" } private func setRoutePlan(_ plan: RouteSimulationPlan?) { routePlan = plan routePolyline = plan.flatMap { makeRoutePolyline(for: $0.displayCoordinates) } } private func makeRoutePolyline(for coordinates: [CLLocationCoordinate2D]) -> MKPolyline? { guard coordinates.count > 1 else { return nil } return coordinates.withUnsafeBufferPointer { buffer in guard let baseAddress = buffer.baseAddress else { return nil } return MKPolyline(coordinates: baseAddress, count: buffer.count) } } // MARK: - Location private func selectSearchResult(_ result: MKLocalSearchCompletion) { searchText = "" searchCompleter.results = [] let request = MKLocalSearch.Request(completion: result) MKLocalSearch(request: request).start { response, _ in if let item = response?.mapItems.first { applySelection(item.placemark.coordinate) } } } private func applyCoordinatesFromSearchText() { let importedCoordinates = CoordinateImportParser.parseInline(searchText) guard !importedCoordinates.isEmpty else { return } searchText = "" searchCompleter.results = [] applyImportedCoordinates(importedCoordinates, sourceName: "Imported") } private func importCoordinates(_ result: Result<[URL], Error>) { switch result { case .success(let urls): guard let url = urls.first else { return } let sourceName = url.deletingPathExtension().lastPathComponent isImportingCoordinates = true Task { do { let coordinates = try await Task.detached(priority: .userInitiated) { try CoordinateImportParser.parse(url: url) }.value await MainActor.run { isImportingCoordinates = false applyImportedCoordinates( coordinates, sourceName: sourceName.isEmpty ? "Imported" : sourceName ) } } catch { await MainActor.run { isImportingCoordinates = false showImportError(error) } } } case .failure(let error): showImportError(error) } } private func applyImportedCoordinates( _ importedCoordinates: [CLLocationCoordinate2D], sourceName: String ) { guard !isRouteRunning else { return } let coordinates = importedCoordinates.filter(CLLocationCoordinate2DIsValid) guard let firstCoordinate = coordinates.first else { showImportError(CoordinateImportError.noCoordinates) return } if coordinates.count == 1 { applySelection(firstCoordinate) return } routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil routeRequestID = UUID() setRoutePlan(nil) routePlaybackSamples = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false coordinate = nil let displayCoordinates = sampledRouteCoordinates( from: coordinates, targetDistance: RouteSimulationDefaults.pathSamplingDistance ) guard displayCoordinates.count > 1, let lastCoordinate = displayCoordinates.last else { applySelection(firstCoordinate) return } let distance = distanceAlong(displayCoordinates) let fallbackSpeed = RouteSimulationDefaults.importedRouteFallbackSpeedMetersPerSecond routeStartSelection = RouteSearchSelection(title: "\(sourceName) Start", coordinate: firstCoordinate) routeEndSelection = RouteSearchSelection(title: "\(sourceName) End", coordinate: lastCoordinate) setRoutePlan(RouteSimulationPlan( displayCoordinates: displayCoordinates, distance: distance, expectedTravelTime: distance / fallbackSpeed )) if let routePolyline { position = .rect(routePolyline.boundingMapRect) } let requestID = UUID() routeRequestID = requestID isPrefetchingRouteSpeeds = true routeSpeedPrefetchTask = Task.detached(priority: .utility) { let playbackSamples = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed ) guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } routePlaybackSamples = playbackSamples isPrefetchingRouteSpeeds = false } } } private func showImportError(_ error: Error) { alertTitle = "Import Failed" alertMessage = error.localizedDescription showAlert = true } @ViewBuilder private var pinControls: some View { if let coord = coordinate { Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) .font(.footnote.monospaced()) .foregroundStyle(.secondary) HStack(spacing: 12) { Button("Stop", action: clear) .buttonStyle(.bordered) .tint(.red) .disabled(!pairingExists || isBusy || !hasActiveSimulation) Button("Simulate Location", action: simulate) .buttonStyle(.borderedProminent) .disabled(!pairingExists || isBusy || isLoadingRoute) Button { showSaveBookmark = true } label: { Image(systemName: "bookmark") } .buttonStyle(.bordered) .tint(.blue) .disabled(isRouteRunning) } } else { Text("Tap map to drop pin") .font(.subheadline) .foregroundStyle(.secondary) } } private var routeControls: some View { VStack(spacing: 10) { Text(routeStatusText) .font(.footnote) .foregroundStyle(.secondary) if isLoadingRoute || isPrefetchingRouteSpeeds { ProgressView() .controlSize(.small) } else if let routeSummaryText { Text(routeSummaryText) .font(.footnote.monospaced()) .foregroundStyle(.secondary) } routeAttributionLink HStack(spacing: 12) { Button("Stop", action: clear) .buttonStyle(.bordered) .tint(.red) .disabled(!pairingExists || isBusy || !hasActiveSimulation) Button("Play Route", action: simulateRoute) .buttonStyle(.borderedProminent) .disabled( !pairingExists || isBusy || isLoadingRoute || isPrefetchingRouteSpeeds || routePlan == nil || routePlaybackSamples.isEmpty ) Button("Reset", action: resetRouteSelection) .buttonStyle(.bordered) .disabled(isBusy || isRouteRunning) } } } private func simulate() { guard pairingExists, let coord = coordinate, !isBusy else { return } runLocationCommand( errorTitle: "Simulation Failed", errorMessage: { code in "Could not simulate location (error \(code)). Make sure the device is connected and the DDI is mounted." }, operation: { locationUpdateCode(for: coord) } ) { routePlaybackCoordinate = nil beginBackgroundTask() startResendLoop(with: coord) BackgroundLocationManager.shared.requestStart() } } private func simulateRoute() { guard pairingExists, routePlan != nil, let firstCoordinate = routePlaybackSamples.first?.coordinate, !isBusy else { return } stopResendLoop() cancelRoutePlayback(resetMarker: false) runLocationCommand( errorTitle: "Route Simulation Failed", errorMessage: { code in "Could not start route simulation (error \(code)). Make sure the device is connected and the DDI is mounted." }, operation: { locationUpdateCode(for: firstCoordinate) } ) { beginBackgroundTask() BackgroundLocationManager.shared.requestStart() simulatedCoordinate = nil routePlaybackCoordinate = firstCoordinate startRoutePlayback() } } private func runLocationCommand( errorTitle: String, errorMessage: @escaping (Int32) -> String, operation: @escaping () -> Int32, onSuccess: @escaping () -> Void ) { isBusy = true LocationSimulationCommandQueue.shared.async { let code = operation() DispatchQueue.main.async { isBusy = false if code == 0 { onSuccess() } else { alertTitle = errorTitle alertMessage = errorMessage(code) showAlert = true } } } } private func clear() { guard pairingExists, !isBusy else { return } routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil cancelRoutePlayback(resetMarker: true) stopResendLoop() runLocationCommand( errorTitle: "Clear Failed", errorMessage: { code in "Could not clear simulated location (error \(code))." }, operation: clear_simulated_location ) { endBackgroundTask() BackgroundLocationManager.shared.requestStop() } } private func beginBackgroundTask() { guard backgroundTaskID == .invalid else { return } backgroundTaskID = UIApplication.shared.beginBackgroundTask { endBackgroundTask() } } private func endBackgroundTask() { guard backgroundTaskID != .invalid else { return } UIApplication.shared.endBackgroundTask(backgroundTaskID) backgroundTaskID = .invalid } private func startResendLoop(with coordinate: CLLocationCoordinate2D) { simulatedCoordinate = coordinate resendTimer?.invalidate() resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in guard let simulatedCoordinate else { return } LocationSimulationCommandQueue.shared.async { _ = locationUpdateCode(for: simulatedCoordinate) } } } private func stopResendLoop() { resendTimer?.invalidate() resendTimer = nil simulatedCoordinate = nil } private func cancelRoutePlayback(resetMarker: Bool) { routePlaybackTask?.cancel() routePlaybackTask = nil if resetMarker { routePlaybackCoordinate = nil } } private func applySelection(_ coordinate: CLLocationCoordinate2D) { guard !isRouteRunning else { return } if hasRouteContext { resetRouteSelection() } self.coordinate = coordinate } private func resetRouteSelection() { routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil routeRequestID = UUID() setRoutePlan(nil) routeStartSelection = nil routeEndSelection = nil routePlaybackSamples = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false } private func refreshRoute() { routeLoadTask?.cancel() routeSpeedPrefetchTask?.cancel() setRoutePlan(nil) routePlaybackSamples = [] guard let routeStart = routeStartSelection?.coordinate, let routeEnd = routeEndSelection?.coordinate else { isLoadingRoute = false isPrefetchingRouteSpeeds = false return } let requestID = UUID() routeRequestID = requestID isLoadingRoute = true isPrefetchingRouteSpeeds = false let request = MKDirections.Request() request.source = MKMapItem(placemark: MKPlacemark(coordinate: routeStart)) request.destination = MKMapItem(placemark: MKPlacemark(coordinate: routeEnd)) request.requestsAlternateRoutes = false request.transportType = .automobile routeLoadTask = Task { do { let response = try await MKDirections(request: request).calculate() guard !Task.isCancelled else { return } guard let route = response.routes.first else { throw NSError( domain: "RouteSimulation", code: -1, userInfo: [NSLocalizedDescriptionKey: "No drivable route was returned."] ) } let displayCoordinates = sampledRouteCoordinates( from: route.polyline.coordinateArray, targetDistance: RouteSimulationDefaults.pathSamplingDistance ) let routePlan = RouteSimulationPlan( displayCoordinates: displayCoordinates, distance: route.distance, expectedTravelTime: route.expectedTravelTime ) await MainActor.run { guard routeRequestID == requestID else { return } self.setRoutePlan(routePlan) isLoadingRoute = false isPrefetchingRouteSpeeds = true if let routePolyline { position = .rect(routePolyline.boundingMapRect) } } let fallbackSpeed = route.expectedTravelTime > 0 ? route.distance / route.expectedTravelTime : 13.4 await MainActor.run { guard routeRequestID == requestID else { return } routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = Task.detached(priority: .utility) { let playbackSamples = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed ) guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } routePlaybackSamples = playbackSamples isPrefetchingRouteSpeeds = false } } } } catch is CancellationError { await MainActor.run { guard routeRequestID == requestID else { return } isLoadingRoute = false isPrefetchingRouteSpeeds = false } } catch { await MainActor.run { guard routeRequestID == requestID else { return } isLoadingRoute = false isPrefetchingRouteSpeeds = false alertTitle = "Route Failed" alertMessage = error.localizedDescription showAlert = true } } } } private func startRoutePlayback() { routePlaybackTask = Task { var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate for sample in routePlaybackSamples.dropFirst() { try? await Task.sleep(for: .seconds(sample.delayFromPrevious)) guard !Task.isCancelled else { return } let code = await sendLocationUpdate(for: sample.coordinate) guard code == 0 else { await MainActor.run { routePlaybackTask = nil routePlaybackCoordinate = lastSuccessfulCoordinate if let lastSuccessfulCoordinate { startResendLoop(with: lastSuccessfulCoordinate) } alertTitle = "Route Simulation Failed" alertMessage = "Could not continue route simulation (error \(code))." showAlert = true } return } lastSuccessfulCoordinate = sample.coordinate await MainActor.run { routePlaybackCoordinate = sample.coordinate } } await MainActor.run { routePlaybackTask = nil if let lastSuccessfulCoordinate { routePlaybackCoordinate = lastSuccessfulCoordinate startResendLoop(with: lastSuccessfulCoordinate) } } } } private func sendLocationUpdate(for coordinate: CLLocationCoordinate2D) async -> Int32 { await withCheckedContinuation { continuation in LocationSimulationCommandQueue.shared.async { continuation.resume(returning: locationUpdateCode(for: coordinate)) } } } private func locationUpdateCode(for coordinate: CLLocationCoordinate2D) -> Int32 { simulate_location(deviceIP, coordinate.latitude, coordinate.longitude, pairingFilePath) } } private struct RouteSearchSheet: View { @Environment(\.dismiss) private var dismiss let initialStart: RouteSearchSelection? let initialEnd: RouteSearchSelection? let onApply: (RouteSearchSelection, RouteSearchSelection) -> Void @StateObject private var startCompleter = LocationSearchCompleter() @StateObject private var endCompleter = LocationSearchCompleter() @State private var startQuery: String @State private var endQuery: String @State private var startSelection: RouteSearchSelection? @State private var endSelection: RouteSearchSelection? @State private var isResolvingSelection = false @State private var errorMessage: String? @FocusState private var focusedField: RouteSearchField? init( initialStart: RouteSearchSelection?, initialEnd: RouteSearchSelection?, onApply: @escaping (RouteSearchSelection, RouteSearchSelection) -> Void ) { self.initialStart = initialStart self.initialEnd = initialEnd self.onApply = onApply _startQuery = State(initialValue: initialStart?.title ?? "") _endQuery = State(initialValue: initialEnd?.title ?? "") _startSelection = State(initialValue: initialStart) _endSelection = State(initialValue: initialEnd) } private var activeResults: [MKLocalSearchCompletion] { switch focusedField { case .start: return startCompleter.results case .end: return endCompleter.results case .none: return [] } } private var canApply: Bool { startSelection != nil && endSelection != nil && !isResolvingSelection } var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 16) { routeField( title: "Start", icon: "circle.fill", tint: .green, text: $startQuery, selection: startSelection, field: .start ) routeField( title: "End", icon: "flag.checkered.circle.fill", tint: .red, text: $endQuery, selection: endSelection, field: .end ) if let errorMessage { Text(errorMessage) .font(.footnote) .foregroundStyle(.red) } if isResolvingSelection { ProgressView("Resolving location…") .font(.footnote) } else if !activeResults.isEmpty { ScrollView { LazyVStack(spacing: 0) { ForEach(Array(activeResults.enumerated()), id: \.offset) { index, result in Button { resolve(result) } label: { VStack(alignment: .leading, spacing: 2) { Text(result.title) .font(.subheadline) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) if !result.subtitle.isEmpty { Text(result.subtitle) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.vertical, 10) .padding(.horizontal, 12) } .buttonStyle(.plain) if index < activeResults.count - 1 { Divider() } } } } .frame(maxHeight: 260) } else { Text("Search for a start and destination to build the route.") .font(.footnote) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(16) .navigationTitle("Simulate Route") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Use Route") { guard let startSelection, let endSelection else { return } onApply(startSelection, endSelection) dismiss() } .disabled(!canApply) } } } .presentationDetents([.medium, .large]) .onAppear { if startSelection == nil { focusedField = .start } else if endSelection == nil { focusedField = .end } } } private func routeField( title: String, icon: String, tint: Color, text: Binding, selection: RouteSearchSelection?, field: RouteSearchField ) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) HStack(spacing: 10) { Image(systemName: icon) .foregroundStyle(tint) TextField(title, text: text) .textInputAutocapitalization(.words) .autocorrectionDisabled() .focused($focusedField, equals: field) .submitLabel(field == .start ? .next : .done) .onChange(of: text.wrappedValue) { _, newValue in errorMessage = nil update(query: newValue, for: field) } .onSubmit { if field == .start { focusedField = .end } else { focusedField = nil } } } .padding(.horizontal, 2) .padding(.vertical, 4) if let selection { Text(String(format: "%.5f, %.5f", selection.coordinate.latitude, selection.coordinate.longitude)) .font(.caption.monospaced()) .foregroundStyle(.secondary) } } } private func update(query: String, for field: RouteSearchField) { switch field { case .start: if query != startSelection?.title { startSelection = nil } startCompleter.update(query: query) case .end: if query != endSelection?.title { endSelection = nil } endCompleter.update(query: query) } } private func resolve(_ completion: MKLocalSearchCompletion) { let field = focusedField ?? .start let request = MKLocalSearch.Request(completion: completion) isResolvingSelection = true errorMessage = nil MKLocalSearch(request: request).start { response, error in DispatchQueue.main.async { isResolvingSelection = false guard let item = response?.mapItems.first else { errorMessage = error?.localizedDescription ?? "Could not resolve that location." return } let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let title = name.isEmpty ? completion.title : name let selection = RouteSearchSelection(title: title, coordinate: item.placemark.coordinate) switch field { case .start: startSelection = selection startQuery = title startCompleter.results = [] focusedField = .end case .end: endSelection = selection endQuery = title endCompleter.results = [] focusedField = nil } } } } } // MARK: - Bookmarks Sheet struct BookmarksView: View { @Binding var bookmarks: [LocationBookmark] let onSelect: (LocationBookmark) -> Void let onDelete: (IndexSet) -> Void var body: some View { NavigationStack { Group { if bookmarks.isEmpty { ContentUnavailableView( "No Bookmarks", systemImage: "bookmark.slash", description: Text("Drop a pin on the map and tap the bookmark icon to save a location.") ) } else { List { ForEach(bookmarks) { bookmark in Button { onSelect(bookmark) } label: { VStack(alignment: .leading, spacing: 2) { Text(bookmark.name) .foregroundStyle(.primary) Text(String(format: "%.6f, %.6f", bookmark.latitude, bookmark.longitude)) .font(.caption.monospaced()) .foregroundStyle(.secondary) } } } .onDelete(perform: onDelete) } } } .navigationTitle("Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { if !bookmarks.isEmpty { EditButton() } } } } }