Improve route statistics

This commit is contained in:
Christoph Hagen
2025-05-02 14:54:41 +02:00
parent 3b2cc75fc3
commit fea06a93b7
9 changed files with 207 additions and 67 deletions

View File

@ -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 = "<group>"; };
E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLocalization.swift; sourceTree = "<group>"; };
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteBlock.swift; sourceTree = "<group>"; };
E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViewComponents.swift; sourceTree = "<group>"; };
E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStatisticType.swift; sourceTree = "<group>"; };
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Route.swift"; sourceTree = "<group>"; };
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryBlock.swift; sourceTree = "<group>"; };
E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Gallery.swift"; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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<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 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
}

View File

@ -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)"
}
}

View File

@ -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 =
"""
<path fill="currentColor" d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5z"/><path fill="currentColor" d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64l.012-.013.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5M8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3"/>
"""
}
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 =
"""
<path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/>
"""
}
/// [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 =
"""
<path fill="currentColor" d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053.918 3.995.78 5.323 1.508 7H.43c-2.128-5.697 4.165-8.83 7.394-5.857q.09.083.176.171a3 3 0 0 1 .176-.17c3.23-2.974 9.522.159 7.394 5.856h-1.078c.728-1.677.59-3.005.108-3.947C13.486.878 10.4.28 8.717 2.01zM2.212 10h1.315C4.593 11.183 6.05 12.458 8 13.795c1.949-1.337 3.407-2.612 4.473-3.795h1.315c-1.265 1.566-3.14 3.25-5.788 5-2.648-1.75-4.523-3.434-5.788-5"/><path fill="currentColor" d="M10.464 3.314a.5.5 0 0 0-.945.049L7.921 8.956 6.464 5.314a.5.5 0 0 0-.88-.091L3.732 8H.5a.5.5 0 0 0 0 1H4a.5.5 0 0 0 .416-.223l1.473-2.209 1.647 4.118a.5.5 0 0 0 .945-.049l1.598-5.593 1.457 3.642A.5.5 0 0 0 12 9h3.5a.5.5 0 0 0 0-1h-3.162z"/>
"""
}
/// [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 =
"""
<path fill="currentColor" d="M8 2a.5.5 0 0 1 .5.5V4a.5.5 0 0 1-1 0V2.5A.5.5 0 0 1 8 2M3.732 3.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 8a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 8m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 7.31A.91.91 0 1 0 8.85 8.569l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill="currentColor" fill-rule="evenodd" d="M6.664 15.889A8 8 0 1 1 9.336.11a8 8 0 0 1-2.672 15.78zm-4.665-4.283A11.95 11.95 0 0 1 8 10c2.186 0 4.236.585 6.001 1.606a7 7 0 1 0-12.002 0"/>
"""
}
}
}

View File

@ -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",

View File

@ -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
}
}

View File

@ -1,7 +0,0 @@
enum RouteViewComponents: String {
case onlyElevation = "elevation"
case all = "all"
case withoutHeartRate = "no-hr"
}

View File

@ -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<RouteStatisticType>
let mapId: String
@ -21,7 +21,7 @@ struct RouteViews: HtmlProducer {
init(localization: RouteLocalization,
chartTitle: String?,
chartId: String,
components: RouteViewComponents,
displayedTypes: Set<RouteStatisticType>,
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 "<button data-type='\(series.id)' unit='\(series.unit)' name='\(label)'>\(series.icon.usageString)<span class='label'>\(label)</span></button>"
}
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 += "<h2>\(chartTitle)</h2>"
}
result += "<div id='\(chartId)' class='charts'>"
result += "<div class='picker y-picker'\(pickerHiddenText)>"
result += "<button data-type='elevation' unit='m' class='active'>\(localization.elevation)</button>"
result += "<button data-type='speed' unit='km/h'>\(localization.speed)</button>"
result += "<button data-type='pace' unit='min/km'>\(localization.pace)</button>"
if components == .all {
result += "<button data-type='hr' unit='bpm'>\(localization.heartRate)</button>"
result += "<div class='picker icon-picker y-picker'>"
for type in series {
result += button(series: type)
}
result += "</div>"
result += "</div>" // Picker
result += "<div class='graph'>"
result += "<canvas>"
result += "<div class='fallback'>\(localization.fallback)</div>"
@ -77,13 +78,13 @@ struct RouteViews: HtmlProducer {
result += "<div class='line'></div>"
result += "<div class='tooltip'></div>"
result += "<div class='load-error'>\(localization.loadFail)</div>"
result += "</div>"
result += "<div class='picker x-picker'\(pickerHiddenText)>"
result += "<button data-type='distance' unit='km' class='active'>\(localization.distance)</button>"
result += "</div>" // Graph
result += "<div class='picker text-picker x-picker'>"
result += "<button data-type='distance' unit='km'>\(localization.distance)</button>"
result += "<button data-type='duration' unit='\(localization.hourUnit)'>\(localization.duration)</button>"
result += "<button data-type='time' unit=''>\(localization.time)</button>"
result += "</div>"
result += "</div>"
result += "</div>" // Picker
result += "</div>" // Chart
}
var script: String {

View File

@ -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<RouteStatisticType> = 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)
}
}
}