Add route files, show overview

This commit is contained in:
Christoph Hagen
2025-08-21 20:26:22 +02:00
parent 43b761b593
commit 9ec207014c
14 changed files with 938 additions and 3 deletions

View File

@@ -125,6 +125,15 @@ final class FileResource: Item, LocalizedItem {
return true
}
@discardableResult
func save(fileData: Foundation.Data) -> Bool {
guard content.storage.save(fileData: fileData, for: identifier) else {
return false
}
modifiedDate = .now
return true
}
func dataContent() -> Foundation.Data? {
content.storage.fileData(for: identifier)
}

View File

@@ -9,6 +9,7 @@ enum FileTypeCategory: String, CaseIterable {
case video
case resource
case audio
case route
var text: String {
switch self {
@@ -19,6 +20,7 @@ enum FileTypeCategory: String, CaseIterable {
case .video: return "Videos"
case .resource: return "Other"
case .audio: return "Audio"
case .route: return "Route"
}
}
@@ -31,6 +33,7 @@ enum FileTypeCategory: String, CaseIterable {
case .video: .video
case .resource: .zipperPage
case .audio: .speakerWave2CircleFill
case .route: .map
}
}
}
@@ -138,6 +141,10 @@ enum FileType: String {
case psd
// MARK: Route
case route
// MARK: Unknown
case unknown
@@ -174,6 +181,8 @@ enum FileType: String {
return .model
case .zip, .cddx, .pdf, .key, .psd, .ttf:
return .resource
case .route:
return .route
case .noExtension, .unknown:
return .resource
}

View File

@@ -414,6 +414,12 @@ final class Storage: ObservableObject {
return contentScope.readData(at: path)
}
func save(fileData: Data, for fileId: String) -> Bool {
guard let contentScope else { return false }
let path = filePath(file: fileId)
return contentScope.write(fileData, to: path)
}
func save(fileContent: String, for fileId: String) -> Bool {
guard let contentScope else { return false }
let path = filePath(file: fileId)

View File

@@ -77,6 +77,9 @@ struct FileContentView: View {
.font(.title)
}
.foregroundStyle(.secondary)
case .route:
RoutePreviewView(file: file)
}
}
}.padding()

View File

@@ -0,0 +1,49 @@
import SwiftUI
import Charts
struct ElevationSample: Identifiable {
let timestamp: Date
let altitude: Double
var id: Date {
timestamp
}
}
struct ElevationGraph: View {
let samples: [ElevationSample]
var body: some View {
Chart {
// Active segments as area + line
ForEach(samples) { sample in
LineMark(
x: .value("Time", sample.timestamp),
y: .value("Altitude", sample.altitude)
)
.foregroundStyle(by: .value("Series", "Altitude"))
//.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Time", sample.timestamp),
y: .value("Altitude", sample.altitude)
)
//.interpolationMethod(.catmullRom)
.foregroundStyle(LinearGradient(
gradient: Gradient(colors: [.blue.opacity(0.8), .blue.opacity(0.2)]),
startPoint: .top,
endPoint: .bottom
))
}
}
.chartYAxis {
AxisMarks(position: .leading)
}
.chartXScale(domain: samples.first!.timestamp...samples.last!.timestamp)
.frame(width: 700, height: 220)
.padding()
}
}

View File

