diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 944e18c..7d3f9b9 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */; }; E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; }; E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA59A2D024A2900AEF16D /* DateItem.swift */; }; + E26C300F2E634B3A00FEB26D /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */; }; E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2720B872DF38BB200FDB543 /* Insert+Video.swift */; }; E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* ContentLabels.swift */; }; E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.swift */; }; @@ -208,6 +209,20 @@ E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */; }; E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */; }; E2BF1BCC2D70EE59003089F1 /* TagPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BCB2D70EE55003089F1 /* TagPickerView.swift */; }; + E2DBA3B12E58F57B00F1E143 /* WorkoutBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */; }; + E2DBA3B32E58FB7500F1E143 /* StatisticsFileGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */; }; + E2DBA3B82E590BEE00F1E143 /* Image+Png.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */; }; + E2DBA3BA2E5CBFAE00F1E143 /* Date+Days.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */; }; + E2DBA3BC2E5CC18500F1E143 /* FilesPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */; }; + E2DBA3C42E5E601B00F1E143 /* RouteSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */; }; + E2DBA3C52E5E601B00F1E143 /* RouteSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */; }; + E2DBA3C62E5E601B00F1E143 /* RouteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C02E5E601B00F1E143 /* RouteData.swift */; }; + E2DBA3C72E5E601B00F1E143 /* RouteProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */; }; + E2DBA3C92E5E603300F1E143 /* DataRanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C82E5E603300F1E143 /* DataRanges.swift */; }; + E2DBA3CB2E5E603900F1E143 /* RangeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */; }; + E2DBA3CF2E5F771F00F1E143 /* Double+Arithmetic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */; }; + E2DBA3D12E61E5FF00F1E143 /* Point.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3D02E61E5FD00F1E143 /* Point.swift */; }; + E2DBA3D32E61F70000F1E143 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */; }; E2DD04742C276F31003BFF1F /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DD04732C276F31003BFF1F /* MainView.swift */; }; E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; }; E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; }; @@ -398,6 +413,7 @@ E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsDetailView.swift; sourceTree = ""; }; E25DA5982D02401A00AEF16D /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = ""; }; E25DA59A2D024A2900AEF16D /* DateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateItem.swift; sourceTree = ""; }; + E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; E2720B872DF38BB200FDB543 /* Insert+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Video.swift"; sourceTree = ""; }; E29D311F2D0320E20051B7F4 /* ContentLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabels.swift; sourceTree = ""; }; E29D31212D0363FA0051B7F4 /* ContentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentButtons.swift; sourceTree = ""; }; @@ -502,6 +518,20 @@ E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Link.swift"; sourceTree = ""; }; E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPropertyView.swift; sourceTree = ""; }; E2BF1BCB2D70EE55003089F1 /* TagPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPickerView.swift; sourceTree = ""; }; + E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutBlock.swift; sourceTree = ""; }; + E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsFileGenerator.swift; sourceTree = ""; }; + E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Png.swift"; sourceTree = ""; }; + E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Days.swift"; sourceTree = ""; }; + E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesPropertyView.swift; sourceTree = ""; }; + E2DBA3C02E5E601B00F1E143 /* RouteData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteData.swift; sourceTree = ""; }; + E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteProfile.swift; sourceTree = ""; }; + E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSample.swift; sourceTree = ""; }; + E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSeries.swift; sourceTree = ""; }; + E2DBA3C82E5E603300F1E143 /* DataRanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRanges.swift; sourceTree = ""; }; + E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeInterval.swift; sourceTree = ""; }; + E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Arithmetic.swift"; sourceTree = ""; }; + E2DBA3D02E61E5FD00F1E143 /* Point.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Point.swift; sourceTree = ""; }; + E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLLocation+Extensions.swift"; sourceTree = ""; }; E2DD04702C276F31003BFF1F /* CHDataManagement.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CHDataManagement.app; sourceTree = BUILT_PRODUCTS_DIR; }; E2DD04732C276F31003BFF1F /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; E2DD04792C276F32003BFF1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -716,14 +746,19 @@ E224E0D72E55074E0031C2B0 /* Workouts */ = { isa = PBXGroup; children = ( + E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */, + E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */, + E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */, E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */, - E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */, - E224E0E82E5668470031C2B0 /* Time+String.swift */, - E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */, - E224E0E12E5652680031C2B0 /* WorkoutData.swift */, + E2DBA3BF2E5E601300F1E143 /* File */, E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */, - E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */, E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */, + E2DBA3D02E61E5FD00F1E143 /* Point.swift */, + E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */, + E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */, + E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */, + E224E0E82E5668470031C2B0 /* Time+String.swift */, + E224E0E12E5652680031C2B0 /* WorkoutData.swift */, ); path = Workouts; sourceTree = ""; @@ -773,7 +808,9 @@ E22990232D0EDBD0009F8D77 /* HeaderElement.swift */, E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */, E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, + E2DBA3B42E590B1E00F1E143 /* Images */, E22990412D107A94009F8D77 /* ImageVersion.swift */, + E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */, E2FE0F182D2723E3002963B7 /* ImageSet.swift */, ); path = Generator; @@ -884,6 +921,7 @@ E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */, E22990292D0F5A10009F8D77 /* DetailTitle.swift */, E22990252D0F5822009F8D77 /* FilePropertyView.swift */, + E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */, E2A21C0F2CB18B390060935B /* FlowHStack.swift */, E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */, E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */, @@ -1076,6 +1114,7 @@ E2B85F552C4BD0AD0047CD0C /* Extensions */ = { isa = PBXGroup; children = ( + E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */, E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */, E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */, E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */, @@ -1093,6 +1132,27 @@ path = Extensions; sourceTree = ""; }; + E2DBA3B42E590B1E00F1E143 /* Images */ = { + isa = PBXGroup; + children = ( + E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */, + ); + path = Images; + sourceTree = ""; + }; + E2DBA3BF2E5E601300F1E143 /* File */ = { + isa = PBXGroup; + children = ( + E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */, + E2DBA3C82E5E603300F1E143 /* DataRanges.swift */, + E2DBA3C02E5E601B00F1E143 /* RouteData.swift */, + E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */, + E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */, + E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */, + ); + path = File; + sourceTree = ""; + }; E2DD04672C276F31003BFF1F = { isa = PBXGroup; children = ( @@ -1216,6 +1276,7 @@ E2FE0F342D2B27E6002963B7 /* Blocks */ = { isa = PBXGroup; children = ( + E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */, E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */, E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */, E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */, @@ -1416,6 +1477,7 @@ E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */, E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */, E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */, + E2DBA3B32E58FB7500F1E143 /* StatisticsFileGenerator.swift in Sources */, E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */, E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */, E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */, @@ -1438,6 +1500,7 @@ E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */, E2FE0F6E2D2D3689002963B7 /* LocalizedAudioPlayerSettings.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */, + E2DBA3B82E590BEE00F1E143 /* Image+Png.swift in Sources */, E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */, E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */, E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */, @@ -1466,6 +1529,7 @@ E229902C2D0F6FC6009F8D77 /* LocalizedItemId.swift in Sources */, E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */, E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */, + E2DBA3D12E61E5FF00F1E143 /* Point.swift in Sources */, E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */, E2B482202D670753005C309D /* WallpaperSlider.swift in Sources */, E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */, @@ -1477,6 +1541,7 @@ E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */, E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */, E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */, + E2DBA3BC2E5CC18500F1E143 /* FilesPropertyView.swift in Sources */, E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */, E22990302D0F75DE009F8D77 /* BoolPropertyView.swift in Sources */, E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */, @@ -1487,6 +1552,7 @@ E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */, E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */, E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */, + E2DBA3C92E5E603300F1E143 /* DataRanges.swift in Sources */, E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */, E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */, E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */, @@ -1494,6 +1560,7 @@ E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */, E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */, E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */, + E2DBA3CF2E5F771F00F1E143 /* Double+Arithmetic.swift in Sources */, E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */, E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */, E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */, @@ -1515,11 +1582,13 @@ E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */, E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */, E2F3B3A22DC769C300CFA712 /* ColoredButton.swift in Sources */, + E2DBA3D32E61F70000F1E143 /* CLLocation+Extensions.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */, E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */, E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */, E21850332CFAFA2F0090B18B /* Settings.swift in Sources */, E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */, + E2DBA3BA2E5CBFAE00F1E143 /* Date+Days.swift in Sources */, E2FD1D392D3BBED300B48627 /* InsertableView.swift in Sources */, E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */, E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */, @@ -1561,6 +1630,7 @@ E21A57392D8C714000E9EBE3 /* File+Mock.swift in Sources */, E21A573A2D8C714000E9EBE3 /* Tag+Mock.swift in Sources */, E21A573B2D8C714000E9EBE3 /* Post+Mock.swift in Sources */, + E2DBA3CB2E5E603900F1E143 /* RangeInterval.swift in Sources */, E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */, E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, @@ -1569,6 +1639,7 @@ E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, + E2DBA3B12E58F57B00F1E143 /* WorkoutBlock.swift in Sources */, E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */, E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */, E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */, @@ -1617,6 +1688,7 @@ E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */, E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */, E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */, + E26C300F2E634B3A00FEB26D /* TimeInterval+Extensions.swift in Sources */, E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */, @@ -1643,6 +1715,10 @@ E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */, E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */, E2FE0F532D2BCE17002963B7 /* SvgCommand.swift in Sources */, + E2DBA3C42E5E601B00F1E143 /* RouteSeries.swift in Sources */, + E2DBA3C52E5E601B00F1E143 /* RouteSample.swift in Sources */, + E2DBA3C62E5E601B00F1E143 /* RouteData.swift in Sources */, + E2DBA3C72E5E601B00F1E143 /* RouteProfile.swift in Sources */, E2FE0F3E2D2B4225002963B7 /* AudioSettingsDetailView.swift in Sources */, E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */, E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */, diff --git a/CHDataManagement/Extensions/Double+Rounded.swift b/CHDataManagement/Extensions/Double+Rounded.swift index b2e7178..f64f799 100644 --- a/CHDataManagement/Extensions/Double+Rounded.swift +++ b/CHDataManagement/Extensions/Double+Rounded.swift @@ -1,7 +1,14 @@ +import Foundation +import _math extension Double { func rounded(to interval: Double) -> Double { (self / interval).rounded() * interval } + + func rounded(decimals: Int) -> Double { + let factor = _math.pow(10.0, Double(decimals)) + return (self * factor).rounded() / factor + } } diff --git a/CHDataManagement/Extensions/TimeInterval+Extensions.swift b/CHDataManagement/Extensions/TimeInterval+Extensions.swift new file mode 100644 index 0000000..e3cb981 --- /dev/null +++ b/CHDataManagement/Extensions/TimeInterval+Extensions.swift @@ -0,0 +1,25 @@ +import Foundation + +extension TimeInterval { + + func duration(locale: Locale) -> String { + let totalMinutes = Int((self / 60).rounded(to: 5)) + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + let suffix = locale.identifier.hasPrefix("de") ? "Std" : "h" + + return String(format: "%d:%02d ", hours, minutes) + suffix + } + + func length(roundingToNearest interval: Double) -> String { + let rounded = Int(self.rounded(to: interval)) + return "\(rounded) m" + } + + func energy(roundingToNearest interval: Double) -> String { + let rounded = Int(self.rounded(to: interval)) + return "\(rounded) kcal" + } + +} diff --git a/CHDataManagement/Generator/Blocks/RouteBlock.swift b/CHDataManagement/Generator/Blocks/RouteBlock.swift index 4f0607c..efec3e6 100644 --- a/CHDataManagement/Generator/Blocks/RouteBlock.swift +++ b/CHDataManagement/Generator/Blocks/RouteBlock.swift @@ -59,7 +59,7 @@ struct RouteBlock: KeyedBlockProcessor { return "" } guard let file = content.file(fileId) else { - results.missing(file: imageId, source: "Route block") + results.missing(file: fileId, source: "Route block") return "" } results.used(file: image) diff --git a/CHDataManagement/Generator/Blocks/VideoBlock.swift b/CHDataManagement/Generator/Blocks/VideoBlock.swift index 28ba569..acd88e5 100644 --- a/CHDataManagement/Generator/Blocks/VideoBlock.swift +++ b/CHDataManagement/Generator/Blocks/VideoBlock.swift @@ -183,7 +183,8 @@ extension VideoBlock { var mimeType: String { switch self { - case .h265, .h264: "video/mp4" + case .h265: "video/mp4; codecs=\"hvc1\"" + case .h264: "video/mp4; codecs=\"avc1\"" case .webm: "video/webm" } } diff --git a/CHDataManagement/Generator/Blocks/WorkoutBlock.swift b/CHDataManagement/Generator/Blocks/WorkoutBlock.swift new file mode 100644 index 0000000..8b82793 --- /dev/null +++ b/CHDataManagement/Generator/Blocks/WorkoutBlock.swift @@ -0,0 +1,90 @@ + +struct WorkoutBlock: KeyedBlockProcessor { + + enum Key: String { + case chartTitle + case components + case mapTitle + case caption + case file + } + + static let blockId: ContentBlock = .route + + let content: Content + + let results: PageGenerationResults + + let language: ContentLanguage + + init(content: Content, results: PageGenerationResults, language: ContentLanguage) { + self.content = content + self.results = results + self.language = language + } + + private var thumbnailWidth: Int { + content.settings.pages.contentWidth + } + + private var largeImageWidth: Int { + content.settings.pages.largeImageWidth + } + + func process(_ arguments: [Key : String], markdown: Substring) -> String { + guard let fileId = arguments[.file] else { + invalid(markdown) + return "" + } + + let rawComponents = arguments[.components] + var displayedTypes: Set = [] + if let rawComponents { + rawComponents.components(separatedBy: ",").compactMap { $0.trimmed.nonEmpty }.forEach { rawType in + if let type = RouteStatisticType(rawValue: rawType) { + displayedTypes.insert(type) + } else { + results.warning("Unknown component type '\(rawType)' in route block") + } + } + } + if displayedTypes.isEmpty { + displayedTypes = Set(RouteStatisticType.allCases) + } + + guard let file = content.file(fileId) else { + results.missing(file: fileId, source: "Route block") + return "" + } + results.used(file: file) + + // Note: Use png type, otherwise the original type would be .route + let thumbnail = file.imageSet(type: .png, width: thumbnailWidth, height: thumbnailWidth, language: language) + results.require(imageSet: thumbnail) + + let largeImage = file.imageSet(type: .png, width: largeImageWidth, height: largeImageWidth, language: language) + results.require(imageSet: largeImage) + + results.require(header: .routeJs) + + let id = fileId.replacingOccurrences(of: ".", with: "-") + + let views = RouteViews( + localization: language == .english ? .english : .german, + chartTitle: arguments[.chartTitle], + chartId: "chart-" + id, + displayedTypes: displayedTypes, + mapTitle: arguments[.mapTitle], + mapId: "map-" + id, + filePath: file.absoluteUrl, + imageId: "image-" + id, + thumbnail: thumbnail, + largeImage: largeImage, + caption: arguments[.caption]) + + results.require(footer: views.script) + results.require(icons: displayedTypes.map { $0.icon }) + + return views.content + } +} diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index 0f20ffb..f2a8185 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -44,6 +44,9 @@ final class ImageGenerator { // MARK: Image operations func generate(version: ImageVersion) -> Bool { + if version.image.type == .route { + return generateImageForRoute(version: version) + } if version.type == .avif { if version.image.type == .gif { // Skip GIFs, since they can't be converted by avifenc @@ -56,12 +59,15 @@ final class ImageGenerator { } return false } - guard let data = version.image.dataContent() else { print("ImageGenerator: Failed to load data for image \(version.image.identifier)") return false } + return generate(version: version, data: data) + } + + private func generate(version: ImageVersion, data: Data) -> Bool { guard let originalImage = NSImage(data: data) else { print("ImageGenerator: Failed to load image \(version.image.identifier)") return false @@ -109,6 +115,34 @@ final class ImageGenerator { return representation } + // MARK: Routes + + private func generateImageForRoute(version: ImageVersion) -> Bool { + let largeImagePath = version.image.mapImagePath + + guard storage.hasFileInOutputFolder(largeImagePath) else { + print("ImageGenerator: No map image generated for route \(version.image.identifier)") + return false + } + + let largeImageUrl = URL(fileURLWithPath: largeImagePath) + guard let imageData = try? Data(contentsOf: largeImageUrl) else { + print("ImageGenerator: Failed to read map image data for route \(version.image.identifier)") + return false + } + + if version.type == .avif { + let originalImagePath = version.image.outputPath(width: version.maximumWidth, height: version.maximumHeight, type: .png) + guard createAvifUsingBash(version: version, imagePath: originalImagePath) else { + return false + } + version.wasNowGenerated() + return true + } + + return generate(version: version, data: imageData) + } + // MARK: Avif images private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? { @@ -139,19 +173,23 @@ final class ImageGenerator { } private func createAvifUsingBash(version: ImageVersion) -> Bool { - let baseVersion = ImageVersion( image: version.image, type: version.image.type, maximumWidth: version.maximumWidth, maximumHeight: version.maximumHeight) let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path() + return createAvifUsingBash(version: version, imagePath: originalImagePath) + } + + private func createAvifUsingBash(version: ImageVersion, imagePath: String) -> Bool { let generatedImagePath = storage.outputPath(to: version.outputPath)!.path() let quality = Int(version.quality * 100) let process = Process() + #warning("TODO: Move avifenc path to settings") process.launchPath = "/opt/homebrew/bin/avifenc" // Adjust based on installation - process.arguments = ["-q", "\(quality)", originalImagePath, generatedImagePath] + process.arguments = ["-q", "\(quality)", imagePath, generatedImagePath] let pipe = Pipe() process.standardOutput = pipe diff --git a/CHDataManagement/Generator/ImageSet.swift b/CHDataManagement/Generator/ImageSet.swift index f0b5c65..31fba7c 100644 --- a/CHDataManagement/Generator/ImageSet.swift +++ b/CHDataManagement/Generator/ImageSet.swift @@ -4,6 +4,8 @@ struct ImageSet: HtmlProducer { let image: FileResource + let type: FileType + let maxWidth: Int let maxHeight: Int @@ -14,8 +16,9 @@ struct ImageSet: HtmlProducer { let extraAttributes: String - init(image: FileResource, maxWidth: Int, maxHeight: Int, description: String?, quality: CGFloat = 0.7, extraAttributes: String? = nil) { + init(image: FileResource, type: FileType? = nil, maxWidth: Int, maxHeight: Int, description: String?, quality: CGFloat = 0.7, extraAttributes: String? = nil) { self.image = image + self.type = type ?? image.type self.maxWidth = maxWidth self.maxHeight = maxHeight self.description = description @@ -24,8 +27,6 @@ struct ImageSet: HtmlProducer { } var jobs: [ImageVersion] { - let type = image.type - let width2x = maxWidth * 2 let height2x = maxHeight * 2 diff --git a/CHDataManagement/Generator/Images/Image+Png.swift b/CHDataManagement/Generator/Images/Image+Png.swift new file mode 100644 index 0000000..d070ae1 --- /dev/null +++ b/CHDataManagement/Generator/Images/Image+Png.swift @@ -0,0 +1,28 @@ +import AppKit + +extension NSImage { + + func writePng(to url: URL) -> Bool { + // Get CGImage from NSImage + guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return false + } + + // Create a bitmap representation + let bitmapRep = NSBitmapImageRep(cgImage: cgImage) + bitmapRep.size = self.size // Preserve image size + + // Convert to PNG data + guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else { + return false + } + + do { + try pngData.write(to: url) + return true + } catch { + print("Error writing PNG:", error) + return false + } + } +} diff --git a/CHDataManagement/Generator/StatisticsFileGenerator.swift b/CHDataManagement/Generator/StatisticsFileGenerator.swift new file mode 100644 index 0000000..43d6711 --- /dev/null +++ b/CHDataManagement/Generator/StatisticsFileGenerator.swift @@ -0,0 +1,174 @@ +import Foundation +import CoreLocation + +struct StatisticsFileGenerationJob { + + let mapCoordinates: [CGPoint] + + let workout: WorkoutData + + let outputPath: URL + + let numberOfSamples: Int + + /// The minimum pace to allow, in min/km + let minimumPace: Double + + init(mapCoordinates: [CGPoint], workout: WorkoutData, path: URL, numberOfSamples: Int, minimumPace: Double = 60) { + self.mapCoordinates = mapCoordinates + self.workout = workout + self.outputPath = path + self.numberOfSamples = numberOfSamples + self.minimumPace = minimumPace + } + + var locations: [CLLocation] { + workout.locations + } + + var heartRateSamples: [WorkoutData.Sample] { + workout.heartRates + } + + var energy: [WorkoutData.Sample] { + workout.energy + } + + var start: Date? { workout.start } + + var end: Date? { workout.end } + + var duration: TimeInterval { + guard let start, let end else { + return 0 + } + return end.timeIntervalSince(start) + } + + /** + Points representing the distance (`y`, in meters) against duration (`x`, in seconds) + */ + func distances() -> [Point] { + guard var current = locations.first else { return [] } + var distanceSoFar = 0.0 + + let start = current.timestamp + return locations.dropFirst().map { next in + let time = next.duration(since: start) + distanceSoFar += next.distance(from: current) + current = next + return Point(x: time, y: distanceSoFar) + } + } + + func run() -> Bool { + let startLocation = locations.first! + let start = startLocation.timestamp + let end = locations.last!.timestamp + let totalDuration = end.timeIntervalSince(start) + let totalDistance = locations.totalDistance // in meter + let minimumPace = minimumPace * 60 // Convert to s/km + + + let rawDistances = distances() // m + /// In meter + let distances: [Point] = rawDistances.resample(numberOfSamples: numberOfSamples, mode: .instantaneous) + let times = distances.map { $0.x } + + let xValues: [Double] = locations.enumerated().map { (index, location) in + Point(x: location.duration(since: start), + y: mapCoordinates[index].x) + }.resample(numberOfSamples: numberOfSamples, mode: .instantaneous) + + let yValues: [Double] = locations.enumerated().map { (index, location) in + Point(x: location.duration(since: start), + y: mapCoordinates[index].y) + }.resample(numberOfSamples: numberOfSamples, mode: .instantaneous) + + let elevations: [Double] = locations.resample( + numberOfSamples: numberOfSamples, + minX: 0, + maxX: totalDuration, + x: { $0.duration(since: start) }, + y: { $0.altitude }, + mode: .instantaneous) + + let speeds: [Double] = rawDistances.resample( + numberOfSamples: numberOfSamples, + minX: 0, + maxX: totalDuration, + mode: .cumulative) + .map { $0 * 3.6 } // Convert from m/s to km/h + .medianFiltered(windowSize: 15) + + // Speed is km/h, pace is s/km + let paces: [Double] = speeds.map { + guard $0 > 0 else { + return minimumPace + } + let converted = 3600 / $0 // s/km + return min(minimumPace, converted) + } + + let heartRates: [Double] = heartRateSamples.resample( + numberOfSamples: numberOfSamples, + minX: 0, + maxX: totalDuration, + x: { $0.time.timeIntervalSince(start) }, + y: { $0.value }, + mode: .instantaneous) + .map { $0 * 60 } // from hz to bpm + .medianFiltered(windowSize: 15) + + let energies: [Double] = energy.resample( + numberOfSamples: numberOfSamples, + minX: 0, + maxX: totalDuration, + x: { $0.time.timeIntervalSince(start) }, + y: { $0.value }, + mode: .instantaneous) + .map { $0 * 60 } // from kcal/s to kcal/min + .medianFiltered(windowSize: 10) + + let series = RouteSeries( + elevation: elevations.fittingAxisLimits(minLimit: 0), + speed: speeds.fittingAxisLimits(minLimit: 0), + pace: paces.fittingTimeAxisLimits(maxLimit: minimumPace), + hr: heartRates.fittingAxisLimits(minLimit: 0), + energy: energies.fittingAxisLimits(minLimit: 0)) + + let ranges = DataRanges( + duration: .init(min: 0, max: totalDuration), + time: .init(min: start.timeIntervalSince1970, + max: end.timeIntervalSince1970), + distance: .init(min: 0, max: totalDistance / 1000)) // km + + let samples: [RouteSample] = (0.. RouteSample in + RouteSample( + x: xValues[index], + y: yValues[index], + time: times[index] / totalDuration, + distance: distances[index].y / totalDistance, + elevation: series.elevation.scale(elevations[index]), + speed: series.speed.scale(speeds[index]), + pace: series.pace.scale(paces[index]), + hr: series.hr.scale(heartRates[index]), + energy: series.energy!.scale(energies[index])) + } + + let result = RouteData( + series: series, + ranges: ranges, + samples: samples) + + let data = result.encoded() + + do { + try data.write(to: outputPath) + return true + } catch { + print("Failed to write file \(outputPath.path()): \(error.localizedDescription)") + return false + } + } +} diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index c9d2454..b1ae276 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -144,6 +144,41 @@ extension Content { } } + // MARK: Routes + + private func generateMapImage(route: FileResource) -> Bool { + let size = route.mapImageDimensions + let path = URL(fileURLWithPath: route.mapImagePath) + + guard let workoutData = route.workoutData else { + print("ImageGenerator: Failed to get workout data for route \(route.identifier)") + return false + } + let mapImager = MapImageCreator(locations: workoutData.locations) + guard let (largeImage, points) = mapImager.createMapSnapshot(size: .init(width: size.width, height: size.height)) else { + print("ImageGenerator: Failed to generate map image for route \(route.identifier)") + return false + } + guard largeImage.writePng(to: path) else { + print("ImageGenerator: Failed to save map image for route \(route.identifier)") + return false + } + + let statisticsGenerator = StatisticsFileGenerationJob( + mapCoordinates: points, + workout: workoutData, + path: route.statisticsFilePath, + numberOfSamples: 600 + ) + + guard statisticsGenerator.run() else { + print("ImageGenerator: Failed to generate statistics for route \(route.identifier)") + return false + } + + return true + } + // MARK: Find items by id func page(_ pageId: String) -> Page? { diff --git a/CHDataManagement/Model/ContentLanguage.swift b/CHDataManagement/Model/ContentLanguage.swift index 8478bff..9f22fa8 100644 --- a/CHDataManagement/Model/ContentLanguage.swift +++ b/CHDataManagement/Model/ContentLanguage.swift @@ -33,8 +33,23 @@ extension ContentLanguage: Comparable { } } + extension ContentLanguage { + func text(days: Int) -> String { + switch self { + case .english: return "\(days) day\(days == 1 ? "" : "s")" + case .german: return "\(days) Tag\(days == 1 ? "" : "e")" + } + } + + var locale: Locale { + switch self { + case .english: Locale(identifier: "en_US") + case .german: Locale(identifier: "de_DE") + } + } + var next: ContentLanguage { switch self { case .english: return .german diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index 3ae3380..23e6b8e 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -269,10 +269,11 @@ final class FileResource: Item, LocalizedItem { return prefix + "." + ext } - func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet { + func imageSet(type: FileType? = nil, width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet { let description = self.localized(in: language) return .init( image: self, + type: type, maxWidth: width, maxHeight: height, description: description, @@ -300,14 +301,34 @@ final class FileResource: Item, LocalizedItem { // MARK: Workout - var routeOverview: RouteOverview? { + #warning("Set correct map image size, ratio from settings?") + var mapImageDimensions: (width: Int, height: Int) { + (width: content.settings.pages.largeImageWidth, height: 0) + } + + var mapImagePath: String { + let dimension = mapImageDimensions + return outputPath(width: dimension.width, height: dimension.height, type: .png) + } + + var statisticsFilePath: URL { + let path = "\(content.settings.paths.filesOutputFolderPath)/\(identifier.fileNameWithoutExtension).route" + let fullPath = makeCleanAbsolutePath(path) + return URL(filePath: fullPath, directoryHint: .notDirectory) + } + + var workoutData: WorkoutData? { guard type == .route else { return nil } guard let data = dataContent() else { return nil } - return try? WorkoutData(data: data).overview + return try? WorkoutData(data: data) + } + + var routeOverview: RouteOverview? { + workoutData?.overview } // MARK: Video thumbnail @@ -386,6 +407,21 @@ final class FileResource: Item, LocalizedItem { } } +extension Array where Element == ContentLabel { + + mutating func insertOrReplace(icon: PageIcon, value: String) { + insertOrReplace(label: .init(icon: icon, value: value)) + } + + mutating func insertOrReplace(label: ContentLabel) { + if let index = firstIndex(where: { $0.icon == label.icon }) { + self[index] = label + } else { + append(label) + } + } +} + extension FileResource: CustomStringConvertible { var description: String { diff --git a/CHDataManagement/Model/LocalizedPost.swift b/CHDataManagement/Model/LocalizedPost.swift index 8536ef0..eeaa6b2 100644 --- a/CHDataManagement/Model/LocalizedPost.swift +++ b/CHDataManagement/Model/LocalizedPost.swift @@ -77,45 +77,6 @@ final class LocalizedPost: ChangeObservingItem { var hasVideos: Bool { images.contains { $0.type.isVideo } } - - func updateLabels(from workout: RouteOverview, locale: Locale) { - insertOrReplace(label: .init(icon: .statisticsDistance, value: String(format: "%.1f km", locale: locale, workout.distance / 1000))) - insertOrReplace(label: .init(icon: .statisticsTime, value: workout.duration.duration(locale: locale))) - insertOrReplace(label: .init(icon: .statisticsElevationUp, value: workout.ascendedElevation.length(roundingToNearest: 50))) - insertOrReplace(label: .init(icon: .statisticsEnergy, value: workout.energy.energy(roundingToNearest: 50))) - } - - func insertOrReplace(label: ContentLabel) { - if let index = labels.firstIndex(where: { $0.icon == label.icon }) { - labels[index] = label - } else { - labels.append(label) - } - } -} - -private extension TimeInterval { - - func duration(locale: Locale) -> String { - let totalMinutes = Int((self / 60).rounded(to: 5)) - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - - let suffix = locale.identifier.hasPrefix("de") ? "Std" : "h" - - return String(format: "%d:%02d ", hours, minutes) + suffix - } - - func length(roundingToNearest interval: Double) -> String { - let rounded = Int(self.rounded(to: interval)) - return "\(rounded) m" - } - - func energy(roundingToNearest interval: Double) -> String { - let rounded = Int(self.rounded(to: interval)) - return "\(rounded) kcal" - } - } // MARK: Storage diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index f988a47..f33d97f 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -38,9 +38,9 @@ final class Post: Item, DateItem, LocalizedItem { @Published var linkedPage: Page? - /// The workout associated with the post + /// The workouts associated with the post @Published - var associatedWorkout: FileResource? + var associatedWorkouts: [FileResource] init(content: Content, id: String, @@ -52,7 +52,7 @@ final class Post: Item, DateItem, LocalizedItem { german: LocalizedPost, english: LocalizedPost, linkedPage: Page? = nil, - associatedWorkout: FileResource? = nil) { + associatedWorkouts: [FileResource] = []) { self.isDraft = isDraft self.createdDate = createdDate self.startDate = startDate @@ -62,7 +62,7 @@ final class Post: Item, DateItem, LocalizedItem { self.german = german self.english = english self.linkedPage = linkedPage - self.associatedWorkout = associatedWorkout + self.associatedWorkouts = associatedWorkouts super.init(content: content, id: id) } @@ -182,11 +182,13 @@ final class Post: Item, DateItem, LocalizedItem { } func updateLabelsFromWorkout() { - guard let overview = associatedWorkout?.routeOverview else { + let workouts = associatedWorkouts.compactMap { $0.routeOverview } + guard !workouts.isEmpty else { return } - german.updateLabels(from: overview, locale: Locale(identifier: "de_DE")) - english.updateLabels(from: overview, locale: Locale(identifier: "en_US")) + let overview = RouteOverview.combine(workouts) + overview.update(labels: &german.labels, language: .german) + overview.update(labels: &english.labels, language: .english) } } @@ -204,7 +206,7 @@ extension Post: StorageItem { german: .init(context: context, data: data.german), english: .init(context: context, data: data.english), linkedPage: data.linkedPageId.map(context.page), - associatedWorkout: data.associatedWorkoutId.map(context.file)) + associatedWorkouts: data.associatedWorkoutIds?.compactMap(context.file) ?? []) savedData = data } @@ -217,7 +219,7 @@ extension Post: StorageItem { let german: LocalizedPost.Data let english: LocalizedPost.Data let linkedPageId: String? - let associatedWorkoutId: String? + let associatedWorkoutIds: [String]? } var data: Data { @@ -230,7 +232,7 @@ extension Post: StorageItem { german: german.data, english: english.data, linkedPageId: linkedPage?.identifier, - associatedWorkoutId: associatedWorkout?.identifier) + associatedWorkoutIds: associatedWorkouts.map { $0.identifier}.nonEmpty ) } func saveToDisk(_ data: Data) -> Bool { diff --git a/CHDataManagement/Views/Generic/FilesPropertyView.swift b/CHDataManagement/Views/Generic/FilesPropertyView.swift new file mode 100644 index 0000000..b71864b --- /dev/null +++ b/CHDataManagement/Views/Generic/FilesPropertyView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct FilesPropertyView: View { + + let title: LocalizedStringKey + + let footer: LocalizedStringKey + + @Binding + var selectedFiles: [FileResource] + + let allowedType: FileTypeCategory? + + init( + title: LocalizedStringKey, + footer: LocalizedStringKey, + selectedFiles: Binding<[FileResource]>, + allowedType: FileTypeCategory? = nil + ) { + self.title = title + self.footer = footer + self._selectedFiles = selectedFiles + self.allowedType = allowedType + } + + private var selectedText: String { + guard !selectedFiles.isEmpty else { + return "No file selected" + } + guard selectedFiles.count == 1 else { + return "\(selectedFiles.count) files selected" + } + return selectedFiles[0].identifier + } + + @State + private var showFileSelectionSheet = false + + var body: some View { + GenericPropertyView(title: title, footer: footer) { + HStack { + Text(selectedText) + Spacer() + Button("Select") { + showFileSelectionSheet = true + } + } + } + .sheet(isPresented: $showFileSelectionSheet) { + MultiFileSelectionView(selectedFiles: $selectedFiles, allowedType: allowedType) + } + } +} diff --git a/CHDataManagement/Views/Pages/Commands/LabelCreationView.swift b/CHDataManagement/Views/Pages/Commands/LabelCreationView.swift index 5a25434..9e665bf 100644 --- a/CHDataManagement/Views/Pages/Commands/LabelCreationView.swift +++ b/CHDataManagement/Views/Pages/Commands/LabelCreationView.swift @@ -5,9 +5,18 @@ struct LabelCreationView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.language) + private var language + @Binding var labels: [ContentLabel] + @State + private var showWorkoutSelection = false + + @State + private var selectedWorkouts: [FileResource] = [] + var body: some View { List { ForEach($labels, id: \.icon) { label in @@ -25,10 +34,25 @@ struct LabelCreationView: View { .cornerRadius(8) } .onMove(perform: moveLabel) + Button("Load workout") { showWorkoutSelection = true } + .padding(.vertical, 2) Button("Add new label", action: addLabel) .padding(.vertical, 2) } .frame(minHeight: 250) + .sheet(isPresented: $showWorkoutSelection) { + MultiFileSelectionView( + selectedFiles: $selectedWorkouts, + allowedType: .route) + } + .onChange(of: selectedWorkouts) { + let workouts = selectedWorkouts.compactMap { $0.routeOverview } + guard !workouts.isEmpty else { + return + } + let overview = RouteOverview.combine(workouts) + overview.update(labels: &labels, language: language) + } } private func addLabel() { diff --git a/CHDataManagement/Views/Posts/PostDetailView.swift b/CHDataManagement/Views/Posts/PostDetailView.swift index ce8ff4f..8dde140 100644 --- a/CHDataManagement/Views/Posts/PostDetailView.swift +++ b/CHDataManagement/Views/Posts/PostDetailView.swift @@ -33,7 +33,15 @@ struct PostDetailView: View { title: "Post", text: "Posts capture quick updates and can link to pages") - if post.linkedPage == nil { + if let page = post.linkedPage { + ColoredButton( + icon: .document, + text: "Show page", + fillColor: .blue, + textColor: .white, + action: { showPage(page) }) + + } else { ColoredButton( icon: .documentBadgePlus, text: "Create page", @@ -81,10 +89,10 @@ struct PostDetailView: View { } } - FilePropertyView( + FilesPropertyView( title: "Associated workout", footer: "The workout file to display with this post", - selectedFile: $post.associatedWorkout, + selectedFiles: $post.associatedWorkouts, allowedType: .route) LocalizedPostDetailView( @@ -106,6 +114,11 @@ struct PostDetailView: View { } } + private func showPage(_ page: Page) { + selection.page = page + selection.tab = .pages + } + private func deletePost() { guard content.storage.delete(post: post.identifier) else { print("Post '\(post.identifier)': Failed to delete file in content folder") diff --git a/CHDataManagement/Views/Posts/PostLabelsView.swift b/CHDataManagement/Views/Posts/PostLabelsView.swift index 570ef2d..8e47b4a 100644 --- a/CHDataManagement/Views/Posts/PostLabelsView.swift +++ b/CHDataManagement/Views/Posts/PostLabelsView.swift @@ -66,7 +66,7 @@ struct PostLabelsView: View { pasteboard.setString(command, forType: .string) } } - if let workout = post.associatedWorkout { + if !post.associatedWorkouts.isEmpty { Button("From workout") { post.updateLabelsFromWorkout() } diff --git a/CHDataManagement/Workouts/CLLocation+Extensions.swift b/CHDataManagement/Workouts/CLLocation+Extensions.swift new file mode 100644 index 0000000..6571d39 --- /dev/null +++ b/CHDataManagement/Workouts/CLLocation+Extensions.swift @@ -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) + } +} diff --git a/CHDataManagement/Workouts/Date+Days.swift b/CHDataManagement/Workouts/Date+Days.swift new file mode 100644 index 0000000..4f986c2 --- /dev/null +++ b/CHDataManagement/Workouts/Date+Days.swift @@ -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 + } +} diff --git a/CHDataManagement/Workouts/Double+Arithmetic.swift b/CHDataManagement/Workouts/Double+Arithmetic.swift new file mode 100644 index 0000000..4c33511 --- /dev/null +++ b/CHDataManagement/Workouts/Double+Arithmetic.swift @@ -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.. 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) + } + +} diff --git a/CHDataManagement/Workouts/File/DataRanges.swift b/CHDataManagement/Workouts/File/DataRanges.swift new file mode 100644 index 0000000..38fdd27 --- /dev/null +++ b/CHDataManagement/Workouts/File/DataRanges.swift @@ -0,0 +1,13 @@ + +struct DataRanges { + + let duration: RangeInterval + + let time: RangeInterval + + let distance: RangeInterval +} + +extension DataRanges: Codable { + +} diff --git a/CHDataManagement/Workouts/File/RangeInterval.swift b/CHDataManagement/Workouts/File/RangeInterval.swift new file mode 100644 index 0000000..4b8ab02 --- /dev/null +++ b/CHDataManagement/Workouts/File/RangeInterval.swift @@ -0,0 +1,11 @@ + +struct RangeInterval { + + let min: Double + + let max: Double +} + +extension RangeInterval: Codable { + +} diff --git a/CHDataManagement/Workouts/File/RouteData.swift b/CHDataManagement/Workouts/File/RouteData.swift new file mode 100644 index 0000000..22a9ab8 --- /dev/null +++ b/CHDataManagement/Workouts/File/RouteData.swift @@ -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) + } +} diff --git a/CHDataManagement/Workouts/File/RouteProfile.swift b/CHDataManagement/Workouts/File/RouteProfile.swift new file mode 100644 index 0000000..07e1a26 --- /dev/null +++ b/CHDataManagement/Workouts/File/RouteProfile.swift @@ -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 + } +} + diff --git a/CHDataManagement/Workouts/File/RouteSample.swift b/CHDataManagement/Workouts/File/RouteSample.swift new file mode 100644 index 0000000..e8a3f18 --- /dev/null +++ b/CHDataManagement/Workouts/File/RouteSample.swift @@ -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 + } +} diff --git a/CHDataManagement/Workouts/File/RouteSeries.swift b/CHDataManagement/Workouts/File/RouteSeries.swift new file mode 100644 index 0000000..c99be44 --- /dev/null +++ b/CHDataManagement/Workouts/File/RouteSeries.swift @@ -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 { + +} diff --git a/CHDataManagement/Workouts/Locations+Sampled.swift b/CHDataManagement/Workouts/Locations+Sampled.swift index 3077570..21ef7c1 100644 --- a/CHDataManagement/Workouts/Locations+Sampled.swift +++ b/CHDataManagement/Workouts/Locations+Sampled.swift @@ -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 - } -} diff --git a/CHDataManagement/Workouts/MapImageCreator.swift b/CHDataManagement/Workouts/MapImageCreator.swift index 20bfb8a..abf2b2a 100644 --- a/CHDataManagement/Workouts/MapImageCreator.swift +++ b/CHDataManagement/Workouts/MapImageCreator.swift @@ -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() } diff --git a/CHDataManagement/Workouts/Point.swift b/CHDataManagement/Workouts/Point.swift new file mode 100644 index 0000000..95c7e89 --- /dev/null +++ b/CHDataManagement/Workouts/Point.swift @@ -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.. 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.. 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) + } + +} diff --git a/CHDataManagement/Workouts/RouteOverview.swift b/CHDataManagement/Workouts/RouteOverview.swift index 8a4adc8..630ed9f 100644 --- a/CHDataManagement/Workouts/RouteOverview.swift +++ b/CHDataManagement/Workouts/RouteOverview.swift @@ -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)) + } } diff --git a/CHDataManagement/Workouts/Sequence+Median.swift b/CHDataManagement/Workouts/Sequence+Median.swift index 44e1219..4037bb6 100644 --- a/CHDataManagement/Workouts/Sequence+Median.swift +++ b/CHDataManagement/Workouts/Sequence+Median.swift @@ -9,6 +9,31 @@ private struct Entry: Comparable { } extension Sequence { + + func firstElement() -> Element? { + for element in self { + return element + } + return nil + } + + func minMax(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).