mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
1860 lines
66 KiB
Swift
1860 lines
66 KiB
Swift
//
|
|
// 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..<text.endIndex, in: text)
|
|
return regex.matches(in: text, range: range).compactMap { match in
|
|
guard let matchRange = Range(match.range, in: text) else { return nil }
|
|
return Double(text[matchRange])
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Bookmark Model
|
|
|
|
struct LocationBookmark: Identifiable, Codable {
|
|
var id: UUID = UUID()
|
|
var name: String
|
|
var latitude: Double
|
|
var longitude: Double
|
|
|
|
var coordinate: CLLocationCoordinate2D {
|
|
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Completer
|
|
|
|
@MainActor
|
|
final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCompleterDelegate {
|
|
@Published var results: [MKLocalSearchCompletion] = []
|
|
private let completer = MKLocalSearchCompleter()
|
|
|
|
override init() {
|
|
super.init()
|
|
completer.delegate = self
|
|
completer.resultTypes = [.address, .pointOfInterest]
|
|
}
|
|
|
|
func update(query: String) {
|
|
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
results = []
|
|
completer.queryFragment = ""
|
|
return
|
|
}
|
|
completer.queryFragment = query
|
|
}
|
|
|
|
nonisolated func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
|
|
let results = completer.results
|
|
Task { @MainActor in self.results = results }
|
|
}
|
|
|
|
nonisolated func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
|
|
Task { @MainActor in self.results = [] }
|
|
}
|
|
}
|
|
|
|
struct LocationSimulationView: View {
|
|
@State private var coordinate: CLLocationCoordinate2D?
|
|
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
|
|
|
|
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
|
@State private var resendTimer: Timer?
|
|
@State private var routeLoadTask: Task<Void, Never>?
|
|
@State private var routeSpeedPrefetchTask: Task<Void, Never>?
|
|
@State private var routePlaybackTask: Task<Void, Never>?
|
|
@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 {
|
|
DeviceConnectionContext.targetIPAddress
|
|
}
|
|
|
|
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<String>,
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|