Andere Apps können jetzt GPX-Tracks zu Ban Yaro Go schicken (Komoot,
Outdooractive, GPSies, AllTrails, Files-App, Mail-Anhänge, AirDrop).
- Info.plist:
- UTImportedTypeDeclarations: com.topografix.gpx (conforms to
public.xml/data/content, ext gpx, MIME application/gpx+xml)
- CFBundleDocumentTypes registriert die UTI als Viewer (LSHandlerRank
Alternate, damit wir nicht die Default-App werden)
- LSSupportsOpeningDocumentsInPlace=true
- Support/GPXParser.swift: schlanker XMLParser/SAX-Reader für
<trkpt>/<wpt>/<rtept>, Track-Name aus <trk><name>, ele + ISO8601 time
- Views/GPXImportSheet.swift: Sheet mit Map(MapPolyline)+Start/Ziel-Pins,
Distanz/Punkte/Dauer-Karte, zwei Aktionen:
1. 'Als Tour übernehmen' — Name editierbar, Hunde-Picker (FlowDogs),
öffentlich-Toggle → POST /api/routes
2. 'Nur ansehen' — Startpunkt in Apple Maps
- BanYaroGoApp.swift: .onOpenURL prüft .gpx, security-scoped resource,
parst und triggert das Sheet via TrackBox-Wrapper
138 lines
4.4 KiB
Swift
138 lines
4.4 KiB
Swift
import Foundation
|
|
import CoreLocation
|
|
|
|
struct GPXTrack {
|
|
let name: String?
|
|
let points: [GPXPoint]
|
|
|
|
var coordinates: [CLLocationCoordinate2D] {
|
|
points.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }
|
|
}
|
|
|
|
/// Total length in meters along the track using great-circle distance.
|
|
var distanceMeters: Double {
|
|
guard points.count > 1 else { return 0 }
|
|
var sum: Double = 0
|
|
for i in 1..<points.count {
|
|
let a = CLLocation(latitude: points[i-1].lat, longitude: points[i-1].lon)
|
|
let b = CLLocation(latitude: points[i].lat, longitude: points[i].lon)
|
|
sum += b.distance(from: a)
|
|
}
|
|
return sum
|
|
}
|
|
|
|
/// Duration in seconds derived from first/last <time> if available.
|
|
var durationSeconds: TimeInterval? {
|
|
guard let first = points.first?.time, let last = points.last?.time else { return nil }
|
|
let delta = last.timeIntervalSince(first)
|
|
return delta > 0 ? delta : nil
|
|
}
|
|
}
|
|
|
|
struct GPXPoint {
|
|
let lat: Double
|
|
let lon: Double
|
|
let ele: Double?
|
|
let time: Date?
|
|
}
|
|
|
|
enum GPXParseError: Error, LocalizedError {
|
|
case noTrack
|
|
case invalid(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noTrack: return "Keine Trackpunkte gefunden."
|
|
case .invalid(let why): return "GPX ungültig: \(why)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Minimaler SAX-Parser für GPX. Liest <trkpt>, <wpt> und <rtept> als Punkte;
|
|
/// Track-Name aus <name> innerhalb des ersten <trk>.
|
|
final class GPXParser: NSObject, XMLParserDelegate {
|
|
private var points: [GPXPoint] = []
|
|
private var trackName: String?
|
|
|
|
private var currentLat: Double?
|
|
private var currentLon: Double?
|
|
private var currentEle: Double?
|
|
private var currentTime: Date?
|
|
private var currentElement: String?
|
|
private var inTrk = false
|
|
private var trkNameCaptured = false
|
|
private var textBuffer = ""
|
|
|
|
private static let isoFormatter: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
|
|
static func parse(data: Data) throws -> GPXTrack {
|
|
let parser = GPXParser()
|
|
let xml = XMLParser(data: data)
|
|
xml.delegate = parser
|
|
if !xml.parse() {
|
|
if let err = xml.parserError {
|
|
throw GPXParseError.invalid(err.localizedDescription)
|
|
}
|
|
}
|
|
guard !parser.points.isEmpty else { throw GPXParseError.noTrack }
|
|
return GPXTrack(name: parser.trackName, points: parser.points)
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, didStartElement elementName: String,
|
|
namespaceURI: String?, qualifiedName qName: String?,
|
|
attributes attributeDict: [String : String] = [:]) {
|
|
currentElement = elementName
|
|
textBuffer = ""
|
|
switch elementName {
|
|
case "trk":
|
|
inTrk = true
|
|
trkNameCaptured = false
|
|
case "trkpt", "wpt", "rtept":
|
|
currentLat = attributeDict["lat"].flatMap { Double($0) }
|
|
currentLon = attributeDict["lon"].flatMap { Double($0) }
|
|
currentEle = nil
|
|
currentTime = nil
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
textBuffer += string
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, didEndElement elementName: String,
|
|
namespaceURI: String?, qualifiedName qName: String?) {
|
|
let text = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
switch elementName {
|
|
case "ele":
|
|
currentEle = Double(text)
|
|
case "time":
|
|
currentTime = Self.isoFormatter.date(from: text)
|
|
?? Self.isoFormatterNoFraction.date(from: text)
|
|
case "name":
|
|
if inTrk && !trkNameCaptured {
|
|
trackName = text
|
|
trkNameCaptured = true
|
|
}
|
|
case "trkpt", "wpt", "rtept":
|
|
if let lat = currentLat, let lon = currentLon {
|
|
points.append(GPXPoint(lat: lat, lon: lon, ele: currentEle, time: currentTime))
|
|
}
|
|
case "trk":
|
|
inTrk = false
|
|
default:
|
|
break
|
|
}
|
|
textBuffer = ""
|
|
}
|
|
}
|