GlucosePercentileChart.swift 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import Charts
  2. import SwiftUI
  3. /// A view that displays an Ambulatory Glucose Profile (AGP) chart.
  4. ///
  5. /// This chart visualizes glucose percentile statistics over a 24-hour period.
  6. /// It includes the 10-90 percentile, 25-75 percentile, median glucose values,
  7. /// and high/low glucose limits.
  8. struct GlucosePercentileChart: View {
  9. /// The list of stored glucose values.
  10. let glucose: [GlucoseStored]
  11. /// The upper glucose limit for the chart.
  12. let highLimit: Decimal
  13. /// The lower glucose limit for the chart.
  14. let lowLimit: Decimal
  15. /// The units used for glucose measurement (mg/dL or mmol/L).
  16. let units: GlucoseUnits
  17. /// The hourly glucose statistics.
  18. let hourlyStats: [HourlyStats]
  19. /// Flag indicating whether the chart represents today's data.
  20. let isToday: Bool
  21. /// The currently selected hour in the chart.
  22. @State private var selection: Date? = nil
  23. /// Retrieves the hourly statistics for the selected time.
  24. private var selectedStats: HourlyStats? {
  25. guard let selection = selection else { return nil }
  26. if isToday && selection > Date() {
  27. return nil
  28. }
  29. let calendar = Calendar.current
  30. let hour = calendar.component(.hour, from: selection)
  31. return hourlyStats.first { Int($0.hour) == hour }
  32. }
  33. var body: some View {
  34. VStack(alignment: .leading, spacing: 8) {
  35. Text("Ambulatory Glucose Profile (AGP)")
  36. .font(.headline)
  37. Chart {
  38. // TODO: ensure data is still correct
  39. // TODO: ensure area marks and line mark take color of respective range
  40. // Statistical view for longer periods
  41. ForEach(hourlyStats, id: \.hour) { stats in
  42. // 10-90 percentile area
  43. AreaMark(
  44. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
  45. yStart: .value("10th Percentile", stats.percentile10),
  46. yEnd: .value("90th Percentile", stats.percentile90),
  47. series: .value("10-90", "10-90")
  48. )
  49. .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.2 : 0))
  50. // 25-75 percentile area
  51. AreaMark(
  52. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
  53. yStart: .value("25th Percentile", stats.percentile25),
  54. yEnd: .value("75th Percentile", stats.percentile75),
  55. series: .value("25-75", "25-75")
  56. )
  57. .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.3 : 0))
  58. // Median line
  59. if stats.median > 0 {
  60. LineMark(
  61. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
  62. y: .value("Median", stats.median),
  63. series: .value("Median", "Median")
  64. )
  65. .lineStyle(StrokeStyle(lineWidth: 2))
  66. .foregroundStyle(.blue)
  67. }
  68. }
  69. // High/Low limit lines
  70. RuleMark(y: .value("High Limit", Double(highLimit)))
  71. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  72. .foregroundStyle(.orange)
  73. RuleMark(y: .value("Low Limit", Double(lowLimit)))
  74. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  75. .foregroundStyle(.red)
  76. if let selectedStats, let selection {
  77. RuleMark(x: .value("Selection", selection))
  78. .foregroundStyle(.secondary.opacity(0.5))
  79. .annotation(
  80. position: .top,
  81. spacing: 0,
  82. overflowResolution: .init(x: .fit, y: .disabled)
  83. ) {
  84. AGPSelectionPopover(
  85. stats: selectedStats,
  86. time: selection,
  87. units: units
  88. )
  89. }
  90. }
  91. }
  92. .chartYAxis {
  93. AxisMarks(position: .trailing) { value in
  94. if let glucose = value.as(Double.self) {
  95. AxisValueLabel {
  96. Text(
  97. units == .mmolL ? glucose.asMmolL.formatted(.number.precision(.fractionLength(0))) : glucose
  98. .formatted(.number.precision(.fractionLength(0)))
  99. )
  100. .font(.footnote)
  101. }
  102. AxisGridLine()
  103. }
  104. }
  105. }
  106. .chartYAxisLabel(alignment: .trailing) {
  107. Text("\(units.rawValue)")
  108. .foregroundStyle(.primary)
  109. .font(.footnote)
  110. .padding(.vertical, 3)
  111. }
  112. .chartXAxis {
  113. AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
  114. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
  115. .font(.footnote)
  116. AxisGridLine()
  117. }
  118. }
  119. .chartXSelection(value: $selection.animation(.easeInOut))
  120. .frame(height: 200)
  121. legend
  122. }
  123. }
  124. /// A view displaying the legend for the chart.
  125. private var legend: some View {
  126. HStack(spacing: 20) {
  127. VStack {
  128. legendItem(color: .blue.opacity(0.2), text: "10% - 90%")
  129. legendItem(color: .blue.opacity(0.3), text: "25% - 75%")
  130. }
  131. legendItem(color: .blue, text: "Median")
  132. VStack {
  133. legendItem(color: .orange, text: "High Limit")
  134. legendItem(color: .red, text: "Low Limit")
  135. }
  136. }
  137. .padding(.horizontal)
  138. }
  139. /// Creates a legend item with a given color and text.
  140. private func legendItem(color: Color, text: String) -> some View {
  141. HStack(spacing: 8) {
  142. Rectangle()
  143. .frame(width: 20, height: 8)
  144. .foregroundStyle(color)
  145. Text(text)
  146. .font(.caption)
  147. .foregroundStyle(.secondary)
  148. }
  149. }
  150. }
  151. /// A popover view displaying detailed glucose statistics for a selected time.
  152. struct AGPSelectionPopover: View {
  153. let stats: HourlyStats
  154. let time: Date
  155. let units: GlucoseUnits
  156. @Environment(\.colorScheme) var colorScheme
  157. private var timeText: String {
  158. if let hour = Calendar.current.dateComponents([.hour], from: time).hour {
  159. return "\(hour):00-\(hour + 1):00"
  160. } else {
  161. return time.formatted(.dateTime.hour().minute())
  162. }
  163. }
  164. /// A helper function to format glucose values based on the selected unit.
  165. private func formattedGlucoseValue(_ value: Double) -> String {
  166. units == .mmolL ? value.formattedAsMmolL :
  167. value.formatted()
  168. }
  169. var body: some View {
  170. VStack(alignment: .leading, spacing: 4) {
  171. Text(timeText).bold().font(.subheadline)
  172. Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) {
  173. GridRow {
  174. Text("Median:").bold()
  175. Text(formattedGlucoseValue(stats.median))
  176. Text(units.rawValue).foregroundStyle(.secondary)
  177. }
  178. GridRow {
  179. Text("90%:").bold()
  180. Text(formattedGlucoseValue(stats.percentile90))
  181. Text(units.rawValue).foregroundStyle(.secondary)
  182. }
  183. GridRow {
  184. Text("75%:").bold()
  185. Text(formattedGlucoseValue(stats.percentile75))
  186. Text(units.rawValue).foregroundStyle(.secondary)
  187. }
  188. GridRow {
  189. Text("25%:").bold()
  190. Text(formattedGlucoseValue(stats.percentile25))
  191. Text(units.rawValue).foregroundStyle(.secondary)
  192. }
  193. GridRow {
  194. Text("10%:").bold()
  195. Text(formattedGlucoseValue(stats.percentile10))
  196. Text(units.rawValue).foregroundStyle(.secondary)
  197. }
  198. }.font(.headline)
  199. }
  200. .padding(20)
  201. .background {
  202. RoundedRectangle(cornerRadius: 10)
  203. .fill(colorScheme == .dark ? Color.bgDarkBlue.opacity(0.9) : Color.white.opacity(0.95))
  204. .shadow(color: Color.secondary, radius: 2)
  205. .overlay(
  206. RoundedRectangle(cornerRadius: 4)
  207. .stroke(Color.blue, lineWidth: 2)
  208. )
  209. }
  210. }
  211. }
  212. private extension Calendar {
  213. func startOfHour(for date: Date) -> Date {
  214. let components = dateComponents([.year, .month, .day, .hour], from: date)
  215. return self.date(from: components) ?? date
  216. }
  217. }