diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 599ab20..730cdfa 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -200,7 +200,7 @@ E2EC1FAB2DC0C99600C41784 /* RouteViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */; }; E2EC1FAD2DC0D2FA00C41784 /* RouteLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */; }; E2EC1FB02DC0D7DA00C41784 /* RouteBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */; }; - E2EC1FB22DC0D8BD00C41784 /* RouteViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */; }; + E2EC1FB22DC0D8BD00C41784 /* RouteStatisticType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */; }; E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */; }; E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */; }; E2F3B3852DC49B7A00CFA712 /* Insert+Gallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */; }; @@ -480,7 +480,7 @@ E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViews.swift; sourceTree = ""; }; E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLocalization.swift; sourceTree = ""; }; E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteBlock.swift; sourceTree = ""; }; - E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViewComponents.swift; sourceTree = ""; }; + E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStatisticType.swift; sourceTree = ""; }; E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Route.swift"; sourceTree = ""; }; E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryBlock.swift; sourceTree = ""; }; E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Gallery.swift"; sourceTree = ""; }; @@ -1081,7 +1081,7 @@ E2EC1FAE2DC0D30100C41784 /* Routes */ = { isa = PBXGroup; children = ( - E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */, + E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */, E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */, E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */, ); @@ -1456,7 +1456,7 @@ E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */, - E2EC1FB22DC0D8BD00C41784 /* RouteViewComponents.swift in Sources */, + E2EC1FB22DC0D8BD00C41784 /* RouteStatisticType.swift in Sources */, E29D31A12D0C75CA0051B7F4 /* Content+Validation.swift in Sources */, E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */, E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */, diff --git a/CHDataManagement/Generator/Blocks/RouteBlock.swift b/CHDataManagement/Generator/Blocks/RouteBlock.swift index 2eafc1e..4f0607c 100644 --- a/CHDataManagement/Generator/Blocks/RouteBlock.swift +++ b/CHDataManagement/Generator/Blocks/RouteBlock.swift @@ -33,14 +33,27 @@ struct RouteBlock: KeyedBlockProcessor { } func process(_ arguments: [Key : String], markdown: Substring) -> String { - let rawComponents = arguments[.components] ?? "all" guard let imageId = arguments[.image], - let fileId = arguments[.file], - let components = RouteViewComponents(rawValue: rawComponents) else { + let fileId = arguments[.file] else { invalid(markdown) return "" } + let rawComponents = arguments[.components] + var displayedTypes: Set = [] + if let rawComponents { + rawComponents.components(separatedBy: ",").compactMap { $0.trimmed.nonEmpty }.forEach { rawType in + if let type = RouteStatisticType(rawValue: rawType) { + displayedTypes.insert(type) + } else { + results.warning("Unknown component type '\(rawType)' in route block") + } + } + } + if displayedTypes.isEmpty { + displayedTypes = Set(RouteStatisticType.allCases) + } + guard let image = content.image(imageId) else { results.missing(file: imageId, source: "Route block") return "" @@ -66,7 +79,7 @@ struct RouteBlock: KeyedBlockProcessor { localization: language == .english ? .english : .german, chartTitle: arguments[.chartTitle], chartId: "chart-" + id, - components: components, + displayedTypes: displayedTypes, mapTitle: arguments[.mapTitle], mapId: "map-" + id, filePath: file.absoluteUrl, @@ -76,6 +89,7 @@ struct RouteBlock: KeyedBlockProcessor { caption: arguments[.caption]) results.require(footer: views.script) + results.require(icons: displayedTypes.map { $0.icon }) return views.content } diff --git a/CHDataManagement/Page Elements/ContentElements/Icons/PageIcon.swift b/CHDataManagement/Page Elements/ContentElements/Icons/PageIcon.swift index 14201db..d1bf110 100644 --- a/CHDataManagement/Page Elements/ContentElements/Icons/PageIcon.swift +++ b/CHDataManagement/Page Elements/ContentElements/Icons/PageIcon.swift @@ -33,6 +33,12 @@ enum PageIcon: String, CaseIterable { case statisticsEnergy = "energy" + case statisticsStopwatch = "stopwatch" + + case statisticsHeart = "heart-pulse" + + case statisticsSpeedometer = "speedometer" + // MARK: Buttons case buttonDownload = "download" @@ -68,6 +74,9 @@ enum PageIcon: String, CaseIterable { case .statisticsElevationDown: Icon.Statistics.ElevationDown.self case .statisticsDistance: Icon.Statistics.Distance.self case .statisticsEnergy: Icon.Statistics.Energy.self + case .statisticsStopwatch: Icon.Statistics.Stopwatch.self + case .statisticsHeart: Icon.Statistics.HeartPulse.self + case .statisticsSpeedometer: Icon.Statistics.Speedometer.self case .buttonDownload: Icon.ArrowDown.self case .buttonExternalLink: Icon.ArrowRight.self case .buttonGitLink: Icon.Git.self @@ -113,11 +122,14 @@ enum PageIcon: String, CaseIterable { case .audioPlayerPrevious: "Audio Player: Previous" case .audioPlayerNext: "Audio Player: Next" case .buttonDownload: "Button: Download" - case .statisticsTime: "Time" - case .statisticsElevationUp: "Elevation Up" - case .statisticsElevationDown: "Elevation Down" - case .statisticsDistance: "Distance" - case .statisticsEnergy: "Energy / Calories" + case .statisticsTime: "Clock (Duration)" + case .statisticsElevationUp: "Arrow Up (Elevation Up)" + case .statisticsElevationDown: "Arrow Down (Elevation Down)" + case .statisticsDistance: "Signpost (Distance)" + case .statisticsEnergy: "Flame (Energy / Calories)" + case .statisticsStopwatch: "Stopwatch (Pace)" + case .statisticsHeart: "Heart Rate" + case .statisticsSpeedometer: "Speedometer (Speed)" } } diff --git a/CHDataManagement/Page Elements/ContentElements/Icons/StatisticsIcons.swift b/CHDataManagement/Page Elements/ContentElements/Icons/StatisticsIcons.swift index 802e4ff..052ac93 100644 --- a/CHDataManagement/Page Elements/ContentElements/Icons/StatisticsIcons.swift +++ b/CHDataManagement/Page Elements/ContentElements/Icons/StatisticsIcons.swift @@ -7,7 +7,7 @@ extension Icon { static let id = "icon-clock" - static let attributes = "viewBox='0 0 16 16' width='16' height='16'" + static let attributes = "viewBox='0 0 16 16'" static let content = """ @@ -15,11 +15,24 @@ extension Icon { """ } + /// [Source](https://icons.getbootstrap.com/icons/stopwatch/) + struct Stopwatch: ContentIcon { + + static let id = "icon-stopwatch" + + static let attributes = "viewBox='0 0 16 16'" + + static let content = + """ + + """ + } + struct ElevationUp: ContentIcon { static let id = "icon-elevation-up" - static let attributes = "width='16' height='16'" + static let attributes = "viewBox='0 0 16 16'" static let content = """ @@ -31,7 +44,7 @@ extension Icon { static let id = "icon-elevation-down" - static let attributes = "width='16' height='16'" + static let attributes = "viewBox='0 0 16 16'" static let content = """ @@ -43,7 +56,7 @@ extension Icon { static let id = "icon-distance" - static let attributes = "width='16' height='16'" + static let attributes = "viewBox='0 0 16 16'" static let content = """ @@ -55,12 +68,38 @@ extension Icon { static let id = "icon-energy" - static let attributes = "width='16' height='16'" + static let attributes = "viewBox='0 0 16 16'" static let content = """ """ } + + /// [Source](https://icons.getbootstrap.com/icons/heart-pulse/) + struct HeartPulse: ContentIcon { + + static let id = "icon-pulse" + + static let attributes = "viewBox='0 0 16 16'" + + static let content = + """ + + """ + } + + /// [Source](https://icons.getbootstrap.com/icons/speedometer/) + struct Speedometer: ContentIcon { + + static let id = "icon-speed" + + static let attributes = "viewBox='0 0 16 16'" + + static let content = + """ + + """ + } } } diff --git a/CHDataManagement/Page Elements/ContentElements/Routes/RouteLocalization.swift b/CHDataManagement/Page Elements/ContentElements/Routes/RouteLocalization.swift index 6b10aa2..5e9494c 100644 --- a/CHDataManagement/Page Elements/ContentElements/Routes/RouteLocalization.swift +++ b/CHDataManagement/Page Elements/ContentElements/Routes/RouteLocalization.swift @@ -1,13 +1,7 @@ struct RouteLocalization { - let elevation: String - - let speed: String - - let pace: String - - let heartRate: String + let statistics: [RouteStatisticType : String] let fallback: String @@ -25,10 +19,13 @@ struct RouteLocalization { extension RouteLocalization { static let german: RouteLocalization = .init( - elevation: "Höhe", - speed: "Geschw.", - pace: "Pace", - heartRate: "Herzfrequenz", + statistics: [ + .elevation: "Höhe", + .speed: "Geschwindigkeit", + .pace: "Pace", + .heartRate: "Herzfrequenz", + .energy: "Aktive Kalorien" + ], fallback: "Zur Anzeige der Statistiken wird JavaScript und Unterstützung für HTML5 Canvas benötigt.", hourUnit: "Std", duration: "Dauer", @@ -37,10 +34,13 @@ extension RouteLocalization { loadFail: "Die Statistiken konnten nicht geladen werden") static let english: RouteLocalization = .init( - elevation: "Elevation", - speed: "Speed", - pace: "Pace", - heartRate: "Heart Rate", + statistics: [ + .elevation: "Elevation", + .speed: "Speed", + .pace: "Pace", + .heartRate: "Heart Rate", + .energy: "Active Energy" + ], fallback: "Javascript and HTML5 Canvas Support are required to display statistics", hourUnit: "h", duration: "Duration", diff --git a/CHDataManagement/Page Elements/ContentElements/Routes/RouteStatisticType.swift b/CHDataManagement/Page Elements/ContentElements/Routes/RouteStatisticType.swift new file mode 100644 index 0000000..eccf80b --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/Routes/RouteStatisticType.swift @@ -0,0 +1,65 @@ + +enum RouteStatisticType: String, CaseIterable { + + case elevation + + case speed + + case pace + + case heartRate = "heart-rate" + + case energy + + var order: Int { + switch self { + case .elevation: 1 + case .speed: 2 + case .pace: 3 + case .heartRate: 4 + case .energy: 5 + } + } + + var id: String { + switch self { + case .elevation: "elevation" + case .speed: "speed" + case .pace: "pace" + case .heartRate: "hr" + case .energy: "energy" + } + } + + var unit: String { + switch self { + case .elevation: "m" + case .speed: "km/h" + case .pace: "min/km" + case .heartRate: "bpm" + case .energy: "kcal" + } + } + + var icon: PageIcon { + switch self { + case .elevation: .statisticsElevationUp + case .speed: .statisticsSpeedometer + case .pace: .statisticsStopwatch + case .heartRate: .statisticsHeart + case .energy: .statisticsEnergy + } + } + + func displayText(in language: ContentLanguage) -> String { + let localization: RouteLocalization = language == .english ? .english : .german + return localization.statistics[self]! + } +} + +extension RouteStatisticType: Comparable { + + static func < (lhs: RouteStatisticType, rhs: RouteStatisticType) -> Bool { + lhs.order < rhs.order + } +} diff --git a/CHDataManagement/Page Elements/ContentElements/Routes/RouteViewComponents.swift b/CHDataManagement/Page Elements/ContentElements/Routes/RouteViewComponents.swift deleted file mode 100644 index 1bfc8eb..0000000 --- a/CHDataManagement/Page Elements/ContentElements/Routes/RouteViewComponents.swift +++ /dev/null @@ -1,7 +0,0 @@ - -enum RouteViewComponents: String { - case onlyElevation = "elevation" - case all = "all" - case withoutHeartRate = "no-hr" -} - diff --git a/CHDataManagement/Page Elements/ContentElements/Routes/RouteViews.swift b/CHDataManagement/Page Elements/ContentElements/Routes/RouteViews.swift index e3ed452..8e4fc03 100644 --- a/CHDataManagement/Page Elements/ContentElements/Routes/RouteViews.swift +++ b/CHDataManagement/Page Elements/ContentElements/Routes/RouteViews.swift @@ -6,7 +6,7 @@ struct RouteViews: HtmlProducer { /// The HTML id attribute used to enable fullscreen images let map: PageImage - let components: RouteViewComponents + let displayedTypes: Set let mapId: String @@ -21,7 +21,7 @@ struct RouteViews: HtmlProducer { init(localization: RouteLocalization, chartTitle: String?, chartId: String, - components: RouteViewComponents, + displayedTypes: Set, mapTitle: String?, mapId: String, filePath: String, @@ -31,7 +31,7 @@ struct RouteViews: HtmlProducer { caption: String? ) { self.localization = localization - self.components = components + self.displayedTypes = displayedTypes self.mapId = mapId self.filePath = filePath self.map = PageImage( @@ -47,9 +47,9 @@ struct RouteViews: HtmlProducer { self.mapTitle = mapTitle } - var pickerHiddenText: String { - guard components == .onlyElevation else { return "" } - return " style='display:none'" + private func button(series: RouteStatisticType) -> String { + let label = localization.statistics[series]! + return "" } func populate(_ result: inout String) { @@ -58,18 +58,19 @@ struct RouteViews: HtmlProducer { } map.populate(&result) + let series = displayedTypes.sorted() + guard !series.isEmpty else { + return + } if let chartTitle { result += "

\(chartTitle)

" } result += "
" - result += "
" - result += "" - result += "" - result += "" - if components == .all { - result += "" + result += "
" + for type in series { + result += button(series: type) } - result += "
" + result += "
" // Picker result += "
" result += "" result += "
\(localization.fallback)
" @@ -77,13 +78,13 @@ struct RouteViews: HtmlProducer { result += "
" result += "
" result += "
\(localization.loadFail)
" - result += "
" - result += "
" - result += "" + result += "
" // Graph + result += "
" + result += "" result += "" result += "" - result += "
" - result += "
" + result += "" // Picker + result += "" // Chart } var script: String { diff --git a/CHDataManagement/Views/Pages/Commands/Insert+Route.swift b/CHDataManagement/Views/Pages/Commands/Insert+Route.swift index 55928e6..9873ed0 100644 --- a/CHDataManagement/Views/Pages/Commands/Insert+Route.swift +++ b/CHDataManagement/Views/Pages/Commands/Insert+Route.swift @@ -3,6 +3,9 @@ import SFSafeSymbols struct InsertableRoute: View, InsertableCommandView { + @Environment(\.language) + var language + final class Model: ObservableObject, InsertableCommandModel { @Published @@ -12,7 +15,7 @@ struct InsertableRoute: View, InsertableCommandView { var chartTitle: String? @Published - var components: RouteViewComponents = .all + var components: Set = Set(RouteStatisticType.allCases) @Published var mapTitle: String? @@ -39,7 +42,12 @@ struct InsertableRoute: View, InsertableCommandView { var result = ["```route"] result.append("\(RouteBlock.Key.image.rawValue): \(selectedImage.id)") result.append("\(RouteBlock.Key.file.rawValue): \(dataFile.id)") - result.append("\(RouteBlock.Key.components.rawValue): \(components.rawValue)") + if components != Set(RouteStatisticType.allCases) { + let list = components + .map { $0.rawValue } + .joined(separator: ", ") + result.append("\(RouteBlock.Key.components.rawValue): \(list)") + } if let caption { result.append("\(RouteBlock.Key.caption.rawValue): \(caption)") } @@ -94,14 +102,22 @@ struct InsertableRoute: View, InsertableCommandView { text: $model.chartTitle, prompt: "Title", footer: "The title to show above the statistics") - Picker(selection: $model.components) { - Text("All").tag(RouteViewComponents.all) - Text("Only elevation").tag(RouteViewComponents.withoutHeartRate) - Text("No heart rate").tag(RouteViewComponents.withoutHeartRate) - } label: { - Text("") + ForEach(RouteStatisticType.allCases.sorted(), id: \.rawValue) { type in + Toggle(isOn: Binding( + get: { + model.components.contains(type) + }, + set: { isSelected in + if isSelected { + model.components.insert(type) + } else { + model.components.remove(type) + } + } + )) { + Text(type.displayText(in: language)) + }.toggleStyle(.checkbox) } - .pickerStyle(.segmented) } } }