@@ -0,0 +1,261 @@
import CoreLocation
extension Array where Element == CLLocation {
/**
Sample the locations using a given time interval.
*/
func samplePeriodically(at interval: TimeInterval) -> [CLLocation] {
guard interval > 0 else { return [] }
guard let start = first, let end = last else { return self }
let totalTime = end.timestamp.timeIntervalSince(start.timestamp)
let numberOfSamples = Int((totalTime / interval).rounded(.up))
return periodicSamples(interval: interval, numberOfSamples: numberOfSamples)
}
/**
Sample the locations at a fixed period determined by the number of desired sampels
*/
func samplePeriodically(numberOfSamples: Int) -> [CLLocation] {
guard numberOfSamples > 0 else { return [] }
guard let start = first, let end = last else { return self }
let totalTime = end.timestamp.timeIntervalSince(start.timestamp)
let timeInterval = totalTime / TimeInterval(count - 1)
return periodicSamples(interval: timeInterval, numberOfSamples: numberOfSamples)
}
private func periodicSamples(interval: TimeInterval, numberOfSamples: Int) -> [CLLocation] {
guard let start = first else { return [] }
var currentIndex = 0
var currentTime = start.timestamp
var samples = [start]
for _ in 1..<numberOfSamples {
currentTime = currentTime.addingTimeInterval(interval)
while true {
let nextIndex = currentIndex + 1
if nextIndex >= count { break }
let nextTime = self[nextIndex].timestamp
if nextTime > currentTime { break }
currentIndex += 1
}
if currentIndex + 1 == count {
samples.append(self[currentIndex])
} else {
let before = self[currentIndex]
let after = self[currentIndex + 1]
let interpolated = before.interpolate(currentTime, to: after)
samples.append(interpolated)
}
}
return samples
}
/// Computes path length by moving along center-to-center lines, intersecting uncertainty spheres
func minimumTraveledDistance3D() -> CLLocationDistance {
guard count > 1 else { return 0 }
// Remove the uncertainty radius of the first location
var current = self.first!
var totalDistance: CLLocationDistance = -current.uncertaintyRadius3D
for next in self[1...] {
let (movement, point) = current.minimumDistance(to: next)
current = point
totalDistance += movement
}
return totalDistance
}
/// Calculates the minimum possible ascended altitude (meters),
/// considering vertical accuracy as an uncertainty interval.
func minimumAscendedAltitude() -> CLLocationDistance {
guard let first = self.first else { return 0 }
// Start with the highest possible value of the first point
var currentAltitude = first.altitude + first.verticalAccuracy
var ascended: CLLocationDistance = 0
for next in self.dropFirst() {
let newMin = next.altitude - next.verticalAccuracy
let newMax = next.altitude + next.verticalAccuracy
if newMin > currentAltitude {
// Lower bound must be adjusted
ascended += newMin - currentAltitude
currentAltitude = newMin
} else if newMax < currentAltitude {
// Upper bound must be adjusted
currentAltitude = newMax
}
}
return ascended
}
/// Calculates the minimum possible ascended altitude (meters),
/// considering a given vertical accuracy threshold
func minimumAscendedAltitude(threshold: CLLocationDistance) -> CLLocationDistance {
guard let first = self.first else { return 0 }
// Start with the highest possible value of the first point
var currentAltitude = first.altitude + threshold
var ascended: CLLocationDistance = 0
for next in self.dropFirst() {
let newMin = next.altitude - threshold
let newMax = next.altitude + threshold
if newMin > currentAltitude {
// Lower bound must be adjusted
ascended += newMin - currentAltitude
currentAltitude = newMin
} else if newMax < currentAltitude {
// Upper bound must be adjusted
currentAltitude = newMax
}
}
return ascended
}
func interpolateAltitudes(
from startDate: Date,
to endDate: Date
) -> [CLLocation] {
// Ensure valid range
guard startDate < endDate else { return self }
// Find first and last locations in the window
guard
let startLocation = first(where: { $0.timestamp >= startDate }),
let endLocation = last(where: { $0.timestamp <= endDate })
else {
return self // No valid range found
}
let startAltitude = startLocation.altitude
let endAltitude = endLocation.altitude
let duration = endDate.timeIntervalSince(startDate)
return map { loc in
let t = loc.timestamp.timeIntervalSince1970
if loc.timestamp >= startDate && loc.timestamp <= endDate {
let progress = (loc.timestamp.timeIntervalSince(startDate)) / duration
let newAltitude = startAltitude + progress * (endAltitude - startAltitude)
return CLLocation(
coordinate: loc.coordinate,
altitude: newAltitude,
horizontalAccuracy: loc.horizontalAccuracy,
verticalAccuracy: loc.verticalAccuracy,
course: loc.course,
speed: loc.speed,
timestamp: loc.timestamp
)
} else {
return loc // outside window, unchanged
}
}
}
}
extension CLLocation {
/// Combined uncertainty sphere radius (meters) from horizontal+vertical accuracy
var uncertaintyRadius3D: CLLocationDistance {
let h = max(0, horizontalAccuracy)
let v = max(0, verticalAccuracy)
return sqrt(h * h + v * v)
}
func verticalDistance(from other: CLLocation) -> CLLocationDistance {
abs(self.altitude - other.altitude)
}
func minimumDistance(to other: CLLocation) -> (distance: CLLocationDistance, point: CLLocation) {
let horizontalDistance = distance(from: other)
let horizontalMovement = Swift.max(0, horizontalDistance - Swift.max(0, other.horizontalAccuracy))
let latitude: CLLocationDegrees
let longitude: CLLocationDegrees
if horizontalDistance == 0 || horizontalMovement == 0 {
latitude = coordinate.latitude
longitude = coordinate.longitude
} else {
let horizontalRatio = horizontalMovement / horizontalDistance
latitude = coordinate.latitude.move(horizontalRatio, to: other.coordinate.latitude)
longitude = coordinate.longitude.move(horizontalRatio, to: other.coordinate.longitude)
}
let verticalDistance = verticalDistance(from: other)
let verticalMovement = Swift.max(0, verticalDistance - Swift.max(0, other.verticalAccuracy))
let altitude: CLLocationDistance
if verticalDistance == 0 || verticalMovement == 0 {
altitude = self.altitude
} else {
let verticalRatio = verticalMovement / verticalDistance
altitude = self.altitude.move(verticalRatio, to: other.altitude)
}
let movement = sqrt(horizontalMovement * horizontalMovement + verticalMovement * verticalMovement)
let point = CLLocation(
coordinate: .init(latitude: latitude, longitude: longitude),
altitude: altitude,
horizontalAccuracy: 0,
verticalAccuracy: 0,
timestamp: other.timestamp
)
return (movement, point)
}
func interpolate(_ time: Date, to other: CLLocation) -> CLLocation {
if self.timestamp > other.timestamp {
return other.interpolate(time, to: self)
}
let totalDuration = other.timestamp.timeIntervalSince(self.timestamp)
if totalDuration == 0 { return move(0.5, to: other) }
let ratio = time.timeIntervalSince(self.timestamp) / totalDuration
return move(ratio, to: other)
}
func move(_ ratio: Double, to other: CLLocation) -> CLLocation {
if ratio <= 0 { return self }
if ratio >= 1 { return other }
let time = timestamp.addingTimeInterval(other.timestamp.timeIntervalSince(timestamp) * ratio)
return CLLocation(
coordinate: .init(
latitude: coordinate.latitude.move(ratio, to: other.coordinate.latitude),
longitude: coordinate.longitude.move(ratio, to: other.coordinate.longitude)),
altitude: altitude.move(ratio, to: other.altitude),
horizontalAccuracy: move(from: horizontalAccuracy, to: other.horizontalAccuracy, by: ratio),
verticalAccuracy: move(from: verticalAccuracy, to: other.verticalAccuracy, by: ratio),
course: move(from: course, to: other.course, by: ratio),
courseAccuracy: move(from: courseAccuracy, to: other.courseAccuracy, by: ratio),
speed: move(from: speed, to: other.speed, by: ratio),
speedAccuracy: move(from: speedAccuracy, to: other.speedAccuracy, by: ratio),
timestamp: time)
}
private func move(from source: Double, to other: Double, by ratio: Double) -> Double {
if source == -1 {
return other
}
if other == -1 {
return source
}
return source.move(ratio, to: other)
}
}
extension Double {
/**
Move to a different value by the given ratio of their distance.
*/
func move(_ ratio: Double, to other: Double) -> Double {
self + (other - self) * ratio
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
import AppKit
import MapKit
struct MapImageCreator {
let locations: [CLLocation]
func createMapSnapshot(
size layoutSize: CGSize,
scale: CGFloat = 2.0,
lineWidth: CGFloat = 5,
paddingFactor: Double = 1.2,
completion: @escaping ((image: NSImage, imagePoints: [CGPoint])?) -> Void
) {
guard !locations.isEmpty else {
completion(nil)
return
}
let coordinates = locations.map { $0.coordinate }
let pixelSize = CGSize(width: layoutSize.width * scale, height: layoutSize.height * scale)
let options = MKMapSnapshotter.Options()
options.size = pixelSize
options.preferredConfiguration = MKHybridMapConfiguration(elevationStyle: .flat)
let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
let boundingMapRect = polyline.boundingMapRect
let region = MKCoordinateRegion(boundingMapRect)
let latDelta = region.span.latitudeDelta * paddingFactor
let lonDelta = region.span.longitudeDelta * paddingFactor
let paddedRegion = MKCoordinateRegion(
center: region.center,
span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta)
)
options.region = paddedRegion
let snapshotter = MKMapSnapshotter(options: options)
snapshotter.start { snapshotOrNil, error in
guard let snapshot = snapshotOrNil, error == nil else {
print("Snapshot error: \(error?.localizedDescription ?? "unknown error")")
completion(nil)
return
}
let image = NSImage(size: pixelSize)
image.lockFocus()
snapshot.image.draw(in: CGRect(origin: .zero, size: pixelSize))
let path = NSBezierPath()
path.lineJoinStyle = .round
let imagePoints = coordinates.map { snapshot.point(for: $0) }
if let first = imagePoints.first {
path.move(to: first)
for point in imagePoints.dropFirst() {
path.line(to: point)
}
NSColor.systemBlue.setStroke()
path.lineWidth = lineWidth * scale
path.stroke()
}
image.unlockFocus()
// Recalculate imagePoints since they were inside the drawing block
let widthFactor = 1 / pixelSize.width
let heightFactor = 1 / pixelSize.height
let finalImagePoints = coordinates.map { coordinate in
let point = snapshot.point(for: coordinate)
return CGPoint(x: point.x * widthFactor,
y: point.y * heightFactor)
}
completion((image, finalImagePoints))
}
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
struct RouteOverview {
/// The total active energy in kcal
let energy: Double
/// The total distance of the track in meters
let distance: Double
/// The total duration in seconds
let duration: TimeInterval
/// The total ascended altitude in meters
let ascendedElevation: Double
let start: Date?
let end: Date?
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
import CoreLocation
struct RoutePreviewView: View {
private let iconSize: CGFloat = 150
@ObservedObject
var file: FileResource
@State
var overview: RouteOverview?
@State
var message: String?
@State
var elevation: [ElevationSample] = []
var body: some View {
VStack {
Image(systemSymbol: .map)
.resizable()
.aspectRatio(contentMode:.fit)
.frame(width: iconSize)
if let message {
Text(message)
.font(.title)
} else if let overview {
if let start = overview.start {
if let end = overview.end {
Text("\(start.formatted()) - \(end.formatted()) (\(overview.duration.timeString))")
} else {
Text(start.formatted())
}
}
Text(String(format: "%.2f km (%.0f m ascended)", overview.distance / 1000, overview.ascendedElevation))
Text("\(Int(overview.energy)) kcal")
if !elevation.isEmpty {
ElevationGraph(samples: elevation)
.frame(width: 500, height: 200)
.padding()
}
} else {
Text("Loading route overview...")
.font(.title)
ProgressView()
.progressViewStyle(.circular)
}
}
.foregroundStyle(.secondary)
.onAppear { loadOverview() }
}
private func loadOverview() {
guard overview == nil && message == nil else {
return
}
Task {
guard let data = file.dataContent() else {
DispatchQueue.main.async {
self.message = "Failed to get file data"
}
return
}
let route: WorkoutData
do {
route = try WorkoutData(data: data)
} catch {
DispatchQueue.main.async {
self.message = "Failed to decode route: \(error)"
}
return
}
let overview = route.overview
let elevations = route.locations.map { ElevationSample(timestamp: $0.timestamp, altitude: $0.altitude) }
DispatchQueue.main.async {
self.overview = overview
self.elevation = elevations
}
}
}
}

View File

@@ -0,0 +1,102 @@
// Store values with indices to handle duplicates uniquely
private struct Entry<T: BinaryFloatingPoint>: Comparable {
let index: Int
let value: T
static func < (lhs: Entry<T>, rhs: Entry<T>) -> Bool {
lhs.value == rhs.value ? lhs.index < rhs.index : lhs.value < rhs.value
}
}
extension Sequence {
/// Applies a centered median filter to the sequence.
/// - Parameters:
/// - windowSize: The number of samples in the median filter window (should be odd for symmetric centering).
/// - transform: Closure to transform each element into a numeric value.
/// - Returns: An array of filtered elements (same type as input).
func medianFiltered<T: BinaryFloatingPoint>(windowSize: Int, transform: (Element) -> T) -> [Element] {
precondition(windowSize > 0, "Window size must be greater than zero")
let input = Array(self)
guard !input.isEmpty else { return [] }
var result: [Element] = []
result.reserveCapacity(input.count)
let halfWindow = windowSize / 2
for i in 0..<input.count {
let start = Swift.max(0, i - halfWindow)
let end = Swift.min(input.count - 1, i + halfWindow)
var window: [Entry<T>] = []
for j in start...end {
window.append(Entry(index: j, value: transform(input[j])))
}
window.sort()
// Median position
let medianIndex = window.count / 2
let medianValue = window[medianIndex].value
// Choose the element closest to the median
let closest = input[start...end]
.min(by: { abs(Double(transform($0) - medianValue)) < abs(Double(transform($1) - medianValue)) })!
result.append(closest)
}
return result
}
/// Default version when Element itself is BinaryFloatingPoint
func medianFiltered(windowSize: Int) -> [Element] where Element: BinaryFloatingPoint {
return self.medianFiltered(windowSize: windowSize, transform: { $0 })
}
/// Iterate over adjacent pairs of elements in the sequence, applying a transform closure.
/// - Parameter transform: A closure that takes two consecutive elements and returns a value of type T.
/// - Returns: An array of transformed values.
func adjacentPairs<T>(_ transform: (Element, Element) -> T) -> [T] {
var result: [T] = []
var iterator = self.makeIterator()
guard var prev = iterator.next() else { return [] }
while let current = iterator.next() {
result.append(transform(prev, current))
prev = current
}
return result
}
}
// MARK: - Helpers
extension Array where Element: Comparable {
/// Binary search returning the index where `predicate` fails (insertion point).
func binarySearch(predicate: (Element) -> Bool) -> Int {
var low = 0
var high = count
while low < high {
let mid = (low + high) / 2
if predicate(self[mid]) {
low = mid + 1
} else {
high = mid
}
}
return low
}
/// Binary search exact element index if present.
func binarySearchExact(_ element: Element) -> Int? {
var low = 0
var high = count - 1
while low <= high {
let mid = (low + high) / 2
if self[mid] == element { return mid }
else if self[mid] < element { low = mid + 1 }
else { high = mid - 1 }
}
return nil
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
extension TimeInterval {
var timeString: String {
let seconds = Int(rounded())
guard seconds > 59 else {
return "\(seconds) s"
}
let min = seconds / 60
let secs = seconds % 60
guard min > 59 else {
return String(format: "%02d:%02d", min, secs)
}
let hours = min / 60
let mins = min % 60
return String(format: "%d:%02d:%02d", hours, mins, secs)
}
}

View File

@@ -0,0 +1,221 @@
import Foundation
import CoreLocation
import BinaryCodable
private struct TrackLocation {
let timestamp: TimeInterval
let latitude: Double
let longitude: Double
let speed: Double?
let speedAccuracy: Double?
let course: Double?
let courseAccuracy: Double?
let elevation: Double
let horizontalAccuracy: Double?
let verticalAccuracy: Double?
init(
timestamp: TimeInterval,
latitude: Double,
longitude: Double,
speed: Double,
speedAccuracy: Double? = nil,
course: Double,
courseAccuracy: Double? = nil,
elevation: Double,
horizontalAccuracy: Double? = nil,
verticalAccuracy: Double? = nil
) {
self.timestamp = timestamp
self.latitude = latitude
self.longitude = longitude
self.speed = speed
self.speedAccuracy = speedAccuracy
self.course = course
self.courseAccuracy = courseAccuracy
self.elevation = elevation
self.horizontalAccuracy = horizontalAccuracy
self.verticalAccuracy = verticalAccuracy
}
init(location: CLLocation) {
self.timestamp = location.timestamp.timeIntervalSince1970
self.elevation = location.altitude
self.latitude = location.coordinate.latitude
self.longitude = location.coordinate.longitude
self.speed = location.speed
self.speedAccuracy = location.speedAccuracy
self.course = location.course
self.courseAccuracy = location.courseAccuracy
self.horizontalAccuracy = location.horizontalAccuracy
self.verticalAccuracy = location.verticalAccuracy
}
var location: CLLocation {
.init(
coordinate: .init(
latitude: latitude,
longitude: longitude),
altitude: elevation,
horizontalAccuracy: horizontalAccuracy ?? -1,
verticalAccuracy: verticalAccuracy ?? -1,
course: course ?? -1,
courseAccuracy: courseAccuracy ?? -1,
speed: speed ?? -1,
speedAccuracy: speedAccuracy ?? -1,
timestamp: .init(timeIntervalSince1970: timestamp))
}
}
extension TrackLocation: Codable {
enum CodingKeys: Int, CodingKey {
case timestamp = 1
case latitude
case longitude
case speed
case speedAccuracy
case course
case courseAccuracy
case elevation
case horizontalAccuracy
case verticalAccuracy
}
}
extension WorkoutData {
struct Sample {
/// The unix time
let timestamp: TimeInterval
let value: Double
init(timestamp: TimeInterval, value: Double) {
self.timestamp = timestamp
self.value = value
}
var time: Date {
.init(timeIntervalSince1970: timestamp)
}
}
}
extension WorkoutData.Sample: Codable {
enum CodingKeys: Int, CodingKey {
case timestamp = 1
case value
}
}
struct WorkoutData {
let locations: [CLLocation]
let heartRates: [Sample]
/// The active energy in kcal
let energy: [Sample]
init(locations: [CLLocation], heartRates: [Sample], energy: [Sample]) {
self.locations = locations
self.heartRates = heartRates
self.energy = energy
}
func encoded() throws -> Data {
let encoder = BinaryEncoder()
return try encoder.encode(self)
}
init(url: URL) throws {
let data = try Data(contentsOf: url)
try self.init(data: data)
}
init(data: Data) throws {
let decoder = BinaryDecoder()
self = try decoder.decode(WorkoutData.self, from: data)
}
/// The total active energy in kcal
var totalEnergy: Double {
energy.reduce(0) { $0 + $1.value }
}
/// The total distance of the track in meters
var totalDistance: CLLocationDistance {
locations.minimumTraveledDistance3D()
}
/// The total duration in seconds
var totalDuration: TimeInterval {
guard let start, let end else {
return 0
}
return end.timeIntervalSince(start)
}
/// The total ascended altitude in meters
var totalAscendedElevation: CLLocationDistance {
locations.minimumAscendedAltitude(threshold: 15)
}
var overview: RouteOverview {
.init(energy: totalEnergy,
distance: totalDistance,
duration: totalDuration,
ascendedElevation: totalAscendedElevation,
start: start,
end: end)
}
var start: Date? {
let starts: [Date?] = [
locations.first?.timestamp,
heartRates.first?.time,
energy.first?.time]
return starts.compactMap { $0 }.min()
}
var end: Date? {
let ends: [Date?] = [locations.last?.timestamp, heartRates.last?.time, energy.last?.time]
return ends.compactMap { $0 }.max()
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(locations.map(TrackLocation.init), forKey: .locations)
try container.encode(heartRates, forKey: .heartRates)
try container.encode(energy, forKey: .energy)
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.locations = try container.decode([TrackLocation].self, forKey: .locations).map { $0.location }
self.heartRates = try container.decode([Sample].self, forKey: .heartRates)
self.energy = try container.decode([Sample].self, forKey: .energy)
}
}
extension WorkoutData: Codable {
enum CodingKeys: Int, CodingKey {
case locations = 1
case heartRates
case energy
}
}