Begin statistics creation

This commit is contained in:
Christoph Hagen
2025-08-31 16:27:32 +02:00
parent f972a2c020
commit 96bd07bdb7
33 changed files with 1406 additions and 187 deletions

View File

@@ -144,6 +144,41 @@ extension Content {
}
}
// MARK: Routes
private func generateMapImage(route: FileResource) -> Bool {
let size = route.mapImageDimensions
let path = URL(fileURLWithPath: route.mapImagePath)
guard let workoutData = route.workoutData else {
print("ImageGenerator: Failed to get workout data for route \(route.identifier)")
return false
}
let mapImager = MapImageCreator(locations: workoutData.locations)
guard let (largeImage, points) = mapImager.createMapSnapshot(size: .init(width: size.width, height: size.height)) else {
print("ImageGenerator: Failed to generate map image for route \(route.identifier)")
return false
}
guard largeImage.writePng(to: path) else {
print("ImageGenerator: Failed to save map image for route \(route.identifier)")
return false
}
let statisticsGenerator = StatisticsFileGenerationJob(
mapCoordinates: points,
workout: workoutData,
path: route.statisticsFilePath,
numberOfSamples: 600
)
guard statisticsGenerator.run() else {
print("ImageGenerator: Failed to generate statistics for route \(route.identifier)")
return false
}
return true
}
// MARK: Find items by id
func page(_ pageId: String) -> Page? {

View File

@@ -33,8 +33,23 @@ extension ContentLanguage: Comparable {
}
}
extension ContentLanguage {
func text(days: Int) -> String {
switch self {
case .english: return "\(days) day\(days == 1 ? "" : "s")"
case .german: return "\(days) Tag\(days == 1 ? "" : "e")"
}
}
var locale: Locale {
switch self {
case .english: Locale(identifier: "en_US")
case .german: Locale(identifier: "de_DE")
}
}
var next: ContentLanguage {
switch self {
case .english: return .german

View File

@@ -269,10 +269,11 @@ final class FileResource: Item, LocalizedItem {
return prefix + "." + ext
}
func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet {
func imageSet(type: FileType? = nil, width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet {
let description = self.localized(in: language)
return .init(
image: self,
type: type,
maxWidth: width,
maxHeight: height,
description: description,
@@ -300,14 +301,34 @@ final class FileResource: Item, LocalizedItem {
// MARK: Workout
var routeOverview: RouteOverview? {
#warning("Set correct map image size, ratio from settings?")
var mapImageDimensions: (width: Int, height: Int) {
(width: content.settings.pages.largeImageWidth, height: 0)
}
var mapImagePath: String {
let dimension = mapImageDimensions
return outputPath(width: dimension.width, height: dimension.height, type: .png)
}
var statisticsFilePath: URL {
let path = "\(content.settings.paths.filesOutputFolderPath)/\(identifier.fileNameWithoutExtension).route"
let fullPath = makeCleanAbsolutePath(path)
return URL(filePath: fullPath, directoryHint: .notDirectory)
}
var workoutData: WorkoutData? {
guard type == .route else {
return nil
}
guard let data = dataContent() else {
return nil
}
return try? WorkoutData(data: data).overview
return try? WorkoutData(data: data)
}
var routeOverview: RouteOverview? {
workoutData?.overview
}
// MARK: Video thumbnail
@@ -386,6 +407,21 @@ final class FileResource: Item, LocalizedItem {
}
}
extension Array where Element == ContentLabel {
mutating func insertOrReplace(icon: PageIcon, value: String) {
insertOrReplace(label: .init(icon: icon, value: value))
}
mutating func insertOrReplace(label: ContentLabel) {
if let index = firstIndex(where: { $0.icon == label.icon }) {
self[index] = label
} else {
append(label)
}
}
}
extension FileResource: CustomStringConvertible {
var description: String {

View File

@@ -77,45 +77,6 @@ final class LocalizedPost: ChangeObservingItem {
var hasVideos: Bool {
images.contains { $0.type.isVideo }
}
func updateLabels(from workout: RouteOverview, locale: Locale) {
insertOrReplace(label: .init(icon: .statisticsDistance, value: String(format: "%.1f km", locale: locale, workout.distance / 1000)))
insertOrReplace(label: .init(icon: .statisticsTime, value: workout.duration.duration(locale: locale)))
insertOrReplace(label: .init(icon: .statisticsElevationUp, value: workout.ascendedElevation.length(roundingToNearest: 50)))
insertOrReplace(label: .init(icon: .statisticsEnergy, value: workout.energy.energy(roundingToNearest: 50)))
}
func insertOrReplace(label: ContentLabel) {
if let index = labels.firstIndex(where: { $0.icon == label.icon }) {
labels[index] = label
} else {
labels.append(label)
}
}
}
private extension TimeInterval {
func duration(locale: Locale) -> String {
let totalMinutes = Int((self / 60).rounded(to: 5))
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
let suffix = locale.identifier.hasPrefix("de") ? "Std" : "h"
return String(format: "%d:%02d ", hours, minutes) + suffix
}
func length(roundingToNearest interval: Double) -> String {
let rounded = Int(self.rounded(to: interval))
return "\(rounded) m"
}
func energy(roundingToNearest interval: Double) -> String {
let rounded = Int(self.rounded(to: interval))
return "\(rounded) kcal"
}
}
// MARK: Storage

View File

@@ -38,9 +38,9 @@ final class Post: Item, DateItem, LocalizedItem {
@Published
var linkedPage: Page?
/// The workout associated with the post
/// The workouts associated with the post
@Published
var associatedWorkout: FileResource?
var associatedWorkouts: [FileResource]
init(content: Content,
id: String,
@@ -52,7 +52,7 @@ final class Post: Item, DateItem, LocalizedItem {
german: LocalizedPost,
english: LocalizedPost,
linkedPage: Page? = nil,
associatedWorkout: FileResource? = nil) {
associatedWorkouts: [FileResource] = []) {
self.isDraft = isDraft
self.createdDate = createdDate
self.startDate = startDate
@@ -62,7 +62,7 @@ final class Post: Item, DateItem, LocalizedItem {
self.german = german
self.english = english
self.linkedPage = linkedPage
self.associatedWorkout = associatedWorkout
self.associatedWorkouts = associatedWorkouts
super.init(content: content, id: id)
}
@@ -182,11 +182,13 @@ final class Post: Item, DateItem, LocalizedItem {
}
func updateLabelsFromWorkout() {
guard let overview = associatedWorkout?.routeOverview else {
let workouts = associatedWorkouts.compactMap { $0.routeOverview }
guard !workouts.isEmpty else {
return
}
german.updateLabels(from: overview, locale: Locale(identifier: "de_DE"))
english.updateLabels(from: overview, locale: Locale(identifier: "en_US"))
let overview = RouteOverview.combine(workouts)
overview.update(labels: &german.labels, language: .german)
overview.update(labels: &english.labels, language: .english)
}
}
@@ -204,7 +206,7 @@ extension Post: StorageItem {
german: .init(context: context, data: data.german),
english: .init(context: context, data: data.english),
linkedPage: data.linkedPageId.map(context.page),
associatedWorkout: data.associatedWorkoutId.map(context.file))
associatedWorkouts: data.associatedWorkoutIds?.compactMap(context.file) ?? [])
savedData = data
}
@@ -217,7 +219,7 @@ extension Post: StorageItem {
let german: LocalizedPost.Data
let english: LocalizedPost.Data
let linkedPageId: String?
let associatedWorkoutId: String?
let associatedWorkoutIds: [String]?
}
var data: Data {
@@ -230,7 +232,7 @@ extension Post: StorageItem {
german: german.data,
english: english.data,
linkedPageId: linkedPage?.identifier,
associatedWorkoutId: associatedWorkout?.identifier)
associatedWorkoutIds: associatedWorkouts.map { $0.identifier}.nonEmpty )
}
func saveToDisk(_ data: Data) -> Bool {