Add route files, show overview

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

View File

@@ -34,6 +34,13 @@
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */; }; 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 */; }; 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 */; }; 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 */; }; E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */; };
E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; }; E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; };
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229901D2D0E4362009F8D77 /* LocalizedItem.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 */; }; E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2A2CED2CC30000979F /* TagDetailView.swift */; };
E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; }; E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; };
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.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 */; }; E2B482002D5D1136005C309D /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = E2B481FF2D5D1136005C309D /* Vapor */; };
E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; }; E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; };
E2B482052D5E7D4A005C309D /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482042D5E7D4A005C309D /* WebView.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 = "<group>"; }; E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; }; E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; };
E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; }; E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; };
E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapImageCreator.swift; sourceTree = "<group>"; };
E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Median.swift"; sourceTree = "<group>"; };
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locations+Sampled.swift"; sourceTree = "<group>"; };
E224E0E12E5652680031C2B0 /* WorkoutData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutData.swift; sourceTree = "<group>"; };
E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePreviewView.swift; sourceTree = "<group>"; };
E224E0E82E5668470031C2B0 /* Time+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Time+String.swift"; sourceTree = "<group>"; };
E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = "<group>"; }; E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = "<group>"; };
E22990182D0E3546009F8D77 /* ItemReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemReference.swift; sourceTree = "<group>"; }; E22990182D0E3546009F8D77 /* ItemReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemReference.swift; sourceTree = "<group>"; };
E229901D2D0E4362009F8D77 /* LocalizedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedItem.swift; sourceTree = "<group>"; }; E229901D2D0E4362009F8D77 /* LocalizedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedItem.swift; sourceTree = "<group>"; };
@@ -460,6 +475,8 @@
E2A37D2A2CED2CC30000979F /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; }; E2A37D2A2CED2CC30000979F /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; };
E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = "<group>"; }; E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = "<group>"; };
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; }; E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteOverview.swift; sourceTree = "<group>"; };
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationGraph.swift; sourceTree = "<group>"; };
E2B482022D5D132D005C309D /* WebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebServer.swift; sourceTree = "<group>"; }; E2B482022D5D132D005C309D /* WebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebServer.swift; sourceTree = "<group>"; };
E2B482042D5E7D4A005C309D /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; }; E2B482042D5E7D4A005C309D /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = "<group>"; }; E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = "<group>"; };
@@ -593,6 +610,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */,
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */, E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */, E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
E2FD1D522D4644B400B48627 /* SVGView in Frameworks */, E2FD1D522D4644B400B48627 /* SVGView in Frameworks */,
@@ -693,6 +711,21 @@
path = Mock; path = Mock;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
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 = "<group>";
};
E229901A2D0E3F09009F8D77 /* Item */ = { E229901A2D0E3F09009F8D77 /* Item */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -1077,6 +1110,7 @@
E2DD04722C276F31003BFF1F /* CHDataManagement */ = { E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E224E0D72E55074E0031C2B0 /* Workouts */,
E2B482162D63AF6F005C309D /* Notifications */, E2B482162D63AF6F005C309D /* Notifications */,
E2B4820E2D5E9FF0005C309D /* Push */, E2B4820E2D5E9FF0005C309D /* Push */,
E2B482012D5D1325005C309D /* Server */, E2B482012D5D1325005C309D /* Server */,
@@ -1277,6 +1311,7 @@
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */, E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
E2FD1D512D4644B400B48627 /* SVGView */, E2FD1D512D4644B400B48627 /* SVGView */,
E2B481FF2D5D1136005C309D /* Vapor */, E2B481FF2D5D1136005C309D /* Vapor */,
E224E0E42E56528F0031C2B0 /* BinaryCodable */,
); );
productName = CHDataManagement; productName = CHDataManagement;
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */; productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
@@ -1316,6 +1351,7 @@
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */, E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */, E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */,
E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */, E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */,
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
); );
productRefGroup = E2DD04712C276F31003BFF1F /* Products */; productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -1418,6 +1454,7 @@
E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */, E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */,
E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */, E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */,
E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */, E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */,
E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */,
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */, E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */,
@@ -1446,6 +1483,7 @@
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */, E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */, E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */,
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */, E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */,
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */,
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */, E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */,
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */, E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */,
E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */, E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */,
@@ -1462,8 +1500,10 @@
E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */, E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */,
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */, E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */,
E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */,
E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */, E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */,
E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */, E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */,
E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */,
E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */, E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */,
E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */, E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */,
E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */, E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */,
@@ -1487,6 +1527,7 @@
E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */, E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */,
E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */, E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */,
E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */, E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */,
E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */,
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */, E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */,
@@ -1508,6 +1549,7 @@
E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */, E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */,
E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */, E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */,
E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */, E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */,
E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */,
E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */, E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */,
E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */, E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */,
E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */, E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */,
@@ -1518,6 +1560,7 @@
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */, E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */,
E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */, E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */,
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */,
E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */, E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */,
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */, E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */,
@@ -1581,6 +1624,7 @@
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */, E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */,
E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */, E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */,
E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */,
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */, E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */, E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */,
E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */, E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */,
@@ -1775,7 +1819,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.3; MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -1814,7 +1858,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.3; MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -1849,6 +1893,14 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference 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" */ = { E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor"; repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor";
@@ -1924,6 +1976,11 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
E224E0E42E56528F0031C2B0 /* BinaryCodable */ = {
isa = XCSwiftPackageProductDependency;
package = E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
productName = BinaryCodable;
};
E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = { E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */; package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "747e13d88856438f8013440b6d706faa50b8e06e8a370d5c6bbfaf192255f3ff", "originHash" : "6a373ae0a2cc4ad97293e2b13e76aa783451436d6a17beb2295cd5e9b2067122",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@@ -19,6 +19,15 @@
"version" : "1.20.0" "version" : "1.20.0"
} }
}, },
{
"identity" : "binarycodable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/christophhagen/BinaryCodable",
"state" : {
"revision" : "4febea33ee5d813fd9c94c9158be6c85472480d2",
"version" : "3.1.0"
}
},
{ {
"identity" : "console-kit", "identity" : "console-kit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -125,6 +125,15 @@ final class FileResource: Item, LocalizedItem {
return true 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? { func dataContent() -> Foundation.Data? {
content.storage.fileData(for: identifier) content.storage.fileData(for: identifier)
} }

View File

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

View File

@@ -414,6 +414,12 @@ final class Storage: ObservableObject {
return contentScope.readData(at: path) 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 { func save(fileContent: String, for fileId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
let path = filePath(file: fileId) let path = filePath(file: fileId)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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