GlucosePercentileChart.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import Charts
  2. import SwiftUI
  3. struct GlucosePercentileChart: View {
  4. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let state: Stat.StateModel
  6. let glucose: [GlucoseStored]
  7. let highLimit: Decimal
  8. let lowLimit: Decimal
  9. let units: GlucoseUnits
  10. let hourlyStats: [HourlyStats]
  11. @State private var scrollPosition = Date()
  12. @State private var selection: Date?
  13. @State private var isScrolling = false
  14. @State private var updateTimer = Stat.UpdateTimer()
  15. private func getDataRange() -> (start: Date, end: Date) {
  16. let calendar = Calendar.current
  17. switch selectedDuration {
  18. case .Day:
  19. return (
  20. calendar.startOfDay(for: scrollPosition),
  21. calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
  22. )
  23. case .Week:
  24. let weekStart = calendar
  25. .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
  26. return (weekStart, weekStart.addingTimeInterval(7 * 24 * 3600))
  27. case .Month:
  28. let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: scrollPosition))!
  29. return (monthStart, calendar.date(byAdding: .month, value: 1, to: monthStart)!)
  30. case .Total:
  31. return (
  32. calendar.date(byAdding: .month, value: -3, to: scrollPosition)!,
  33. scrollPosition
  34. )
  35. }
  36. }
  37. private var visibleDateRange: (start: Date, end: Date) {
  38. let calendar = Calendar.current
  39. // Die X-Achse zeigt immer einen 24h-Tag
  40. return (
  41. calendar.startOfDay(for: scrollPosition),
  42. calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
  43. )
  44. }
  45. private func formatVisibleDateRange() -> String {
  46. let calendar = Calendar.current
  47. let today = Date()
  48. switch selectedDuration {
  49. case .Day:
  50. let isToday = calendar.isDate(scrollPosition, inSameDayAs: today)
  51. let isYesterday = calendar.isDate(
  52. scrollPosition,
  53. inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!
  54. )
  55. return if isToday {
  56. "Today"
  57. } else if isYesterday {
  58. "Yesterday"
  59. } else {
  60. scrollPosition.formatted(date: .numeric, time: .omitted)
  61. }
  62. case .Week:
  63. let weekStart = calendar
  64. .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
  65. let weekEnd = calendar.date(byAdding: .day, value: 6, to: weekStart)!
  66. return "\(weekStart.formatted(date: .numeric, time: .omitted)) - \(weekEnd.formatted(date: .numeric, time: .omitted))"
  67. case .Month:
  68. let monthStart = calendar.date(
  69. from: calendar.dateComponents([.year, .month], from: scrollPosition)
  70. )!
  71. let monthEnd = calendar.date(byAdding: .month, value: 1, to: monthStart)!
  72. let lastDayOfMonth = calendar.date(byAdding: .day, value: -1, to: monthEnd)!
  73. return "\(monthStart.formatted(date: .numeric, time: .omitted)) - \(lastDayOfMonth.formatted(date: .numeric, time: .omitted))"
  74. case .Total:
  75. let endDate = scrollPosition
  76. let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
  77. return "\(startDate.formatted(date: .numeric, time: .omitted)) - \(endDate.formatted(date: .numeric, time: .omitted))"
  78. }
  79. }
  80. var body: some View {
  81. VStack(alignment: .leading, spacing: 8) {
  82. HStack(alignment: .top) {
  83. VStack(alignment: .leading) {
  84. Text("Ambulatory Glucose Profile")
  85. .font(.headline)
  86. Text("(AGP)")
  87. .font(.subheadline)
  88. .foregroundStyle(.secondary)
  89. }
  90. Spacer()
  91. Text(formatVisibleDateRange())
  92. .font(.subheadline)
  93. .foregroundStyle(.secondary)
  94. }
  95. Chart {
  96. // TODO: ensure data is still correct
  97. // TODO: ensure area marks and line mark take color of respective range
  98. // Statistical view for longer periods
  99. // 10-90 percentile area
  100. ForEach(hourlyStats, id: \.hour) { stats in
  101. AreaMark(
  102. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
  103. yStart: .value("10th Percentile", stats.percentile10),
  104. yEnd: .value("90th Percentile", stats.percentile90),
  105. series: .value("10-90", "10-90")
  106. )
  107. .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.2 : 0))
  108. }
  109. // 25-75 percentile area
  110. ForEach(hourlyStats, id: \.hour) { stats in
  111. AreaMark(
  112. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
  113. yStart: .value("25th Percentile", stats.percentile25),
  114. yEnd: .value("75th Percentile", stats.percentile75),
  115. series: .value("25-75", "25-75")
  116. )
  117. .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.3 : 0))
  118. }
  119. // Median line
  120. ForEach(hourlyStats.filter { $0.median > 0 }, id: \.hour) { stats in
  121. LineMark(
  122. x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
  123. y: .value("Median", stats.median),
  124. series: .value("Median", "Median")
  125. )
  126. .lineStyle(StrokeStyle(lineWidth: 2))
  127. .foregroundStyle(.blue)
  128. }
  129. // Target range
  130. RuleMark(
  131. y: .value("High Limit", highLimit)
  132. )
  133. .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
  134. .foregroundStyle(.orange.gradient)
  135. // TODO: - Get target
  136. RuleMark(
  137. y: .value("Target", 100)
  138. )
  139. .lineStyle(StrokeStyle(lineWidth: 1.5))
  140. .foregroundStyle(.green.gradient)
  141. RuleMark(
  142. y: .value("Low Limit", lowLimit)
  143. )
  144. .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
  145. .foregroundStyle(.red.gradient)
  146. if let selection = selection,
  147. let stats = selectedStats
  148. {
  149. RuleMark(
  150. x: .value("Selected Time", selection)
  151. )
  152. .foregroundStyle(.secondary.opacity(0.3))
  153. .annotation(
  154. position: .top,
  155. spacing: 0,
  156. overflowResolution: .init(x: .fit, y: .disabled)
  157. ) {
  158. AGPSelectionPopover(
  159. stats: stats,
  160. time: selection,
  161. units: units
  162. )
  163. }
  164. }
  165. }
  166. .chartYAxis {
  167. AxisMarks(position: .trailing) { value in
  168. if let glucose = value.as(Double.self) {
  169. let glucoseValue = units == .mmolL ? Decimal(glucose).asMmolL : Decimal(glucose)
  170. AxisValueLabel {
  171. Text(glucoseValue.formatted(.number.precision(.fractionLength(units == .mmolL ? 1 : 0))))
  172. }
  173. AxisGridLine()
  174. }
  175. }
  176. }
  177. .chartXAxis {
  178. AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
  179. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), centered: true, anchor: .top)
  180. AxisGridLine()
  181. }
  182. }
  183. .chartScrollableAxes(.horizontal)
  184. .chartScrollPosition(x: $scrollPosition)
  185. .chartXSelection(value: $selection)
  186. .chartXVisibleDomain(length: 24 * 3600)
  187. .chartScrollTargetBehavior(
  188. .valueAligned(
  189. matching: DateComponents(minute: 0),
  190. majorAlignment: .matching(DateComponents(hour: 0))
  191. )
  192. )
  193. .frame(height: 200)
  194. }
  195. // Update chart when scrolling
  196. .onChange(of: scrollPosition) {
  197. state.glucoseScrollPosition = scrollPosition
  198. state.updateDisplayedStats(for: .percentile)
  199. }
  200. // Reset scroll position when duration changes
  201. .onChange(of: selectedDuration) {
  202. scrollPosition = Date()
  203. state.glucoseScrollPosition = scrollPosition
  204. }
  205. }
  206. private var selectedStats: HourlyStats? {
  207. guard let selection = selection else { return nil }
  208. // Don't show stats for future times if viewing today
  209. if isToday && selection > Date() {
  210. return nil
  211. }
  212. let calendar = Calendar.current
  213. let hour = calendar.component(.hour, from: selection)
  214. return hourlyStats.first { Int($0.hour) == hour }
  215. }
  216. private var isToday: Bool {
  217. let calendar = Calendar.current
  218. let now = Date()
  219. return calendar.isDate(now, inSameDayAs: calendar.startOfDay(for: now))
  220. }
  221. }
  222. struct AGPSelectionPopover: View {
  223. let stats: HourlyStats
  224. let time: Date
  225. let units: GlucoseUnits
  226. var body: some View {
  227. VStack(alignment: .leading, spacing: 4) {
  228. HStack {
  229. Image(systemName: "clock")
  230. Text(time.formatted(.dateTime.hour().minute(.twoDigits)))
  231. .font(.body).bold()
  232. }
  233. .font(.caption)
  234. .foregroundStyle(.secondary)
  235. Grid(alignment: .leading, horizontalSpacing: 8) {
  236. GridRow {
  237. Text("90%:")
  238. Text(stats.percentile90.formatted(.number))
  239. Text(units.rawValue)
  240. .foregroundStyle(.secondary)
  241. }
  242. GridRow {
  243. Text("75%:")
  244. Text(stats.percentile75.formatted(.number))
  245. Text(units.rawValue)
  246. .foregroundStyle(.secondary)
  247. }
  248. GridRow {
  249. Text("Median:")
  250. Text(stats.median.formatted(.number))
  251. Text(units.rawValue)
  252. .foregroundStyle(.secondary)
  253. }
  254. GridRow {
  255. Text("25%:")
  256. Text(stats.percentile25.formatted(.number))
  257. Text(units.rawValue)
  258. .foregroundStyle(.secondary)
  259. }
  260. GridRow {
  261. Text("10%:")
  262. Text(stats.percentile10.formatted(.number))
  263. Text(units.rawValue)
  264. .foregroundStyle(.secondary)
  265. }
  266. }
  267. .font(.caption)
  268. }
  269. .padding(8)
  270. .background {
  271. RoundedRectangle(cornerRadius: 8)
  272. .fill(.background)
  273. .shadow(radius: 2)
  274. }
  275. }
  276. }
  277. private extension Calendar {
  278. func startOfHour(for date: Date) -> Date {
  279. let components = dateComponents([.year, .month, .day, .hour], from: date)
  280. return self.date(from: components) ?? date
  281. }
  282. }