diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 45ae075..04c4f16 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -34,6 +34,13 @@ E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */; }; E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; }; E21A573E2D8C714000E9EBE3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */; }; + E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */; }; + E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */; }; + E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */; }; + E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E12E5652680031C2B0 /* WorkoutData.swift */; }; + E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = E224E0E42E56528F0031C2B0 /* BinaryCodable */; }; + E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */; }; + E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E82E5668470031C2B0 /* Time+String.swift */; }; E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */; }; E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; }; E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229901D2D0E4362009F8D77 /* LocalizedItem.swift */; }; @@ -174,6 +181,8 @@ E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2A2CED2CC30000979F /* TagDetailView.swift */; }; E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; }; E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.swift */; }; + E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */; }; + E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */; }; E2B482002D5D1136005C309D /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = E2B481FF2D5D1136005C309D /* Vapor */; }; E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; }; E2B482052D5E7D4A005C309D /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482042D5E7D4A005C309D /* WebView.swift */; }; @@ -323,6 +332,12 @@ E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = ""; }; E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = ""; }; + E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapImageCreator.swift; sourceTree = ""; }; + E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Median.swift"; sourceTree = ""; }; + E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locations+Sampled.swift"; sourceTree = ""; }; + E224E0E12E5652680031C2B0 /* WorkoutData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutData.swift; sourceTree = ""; }; + E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePreviewView.swift; sourceTree = ""; }; + E224E0E82E5668470031C2B0 /* Time+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Time+String.swift"; sourceTree = ""; }; E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = ""; }; E22990182D0E3546009F8D77 /* ItemReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemReference.swift; sourceTree = ""; }; E229901D2D0E4362009F8D77 /* LocalizedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedItem.swift; sourceTree = ""; }; @@ -460,6 +475,8 @@ E2A37D2A2CED2CC30000979F /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = ""; }; E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = ""; }; E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; + E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteOverview.swift; sourceTree = ""; }; + E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationGraph.swift; sourceTree = ""; }; E2B482022D5D132D005C309D /* WebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebServer.swift; sourceTree = ""; }; E2B482042D5E7D4A005C309D /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = ""; }; @@ -593,6 +610,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */, E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */, E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */, E2FD1D522D4644B400B48627 /* SVGView in Frameworks */, @@ -693,6 +711,21 @@ path = Mock; sourceTree = ""; }; + E224E0D72E55074E0031C2B0 /* Workouts */ = { + isa = PBXGroup; + children = ( + E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */, + E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */, + E224E0E82E5668470031C2B0 /* Time+String.swift */, + E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */, + E224E0E12E5652680031C2B0 /* WorkoutData.swift */, + E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */, + E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */, + E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */, + ); + path = Workouts; + sourceTree = ""; + }; E229901A2D0E3F09009F8D77 /* Item */ = { isa = PBXGroup; children = ( @@ -1077,6 +1110,7 @@ E2DD04722C276F31003BFF1F /* CHDataManagement */ = { isa = PBXGroup; children = ( + E224E0D72E55074E0031C2B0 /* Workouts */, E2B482162D63AF6F005C309D /* Notifications */, E2B4820E2D5E9FF0005C309D /* Push */, E2B482012D5D1325005C309D /* Server */, @@ -1277,6 +1311,7 @@ E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */, E2FD1D512D4644B400B48627 /* SVGView */, E2B481FF2D5D1136005C309D /* Vapor */, + E224E0E42E56528F0031C2B0 /* BinaryCodable */, ); productName = CHDataManagement; productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */; @@ -1316,6 +1351,7 @@ E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */, E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */, E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */, + E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */, ); productRefGroup = E2DD04712C276F31003BFF1F /* Products */; projectDirPath = ""; @@ -1418,6 +1454,7 @@ E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */, E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */, E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */, + E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */, E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */, @@ -1446,6 +1483,7 @@ E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */, E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */, E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */, + E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */, E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */, E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */, E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */, @@ -1462,8 +1500,10 @@ E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */, E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */, + E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */, E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */, E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */, + E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */, E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */, E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */, E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */, @@ -1487,6 +1527,7 @@ E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */, E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */, E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */, + E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */, E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */, @@ -1508,6 +1549,7 @@ E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */, E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */, E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */, + E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */, E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */, E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */, E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */, @@ -1518,6 +1560,7 @@ E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */, E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, + E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */, E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */, E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */, @@ -1581,6 +1624,7 @@ E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */, E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */, + E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */, E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */, E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */, E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */, @@ -1775,7 +1819,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -1814,7 +1858,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -1849,6 +1893,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/christophhagen/BinaryCodable"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.0; + }; + }; E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor"; @@ -1924,6 +1976,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + E224E0E42E56528F0031C2B0 /* BinaryCodable */ = { + isa = XCSwiftPackageProductDependency; + package = E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */; + productName = BinaryCodable; + }; E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = { isa = XCSwiftPackageProductDependency; package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */; diff --git a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b29ccd6..94fc30c 100644 --- a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "747e13d88856438f8013440b6d706faa50b8e06e8a370d5c6bbfaf192255f3ff", + "originHash" : "6a373ae0a2cc4ad97293e2b13e76aa783451436d6a17beb2295cd5e9b2067122", "pins" : [ { "identity" : "async-http-client", @@ -19,6 +19,15 @@ "version" : "1.20.0" } }, + { + "identity" : "binarycodable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/christophhagen/BinaryCodable", + "state" : { + "revision" : "4febea33ee5d813fd9c94c9158be6c85472480d2", + "version" : "3.1.0" + } + }, { "identity" : "console-kit", "kind" : "remoteSourceControl", diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index f8e570f..2c39436 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -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) } diff --git a/CHDataManagement/Model/FileType.swift b/CHDataManagement/Model/FileType.swift index 5389f96..e6f01af 100644 --- a/CHDataManagement/Model/FileType.swift +++ b/CHDataManagement/Model/FileType.swift @@ -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 } diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 95f25d2..f49522d 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -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) diff --git a/CHDataManagement/Views/Files/FileContentView.swift b/CHDataManagement/Views/Files/FileContentView.swift index 3c263d9..ceba41b 100644 --- a/CHDataManagement/Views/Files/FileContentView.swift +++ b/CHDataManagement/Views/Files/FileContentView.swift @@ -77,6 +77,9 @@ struct FileContentView: View { .font(.title) } .foregroundStyle(.secondary) + + case .route: + RoutePreviewView(file: file) } } }.padding() diff --git a/CHDataManagement/Workouts/ElevationGraph.swift b/CHDataManagement/Workouts/ElevationGraph.swift new file mode 100644 index 0000000..fa2e9b4 --- /dev/null +++ b/CHDataManagement/Workouts/ElevationGraph.swift @@ -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() + } +} diff --git a/CHDataManagement/Workouts/Locations+Sampled.swift b/CHDataManagement/Workouts/Locations+Sampled.swift new file mode 100644 index 0000000..3077570 --- /dev/null +++ b/CHDataManagement/Workouts/Locations+Sampled.swift @@ -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..= 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 + } +} diff --git a/CHDataManagement/Workouts/MapImageCreator.swift b/CHDataManagement/Workouts/MapImageCreator.swift new file mode 100644 index 0000000..20bfb8a --- /dev/null +++ b/CHDataManagement/Workouts/MapImageCreator.swift @@ -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)) + } + } +} diff --git a/CHDataManagement/Workouts/RouteOverview.swift b/CHDataManagement/Workouts/RouteOverview.swift new file mode 100644 index 0000000..8a4adc8 --- /dev/null +++ b/CHDataManagement/Workouts/RouteOverview.swift @@ -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? +} diff --git a/CHDataManagement/Workouts/RoutePreviewView.swift b/CHDataManagement/Workouts/RoutePreviewView.swift new file mode 100644 index 0000000..2fe0efc --- /dev/null +++ b/CHDataManagement/Workouts/RoutePreviewView.swift @@ -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 + } + } + } +} diff --git a/CHDataManagement/Workouts/Sequence+Median.swift b/CHDataManagement/Workouts/Sequence+Median.swift new file mode 100644 index 0000000..44e1219 --- /dev/null +++ b/CHDataManagement/Workouts/Sequence+Median.swift @@ -0,0 +1,102 @@ + +// Store values with indices to handle duplicates uniquely +private struct Entry: Comparable { + let index: Int + let value: T + static func < (lhs: Entry, rhs: Entry) -> 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(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..] = [] + + 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(_ 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 + } +} diff --git a/CHDataManagement/Workouts/Time+String.swift b/CHDataManagement/Workouts/Time+String.swift new file mode 100644 index 0000000..4782635 --- /dev/null +++ b/CHDataManagement/Workouts/Time+String.swift @@ -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) + } +} diff --git a/CHDataManagement/Workouts/WorkoutData.swift b/CHDataManagement/Workouts/WorkoutData.swift new file mode 100644 index 0000000..96713f2 --- /dev/null +++ b/CHDataManagement/Workouts/WorkoutData.swift @@ -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 + } +}