Add route files, show overview
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -77,6 +77,9 @@ struct FileContentView: View {
|
||||
.font(.title)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
case .route:
|
||||
RoutePreviewView(file: file)
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
|
||||
49
CHDataManagement/Workouts/ElevationGraph.swift
Normal file
49
CHDataManagement/Workouts/ElevationGraph.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
261
CHDataManagement/Workouts/Locations+Sampled.swift
Normal file
261
CHDataManagement/Workouts/Locations+Sampled.swift
Normal 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
|
||||
}
|
||||
}
|
||||
83
CHDataManagement/Workouts/MapImageCreator.swift
Normal file
83
CHDataManagement/Workouts/MapImageCreator.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
20
CHDataManagement/Workouts/RouteOverview.swift
Normal file
20
CHDataManagement/Workouts/RouteOverview.swift
Normal 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?
|
||||
}
|
||||
87
CHDataManagement/Workouts/RoutePreviewView.swift
Normal file
87
CHDataManagement/Workouts/RoutePreviewView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
CHDataManagement/Workouts/Sequence+Median.swift
Normal file
102
CHDataManagement/Workouts/Sequence+Median.swift
Normal 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
|
||||
}
|
||||
}
|
||||
19
CHDataManagement/Workouts/Time+String.swift
Normal file
19
CHDataManagement/Workouts/Time+String.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
221
CHDataManagement/Workouts/WorkoutData.swift
Normal file
221
CHDataManagement/Workouts/WorkoutData.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user