StatChartUtils.swift 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import Charts
  2. import Foundation
  3. import SwiftUI
  4. struct StatChartUtils {
  5. /// Returns the time interval length for the visible domain based on the selected duration.
  6. /// - Parameter selectedInterval: The selected time interval for statistics.
  7. /// - Returns: The time interval in seconds.
  8. static func visibleDomainLength(for selectedInterval: Stat.StateModel.StatsTimeInterval) -> TimeInterval {
  9. switch selectedInterval {
  10. case .day: return 24 * 3600
  11. case .week: return 7 * 24 * 3600
  12. case .month: return 30 * 24 * 3600
  13. case .total: return 90 * 24 * 3600
  14. }
  15. }
  16. /// Computes the visible date range based on the scroll position and selected duration.
  17. /// - Parameters:
  18. /// - scrollPosition: The current scroll position in the chart.
  19. /// - selectedInterval: The selected time interval for statistics.
  20. /// - Returns: A tuple containing the start and end dates of the visible range.
  21. static func visibleDateRange(
  22. from scrollPosition: Date,
  23. for selectedInterval: Stat.StateModel.StatsTimeInterval
  24. ) -> (start: Date, end: Date) {
  25. let calendar = Calendar.current
  26. if selectedInterval == .day {
  27. // For day view, don't modify the scroll position
  28. let end = scrollPosition.addingTimeInterval(visibleDomainLength(for: selectedInterval) - 1)
  29. return (scrollPosition, end)
  30. } else {
  31. // For week and longer intervals, we need smart alignment
  32. // Find the nearest day boundary
  33. let startOfDay = calendar.startOfDay(for: scrollPosition)
  34. let components = calendar.dateComponents([.hour, .minute, .second], from: scrollPosition)
  35. let totalSeconds = Double(components.hour ?? 0) * 3600 + Double(components.minute ?? 0) * 60 +
  36. Double(components.second ?? 0)
  37. // Align start end to midnight
  38. let alignedStart = totalSeconds > 12 * 3600 ?
  39. calendar.date(byAdding: .day, value: 1, to: startOfDay)! : startOfDay
  40. let intervalLength = visibleDomainLength(for: selectedInterval)
  41. let end = alignedStart.addingTimeInterval(intervalLength + (2 * 3600))
  42. let alignedEnd = calendar.startOfDay(for: end).addingTimeInterval(-1)
  43. return (alignedStart, alignedEnd)
  44. }
  45. }
  46. /// Returns the appropriate date format style based on the selected time interval.
  47. /// - Parameter selectedInterval: The selected time interval for statistics.
  48. /// - Returns: A Date.FormatStyle configured for the current time interval.
  49. static func dateFormat(for selectedInterval: Stat.StateModel.StatsTimeInterval) -> Date.FormatStyle {
  50. switch selectedInterval {
  51. case .day: return .dateTime.hour()
  52. case .week: return .dateTime.weekday(.abbreviated)
  53. case .month: return .dateTime.day()
  54. case .total: return .dateTime.month(.abbreviated)
  55. }
  56. }
  57. /// Returns DateComponents for aligning dates based on the selected duration.
  58. /// - Parameter selectedInterval: The selected time interval for statistics.
  59. /// - Returns: DateComponents configured for the appropriate alignment.
  60. static func alignmentComponents(for selectedInterval: Stat.StateModel.StatsTimeInterval) -> DateComponents {
  61. switch selectedInterval {
  62. case .day: return DateComponents(hour: 0)
  63. case .week:
  64. let calendar = Calendar.current
  65. return DateComponents(weekday: calendar.firstWeekday)
  66. case .month,
  67. .total: return DateComponents(day: 1)
  68. }
  69. }
  70. /// Returns the initial scroll position date based on the selected duration.
  71. /// - Parameter selectedInterval: The selected time interval for statistics.
  72. /// - Returns: A Date representing the initial scroll position.
  73. static func getInitialScrollPosition(for selectedInterval: Stat.StateModel.StatsTimeInterval) -> Date {
  74. let calendar = Calendar.current
  75. let now = Date()
  76. let today = calendar.startOfDay(for: now)
  77. let baseDate: Date
  78. switch selectedInterval {
  79. case .day:
  80. baseDate = today
  81. case .week:
  82. baseDate = calendar.date(byAdding: .day, value: -6, to: today)!
  83. case .month:
  84. baseDate = calendar.date(byAdding: .day, value: -29, to: today)!
  85. case .total:
  86. baseDate = calendar.date(byAdding: .day, value: -89, to: today)!
  87. }
  88. return calendar.date(byAdding: .second, value: 1, to: baseDate)!
  89. }
  90. /// Checks if two dates belong to the same time unit based on the selected duration.
  91. /// - Parameters:
  92. /// - date1: The first date.
  93. /// - date2: The second date.
  94. /// - selectedInterval: The selected time interval for statistics.
  95. /// - Returns: A Boolean indicating whether the two dates are in the same time unit.
  96. static func isSameTimeUnit(
  97. _ date1: Date,
  98. _ date2: Date,
  99. for selectedInterval: Stat.StateModel.StatsTimeInterval
  100. ) -> Bool {
  101. let calendar = Calendar.current
  102. switch selectedInterval {
  103. case .day:
  104. return calendar.isDate(date1, equalTo: date2, toGranularity: .hour)
  105. default:
  106. return calendar.isDate(date1, inSameDayAs: date2)
  107. }
  108. }
  109. /// Formats the visible date range into a human-readable string.
  110. /// - Parameters:
  111. /// - start: The start date of the range.
  112. /// - end: The end date of the range.
  113. /// - selectedInterval: The selected time interval for statistics.
  114. /// - Returns: A formatted string representing the visible date range.
  115. static func formatVisibleDateRange(
  116. from start: Date,
  117. to end: Date,
  118. for selectedInterval: Stat.StateModel.StatsTimeInterval
  119. ) -> String {
  120. let calendar = Calendar.current
  121. // If not .day, we just return "startText - endText", e.g. "Jan 1 - Jan 8"
  122. guard selectedInterval == .day else {
  123. let formatDate: (Date) -> String = { date in
  124. date.formatted(.dateTime.day().month())
  125. }
  126. let startText = formatDate(start)
  127. let endText = formatDate(end)
  128. return "\(startText) - \(endText)"
  129. }
  130. // For .day mode, we figure out if we are near the boundaries for a "full day" (00:00 - 23:59)
  131. let dayStart = calendar.startOfDay(for: start)
  132. let nextDayStart = calendar.date(byAdding: .day, value: 1, to: dayStart)!
  133. // Allow +/- 15 minutes from midnight as buffer, so slow scrolling doesn't break the "full day"
  134. let tolerance: TimeInterval = 60 * 15
  135. let isStartNearMidnight = abs(start.timeIntervalSince(dayStart)) < tolerance
  136. let isEndNearNextMidnight = abs(end.timeIntervalSince(nextDayStart)) < tolerance
  137. let formatDay: (Date) -> String = { date in
  138. date.formatted(.dateTime.day().month(.abbreviated))
  139. }
  140. if isStartNearMidnight, isEndNearNextMidnight {
  141. // Full day: show just start as "Mon, Jan 1"
  142. return dayStart.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
  143. } else {
  144. // Partial day: show start and end
  145. let startText = formatDay(start)
  146. let endText = formatDay(end)
  147. return "\(startText) - \(endText)"
  148. }
  149. }
  150. /// A helper function to create a `VStack` for each statistic.
  151. ///
  152. /// - Parameters:
  153. /// - title: The title of the statistic.
  154. /// - value: The formatted value to display.
  155. /// - Returns: A `VStack` with the title and value.
  156. static func statView(title: String, value: String) -> some View {
  157. VStack(spacing: 5) {
  158. Text(title)
  159. .font(.subheadline)
  160. .foregroundStyle(Color.secondary)
  161. Text(value)
  162. }
  163. }
  164. /// Computes the median value of an array of integers.
  165. ///
  166. /// - Parameter array: An array of integers.
  167. /// - Returns: The median value as a `Double`. Returns `0` if the array is empty.
  168. static func medianCalculation(array: [Int]) -> Double {
  169. guard !array.isEmpty else { return 0 }
  170. let sorted = array.sorted()
  171. let length = array.count
  172. if length % 2 == 0 {
  173. return Double((sorted[length / 2 - 1] + sorted[length / 2]) / 2)
  174. }
  175. return Double(sorted[length / 2])
  176. }
  177. /// Computes the median value of an array of doubles.
  178. ///
  179. /// - Parameter array: An array of `Double` values.
  180. /// - Returns: The median value. Returns `0` if the array is empty.
  181. static func medianCalculationDouble(array: [Double]) -> Double {
  182. guard !array.isEmpty else { return 0 }
  183. let sorted = array.sorted()
  184. let length = array.count
  185. if length % 2 == 0 {
  186. return (sorted[length / 2 - 1] + sorted[length / 2]) / 2
  187. }
  188. return sorted[length / 2]
  189. }
  190. /// Creates a legend item view for use in a chart legend.
  191. ///
  192. /// - Parameters:
  193. /// - label: The text label for the legend item.
  194. /// - color: The color associated with the legend item.
  195. /// - Returns: A SwiftUI view displaying a colored symbol and a label.
  196. @ViewBuilder static func legendItem(label: String, color: Color) -> some View {
  197. HStack(spacing: 4) {
  198. Image(systemName: "circle.fill").foregroundStyle(color)
  199. Text(label).foregroundStyle(Color.secondary)
  200. }.font(.caption)
  201. }
  202. }