GlucoseDailyDistributionChart.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import Charts
  2. import SwiftUI
  3. struct GlucoseDailyDistributionChart: View {
  4. let glucose: [GlucoseStored]
  5. let highLimit: Decimal
  6. let units: GlucoseUnits
  7. let timeInRangeType: TimeInRangeType
  8. let selectedInterval: Stat.StateModel.StatsTimeInterval
  9. let eA1cDisplayUnit: EstimatedA1cDisplayUnit
  10. @Binding var isDaySelected: Bool
  11. // Scrolling and selection states
  12. @State private var scrollPosition = Date()
  13. @State private var selectedDate: Date?
  14. @State private var updateTimer = Stat.UpdateTimer()
  15. @State private var visibleGlucose: [GlucoseStored] = []
  16. // State model for accessing the shared data
  17. @Environment(Stat.StateModel.self) private var state
  18. // Computes the visible date range based on the current scroll position
  19. @State private var visibleDateRange: (start: Date, end: Date) = (Date(), Date())
  20. // Gets daily distribution stats for the visible date range
  21. private var visibleDailyStats: [GlucoseDailyDistributionStats] {
  22. let calendar = Calendar.current
  23. return state.dailyGlucoseDistributionStats.filter { stat in
  24. let statDate = calendar.startOfDay(for: stat.date)
  25. return statDate >= calendar.startOfDay(for: visibleDateRange.start) &&
  26. statDate <= calendar.startOfDay(for: visibleDateRange.end)
  27. }
  28. }
  29. private func calculateVisibleDateRange() {
  30. visibleDateRange = StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
  31. }
  32. // Gets selected day stats
  33. private var selectedDateStats: GlucoseDailyDistributionStats? {
  34. guard let selectedDate = selectedDate else { return nil }
  35. let calendar = Calendar.current
  36. let startOfSelectedDate = calendar.startOfDay(for: selectedDate)
  37. return state.glucoseDistributionCache[startOfSelectedDate]
  38. }
  39. private func calculateVisibleGlucose() {
  40. let calendar = Calendar.current
  41. visibleGlucose = glucose.filter { reading in
  42. guard let date = reading.date else { return false }
  43. return date >= calendar.startOfDay(for: visibleDateRange.start) &&
  44. date <= calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: visibleDateRange.end))!
  45. }
  46. }
  47. // Compute selected day glucose readings
  48. private var selectedDateGlucose: [GlucoseStored] {
  49. guard let selectedDate = selectedDate else { return [] }
  50. let calendar = Calendar.current
  51. let dayStart = calendar.startOfDay(for: selectedDate)
  52. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
  53. return glucose.filter { reading in
  54. guard let date = reading.date else { return false }
  55. return date >= dayStart && date < dayEnd
  56. }
  57. }
  58. // Active glucose data - either selected day or visible range
  59. private var activeGlucoseData: [GlucoseStored] {
  60. selectedDate != nil ? selectedDateGlucose : visibleGlucose
  61. }
  62. var body: some View {
  63. VStack(alignment: .leading, spacing: 8) {
  64. chartView
  65. .frame(height: 200)
  66. // Date label with transition
  67. Text(selectedDate.map { formattedDate(for: $0) } ?? StatChartUtils.formatVisibleDateRange(
  68. from: visibleDateRange.start,
  69. to: visibleDateRange.end,
  70. for: selectedInterval
  71. ))
  72. .font(.subheadline)
  73. .frame(maxWidth: .infinity, alignment: .center)
  74. .padding(.top, 8)
  75. .animation(.easeInOut, value: selectedDate)
  76. // Single sector chart with data switching
  77. GlucoseSectorChart(
  78. highLimit: highLimit,
  79. units: units,
  80. glucose: activeGlucoseData,
  81. timeInRangeType: timeInRangeType,
  82. showChart: false
  83. )
  84. .animation(.easeInOut, value: selectedDate)
  85. Divider().padding(.vertical, 4)
  86. // Single metrics view with data switching
  87. GlucoseMetricsView(
  88. units: units,
  89. eA1cDisplayUnit: eA1cDisplayUnit,
  90. glucose: activeGlucoseData
  91. )
  92. .animation(.easeInOut, value: selectedDate)
  93. }
  94. .onAppear {
  95. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
  96. calculateVisibleDateRange()
  97. calculateVisibleGlucose()
  98. }
  99. .onChange(of: scrollPosition) {
  100. updateTimer.scheduleUpdate {
  101. calculateVisibleDateRange()
  102. calculateVisibleGlucose()
  103. }
  104. }
  105. .onChange(of: selectedInterval) { _, _ in
  106. selectedDate = nil
  107. isDaySelected = false
  108. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
  109. }
  110. }
  111. /// Formatted date string for display
  112. private func formattedDate(for date: Date) -> String {
  113. let dateFormatter = DateFormatter()
  114. dateFormatter.dateFormat = "EEEE, MMMM d, yyyy"
  115. return dateFormatter.string(from: date)
  116. }
  117. /// The main chart visualization showing glucose distribution by day
  118. private var chartView: some View {
  119. Chart {
  120. ForEach(state.dailyGlucoseDistributionStats) { day in
  121. barMark(x: day, y: day.veryLowPct, rangeName: "veryLow")
  122. barMark(x: day, y: day.lowPct, rangeName: "low")
  123. barMark(x: day, y: day.inSmallRangePct, rangeName: "inSmallRange")
  124. barMark(x: day, y: day.inRangePct - day.inSmallRangePct, rangeName: "inRange")
  125. barMark(x: day, y: day.highPct, rangeName: "high")
  126. barMark(x: day, y: day.veryHighPct, rangeName: "veryHigh")
  127. }
  128. }
  129. .chartForegroundStyleScale([
  130. legend("veryLow"): .purple,
  131. legend("low"): .red,
  132. legend("inSmallRange"): .green,
  133. legend("inRange"): .darkGreen,
  134. legend("high"): .loopYellow,
  135. legend("veryHigh"): .orange
  136. ])
  137. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  138. .onChange(of: selectedDate) { _, newValue in
  139. withAnimation(.easeInOut) {
  140. isDaySelected = newValue != nil
  141. }
  142. }
  143. .chartYScale(domain: 0 ... 100)
  144. .chartXAxis {
  145. AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
  146. if let date = value.as(Date.self) {
  147. let calendar = Calendar.current
  148. switch selectedInterval {
  149. case .month:
  150. // Mark the first day of the week
  151. let weekday = calendar.component(.weekday, from: date)
  152. if weekday == calendar.firstWeekday {
  153. AxisValueLabel(format: .dateTime.day(), centered: true)
  154. .font(.footnote)
  155. AxisGridLine()
  156. }
  157. case .total:
  158. // Mark the start of the month
  159. let day = calendar.component(.day, from: date)
  160. if day == 1 {
  161. AxisValueLabel(format: .dateTime.month(.abbreviated), centered: true)
  162. .font(.footnote)
  163. AxisGridLine()
  164. }
  165. default:
  166. // Mark every day
  167. AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true)
  168. .font(.footnote)
  169. AxisGridLine()
  170. }
  171. }
  172. }
  173. }
  174. .chartYAxis {
  175. AxisMarks(position: .trailing, values: [4, 25, 50, 75, 100]) { value in
  176. if let percentage = value.as(Double.self) {
  177. AxisValueLabel {
  178. Text((percentage / 100).formatted(.percent.precision(.fractionLength(0))))
  179. .font(.footnote)
  180. }
  181. AxisGridLine()
  182. }
  183. }
  184. }
  185. .chartYAxisLabel(alignment: .trailing) {
  186. Text("Percentage")
  187. .foregroundStyle(.primary)
  188. .font(.footnote)
  189. .padding(.vertical, 3)
  190. }
  191. .chartScrollableAxes(.horizontal)
  192. .chartScrollPosition(x: $scrollPosition.animation(.easeInOut))
  193. .chartScrollTargetBehavior(
  194. .valueAligned(
  195. matching: DateComponents(hour: 0),
  196. majorAlignment: .matching(
  197. StatChartUtils.alignmentComponents(for: selectedInterval)
  198. )
  199. )
  200. )
  201. .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
  202. }
  203. /// Formats a short string with the glucose values of the requested range.
  204. private func legend(_ rangeName: String) -> String {
  205. switch rangeName {
  206. case "veryLow":
  207. return "<\(Decimal(54).formatted(for: units))"
  208. case "low":
  209. return "\(Decimal(54).formatted(for: units))-\(Decimal(timeInRangeType.bottomThreshold - 1).formatted(for: units))"
  210. case "inSmallRange":
  211. return "\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units))"
  212. case "inRange":
  213. return "\(Decimal(timeInRangeType.topThreshold + 1).formatted(for: units))-\(highLimit.formatted(for: units))"
  214. case "high":
  215. return "\((highLimit + 1).formatted(for: units))-\(Decimal(250).formatted(for: units))"
  216. case "veryHigh":
  217. return ">\(Decimal(250).formatted(for: units))"
  218. default:
  219. return "error"
  220. }
  221. }
  222. /// Creates a bar mark for the requested date and range
  223. private func barMark(x: GlucoseDailyDistributionStats, y: Double, rangeName: String) -> some ChartContent {
  224. BarMark(
  225. x: .value("Date", x.date, unit: .day),
  226. y: .value("Percentage", y)
  227. )
  228. .foregroundStyle(by: .value("Range", legend(rangeName)))
  229. .opacity(selectedDate == nil || Calendar.current.isDate(selectedDate!, inSameDayAs: x.date) ? 1 : 0.3)
  230. }
  231. }