Begin statistics creation
This commit is contained in:
104
CHDataManagement/Workouts/CLLocation+Extensions.swift
Normal file
104
CHDataManagement/Workouts/CLLocation+Extensions.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import CoreLocation
|
||||
|
||||
extension CLLocation {
|
||||
|
||||
func duration(since other: CLLocation) -> TimeInterval {
|
||||
duration(since: other.timestamp)
|
||||
}
|
||||
|
||||
func duration(since other: Date) -> TimeInterval {
|
||||
timestamp.timeIntervalSince(other)
|
||||
}
|
||||
|
||||
func speed(from other: CLLocation) -> Double {
|
||||
distance(from: other) / duration(since: other)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
15
CHDataManagement/Workouts/Date+Days.swift
Normal file
15
CHDataManagement/Workouts/Date+Days.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
|
||||
func inclusiveDays(to other: Date, calendar: Calendar = .current) -> Int {
|
||||
let startDay = calendar.startOfDay(for: self)
|
||||
let endDay = calendar.startOfDay(for: other)
|
||||
|
||||
guard let days = calendar.dateComponents([.day], from: startDay, to: endDay).day else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return abs(days) + 1
|
||||
}
|
||||
}
|
||||
115
CHDataManagement/Workouts/Double+Arithmetic.swift
Normal file
115
CHDataManagement/Workouts/Double+Arithmetic.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == Double {
|
||||
|
||||
func sum() -> Double {
|
||||
reduce(0, +)
|
||||
}
|
||||
|
||||
func average() -> Double {
|
||||
sum() / Double(count)
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == Double, Index == Int {
|
||||
|
||||
func floatingMeanFiltered(windowSize: Int) -> [Double] {
|
||||
guard windowSize > 1 else {
|
||||
return self.map { $0 }
|
||||
}
|
||||
guard count >= windowSize else {
|
||||
return .init(repeating: average(), count: count)
|
||||
}
|
||||
|
||||
let firstHalf = windowSize / 2
|
||||
let secondHalf = windowSize - firstHalf
|
||||
var minSpeed: Double = 0
|
||||
var maxSpeed: Double = 0
|
||||
let averageScale = 1.0 / Double(windowSize)
|
||||
|
||||
// First calculate the filtered speeds in the normal unit
|
||||
var currentAverage = self[0..<windowSize].average()
|
||||
var result: [Double] = .init(repeating: currentAverage, count: firstHalf + 1)
|
||||
for index in firstHalf..<count-firstHalf-1 { // Index in self
|
||||
let removed = self[index-firstHalf]
|
||||
let added = self[index+secondHalf]
|
||||
currentAverage += (added - removed) * averageScale
|
||||
result.append(currentAverage)
|
||||
if currentAverage < minSpeed { minSpeed = currentAverage }
|
||||
else if currentAverage > maxSpeed { maxSpeed = currentAverage }
|
||||
}
|
||||
result.append(contentsOf: [Double](repeating: currentAverage, count: secondHalf))
|
||||
return result
|
||||
}
|
||||
|
||||
func fittingAxisLimits(desiredNumSteps: Int = 5, minLimit: Double? = nil, maxLimit: Double? = nil) -> RouteProfile {
|
||||
let (dataMin, dataMax) = minMax(minLimit: minLimit, maxLimit: maxLimit)
|
||||
|
||||
let dataRange = dataMax - dataMin
|
||||
let roughStep = dataRange / Double(desiredNumSteps)
|
||||
|
||||
let exponent = floor(log10(roughStep))
|
||||
let base = pow(10.0, exponent)
|
||||
|
||||
let step: Double
|
||||
if roughStep <= base {
|
||||
step = base
|
||||
} else if roughStep <= 2 * base {
|
||||
step = 2 * base
|
||||
} else if roughStep <= 5 * base {
|
||||
step = 5 * base
|
||||
} else {
|
||||
step = 10 * base
|
||||
}
|
||||
|
||||
let graphMin = floor(dataMin / step) * step
|
||||
let graphMax = ceil(dataMax / step) * step
|
||||
let numTicks = Int((graphMax - graphMin) / step)
|
||||
return .init(min: graphMin, max: graphMax, ticks: numTicks)
|
||||
}
|
||||
|
||||
func minMax(minLimit: Double? = nil, maxLimit: Double? = nil) -> (min: Double, max: Double) {
|
||||
var dataMin = self.min() ?? 0
|
||||
var dataMax = self.max() ?? 1
|
||||
if let minLimit {
|
||||
dataMin = Swift.max(dataMin, minLimit)
|
||||
}
|
||||
if let maxLimit {
|
||||
dataMax = Swift.min(dataMax, maxLimit)
|
||||
}
|
||||
return (dataMin, dataMax)
|
||||
}
|
||||
|
||||
func fittingTimeAxisLimits(minTicks: Int = 3, maxTicks: Int = 7, minLimit: Double? = nil, maxLimit: Double? = nil) -> RouteProfile {
|
||||
let (dataMin, dataMax) = minMax(minLimit: minLimit, maxLimit: maxLimit)
|
||||
|
||||
let dataRange = dataMax - dataMin
|
||||
let niceSteps: [Double] = [15, 30, 60, 120, 300, 600, 900, 1800, 3600] // in seconds
|
||||
|
||||
// Find the step size that gives a nice number of ticks
|
||||
var chosenStep = niceSteps.last! // fallback to largest if none fit
|
||||
for step in niceSteps {
|
||||
let numTicks = dataRange / step
|
||||
if Double(minTicks) <= numTicks && numTicks <= Double(maxTicks) {
|
||||
chosenStep = step
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let graphMin = floor(dataMin / chosenStep) * chosenStep
|
||||
let graphMax = ceil(dataMax / chosenStep) * chosenStep
|
||||
let numTicks = Int((graphMax-graphMin) / chosenStep)
|
||||
return .init(min: graphMin, max: graphMax, ticks: numTicks)
|
||||
}
|
||||
|
||||
}
|
||||
13
CHDataManagement/Workouts/File/DataRanges.swift
Normal file
13
CHDataManagement/Workouts/File/DataRanges.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
struct DataRanges {
|
||||
|
||||
let duration: RangeInterval
|
||||
|
||||
let time: RangeInterval
|
||||
|
||||
let distance: RangeInterval
|
||||
}
|
||||
|
||||
extension DataRanges: Codable {
|
||||
|
||||
}
|
||||
11
CHDataManagement/Workouts/File/RangeInterval.swift
Normal file
11
CHDataManagement/Workouts/File/RangeInterval.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
struct RangeInterval {
|
||||
|
||||
let min: Double
|
||||
|
||||
let max: Double
|
||||
}
|
||||
|
||||
extension RangeInterval: Codable {
|
||||
|
||||
}
|
||||
26
CHDataManagement/Workouts/File/RouteData.swift
Normal file
26
CHDataManagement/Workouts/File/RouteData.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
All data needed to create statistic displays
|
||||
*/
|
||||
struct RouteData {
|
||||
|
||||
let series: RouteSeries
|
||||
|
||||
let ranges: DataRanges
|
||||
|
||||
let samples: [RouteSample]
|
||||
|
||||
}
|
||||
|
||||
extension RouteData: Codable {
|
||||
|
||||
func encoded(prettyPrinted: Bool = false) -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .sortedKeys
|
||||
if prettyPrinted {
|
||||
encoder.outputFormatting.insert(.prettyPrinted)
|
||||
}
|
||||
return try! encoder.encode(self)
|
||||
}
|
||||
}
|
||||
32
CHDataManagement/Workouts/File/RouteProfile.swift
Normal file
32
CHDataManagement/Workouts/File/RouteProfile.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
struct RouteProfile: Codable {
|
||||
|
||||
let min: Double
|
||||
|
||||
let max: Double
|
||||
|
||||
let ticks: Int
|
||||
|
||||
let span: Double
|
||||
|
||||
let scale: Double
|
||||
|
||||
init(min: Double, max: Double, ticks: Int) {
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.ticks = ticks
|
||||
self.span = max - min
|
||||
self.scale = 1 / span
|
||||
}
|
||||
|
||||
func scale(_ value: Double) -> Double {
|
||||
if value < min {
|
||||
return 0
|
||||
}
|
||||
if value > max {
|
||||
return 1
|
||||
}
|
||||
return (value - min) / span
|
||||
}
|
||||
}
|
||||
|
||||
95
CHDataManagement/Workouts/File/RouteSample.swift
Normal file
95
CHDataManagement/Workouts/File/RouteSample.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
struct RouteSample {
|
||||
|
||||
/// The x-coordinate in the map image (left to right) in the range [0,1]
|
||||
var x: Double
|
||||
|
||||
/// The y-coordinate in the map image (top to bottom) in the range [0,1]
|
||||
var y: Double
|
||||
|
||||
/// The timestamp of the sample in the range [0,1]
|
||||
var time: Double
|
||||
|
||||
/// The distance of the sample in the range [0,1]
|
||||
var distance: Double
|
||||
|
||||
/// The elevation of the sample in the range [0,1]
|
||||
var elevation: Double
|
||||
|
||||
/// The speed of the sample in the range [0,1]
|
||||
var speed: Double
|
||||
|
||||
/// The pace of the sample in the range [0,1]
|
||||
var pace: Double
|
||||
|
||||
/// The heart rate of the sample in the range [0,1]
|
||||
var hr: Double
|
||||
|
||||
/// The active energy rate of the sample in the range [0,1]
|
||||
var energy: Double?
|
||||
}
|
||||
|
||||
extension RouteSample: Codable {
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(x.rounded(decimals: 4), forKey: .x)
|
||||
try container.encode(y.rounded(decimals: 4), forKey: .y)
|
||||
try container.encode(time.rounded(decimals: 4), forKey: .time)
|
||||
try container.encode(distance.rounded(decimals: 4), forKey: .distance)
|
||||
try container.encode(elevation.rounded(decimals: 4), forKey: .elevation)
|
||||
try container.encode(speed.rounded(decimals: 4), forKey: .speed)
|
||||
try container.encode(pace.rounded(decimals: 4), forKey: .pace)
|
||||
try container.encode(hr.rounded(decimals: 4), forKey: .hr)
|
||||
try container.encodeIfPresent(energy?.rounded(decimals: 4), forKey: .energy)
|
||||
}
|
||||
}
|
||||
|
||||
extension RouteSample: Identifiable {
|
||||
|
||||
var id: Double { x }
|
||||
}
|
||||
|
||||
extension RouteSample {
|
||||
|
||||
static var zero: RouteSample {
|
||||
.init(x: 0, y: 0, time: 0, distance: 0, elevation: 0, speed: 0, pace: 0, hr: 0, energy: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == RouteSample {
|
||||
|
||||
var averageSample: RouteSample {
|
||||
guard var average = first else {
|
||||
return .zero
|
||||
}
|
||||
var energySamples = average.energy == nil ? 0 : 1
|
||||
for sample in dropFirst() {
|
||||
average.x += sample.x
|
||||
average.y += sample.y
|
||||
average.time += sample.time
|
||||
average.distance += sample.distance
|
||||
average.elevation += sample.elevation
|
||||
average.speed += sample.speed
|
||||
average.pace += sample.pace
|
||||
average.hr += sample.hr
|
||||
if let energy = sample.energy {
|
||||
average.energy = (average.energy ?? 0) + energy
|
||||
energySamples += 1
|
||||
}
|
||||
}
|
||||
let scale = 1 / Double(count)
|
||||
average.x *= scale
|
||||
average.y *= scale
|
||||
average.time *= scale
|
||||
average.distance *= scale
|
||||
average.elevation *= scale
|
||||
average.speed *= scale
|
||||
average.pace *= scale
|
||||
average.hr *= scale
|
||||
if let energy = average.energy, energySamples > 0 {
|
||||
average.energy = energy / Double(energySamples)
|
||||
}
|
||||
return average
|
||||
}
|
||||
}
|
||||
18
CHDataManagement/Workouts/File/RouteSeries.swift
Normal file
18
CHDataManagement/Workouts/File/RouteSeries.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct RouteSeries {
|
||||
|
||||
let elevation: RouteProfile
|
||||
|
||||
let speed: RouteProfile
|
||||
|
||||
let pace: RouteProfile
|
||||
|
||||
let hr: RouteProfile
|
||||
|
||||
let energy: RouteProfile?
|
||||
}
|
||||
|
||||
extension RouteSeries: Codable {
|
||||
|
||||
}
|
||||
@@ -2,6 +2,17 @@ import CoreLocation
|
||||
|
||||
extension Array where Element == CLLocation {
|
||||
|
||||
var totalDistance: CLLocationDistance {
|
||||
zip(self, dropFirst())
|
||||
.map { $0.distance(from: $1) }
|
||||
.reduce(0, +)
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
guard let start = first, let end = last else { return 0 }
|
||||
return end.timestamp.timeIntervalSince(start.timestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
Sample the locations using a given time interval.
|
||||
*/
|
||||
@@ -137,125 +148,21 @@ extension Array where Element == CLLocation {
|
||||
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 {
|
||||
guard loc.timestamp >= startDate && loc.timestamp <= endDate else {
|
||||
return loc // outside window, unchanged
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,51 @@ struct MapImageCreator {
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
lineColor: NSColor = .systemBlue
|
||||
) -> (image: NSImage, imagePoints: [CGPoint])? {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
var result: (image: NSImage, imagePoints: [CGPoint])?
|
||||
|
||||
self.createMapSnapshot(
|
||||
size: layoutSize,
|
||||
scale: scale,
|
||||
lineWidth: lineWidth,
|
||||
paddingFactor: paddingFactor,
|
||||
lineColor: lineColor
|
||||
) { res in
|
||||
result = res
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
semaphore.wait()
|
||||
return result
|
||||
}
|
||||
|
||||
func createMapSnapshot(
|
||||
size layoutSize: CGSize,
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
lineColor: NSColor = .systemBlue
|
||||
) async -> (image: NSImage, imagePoints: [CGPoint])? {
|
||||
await withCheckedContinuation { c in
|
||||
self.createMapSnapshot(
|
||||
size: layoutSize,
|
||||
scale: scale,
|
||||
lineWidth: lineWidth,
|
||||
paddingFactor: paddingFactor,
|
||||
lineColor: lineColor
|
||||
) { c.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
func createMapSnapshot(
|
||||
size layoutSize: CGSize,
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
lineColor: NSColor = .systemBlue,
|
||||
completion: @escaping ((image: NSImage, imagePoints: [CGPoint])?) -> Void
|
||||
) {
|
||||
guard !locations.isEmpty else {
|
||||
@@ -61,7 +106,7 @@ struct MapImageCreator {
|
||||
path.line(to: point)
|
||||
}
|
||||
|
||||
NSColor.systemBlue.setStroke()
|
||||
lineColor.setStroke()
|
||||
path.lineWidth = lineWidth * scale
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
200
CHDataManagement/Workouts/Point.swift
Normal file
200
CHDataManagement/Workouts/Point.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
|
||||
enum SamplingMode {
|
||||
case cumulative // e.g., distance, energy
|
||||
case instantaneous // e.g., heart rate, altitude
|
||||
}
|
||||
|
||||
struct Point: Identifiable, Equatable, Hashable {
|
||||
|
||||
var x, y: Double
|
||||
|
||||
var id: Double { x }
|
||||
|
||||
/**
|
||||
Interpolate the y value at an x coordinate toward another point.
|
||||
*/
|
||||
func interpolate(to other: Point, at location: Double) -> Double {
|
||||
let totalX = other.x - x
|
||||
if totalX == 0 {
|
||||
return (y + other.y) / 2
|
||||
}
|
||||
let diffX = location - x
|
||||
if diffX == 0 {
|
||||
return y
|
||||
}
|
||||
let ratio = diffX / totalX
|
||||
return y + (other.y - y) * ratio
|
||||
}
|
||||
|
||||
static let zero = Point(x: 0, y: 0)
|
||||
}
|
||||
|
||||
extension Array where Element == Point {
|
||||
|
||||
func resample(numberOfSamples: Int, minX: Double? = nil, maxX: Double? = nil, mode: SamplingMode) -> [Point] {
|
||||
guard count >= 2, numberOfSamples > 0 else { return [] }
|
||||
let firstX = minX ?? first!.x
|
||||
let lastX = maxX ?? last!.x
|
||||
let totalDuration = lastX - firstX
|
||||
guard totalDuration > 0 else { return [] }
|
||||
|
||||
let interval = totalDuration / Double(numberOfSamples)
|
||||
var result: [Point] = .init(repeating: .zero, count: numberOfSamples)
|
||||
|
||||
var currentIndex = 0
|
||||
var current = self[0]
|
||||
|
||||
for i in 0..<numberOfSamples {
|
||||
let startOfInterval = Double(i) * interval
|
||||
let endOfInterval = startOfInterval + interval
|
||||
|
||||
var accumulated = 0.0
|
||||
var accumulatedDuration = 0.0
|
||||
|
||||
while currentIndex < count - 1 {
|
||||
let next = self[currentIndex + 1]
|
||||
|
||||
if next.x <= endOfInterval {
|
||||
let dt = next.x - current.x
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += next.y - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + next.y) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
currentIndex += 1
|
||||
current = next
|
||||
} else {
|
||||
// partial segment at the interval end
|
||||
let dt = endOfInterval - current.x
|
||||
let slope = (next.y - current.y) / (next.x - current.x)
|
||||
let interpolatedY = current.y + slope * dt
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += interpolatedY - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + interpolatedY) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
current = Point(x: endOfInterval, y: interpolatedY)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result[i].x = (startOfInterval + endOfInterval) / 2 // midpoint
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
result[i].y = accumulated / interval // average rate over interval
|
||||
case .instantaneous:
|
||||
result[i].y = accumulatedDuration > 0 ? accumulated / accumulatedDuration : current.y
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resample(
|
||||
numberOfSamples: Int,
|
||||
minX: Double? = nil,
|
||||
maxX: Double? = nil,
|
||||
mode: SamplingMode
|
||||
) -> [Double] {
|
||||
guard count >= 2, numberOfSamples > 0 else { return [] }
|
||||
let minX = minX ?? first!.x
|
||||
let maxX = maxX ?? last!.x
|
||||
let totalDuration = maxX - minX
|
||||
guard totalDuration > 0 else { return [] }
|
||||
|
||||
let interval = totalDuration / Double(numberOfSamples)
|
||||
var result = [Double](repeating: 0.0, count: numberOfSamples)
|
||||
|
||||
var currentIndex = 0
|
||||
var current = self[0]
|
||||
|
||||
for i in 0..<numberOfSamples {
|
||||
let startOfInterval = minX + Double(i) * interval
|
||||
let endOfInterval = startOfInterval + interval
|
||||
|
||||
var accumulated = 0.0
|
||||
var accumulatedDuration = 0.0
|
||||
|
||||
while currentIndex < count - 1 {
|
||||
let next = self[currentIndex + 1]
|
||||
|
||||
if next.x <= endOfInterval {
|
||||
let dt = next.x - current.x
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += next.y - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + next.y) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
currentIndex += 1
|
||||
current = next
|
||||
} else {
|
||||
let dt = endOfInterval - current.x
|
||||
let slope = (next.y - current.y) / (next.x - current.x)
|
||||
let interpolatedY = current.y + slope * dt
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += interpolatedY - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + interpolatedY) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
current = Point(x: endOfInterval, y: interpolatedY)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let value: Double
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
value = accumulated / interval
|
||||
case .instantaneous:
|
||||
value = accumulatedDuration > 0 ? accumulated / accumulatedDuration : current.y
|
||||
}
|
||||
result[i] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence {
|
||||
/// Resamples any sequence of elements into evenly spaced intervals.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - numberOfSamples: Number of output points
|
||||
/// - xSelector: Closure to get the independent variable (e.g., time)
|
||||
/// - ySelector: Closure to get the dependent variable (e.g., distance, heart rate)
|
||||
/// - mode: .cumulative or .instantaneous
|
||||
/// - Returns: Array of Points with evenly spaced `x` and averaged `y`
|
||||
func resample(
|
||||
numberOfSamples: Int,
|
||||
x: (Element) -> Double,
|
||||
y: (Element) -> Double,
|
||||
mode: SamplingMode
|
||||
) -> [Point] {
|
||||
let points = self.map { Point(x: x($0), y: y($0)) }
|
||||
return points.resample(numberOfSamples: numberOfSamples, mode: mode)
|
||||
}
|
||||
|
||||
func resample(
|
||||
numberOfSamples: Int,
|
||||
minX: Double? = nil,
|
||||
maxX: Double? = nil,
|
||||
x: (Element) -> Double,
|
||||
y: (Element) -> Double,
|
||||
mode: SamplingMode
|
||||
) -> [Double] {
|
||||
let points = self.map { Point(x: x($0), y: y($0)) }
|
||||
return points.resample(
|
||||
numberOfSamples: numberOfSamples,
|
||||
minX: minX,
|
||||
maxX: maxX,
|
||||
mode: mode)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,4 +17,38 @@ struct RouteOverview {
|
||||
let start: Date?
|
||||
|
||||
let end: Date?
|
||||
|
||||
static func combine(_ overviews: [RouteOverview]) -> RouteOverview {
|
||||
RouteOverview(
|
||||
energy: overviews.reduce(0) { $0 + $1.energy },
|
||||
distance: overviews.reduce(0) { $0 + $1.distance },
|
||||
duration: overviews.reduce(0) { $0 + $1.duration },
|
||||
ascendedElevation: overviews.reduce(0) { $0 + $1.ascendedElevation },
|
||||
start: overviews.compactMap { $0.start }.min(),
|
||||
end: overviews.compactMap { $0.end }.max())
|
||||
}
|
||||
|
||||
var days: Int {
|
||||
guard let start, let end else {
|
||||
return 0
|
||||
}
|
||||
return start.inclusiveDays(to: end)
|
||||
}
|
||||
}
|
||||
|
||||
extension RouteOverview {
|
||||
|
||||
func update(labels: inout [ContentLabel], language: ContentLanguage) {
|
||||
let locale = language.locale
|
||||
let days = self.days
|
||||
|
||||
if days != 1 {
|
||||
labels.insertOrReplace(icon: .calendar, value: language.text(days: days))
|
||||
} else {
|
||||
labels.insertOrReplace(icon: .statisticsTime, value: duration.duration(locale: locale))
|
||||
}
|
||||
labels.insertOrReplace(icon: .statisticsDistance, value: String(format: "%.1f km", locale: locale, distance / 1000))
|
||||
labels.insertOrReplace(icon: .statisticsElevationUp, value: ascendedElevation.length(roundingToNearest: 50))
|
||||
labels.insertOrReplace(icon: .statisticsEnergy, value: energy.energy(roundingToNearest: 50))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,31 @@ private struct Entry<T: BinaryFloatingPoint>: Comparable {
|
||||
}
|
||||
|
||||
extension Sequence {
|
||||
|
||||
func firstElement() -> Element? {
|
||||
for element in self {
|
||||
return element
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minMax<E>(by converting: (Element) -> E) -> (min: E, max: E)? where E: Comparable {
|
||||
guard let first = firstElement() else {
|
||||
return nil
|
||||
}
|
||||
var minimum = converting(first)
|
||||
var maximum = minimum
|
||||
for location in dropFirst() {
|
||||
let value = converting(location)
|
||||
if value < minimum {
|
||||
minimum = value
|
||||
} else if value > maximum {
|
||||
maximum = value
|
||||
}
|
||||
}
|
||||
return (minimum, maximum)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
Reference in New Issue
Block a user