GlucosePercentileChart.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import Charts
  2. import SwiftUI
  3. struct GlucosePercentileChart: View {
  4. let glucose: [GlucoseStored]
  5. let highLimit: Decimal
  6. let lowLimit: Decimal
  7. let units: GlucoseUnits
  8. let hourlyStats: [HourlyStats]
  9. let isToday: Bool
  10. @State private var selection: Date? = nil
  11. private var selectedStats: HourlyStats? {
  12. guard let selection = selection else { return nil }
  13. // Don't show stats for future times if viewing today
  14. if isToday && selection > Date() {
  15. return nil
  16. }
  17. let calendar = Calendar.current
  18. let hour = calendar.component(.hour, from: selection)
  19. return hourlyStats.first { Int($0.hour) == hour }
  20. }
  21. var body: some View {
  22. VStack(alignment: .leading, spacing: 8) {
  23. Text("Ambulatory Glucose Profile (AGP)")
  24. .font(.headline)
  25. Chart {
  26. // TODO: ensure data is still correct
  27. // TODO: ensure area marks and line mark take color of respective range
  28. // Statistical view for longer periods
  29. // 10-90 percentile area
  30. ForEach(hourlyStats, id: \.hour) { stats in
  31. AreaMark(
  32. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
  33. yStart: .value("10th Percentile", stats.percentile10),
  34. yEnd: .value("90th Percentile", stats.percentile90),
  35. series: .value("10-90", "10-90")
  36. )
  37. .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.2 : 0))
  38. }
  39. // 25-75 percentile area
  40. ForEach(hourlyStats, id: \.hour) { stats in
  41. AreaMark(
  42. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
  43. yStart: .value("25th Percentile", stats.percentile25),
  44. yEnd: .value("75th Percentile", stats.percentile75),
  45. series: .value("25-75", "25-75")
  46. )
  47. .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.3 : 0))
  48. }
  49. // Median line
  50. ForEach(hourlyStats.filter { $0.median > 0 }, id: \.hour) { stats in
  51. LineMark(
  52. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour)),
  53. y: .value("Median", stats.median),
  54. series: .value("Median", "Median")
  55. )
  56. .lineStyle(StrokeStyle(lineWidth: 2))
  57. .foregroundStyle(.blue)
  58. }
  59. // High/Low limit lines
  60. RuleMark(y: .value("High Limit", Double(highLimit)))
  61. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  62. .foregroundStyle(.orange)
  63. RuleMark(y: .value("Low Limit", Double(lowLimit)))
  64. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  65. .foregroundStyle(.red)
  66. if let selectedStats, let selection {
  67. RuleMark(x: .value("Selection", selection))
  68. .foregroundStyle(.secondary.opacity(0.3))
  69. .annotation(
  70. position: .top,
  71. spacing: 0,
  72. overflowResolution: .init(x: .fit, y: .disabled)
  73. ) {
  74. AGPSelectionPopover(
  75. stats: selectedStats,
  76. time: selection,
  77. units: units
  78. )
  79. }
  80. }
  81. }
  82. .chartYAxis {
  83. AxisMarks(position: .leading)
  84. }
  85. .chartYAxisLabel(alignment: .leading) {
  86. Text("\(units.rawValue)")
  87. .foregroundStyle(.primary)
  88. .font(.caption)
  89. .padding(.vertical, 3)
  90. }
  91. .chartXAxis {
  92. AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
  93. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
  94. AxisGridLine()
  95. }
  96. }
  97. .chartXSelection(value: $selection)
  98. .frame(height: 200)
  99. legend
  100. }
  101. }
  102. private var legend: some View {
  103. HStack(spacing: 20) {
  104. VStack {
  105. // 10-90 Percentile
  106. HStack(spacing: 8) {
  107. Rectangle()
  108. .frame(width: 20, height: 8)
  109. .foregroundStyle(.blue.opacity(0.2))
  110. Text("10% - 90%")
  111. .font(.caption)
  112. .foregroundStyle(.secondary)
  113. }
  114. // 25-75 Percentile
  115. HStack(spacing: 8) {
  116. Rectangle()
  117. .frame(width: 20, height: 8)
  118. .foregroundStyle(.blue.opacity(0.3))
  119. Text("25% - 75%")
  120. .font(.caption)
  121. .foregroundStyle(.secondary)
  122. }
  123. }
  124. // Median
  125. HStack(spacing: 8) {
  126. Rectangle()
  127. .frame(width: 20, height: 2)
  128. .foregroundStyle(.blue)
  129. Text("Median")
  130. .font(.caption)
  131. .foregroundStyle(.secondary)
  132. }
  133. VStack {
  134. // High Limit
  135. HStack(spacing: 8) {
  136. Rectangle()
  137. .frame(width: 20, height: 1)
  138. .foregroundStyle(.orange)
  139. Text("High Limit")
  140. .font(.caption)
  141. .foregroundStyle(.secondary)
  142. }
  143. // Low Limit
  144. HStack(spacing: 8) {
  145. Rectangle()
  146. .frame(width: 20, height: 1)
  147. .foregroundStyle(.red)
  148. Text("Low Limit")
  149. .font(.caption)
  150. .foregroundStyle(.secondary)
  151. }
  152. }
  153. }
  154. .padding(.horizontal)
  155. }
  156. }
  157. struct AGPSelectionPopover: View {
  158. let stats: HourlyStats
  159. let time: Date
  160. let units: GlucoseUnits
  161. var body: some View {
  162. VStack(alignment: .leading, spacing: 4) {
  163. HStack {
  164. Image(systemName: "clock")
  165. Text(time.formatted(.dateTime.hour().minute(.twoDigits)))
  166. .font(.body).bold()
  167. }
  168. .font(.caption)
  169. .foregroundStyle(.secondary)
  170. Grid(alignment: .leading, horizontalSpacing: 8) {
  171. GridRow {
  172. Text("90%:")
  173. Text(stats.percentile90.formatted(.number))
  174. Text(units.rawValue)
  175. .foregroundStyle(.secondary)
  176. }
  177. GridRow {
  178. Text("75%:")
  179. Text(stats.percentile75.formatted(.number))
  180. Text(units.rawValue)
  181. .foregroundStyle(.secondary)
  182. }
  183. GridRow {
  184. Text("Median:")
  185. Text(stats.median.formatted(.number))
  186. Text(units.rawValue)
  187. .foregroundStyle(.secondary)
  188. }
  189. GridRow {
  190. Text("25%:")
  191. Text(stats.percentile25.formatted(.number))
  192. Text(units.rawValue)
  193. .foregroundStyle(.secondary)
  194. }
  195. GridRow {
  196. Text("10%:")
  197. Text(stats.percentile10.formatted(.number))
  198. Text(units.rawValue)
  199. .foregroundStyle(.secondary)
  200. }
  201. }
  202. .font(.caption)
  203. }
  204. .padding(8)
  205. .background {
  206. RoundedRectangle(cornerRadius: 8)
  207. .fill(.background)
  208. .shadow(radius: 2)
  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. }