Begin statistics creation
This commit is contained in:
@@ -103,6 +103,7 @@
|
|||||||
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */; };
|
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */; };
|
||||||
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; };
|
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; };
|
||||||
E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA59A2D024A2900AEF16D /* DateItem.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 */; };
|
E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2720B872DF38BB200FDB543 /* Insert+Video.swift */; };
|
||||||
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* ContentLabels.swift */; };
|
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* ContentLabels.swift */; };
|
||||||
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.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 */; };
|
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */; };
|
||||||
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */; };
|
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */; };
|
||||||
E2BF1BCC2D70EE59003089F1 /* TagPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BCB2D70EE55003089F1 /* TagPickerView.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 */; };
|
E2DD04742C276F31003BFF1F /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DD04732C276F31003BFF1F /* MainView.swift */; };
|
||||||
E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; };
|
E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; };
|
||||||
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; };
|
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 = "<group>"; };
|
E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsDetailView.swift; sourceTree = "<group>"; };
|
||||||
E25DA5982D02401A00AEF16D /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = "<group>"; };
|
E25DA5982D02401A00AEF16D /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = "<group>"; };
|
||||||
E25DA59A2D024A2900AEF16D /* DateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateItem.swift; sourceTree = "<group>"; };
|
E25DA59A2D024A2900AEF16D /* DateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateItem.swift; sourceTree = "<group>"; };
|
||||||
|
E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E2720B872DF38BB200FDB543 /* Insert+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Video.swift"; sourceTree = "<group>"; };
|
E2720B872DF38BB200FDB543 /* Insert+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Video.swift"; sourceTree = "<group>"; };
|
||||||
E29D311F2D0320E20051B7F4 /* ContentLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabels.swift; sourceTree = "<group>"; };
|
E29D311F2D0320E20051B7F4 /* ContentLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabels.swift; sourceTree = "<group>"; };
|
||||||
E29D31212D0363FA0051B7F4 /* ContentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentButtons.swift; sourceTree = "<group>"; };
|
E29D31212D0363FA0051B7F4 /* ContentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentButtons.swift; sourceTree = "<group>"; };
|
||||||
@@ -502,6 +518,20 @@
|
|||||||
E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Link.swift"; sourceTree = "<group>"; };
|
E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Link.swift"; sourceTree = "<group>"; };
|
||||||
E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPropertyView.swift; sourceTree = "<group>"; };
|
E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPropertyView.swift; sourceTree = "<group>"; };
|
||||||
E2BF1BCB2D70EE55003089F1 /* TagPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPickerView.swift; sourceTree = "<group>"; };
|
E2BF1BCB2D70EE55003089F1 /* TagPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPickerView.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutBlock.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsFileGenerator.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Png.swift"; sourceTree = "<group>"; };
|
||||||
|
E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Days.swift"; sourceTree = "<group>"; };
|
||||||
|
E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesPropertyView.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3C02E5E601B00F1E143 /* RouteData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteData.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteProfile.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSample.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSeries.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3C82E5E603300F1E143 /* DataRanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRanges.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeInterval.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Arithmetic.swift"; sourceTree = "<group>"; };
|
||||||
|
E2DBA3D02E61E5FD00F1E143 /* Point.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Point.swift; sourceTree = "<group>"; };
|
||||||
|
E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLLocation+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E2DD04702C276F31003BFF1F /* CHDataManagement.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CHDataManagement.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
E2DD04732C276F31003BFF1F /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
E2DD04792C276F32003BFF1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
E2DD04792C276F32003BFF1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -716,14 +746,19 @@
|
|||||||
E224E0D72E55074E0031C2B0 /* Workouts */ = {
|
E224E0D72E55074E0031C2B0 /* Workouts */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */,
|
||||||
|
E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */,
|
||||||
|
E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */,
|
||||||
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */,
|
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */,
|
||||||
E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */,
|
E2DBA3BF2E5E601300F1E143 /* File */,
|
||||||
E224E0E82E5668470031C2B0 /* Time+String.swift */,
|
|
||||||
E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */,
|
|
||||||
E224E0E12E5652680031C2B0 /* WorkoutData.swift */,
|
|
||||||
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */,
|
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */,
|
||||||
E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */,
|
|
||||||
E224E0D82E55075C0031C2B0 /* MapImageCreator.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;
|
path = Workouts;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -773,7 +808,9 @@
|
|||||||
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
|
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
|
||||||
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
|
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
|
||||||
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
|
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
|
||||||
|
E2DBA3B42E590B1E00F1E143 /* Images */,
|
||||||
E22990412D107A94009F8D77 /* ImageVersion.swift */,
|
E22990412D107A94009F8D77 /* ImageVersion.swift */,
|
||||||
|
E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */,
|
||||||
E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
|
E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
|
||||||
);
|
);
|
||||||
path = Generator;
|
path = Generator;
|
||||||
@@ -884,6 +921,7 @@
|
|||||||
E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */,
|
E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */,
|
||||||
E22990292D0F5A10009F8D77 /* DetailTitle.swift */,
|
E22990292D0F5A10009F8D77 /* DetailTitle.swift */,
|
||||||
E22990252D0F5822009F8D77 /* FilePropertyView.swift */,
|
E22990252D0F5822009F8D77 /* FilePropertyView.swift */,
|
||||||
|
E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */,
|
||||||
E2A21C0F2CB18B390060935B /* FlowHStack.swift */,
|
E2A21C0F2CB18B390060935B /* FlowHStack.swift */,
|
||||||
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */,
|
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */,
|
||||||
E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */,
|
E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */,
|
||||||
@@ -1076,6 +1114,7 @@
|
|||||||
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
|
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */,
|
||||||
E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */,
|
E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */,
|
||||||
E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */,
|
E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */,
|
||||||
E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */,
|
E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */,
|
||||||
@@ -1093,6 +1132,27 @@
|
|||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E2DBA3B42E590B1E00F1E143 /* Images */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */,
|
||||||
|
);
|
||||||
|
path = Images;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
E2DD04672C276F31003BFF1F = {
|
E2DD04672C276F31003BFF1F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -1216,6 +1276,7 @@
|
|||||||
E2FE0F342D2B27E6002963B7 /* Blocks */ = {
|
E2FE0F342D2B27E6002963B7 /* Blocks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */,
|
||||||
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */,
|
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */,
|
||||||
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
|
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
|
||||||
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
|
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
|
||||||
@@ -1416,6 +1477,7 @@
|
|||||||
E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */,
|
E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */,
|
||||||
E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */,
|
E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */,
|
||||||
E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */,
|
E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */,
|
||||||
|
E2DBA3B32E58FB7500F1E143 /* StatisticsFileGenerator.swift in Sources */,
|
||||||
E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */,
|
E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */,
|
||||||
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
|
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
|
||||||
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
|
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
|
||||||
@@ -1438,6 +1500,7 @@
|
|||||||
E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */,
|
E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */,
|
||||||
E2FE0F6E2D2D3689002963B7 /* LocalizedAudioPlayerSettings.swift in Sources */,
|
E2FE0F6E2D2D3689002963B7 /* LocalizedAudioPlayerSettings.swift in Sources */,
|
||||||
E2A21C082CB17B870060935B /* TagView.swift in Sources */,
|
E2A21C082CB17B870060935B /* TagView.swift in Sources */,
|
||||||
|
E2DBA3B82E590BEE00F1E143 /* Image+Png.swift in Sources */,
|
||||||
E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */,
|
E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */,
|
||||||
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */,
|
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */,
|
||||||
E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */,
|
E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */,
|
||||||
@@ -1466,6 +1529,7 @@
|
|||||||
E229902C2D0F6FC6009F8D77 /* LocalizedItemId.swift in Sources */,
|
E229902C2D0F6FC6009F8D77 /* LocalizedItemId.swift in Sources */,
|
||||||
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */,
|
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */,
|
||||||
E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */,
|
E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */,
|
||||||
|
E2DBA3D12E61E5FF00F1E143 /* Point.swift in Sources */,
|
||||||
E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */,
|
E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */,
|
||||||
E2B482202D670753005C309D /* WallpaperSlider.swift in Sources */,
|
E2B482202D670753005C309D /* WallpaperSlider.swift in Sources */,
|
||||||
E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */,
|
E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */,
|
||||||
@@ -1477,6 +1541,7 @@
|
|||||||
E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */,
|
E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */,
|
||||||
E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */,
|
E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */,
|
||||||
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */,
|
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */,
|
||||||
|
E2DBA3BC2E5CC18500F1E143 /* FilesPropertyView.swift in Sources */,
|
||||||
E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */,
|
E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */,
|
||||||
E22990302D0F75DE009F8D77 /* BoolPropertyView.swift in Sources */,
|
E22990302D0F75DE009F8D77 /* BoolPropertyView.swift in Sources */,
|
||||||
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */,
|
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */,
|
||||||
@@ -1487,6 +1552,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 */,
|
||||||
|
E2DBA3C92E5E603300F1E143 /* DataRanges.swift in Sources */,
|
||||||
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.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 */,
|
||||||
@@ -1494,6 +1560,7 @@
|
|||||||
E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */,
|
E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */,
|
||||||
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
|
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
|
||||||
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
|
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
|
||||||
|
E2DBA3CF2E5F771F00F1E143 /* Double+Arithmetic.swift in Sources */,
|
||||||
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
|
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
|
||||||
E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */,
|
E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */,
|
||||||
E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */,
|
E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */,
|
||||||
@@ -1515,11 +1582,13 @@
|
|||||||
E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */,
|
E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */,
|
||||||
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
|
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
|
||||||
E2F3B3A22DC769C300CFA712 /* ColoredButton.swift in Sources */,
|
E2F3B3A22DC769C300CFA712 /* ColoredButton.swift in Sources */,
|
||||||
|
E2DBA3D32E61F70000F1E143 /* CLLocation+Extensions.swift in Sources */,
|
||||||
E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */,
|
E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */,
|
||||||
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */,
|
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */,
|
||||||
E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */,
|
E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */,
|
||||||
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */,
|
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */,
|
||||||
E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */,
|
E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */,
|
||||||
|
E2DBA3BA2E5CBFAE00F1E143 /* Date+Days.swift in Sources */,
|
||||||
E2FD1D392D3BBED300B48627 /* InsertableView.swift in Sources */,
|
E2FD1D392D3BBED300B48627 /* InsertableView.swift in Sources */,
|
||||||
E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */,
|
E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */,
|
||||||
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */,
|
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */,
|
||||||
@@ -1561,6 +1630,7 @@
|
|||||||
E21A57392D8C714000E9EBE3 /* File+Mock.swift in Sources */,
|
E21A57392D8C714000E9EBE3 /* File+Mock.swift in Sources */,
|
||||||
E21A573A2D8C714000E9EBE3 /* Tag+Mock.swift in Sources */,
|
E21A573A2D8C714000E9EBE3 /* Tag+Mock.swift in Sources */,
|
||||||
E21A573B2D8C714000E9EBE3 /* Post+Mock.swift in Sources */,
|
E21A573B2D8C714000E9EBE3 /* Post+Mock.swift in Sources */,
|
||||||
|
E2DBA3CB2E5E603900F1E143 /* RangeInterval.swift in Sources */,
|
||||||
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 */,
|
||||||
@@ -1569,6 +1639,7 @@
|
|||||||
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
|
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
|
||||||
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */,
|
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */,
|
||||||
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
|
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
|
||||||
|
E2DBA3B12E58F57B00F1E143 /* WorkoutBlock.swift in Sources */,
|
||||||
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */,
|
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */,
|
||||||
E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */,
|
E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */,
|
||||||
E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */,
|
E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */,
|
||||||
@@ -1617,6 +1688,7 @@
|
|||||||
E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */,
|
E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */,
|
||||||
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */,
|
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */,
|
||||||
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */,
|
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */,
|
||||||
|
E26C300F2E634B3A00FEB26D /* TimeInterval+Extensions.swift in Sources */,
|
||||||
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */,
|
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */,
|
||||||
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
|
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
|
||||||
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */,
|
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */,
|
||||||
@@ -1643,6 +1715,10 @@
|
|||||||
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */,
|
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */,
|
||||||
E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */,
|
E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */,
|
||||||
E2FE0F532D2BCE17002963B7 /* SvgCommand.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 */,
|
E2FE0F3E2D2B4225002963B7 /* AudioSettingsDetailView.swift in Sources */,
|
||||||
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */,
|
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */,
|
||||||
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
|
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
import _math
|
||||||
|
|
||||||
extension Double {
|
extension Double {
|
||||||
|
|
||||||
func rounded(to interval: Double) -> Double {
|
func rounded(to interval: Double) -> Double {
|
||||||
(self / interval).rounded() * interval
|
(self / interval).rounded() * interval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rounded(decimals: Int) -> Double {
|
||||||
|
let factor = _math.pow(10.0, Double(decimals))
|
||||||
|
return (self * factor).rounded() / factor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
CHDataManagement/Extensions/TimeInterval+Extensions.swift
Normal file
25
CHDataManagement/Extensions/TimeInterval+Extensions.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ struct RouteBlock: KeyedBlockProcessor {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
guard let file = content.file(fileId) else {
|
guard let file = content.file(fileId) else {
|
||||||
results.missing(file: imageId, source: "Route block")
|
results.missing(file: fileId, source: "Route block")
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
results.used(file: image)
|
results.used(file: image)
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ extension VideoBlock {
|
|||||||
|
|
||||||
var mimeType: String {
|
var mimeType: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .h265, .h264: "video/mp4"
|
case .h265: "video/mp4; codecs=\"hvc1\""
|
||||||
|
case .h264: "video/mp4; codecs=\"avc1\""
|
||||||
case .webm: "video/webm"
|
case .webm: "video/webm"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
CHDataManagement/Generator/Blocks/WorkoutBlock.swift
Normal file
90
CHDataManagement/Generator/Blocks/WorkoutBlock.swift
Normal file
@@ -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<RouteStatisticType> = []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ final class ImageGenerator {
|
|||||||
// MARK: Image operations
|
// MARK: Image operations
|
||||||
|
|
||||||
func generate(version: ImageVersion) -> Bool {
|
func generate(version: ImageVersion) -> Bool {
|
||||||
|
if version.image.type == .route {
|
||||||
|
return generateImageForRoute(version: version)
|
||||||
|
}
|
||||||
if version.type == .avif {
|
if version.type == .avif {
|
||||||
if version.image.type == .gif {
|
if version.image.type == .gif {
|
||||||
// Skip GIFs, since they can't be converted by avifenc
|
// Skip GIFs, since they can't be converted by avifenc
|
||||||
@@ -56,12 +59,15 @@ final class ImageGenerator {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = version.image.dataContent() else {
|
guard let data = version.image.dataContent() else {
|
||||||
print("ImageGenerator: Failed to load data for image \(version.image.identifier)")
|
print("ImageGenerator: Failed to load data for image \(version.image.identifier)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return generate(version: version, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generate(version: ImageVersion, data: Data) -> Bool {
|
||||||
guard let originalImage = NSImage(data: data) else {
|
guard let originalImage = NSImage(data: data) else {
|
||||||
print("ImageGenerator: Failed to load image \(version.image.identifier)")
|
print("ImageGenerator: Failed to load image \(version.image.identifier)")
|
||||||
return false
|
return false
|
||||||
@@ -109,6 +115,34 @@ final class ImageGenerator {
|
|||||||
return representation
|
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
|
// MARK: Avif images
|
||||||
|
|
||||||
private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? {
|
private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? {
|
||||||
@@ -139,19 +173,23 @@ final class ImageGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func createAvifUsingBash(version: ImageVersion) -> Bool {
|
private func createAvifUsingBash(version: ImageVersion) -> Bool {
|
||||||
|
|
||||||
let baseVersion = ImageVersion(
|
let baseVersion = ImageVersion(
|
||||||
image: version.image,
|
image: version.image,
|
||||||
type: version.image.type,
|
type: version.image.type,
|
||||||
maximumWidth: version.maximumWidth,
|
maximumWidth: version.maximumWidth,
|
||||||
maximumHeight: version.maximumHeight)
|
maximumHeight: version.maximumHeight)
|
||||||
let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path()
|
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 generatedImagePath = storage.outputPath(to: version.outputPath)!.path()
|
||||||
let quality = Int(version.quality * 100)
|
let quality = Int(version.quality * 100)
|
||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
|
#warning("TODO: Move avifenc path to settings")
|
||||||
process.launchPath = "/opt/homebrew/bin/avifenc" // Adjust based on installation
|
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()
|
let pipe = Pipe()
|
||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ struct ImageSet: HtmlProducer {
|
|||||||
|
|
||||||
let image: FileResource
|
let image: FileResource
|
||||||
|
|
||||||
|
let type: FileType
|
||||||
|
|
||||||
let maxWidth: Int
|
let maxWidth: Int
|
||||||
|
|
||||||
let maxHeight: Int
|
let maxHeight: Int
|
||||||
@@ -14,8 +16,9 @@ struct ImageSet: HtmlProducer {
|
|||||||
|
|
||||||
let extraAttributes: String
|
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.image = image
|
||||||
|
self.type = type ?? image.type
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self.maxHeight = maxHeight
|
self.maxHeight = maxHeight
|
||||||
self.description = description
|
self.description = description
|
||||||
@@ -24,8 +27,6 @@ struct ImageSet: HtmlProducer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var jobs: [ImageVersion] {
|
var jobs: [ImageVersion] {
|
||||||
let type = image.type
|
|
||||||
|
|
||||||
let width2x = maxWidth * 2
|
let width2x = maxWidth * 2
|
||||||
let height2x = maxHeight * 2
|
let height2x = maxHeight * 2
|
||||||
|
|
||||||
|
|||||||
28
CHDataManagement/Generator/Images/Image+Png.swift
Normal file
28
CHDataManagement/Generator/Images/Image+Png.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
CHDataManagement/Generator/StatisticsFileGenerator.swift
Normal file
174
CHDataManagement/Generator/StatisticsFileGenerator.swift
Normal file
@@ -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..<numberOfSamples).map { index -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// MARK: Find items by id
|
||||||
|
|
||||||
func page(_ pageId: String) -> Page? {
|
func page(_ pageId: String) -> Page? {
|
||||||
|
|||||||
@@ -33,8 +33,23 @@ extension ContentLanguage: Comparable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension ContentLanguage {
|
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 {
|
var next: ContentLanguage {
|
||||||
switch self {
|
switch self {
|
||||||
case .english: return .german
|
case .english: return .german
|
||||||
|
|||||||
@@ -269,10 +269,11 @@ final class FileResource: Item, LocalizedItem {
|
|||||||
return prefix + "." + ext
|
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)
|
let description = self.localized(in: language)
|
||||||
return .init(
|
return .init(
|
||||||
image: self,
|
image: self,
|
||||||
|
type: type,
|
||||||
maxWidth: width,
|
maxWidth: width,
|
||||||
maxHeight: height,
|
maxHeight: height,
|
||||||
description: description,
|
description: description,
|
||||||
@@ -300,14 +301,34 @@ final class FileResource: Item, LocalizedItem {
|
|||||||
|
|
||||||
// MARK: Workout
|
// 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 {
|
guard type == .route else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let data = dataContent() else {
|
guard let data = dataContent() else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return try? WorkoutData(data: data).overview
|
return try? WorkoutData(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var routeOverview: RouteOverview? {
|
||||||
|
workoutData?.overview
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Video thumbnail
|
// 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 {
|
extension FileResource: CustomStringConvertible {
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
|
|||||||
@@ -77,45 +77,6 @@ final class LocalizedPost: ChangeObservingItem {
|
|||||||
var hasVideos: Bool {
|
var hasVideos: Bool {
|
||||||
images.contains { $0.type.isVideo }
|
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
|
// MARK: Storage
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ final class Post: Item, DateItem, LocalizedItem {
|
|||||||
@Published
|
@Published
|
||||||
var linkedPage: Page?
|
var linkedPage: Page?
|
||||||
|
|
||||||
/// The workout associated with the post
|
/// The workouts associated with the post
|
||||||
@Published
|
@Published
|
||||||
var associatedWorkout: FileResource?
|
var associatedWorkouts: [FileResource]
|
||||||
|
|
||||||
init(content: Content,
|
init(content: Content,
|
||||||
id: String,
|
id: String,
|
||||||
@@ -52,7 +52,7 @@ final class Post: Item, DateItem, LocalizedItem {
|
|||||||
german: LocalizedPost,
|
german: LocalizedPost,
|
||||||
english: LocalizedPost,
|
english: LocalizedPost,
|
||||||
linkedPage: Page? = nil,
|
linkedPage: Page? = nil,
|
||||||
associatedWorkout: FileResource? = nil) {
|
associatedWorkouts: [FileResource] = []) {
|
||||||
self.isDraft = isDraft
|
self.isDraft = isDraft
|
||||||
self.createdDate = createdDate
|
self.createdDate = createdDate
|
||||||
self.startDate = startDate
|
self.startDate = startDate
|
||||||
@@ -62,7 +62,7 @@ final class Post: Item, DateItem, LocalizedItem {
|
|||||||
self.german = german
|
self.german = german
|
||||||
self.english = english
|
self.english = english
|
||||||
self.linkedPage = linkedPage
|
self.linkedPage = linkedPage
|
||||||
self.associatedWorkout = associatedWorkout
|
self.associatedWorkouts = associatedWorkouts
|
||||||
super.init(content: content, id: id)
|
super.init(content: content, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,11 +182,13 @@ final class Post: Item, DateItem, LocalizedItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateLabelsFromWorkout() {
|
func updateLabelsFromWorkout() {
|
||||||
guard let overview = associatedWorkout?.routeOverview else {
|
let workouts = associatedWorkouts.compactMap { $0.routeOverview }
|
||||||
|
guard !workouts.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
german.updateLabels(from: overview, locale: Locale(identifier: "de_DE"))
|
let overview = RouteOverview.combine(workouts)
|
||||||
english.updateLabels(from: overview, locale: Locale(identifier: "en_US"))
|
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),
|
german: .init(context: context, data: data.german),
|
||||||
english: .init(context: context, data: data.english),
|
english: .init(context: context, data: data.english),
|
||||||
linkedPage: data.linkedPageId.map(context.page),
|
linkedPage: data.linkedPageId.map(context.page),
|
||||||
associatedWorkout: data.associatedWorkoutId.map(context.file))
|
associatedWorkouts: data.associatedWorkoutIds?.compactMap(context.file) ?? [])
|
||||||
savedData = data
|
savedData = data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +219,7 @@ extension Post: StorageItem {
|
|||||||
let german: LocalizedPost.Data
|
let german: LocalizedPost.Data
|
||||||
let english: LocalizedPost.Data
|
let english: LocalizedPost.Data
|
||||||
let linkedPageId: String?
|
let linkedPageId: String?
|
||||||
let associatedWorkoutId: String?
|
let associatedWorkoutIds: [String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data {
|
var data: Data {
|
||||||
@@ -230,7 +232,7 @@ extension Post: StorageItem {
|
|||||||
german: german.data,
|
german: german.data,
|
||||||
english: english.data,
|
english: english.data,
|
||||||
linkedPageId: linkedPage?.identifier,
|
linkedPageId: linkedPage?.identifier,
|
||||||
associatedWorkoutId: associatedWorkout?.identifier)
|
associatedWorkoutIds: associatedWorkouts.map { $0.identifier}.nonEmpty )
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveToDisk(_ data: Data) -> Bool {
|
func saveToDisk(_ data: Data) -> Bool {
|
||||||
|
|||||||
53
CHDataManagement/Views/Generic/FilesPropertyView.swift
Normal file
53
CHDataManagement/Views/Generic/FilesPropertyView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,18 @@ struct LabelCreationView: View {
|
|||||||
@Environment(\.colorScheme)
|
@Environment(\.colorScheme)
|
||||||
private var colorScheme
|
private var colorScheme
|
||||||
|
|
||||||
|
@Environment(\.language)
|
||||||
|
private var language
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var labels: [ContentLabel]
|
var labels: [ContentLabel]
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var showWorkoutSelection = false
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var selectedWorkouts: [FileResource] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
ForEach($labels, id: \.icon) { label in
|
ForEach($labels, id: \.icon) { label in
|
||||||
@@ -25,10 +34,25 @@ struct LabelCreationView: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.onMove(perform: moveLabel)
|
.onMove(perform: moveLabel)
|
||||||
|
Button("Load workout") { showWorkoutSelection = true }
|
||||||
|
.padding(.vertical, 2)
|
||||||
Button("Add new label", action: addLabel)
|
Button("Add new label", action: addLabel)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 250)
|
.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() {
|
private func addLabel() {
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ struct PostDetailView: View {
|
|||||||
title: "Post",
|
title: "Post",
|
||||||
text: "Posts capture quick updates and can link to pages")
|
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(
|
ColoredButton(
|
||||||
icon: .documentBadgePlus,
|
icon: .documentBadgePlus,
|
||||||
text: "Create page",
|
text: "Create page",
|
||||||
@@ -81,10 +89,10 @@ struct PostDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FilePropertyView(
|
FilesPropertyView(
|
||||||
title: "Associated workout",
|
title: "Associated workout",
|
||||||
footer: "The workout file to display with this post",
|
footer: "The workout file to display with this post",
|
||||||
selectedFile: $post.associatedWorkout,
|
selectedFiles: $post.associatedWorkouts,
|
||||||
allowedType: .route)
|
allowedType: .route)
|
||||||
|
|
||||||
LocalizedPostDetailView(
|
LocalizedPostDetailView(
|
||||||
@@ -106,6 +114,11 @@ struct PostDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showPage(_ page: Page) {
|
||||||
|
selection.page = page
|
||||||
|
selection.tab = .pages
|
||||||
|
}
|
||||||
|
|
||||||
private func deletePost() {
|
private func deletePost() {
|
||||||
guard content.storage.delete(post: post.identifier) else {
|
guard content.storage.delete(post: post.identifier) else {
|
||||||
print("Post '\(post.identifier)': Failed to delete file in content folder")
|
print("Post '\(post.identifier)': Failed to delete file in content folder")
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ struct PostLabelsView: View {
|
|||||||
pasteboard.setString(command, forType: .string)
|
pasteboard.setString(command, forType: .string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let workout = post.associatedWorkout {
|
if !post.associatedWorkouts.isEmpty {
|
||||||
Button("From workout") {
|
Button("From workout") {
|
||||||
post.updateLabelsFromWorkout()
|
post.updateLabelsFromWorkout()
|
||||||
}
|
}
|
||||||
|
|||||||
104
CHDataManagement/Workouts/CLLocation+Extensions.swift
Normal file
104
CHDataManagement/Workouts/CLLocation+Extensions.swift
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
extension CLLocation {
|
||||||
|
|
||||||
|
func duration(since other: CLLocation) -> TimeInterval {
|
||||||
|
duration(since: other.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func duration(since other: Date) -> TimeInterval {
|
||||||
|
timestamp.timeIntervalSince(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
func speed(from other: CLLocation) -> Double {
|
||||||
|
distance(from: other) / duration(since: other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined uncertainty sphere radius (meters) from horizontal+vertical accuracy
|
||||||
|
var uncertaintyRadius3D: CLLocationDistance {
|
||||||
|
let h = max(0, horizontalAccuracy)
|
||||||
|
let v = max(0, verticalAccuracy)
|
||||||
|
return sqrt(h * h + v * v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verticalDistance(from other: CLLocation) -> CLLocationDistance {
|
||||||
|
abs(self.altitude - other.altitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimumDistance(to other: CLLocation) -> (distance: CLLocationDistance, point: CLLocation) {
|
||||||
|
let horizontalDistance = distance(from: other)
|
||||||
|
let horizontalMovement = Swift.max(0, horizontalDistance - Swift.max(0, other.horizontalAccuracy))
|
||||||
|
|
||||||
|
let latitude: CLLocationDegrees
|
||||||
|
let longitude: CLLocationDegrees
|
||||||
|
if horizontalDistance == 0 || horizontalMovement == 0 {
|
||||||
|
latitude = coordinate.latitude
|
||||||
|
longitude = coordinate.longitude
|
||||||
|
} else {
|
||||||
|
let horizontalRatio = horizontalMovement / horizontalDistance
|
||||||
|
latitude = coordinate.latitude.move(horizontalRatio, to: other.coordinate.latitude)
|
||||||
|
longitude = coordinate.longitude.move(horizontalRatio, to: other.coordinate.longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
let verticalDistance = verticalDistance(from: other)
|
||||||
|
let verticalMovement = Swift.max(0, verticalDistance - Swift.max(0, other.verticalAccuracy))
|
||||||
|
|
||||||
|
let altitude: CLLocationDistance
|
||||||
|
if verticalDistance == 0 || verticalMovement == 0 {
|
||||||
|
altitude = self.altitude
|
||||||
|
} else {
|
||||||
|
let verticalRatio = verticalMovement / verticalDistance
|
||||||
|
altitude = self.altitude.move(verticalRatio, to: other.altitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
let movement = sqrt(horizontalMovement * horizontalMovement + verticalMovement * verticalMovement)
|
||||||
|
let point = CLLocation(
|
||||||
|
coordinate: .init(latitude: latitude, longitude: longitude),
|
||||||
|
altitude: altitude,
|
||||||
|
horizontalAccuracy: 0,
|
||||||
|
verticalAccuracy: 0,
|
||||||
|
timestamp: other.timestamp
|
||||||
|
)
|
||||||
|
return (movement, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpolate(_ time: Date, to other: CLLocation) -> CLLocation {
|
||||||
|
if self.timestamp > other.timestamp {
|
||||||
|
return other.interpolate(time, to: self)
|
||||||
|
}
|
||||||
|
let totalDuration = other.timestamp.timeIntervalSince(self.timestamp)
|
||||||
|
if totalDuration == 0 { return move(0.5, to: other) }
|
||||||
|
let ratio = time.timeIntervalSince(self.timestamp) / totalDuration
|
||||||
|
return move(ratio, to: other)
|
||||||
|
}
|
||||||
|
|
||||||
|
func move(_ ratio: Double, to other: CLLocation) -> CLLocation {
|
||||||
|
if ratio <= 0 { return self }
|
||||||
|
if ratio >= 1 { return other }
|
||||||
|
|
||||||
|
let time = timestamp.addingTimeInterval(other.timestamp.timeIntervalSince(timestamp) * ratio)
|
||||||
|
|
||||||
|
return CLLocation(
|
||||||
|
coordinate: .init(
|
||||||
|
latitude: coordinate.latitude.move(ratio, to: other.coordinate.latitude),
|
||||||
|
longitude: coordinate.longitude.move(ratio, to: other.coordinate.longitude)),
|
||||||
|
altitude: altitude.move(ratio, to: other.altitude),
|
||||||
|
horizontalAccuracy: move(from: horizontalAccuracy, to: other.horizontalAccuracy, by: ratio),
|
||||||
|
verticalAccuracy: move(from: verticalAccuracy, to: other.verticalAccuracy, by: ratio),
|
||||||
|
course: move(from: course, to: other.course, by: ratio),
|
||||||
|
courseAccuracy: move(from: courseAccuracy, to: other.courseAccuracy, by: ratio),
|
||||||
|
speed: move(from: speed, to: other.speed, by: ratio),
|
||||||
|
speedAccuracy: move(from: speedAccuracy, to: other.speedAccuracy, by: ratio),
|
||||||
|
timestamp: time)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(from source: Double, to other: Double, by ratio: Double) -> Double {
|
||||||
|
if source == -1 {
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
if other == -1 {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
return source.move(ratio, to: other)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
CHDataManagement/Workouts/Date+Days.swift
Normal file
15
CHDataManagement/Workouts/Date+Days.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
|
||||||
|
func inclusiveDays(to other: Date, calendar: Calendar = .current) -> Int {
|
||||||
|
let startDay = calendar.startOfDay(for: self)
|
||||||
|
let endDay = calendar.startOfDay(for: other)
|
||||||
|
|
||||||
|
guard let days = calendar.dateComponents([.day], from: startDay, to: endDay).day else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return abs(days) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
115
CHDataManagement/Workouts/Double+Arithmetic.swift
Normal file
115
CHDataManagement/Workouts/Double+Arithmetic.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Double {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Move to a different value by the given ratio of their distance.
|
||||||
|
*/
|
||||||
|
func move(_ ratio: Double, to other: Double) -> Double {
|
||||||
|
self + (other - self) * ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Collection where Element == Double {
|
||||||
|
|
||||||
|
func sum() -> Double {
|
||||||
|
reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
func average() -> Double {
|
||||||
|
sum() / Double(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Collection where Element == Double, Index == Int {
|
||||||
|
|
||||||
|
func floatingMeanFiltered(windowSize: Int) -> [Double] {
|
||||||
|
guard windowSize > 1 else {
|
||||||
|
return self.map { $0 }
|
||||||
|
}
|
||||||
|
guard count >= windowSize else {
|
||||||
|
return .init(repeating: average(), count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstHalf = windowSize / 2
|
||||||
|
let secondHalf = windowSize - firstHalf
|
||||||
|
var minSpeed: Double = 0
|
||||||
|
var maxSpeed: Double = 0
|
||||||
|
let averageScale = 1.0 / Double(windowSize)
|
||||||
|
|
||||||
|
// First calculate the filtered speeds in the normal unit
|
||||||
|
var currentAverage = self[0..<windowSize].average()
|
||||||
|
var result: [Double] = .init(repeating: currentAverage, count: firstHalf + 1)
|
||||||
|
for index in firstHalf..<count-firstHalf-1 { // Index in self
|
||||||
|
let removed = self[index-firstHalf]
|
||||||
|
let added = self[index+secondHalf]
|
||||||
|
currentAverage += (added - removed) * averageScale
|
||||||
|
result.append(currentAverage)
|
||||||
|
if currentAverage < minSpeed { minSpeed = currentAverage }
|
||||||
|
else if currentAverage > maxSpeed { maxSpeed = currentAverage }
|
||||||
|
}
|
||||||
|
result.append(contentsOf: [Double](repeating: currentAverage, count: secondHalf))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fittingAxisLimits(desiredNumSteps: Int = 5, minLimit: Double? = nil, maxLimit: Double? = nil) -> RouteProfile {
|
||||||
|
let (dataMin, dataMax) = minMax(minLimit: minLimit, maxLimit: maxLimit)
|
||||||
|
|
||||||
|
let dataRange = dataMax - dataMin
|
||||||
|
let roughStep = dataRange / Double(desiredNumSteps)
|
||||||
|
|
||||||
|
let exponent = floor(log10(roughStep))
|
||||||
|
let base = pow(10.0, exponent)
|
||||||
|
|
||||||
|
let step: Double
|
||||||
|
if roughStep <= base {
|
||||||
|
step = base
|
||||||
|
} else if roughStep <= 2 * base {
|
||||||
|
step = 2 * base
|
||||||
|
} else if roughStep <= 5 * base {
|
||||||
|
step = 5 * base
|
||||||
|
} else {
|
||||||
|
step = 10 * base
|
||||||
|
}
|
||||||
|
|
||||||
|
let graphMin = floor(dataMin / step) * step
|
||||||
|
let graphMax = ceil(dataMax / step) * step
|
||||||
|
let numTicks = Int((graphMax - graphMin) / step)
|
||||||
|
return .init(min: graphMin, max: graphMax, ticks: numTicks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minMax(minLimit: Double? = nil, maxLimit: Double? = nil) -> (min: Double, max: Double) {
|
||||||
|
var dataMin = self.min() ?? 0
|
||||||
|
var dataMax = self.max() ?? 1
|
||||||
|
if let minLimit {
|
||||||
|
dataMin = Swift.max(dataMin, minLimit)
|
||||||
|
}
|
||||||
|
if let maxLimit {
|
||||||
|
dataMax = Swift.min(dataMax, maxLimit)
|
||||||
|
}
|
||||||
|
return (dataMin, dataMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fittingTimeAxisLimits(minTicks: Int = 3, maxTicks: Int = 7, minLimit: Double? = nil, maxLimit: Double? = nil) -> RouteProfile {
|
||||||
|
let (dataMin, dataMax) = minMax(minLimit: minLimit, maxLimit: maxLimit)
|
||||||
|
|
||||||
|
let dataRange = dataMax - dataMin
|
||||||
|
let niceSteps: [Double] = [15, 30, 60, 120, 300, 600, 900, 1800, 3600] // in seconds
|
||||||
|
|
||||||
|
// Find the step size that gives a nice number of ticks
|
||||||
|
var chosenStep = niceSteps.last! // fallback to largest if none fit
|
||||||
|
for step in niceSteps {
|
||||||
|
let numTicks = dataRange / step
|
||||||
|
if Double(minTicks) <= numTicks && numTicks <= Double(maxTicks) {
|
||||||
|
chosenStep = step
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let graphMin = floor(dataMin / chosenStep) * chosenStep
|
||||||
|
let graphMax = ceil(dataMax / chosenStep) * chosenStep
|
||||||
|
let numTicks = Int((graphMax-graphMin) / chosenStep)
|
||||||
|
return .init(min: graphMin, max: graphMax, ticks: numTicks)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
CHDataManagement/Workouts/File/DataRanges.swift
Normal file
13
CHDataManagement/Workouts/File/DataRanges.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
struct DataRanges {
|
||||||
|
|
||||||
|
let duration: RangeInterval
|
||||||
|
|
||||||
|
let time: RangeInterval
|
||||||
|
|
||||||
|
let distance: RangeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DataRanges: Codable {
|
||||||
|
|
||||||
|
}
|
||||||
11
CHDataManagement/Workouts/File/RangeInterval.swift
Normal file
11
CHDataManagement/Workouts/File/RangeInterval.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
struct RangeInterval {
|
||||||
|
|
||||||
|
let min: Double
|
||||||
|
|
||||||
|
let max: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RangeInterval: Codable {
|
||||||
|
|
||||||
|
}
|
||||||
26
CHDataManagement/Workouts/File/RouteData.swift
Normal file
26
CHDataManagement/Workouts/File/RouteData.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/**
|
||||||
|
All data needed to create statistic displays
|
||||||
|
*/
|
||||||
|
struct RouteData {
|
||||||
|
|
||||||
|
let series: RouteSeries
|
||||||
|
|
||||||
|
let ranges: DataRanges
|
||||||
|
|
||||||
|
let samples: [RouteSample]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteData: Codable {
|
||||||
|
|
||||||
|
func encoded(prettyPrinted: Bool = false) -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .sortedKeys
|
||||||
|
if prettyPrinted {
|
||||||
|
encoder.outputFormatting.insert(.prettyPrinted)
|
||||||
|
}
|
||||||
|
return try! encoder.encode(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
CHDataManagement/Workouts/File/RouteProfile.swift
Normal file
32
CHDataManagement/Workouts/File/RouteProfile.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
struct RouteProfile: Codable {
|
||||||
|
|
||||||
|
let min: Double
|
||||||
|
|
||||||
|
let max: Double
|
||||||
|
|
||||||
|
let ticks: Int
|
||||||
|
|
||||||
|
let span: Double
|
||||||
|
|
||||||
|
let scale: Double
|
||||||
|
|
||||||
|
init(min: Double, max: Double, ticks: Int) {
|
||||||
|
self.min = min
|
||||||
|
self.max = max
|
||||||
|
self.ticks = ticks
|
||||||
|
self.span = max - min
|
||||||
|
self.scale = 1 / span
|
||||||
|
}
|
||||||
|
|
||||||
|
func scale(_ value: Double) -> Double {
|
||||||
|
if value < min {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return (value - min) / span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
95
CHDataManagement/Workouts/File/RouteSample.swift
Normal file
95
CHDataManagement/Workouts/File/RouteSample.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
|
||||||
|
struct RouteSample {
|
||||||
|
|
||||||
|
/// The x-coordinate in the map image (left to right) in the range [0,1]
|
||||||
|
var x: Double
|
||||||
|
|
||||||
|
/// The y-coordinate in the map image (top to bottom) in the range [0,1]
|
||||||
|
var y: Double
|
||||||
|
|
||||||
|
/// The timestamp of the sample in the range [0,1]
|
||||||
|
var time: Double
|
||||||
|
|
||||||
|
/// The distance of the sample in the range [0,1]
|
||||||
|
var distance: Double
|
||||||
|
|
||||||
|
/// The elevation of the sample in the range [0,1]
|
||||||
|
var elevation: Double
|
||||||
|
|
||||||
|
/// The speed of the sample in the range [0,1]
|
||||||
|
var speed: Double
|
||||||
|
|
||||||
|
/// The pace of the sample in the range [0,1]
|
||||||
|
var pace: Double
|
||||||
|
|
||||||
|
/// The heart rate of the sample in the range [0,1]
|
||||||
|
var hr: Double
|
||||||
|
|
||||||
|
/// The active energy rate of the sample in the range [0,1]
|
||||||
|
var energy: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteSample: Codable {
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(x.rounded(decimals: 4), forKey: .x)
|
||||||
|
try container.encode(y.rounded(decimals: 4), forKey: .y)
|
||||||
|
try container.encode(time.rounded(decimals: 4), forKey: .time)
|
||||||
|
try container.encode(distance.rounded(decimals: 4), forKey: .distance)
|
||||||
|
try container.encode(elevation.rounded(decimals: 4), forKey: .elevation)
|
||||||
|
try container.encode(speed.rounded(decimals: 4), forKey: .speed)
|
||||||
|
try container.encode(pace.rounded(decimals: 4), forKey: .pace)
|
||||||
|
try container.encode(hr.rounded(decimals: 4), forKey: .hr)
|
||||||
|
try container.encodeIfPresent(energy?.rounded(decimals: 4), forKey: .energy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteSample: Identifiable {
|
||||||
|
|
||||||
|
var id: Double { x }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteSample {
|
||||||
|
|
||||||
|
static var zero: RouteSample {
|
||||||
|
.init(x: 0, y: 0, time: 0, distance: 0, elevation: 0, speed: 0, pace: 0, hr: 0, energy: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Collection where Element == RouteSample {
|
||||||
|
|
||||||
|
var averageSample: RouteSample {
|
||||||
|
guard var average = first else {
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
var energySamples = average.energy == nil ? 0 : 1
|
||||||
|
for sample in dropFirst() {
|
||||||
|
average.x += sample.x
|
||||||
|
average.y += sample.y
|
||||||
|
average.time += sample.time
|
||||||
|
average.distance += sample.distance
|
||||||
|
average.elevation += sample.elevation
|
||||||
|
average.speed += sample.speed
|
||||||
|
average.pace += sample.pace
|
||||||
|
average.hr += sample.hr
|
||||||
|
if let energy = sample.energy {
|
||||||
|
average.energy = (average.energy ?? 0) + energy
|
||||||
|
energySamples += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let scale = 1 / Double(count)
|
||||||
|
average.x *= scale
|
||||||
|
average.y *= scale
|
||||||
|
average.time *= scale
|
||||||
|
average.distance *= scale
|
||||||
|
average.elevation *= scale
|
||||||
|
average.speed *= scale
|
||||||
|
average.pace *= scale
|
||||||
|
average.hr *= scale
|
||||||
|
if let energy = average.energy, energySamples > 0 {
|
||||||
|
average.energy = energy / Double(energySamples)
|
||||||
|
}
|
||||||
|
return average
|
||||||
|
}
|
||||||
|
}
|
||||||
18
CHDataManagement/Workouts/File/RouteSeries.swift
Normal file
18
CHDataManagement/Workouts/File/RouteSeries.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct RouteSeries {
|
||||||
|
|
||||||
|
let elevation: RouteProfile
|
||||||
|
|
||||||
|
let speed: RouteProfile
|
||||||
|
|
||||||
|
let pace: RouteProfile
|
||||||
|
|
||||||
|
let hr: RouteProfile
|
||||||
|
|
||||||
|
let energy: RouteProfile?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteSeries: Codable {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,17 @@ import CoreLocation
|
|||||||
|
|
||||||
extension Array where Element == CLLocation {
|
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.
|
Sample the locations using a given time interval.
|
||||||
*/
|
*/
|
||||||
@@ -137,125 +148,21 @@ extension Array where Element == CLLocation {
|
|||||||
let duration = endDate.timeIntervalSince(startDate)
|
let duration = endDate.timeIntervalSince(startDate)
|
||||||
|
|
||||||
return map { loc in
|
return map { loc in
|
||||||
let t = loc.timestamp.timeIntervalSince1970
|
guard loc.timestamp >= startDate && loc.timestamp <= endDate else {
|
||||||
|
|
||||||
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
|
return loc // outside window, unchanged
|
||||||
}
|
}
|
||||||
|
let progress = (loc.timestamp.timeIntervalSince(startDate)) / duration
|
||||||
|
let newAltitude = startAltitude + progress * (endAltitude - startAltitude)
|
||||||
|
|
||||||
|
return CLLocation(
|
||||||
|
coordinate: loc.coordinate,
|
||||||
|
altitude: newAltitude,
|
||||||
|
horizontalAccuracy: loc.horizontalAccuracy,
|
||||||
|
verticalAccuracy: loc.verticalAccuracy,
|
||||||
|
course: loc.course,
|
||||||
|
speed: loc.speed,
|
||||||
|
timestamp: loc.timestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CLLocation {
|
|
||||||
|
|
||||||
/// Combined uncertainty sphere radius (meters) from horizontal+vertical accuracy
|
|
||||||
var uncertaintyRadius3D: CLLocationDistance {
|
|
||||||
let h = max(0, horizontalAccuracy)
|
|
||||||
let v = max(0, verticalAccuracy)
|
|
||||||
return sqrt(h * h + v * v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verticalDistance(from other: CLLocation) -> CLLocationDistance {
|
|
||||||
abs(self.altitude - other.altitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
func minimumDistance(to other: CLLocation) -> (distance: CLLocationDistance, point: CLLocation) {
|
|
||||||
let horizontalDistance = distance(from: other)
|
|
||||||
let horizontalMovement = Swift.max(0, horizontalDistance - Swift.max(0, other.horizontalAccuracy))
|
|
||||||
|
|
||||||
let latitude: CLLocationDegrees
|
|
||||||
let longitude: CLLocationDegrees
|
|
||||||
if horizontalDistance == 0 || horizontalMovement == 0 {
|
|
||||||
latitude = coordinate.latitude
|
|
||||||
longitude = coordinate.longitude
|
|
||||||
} else {
|
|
||||||
let horizontalRatio = horizontalMovement / horizontalDistance
|
|
||||||
latitude = coordinate.latitude.move(horizontalRatio, to: other.coordinate.latitude)
|
|
||||||
longitude = coordinate.longitude.move(horizontalRatio, to: other.coordinate.longitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
let verticalDistance = verticalDistance(from: other)
|
|
||||||
let verticalMovement = Swift.max(0, verticalDistance - Swift.max(0, other.verticalAccuracy))
|
|
||||||
|
|
||||||
let altitude: CLLocationDistance
|
|
||||||
if verticalDistance == 0 || verticalMovement == 0 {
|
|
||||||
altitude = self.altitude
|
|
||||||
} else {
|
|
||||||
let verticalRatio = verticalMovement / verticalDistance
|
|
||||||
altitude = self.altitude.move(verticalRatio, to: other.altitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
let movement = sqrt(horizontalMovement * horizontalMovement + verticalMovement * verticalMovement)
|
|
||||||
let point = CLLocation(
|
|
||||||
coordinate: .init(latitude: latitude, longitude: longitude),
|
|
||||||
altitude: altitude,
|
|
||||||
horizontalAccuracy: 0,
|
|
||||||
verticalAccuracy: 0,
|
|
||||||
timestamp: other.timestamp
|
|
||||||
)
|
|
||||||
return (movement, point)
|
|
||||||
}
|
|
||||||
|
|
||||||
func interpolate(_ time: Date, to other: CLLocation) -> CLLocation {
|
|
||||||
if self.timestamp > other.timestamp {
|
|
||||||
return other.interpolate(time, to: self)
|
|
||||||
}
|
|
||||||
let totalDuration = other.timestamp.timeIntervalSince(self.timestamp)
|
|
||||||
if totalDuration == 0 { return move(0.5, to: other) }
|
|
||||||
let ratio = time.timeIntervalSince(self.timestamp) / totalDuration
|
|
||||||
return move(ratio, to: other)
|
|
||||||
}
|
|
||||||
|
|
||||||
func move(_ ratio: Double, to other: CLLocation) -> CLLocation {
|
|
||||||
if ratio <= 0 { return self }
|
|
||||||
if ratio >= 1 { return other }
|
|
||||||
|
|
||||||
let time = timestamp.addingTimeInterval(other.timestamp.timeIntervalSince(timestamp) * ratio)
|
|
||||||
|
|
||||||
return CLLocation(
|
|
||||||
coordinate: .init(
|
|
||||||
latitude: coordinate.latitude.move(ratio, to: other.coordinate.latitude),
|
|
||||||
longitude: coordinate.longitude.move(ratio, to: other.coordinate.longitude)),
|
|
||||||
altitude: altitude.move(ratio, to: other.altitude),
|
|
||||||
horizontalAccuracy: move(from: horizontalAccuracy, to: other.horizontalAccuracy, by: ratio),
|
|
||||||
verticalAccuracy: move(from: verticalAccuracy, to: other.verticalAccuracy, by: ratio),
|
|
||||||
course: move(from: course, to: other.course, by: ratio),
|
|
||||||
courseAccuracy: move(from: courseAccuracy, to: other.courseAccuracy, by: ratio),
|
|
||||||
speed: move(from: speed, to: other.speed, by: ratio),
|
|
||||||
speedAccuracy: move(from: speedAccuracy, to: other.speedAccuracy, by: ratio),
|
|
||||||
timestamp: time)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func move(from source: Double, to other: Double, by ratio: Double) -> Double {
|
|
||||||
if source == -1 {
|
|
||||||
return other
|
|
||||||
}
|
|
||||||
if other == -1 {
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
return source.move(ratio, to: other)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Double {
|
|
||||||
|
|
||||||
/**
|
|
||||||
Move to a different value by the given ratio of their distance.
|
|
||||||
*/
|
|
||||||
func move(_ ratio: Double, to other: Double) -> Double {
|
|
||||||
self + (other - self) * ratio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,51 @@ struct MapImageCreator {
|
|||||||
scale: CGFloat = 2.0,
|
scale: CGFloat = 2.0,
|
||||||
lineWidth: CGFloat = 5,
|
lineWidth: CGFloat = 5,
|
||||||
paddingFactor: Double = 1.2,
|
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
|
completion: @escaping ((image: NSImage, imagePoints: [CGPoint])?) -> Void
|
||||||
) {
|
) {
|
||||||
guard !locations.isEmpty else {
|
guard !locations.isEmpty else {
|
||||||
@@ -61,7 +106,7 @@ struct MapImageCreator {
|
|||||||
path.line(to: point)
|
path.line(to: point)
|
||||||
}
|
}
|
||||||
|
|
||||||
NSColor.systemBlue.setStroke()
|
lineColor.setStroke()
|
||||||
path.lineWidth = lineWidth * scale
|
path.lineWidth = lineWidth * scale
|
||||||
path.stroke()
|
path.stroke()
|
||||||
}
|
}
|
||||||
|
|||||||
200
CHDataManagement/Workouts/Point.swift
Normal file
200
CHDataManagement/Workouts/Point.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
|
||||||
|
enum SamplingMode {
|
||||||
|
case cumulative // e.g., distance, energy
|
||||||
|
case instantaneous // e.g., heart rate, altitude
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Point: Identifiable, Equatable, Hashable {
|
||||||
|
|
||||||
|
var x, y: Double
|
||||||
|
|
||||||
|
var id: Double { x }
|
||||||
|
|
||||||
|
/**
|
||||||
|
Interpolate the y value at an x coordinate toward another point.
|
||||||
|
*/
|
||||||
|
func interpolate(to other: Point, at location: Double) -> Double {
|
||||||
|
let totalX = other.x - x
|
||||||
|
if totalX == 0 {
|
||||||
|
return (y + other.y) / 2
|
||||||
|
}
|
||||||
|
let diffX = location - x
|
||||||
|
if diffX == 0 {
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
let ratio = diffX / totalX
|
||||||
|
return y + (other.y - y) * ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
static let zero = Point(x: 0, y: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Point {
|
||||||
|
|
||||||
|
func resample(numberOfSamples: Int, minX: Double? = nil, maxX: Double? = nil, mode: SamplingMode) -> [Point] {
|
||||||
|
guard count >= 2, numberOfSamples > 0 else { return [] }
|
||||||
|
let firstX = minX ?? first!.x
|
||||||
|
let lastX = maxX ?? last!.x
|
||||||
|
let totalDuration = lastX - firstX
|
||||||
|
guard totalDuration > 0 else { return [] }
|
||||||
|
|
||||||
|
let interval = totalDuration / Double(numberOfSamples)
|
||||||
|
var result: [Point] = .init(repeating: .zero, count: numberOfSamples)
|
||||||
|
|
||||||
|
var currentIndex = 0
|
||||||
|
var current = self[0]
|
||||||
|
|
||||||
|
for i in 0..<numberOfSamples {
|
||||||
|
let startOfInterval = Double(i) * interval
|
||||||
|
let endOfInterval = startOfInterval + interval
|
||||||
|
|
||||||
|
var accumulated = 0.0
|
||||||
|
var accumulatedDuration = 0.0
|
||||||
|
|
||||||
|
while currentIndex < count - 1 {
|
||||||
|
let next = self[currentIndex + 1]
|
||||||
|
|
||||||
|
if next.x <= endOfInterval {
|
||||||
|
let dt = next.x - current.x
|
||||||
|
switch mode {
|
||||||
|
case .cumulative:
|
||||||
|
accumulated += next.y - current.y
|
||||||
|
case .instantaneous:
|
||||||
|
accumulated += (current.y + next.y) / 2 * dt
|
||||||
|
accumulatedDuration += dt
|
||||||
|
}
|
||||||
|
currentIndex += 1
|
||||||
|
current = next
|
||||||
|
} else {
|
||||||
|
// partial segment at the interval end
|
||||||
|
let dt = endOfInterval - current.x
|
||||||
|
let slope = (next.y - current.y) / (next.x - current.x)
|
||||||
|
let interpolatedY = current.y + slope * dt
|
||||||
|
switch mode {
|
||||||
|
case .cumulative:
|
||||||
|
accumulated += interpolatedY - current.y
|
||||||
|
case .instantaneous:
|
||||||
|
accumulated += (current.y + interpolatedY) / 2 * dt
|
||||||
|
accumulatedDuration += dt
|
||||||
|
}
|
||||||
|
current = Point(x: endOfInterval, y: interpolatedY)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i].x = (startOfInterval + endOfInterval) / 2 // midpoint
|
||||||
|
switch mode {
|
||||||
|
case .cumulative:
|
||||||
|
result[i].y = accumulated / interval // average rate over interval
|
||||||
|
case .instantaneous:
|
||||||
|
result[i].y = accumulatedDuration > 0 ? accumulated / accumulatedDuration : current.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func resample(
|
||||||
|
numberOfSamples: Int,
|
||||||
|
minX: Double? = nil,
|
||||||
|
maxX: Double? = nil,
|
||||||
|
mode: SamplingMode
|
||||||
|
) -> [Double] {
|
||||||
|
guard count >= 2, numberOfSamples > 0 else { return [] }
|
||||||
|
let minX = minX ?? first!.x
|
||||||
|
let maxX = maxX ?? last!.x
|
||||||
|
let totalDuration = maxX - minX
|
||||||
|
guard totalDuration > 0 else { return [] }
|
||||||
|
|
||||||
|
let interval = totalDuration / Double(numberOfSamples)
|
||||||
|
var result = [Double](repeating: 0.0, count: numberOfSamples)
|
||||||
|
|
||||||
|
var currentIndex = 0
|
||||||
|
var current = self[0]
|
||||||
|
|
||||||
|
for i in 0..<numberOfSamples {
|
||||||
|
let startOfInterval = minX + Double(i) * interval
|
||||||
|
let endOfInterval = startOfInterval + interval
|
||||||
|
|
||||||
|
var accumulated = 0.0
|
||||||
|
var accumulatedDuration = 0.0
|
||||||
|
|
||||||
|
while currentIndex < count - 1 {
|
||||||
|
let next = self[currentIndex + 1]
|
||||||
|
|
||||||
|
if next.x <= endOfInterval {
|
||||||
|
let dt = next.x - current.x
|
||||||
|
switch mode {
|
||||||
|
case .cumulative:
|
||||||
|
accumulated += next.y - current.y
|
||||||
|
case .instantaneous:
|
||||||
|
accumulated += (current.y + next.y) / 2 * dt
|
||||||
|
accumulatedDuration += dt
|
||||||
|
}
|
||||||
|
currentIndex += 1
|
||||||
|
current = next
|
||||||
|
} else {
|
||||||
|
let dt = endOfInterval - current.x
|
||||||
|
let slope = (next.y - current.y) / (next.x - current.x)
|
||||||
|
let interpolatedY = current.y + slope * dt
|
||||||
|
switch mode {
|
||||||
|
case .cumulative:
|
||||||
|
accumulated += interpolatedY - current.y
|
||||||
|
case .instantaneous:
|
||||||
|
accumulated += (current.y + interpolatedY) / 2 * dt
|
||||||
|
accumulatedDuration += dt
|
||||||
|
}
|
||||||
|
current = Point(x: endOfInterval, y: interpolatedY)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: Double
|
||||||
|
switch mode {
|
||||||
|
case .cumulative:
|
||||||
|
value = accumulated / interval
|
||||||
|
case .instantaneous:
|
||||||
|
value = accumulatedDuration > 0 ? accumulated / accumulatedDuration : current.y
|
||||||
|
}
|
||||||
|
result[i] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Sequence {
|
||||||
|
/// Resamples any sequence of elements into evenly spaced intervals.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - numberOfSamples: Number of output points
|
||||||
|
/// - xSelector: Closure to get the independent variable (e.g., time)
|
||||||
|
/// - ySelector: Closure to get the dependent variable (e.g., distance, heart rate)
|
||||||
|
/// - mode: .cumulative or .instantaneous
|
||||||
|
/// - Returns: Array of Points with evenly spaced `x` and averaged `y`
|
||||||
|
func resample(
|
||||||
|
numberOfSamples: Int,
|
||||||
|
x: (Element) -> Double,
|
||||||
|
y: (Element) -> Double,
|
||||||
|
mode: SamplingMode
|
||||||
|
) -> [Point] {
|
||||||
|
let points = self.map { Point(x: x($0), y: y($0)) }
|
||||||
|
return points.resample(numberOfSamples: numberOfSamples, mode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resample(
|
||||||
|
numberOfSamples: Int,
|
||||||
|
minX: Double? = nil,
|
||||||
|
maxX: Double? = nil,
|
||||||
|
x: (Element) -> Double,
|
||||||
|
y: (Element) -> Double,
|
||||||
|
mode: SamplingMode
|
||||||
|
) -> [Double] {
|
||||||
|
let points = self.map { Point(x: x($0), y: y($0)) }
|
||||||
|
return points.resample(
|
||||||
|
numberOfSamples: numberOfSamples,
|
||||||
|
minX: minX,
|
||||||
|
maxX: maxX,
|
||||||
|
mode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,4 +17,38 @@ struct RouteOverview {
|
|||||||
let start: Date?
|
let start: Date?
|
||||||
|
|
||||||
let end: Date?
|
let end: Date?
|
||||||
|
|
||||||
|
static func combine(_ overviews: [RouteOverview]) -> RouteOverview {
|
||||||
|
RouteOverview(
|
||||||
|
energy: overviews.reduce(0) { $0 + $1.energy },
|
||||||
|
distance: overviews.reduce(0) { $0 + $1.distance },
|
||||||
|
duration: overviews.reduce(0) { $0 + $1.duration },
|
||||||
|
ascendedElevation: overviews.reduce(0) { $0 + $1.ascendedElevation },
|
||||||
|
start: overviews.compactMap { $0.start }.min(),
|
||||||
|
end: overviews.compactMap { $0.end }.max())
|
||||||
|
}
|
||||||
|
|
||||||
|
var days: Int {
|
||||||
|
guard let start, let end else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return start.inclusiveDays(to: end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteOverview {
|
||||||
|
|
||||||
|
func update(labels: inout [ContentLabel], language: ContentLanguage) {
|
||||||
|
let locale = language.locale
|
||||||
|
let days = self.days
|
||||||
|
|
||||||
|
if days != 1 {
|
||||||
|
labels.insertOrReplace(icon: .calendar, value: language.text(days: days))
|
||||||
|
} else {
|
||||||
|
labels.insertOrReplace(icon: .statisticsTime, value: duration.duration(locale: locale))
|
||||||
|
}
|
||||||
|
labels.insertOrReplace(icon: .statisticsDistance, value: String(format: "%.1f km", locale: locale, distance / 1000))
|
||||||
|
labels.insertOrReplace(icon: .statisticsElevationUp, value: ascendedElevation.length(roundingToNearest: 50))
|
||||||
|
labels.insertOrReplace(icon: .statisticsEnergy, value: energy.energy(roundingToNearest: 50))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,31 @@ private struct Entry<T: BinaryFloatingPoint>: Comparable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Sequence {
|
extension Sequence {
|
||||||
|
|
||||||
|
func firstElement() -> Element? {
|
||||||
|
for element in self {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func minMax<E>(by converting: (Element) -> E) -> (min: E, max: E)? where E: Comparable {
|
||||||
|
guard let first = firstElement() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var minimum = converting(first)
|
||||||
|
var maximum = minimum
|
||||||
|
for location in dropFirst() {
|
||||||
|
let value = converting(location)
|
||||||
|
if value < minimum {
|
||||||
|
minimum = value
|
||||||
|
} else if value > maximum {
|
||||||
|
maximum = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (minimum, maximum)
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies a centered median filter to the sequence.
|
/// Applies a centered median filter to the sequence.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - windowSize: The number of samples in the median filter window (should be odd for symmetric centering).
|
/// - windowSize: The number of samples in the median filter window (should be odd for symmetric centering).
|
||||||
|
|||||||
Reference in New Issue
Block a user