GlucoseDailyPercentileChart.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import Charts
  2. import SwiftUI
  3. enum GlucosePercentileType: String, Identifiable {
  4. case minimum = "Min"
  5. case percentile10 = "10th"
  6. case percentile25 = "25th"
  7. case median = "Median"
  8. case percentile75 = "75th"
  9. case percentile90 = "90th"
  10. case maximum = "Max"
  11. var id: String { rawValue }
  12. // Function to get the percentile value from a stats object
  13. func getValue(from stats: GlucoseDailyPercentileStats) -> Double {
  14. switch self {
  15. case .minimum: return stats.minimum
  16. case .percentile10: return stats.percentile10
  17. case .percentile25: return stats.percentile25
  18. case .median: return stats.median
  19. case .percentile75: return stats.percentile75
  20. case .percentile90: return stats.percentile90
  21. case .maximum: return stats.maximum
  22. }
  23. }
  24. }
  25. struct GlucoseDailyPercentileChart: View {
  26. let glucose: [GlucoseStored]
  27. let highLimit: Decimal
  28. let units: GlucoseUnits
  29. let timeInRangeType: TimeInRangeType
  30. let selectedInterval: Stat.StateModel.StatsTimeInterval
  31. @Binding var isDaySelected: Bool
  32. // Scrolling and selection states
  33. @State private var scrollPosition = Date()
  34. @State private var selectedDate: Date?
  35. @State private var updateTimer = Stat.UpdateTimer()
  36. @State private var visibleDailyStats: [GlucoseDailyPercentileStats] = []
  37. // State for selected percentile
  38. @State private var selectedPercentile: GlucosePercentileType?
  39. // State model for accessing the shared calculations
  40. let state: Stat.StateModel
  41. // Computes the visible date range based on the current scroll position
  42. @State private var visibleDateRange: (start: Date, end: Date) = (Date(), Date())
  43. private func calculateVisibleDailyStats() {
  44. let calendar = Calendar.current
  45. visibleDailyStats = state.dailyGlucosePercentileStats.filter { stat in
  46. let statDate = calendar.startOfDay(for: stat.date)
  47. return statDate >= calendar.startOfDay(for: visibleDateRange.start) &&
  48. statDate <= calendar.startOfDay(for: visibleDateRange.end)
  49. }
  50. }
  51. private func calculateVisibleDateRange() {
  52. visibleDateRange = StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
  53. }
  54. // Gets selected day stats
  55. private var selectedDateStats: GlucoseDailyPercentileStats? {
  56. selectedDate.flatMap { day in
  57. state.glucosePercentileCache[Calendar.current.startOfDay(for: day)]
  58. }
  59. }
  60. // Aggregates data from all visible days
  61. private var aggregatedVisibleStats: GlucoseDailyPercentileStats? {
  62. guard !visibleDailyStats.isEmpty else { return nil }
  63. // Collect all glucose values from visible days
  64. var allMinimums: [Double] = []
  65. var allMaximums: [Double] = []
  66. var all10thPercentiles: [Double] = []
  67. var all25thPercentiles: [Double] = []
  68. var allMedians: [Double] = []
  69. var all75thPercentiles: [Double] = []
  70. var all90thPercentiles: [Double] = []
  71. // Collect data from all visible days
  72. for stats in visibleDailyStats where stats.median > 0 {
  73. allMinimums.append(stats.minimum)
  74. allMaximums.append(stats.maximum)
  75. all10thPercentiles.append(stats.percentile10)
  76. all25thPercentiles.append(stats.percentile25)
  77. allMedians.append(stats.median)
  78. all75thPercentiles.append(stats.percentile75)
  79. all90thPercentiles.append(stats.percentile90)
  80. }
  81. // Calculate aggregated values
  82. let aggMinimum = allMinimums.min() ?? 0
  83. let aggMaximum = allMaximums.max() ?? 0
  84. let aggP10 = StatChartUtils.medianCalculationDouble(array: all10thPercentiles)
  85. let aggP25 = StatChartUtils.medianCalculationDouble(array: all25thPercentiles)
  86. let aggMedian = StatChartUtils.medianCalculationDouble(array: allMedians)
  87. let aggP75 = StatChartUtils.medianCalculationDouble(array: all75thPercentiles)
  88. let aggP90 = StatChartUtils.medianCalculationDouble(array: all90thPercentiles)
  89. // Create a new stats object with the visible date range and aggregated values
  90. return GlucoseDailyPercentileStats(
  91. date: visibleDateRange.start,
  92. readings: [], // Empty array since this is aggregated data
  93. minimum: aggMinimum,
  94. percentile10: aggP10,
  95. percentile25: aggP25,
  96. median: aggMedian,
  97. percentile75: aggP75,
  98. percentile90: aggP90,
  99. maximum: aggMaximum
  100. )
  101. }
  102. // Format a single date for display
  103. private func formatDate(_ date: Date) -> String {
  104. date.formatted(.dateTime.weekday(.wide).month(.wide).day().year())
  105. }
  106. // Get the appropriate detail view data
  107. private var detailViewData: (data: GlucoseDailyPercentileStats, dateText: String)? {
  108. if let selectedData = selectedDateStats {
  109. // Case 1: Selected specific day
  110. return (selectedData, selectedData.date.formatted(.dateTime.weekday(.wide).month(.wide).day().year()))
  111. } else if let aggregatedData = aggregatedVisibleStats {
  112. // Case 2: Using aggregated data
  113. return (aggregatedData, StatChartUtils.formatVisibleDateRange(
  114. from: visibleDateRange.start,
  115. to: visibleDateRange.end,
  116. for: selectedInterval
  117. ))
  118. }
  119. return nil
  120. }
  121. var body: some View {
  122. VStack(alignment: .leading, spacing: 8) {
  123. boxplotChart
  124. .frame(height: 300)
  125. // Display detail view if we have data
  126. if let viewData = detailViewData {
  127. GlucoseDailyPercentileDetailView(
  128. dayData: viewData.data,
  129. units: units,
  130. dateRangeText: viewData.dateText,
  131. selectedPercentile: $selectedPercentile
  132. )
  133. .padding(.top, 4)
  134. }
  135. }
  136. .onAppear {
  137. calculateVisibleDateRange()
  138. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
  139. calculateVisibleDailyStats()
  140. }
  141. .onChange(of: scrollPosition) {
  142. updateTimer.scheduleUpdate {
  143. calculateVisibleDateRange()
  144. calculateVisibleDailyStats()
  145. }
  146. }
  147. .onChange(of: selectedInterval) { _, _ in
  148. selectedDate = nil
  149. selectedPercentile = nil
  150. isDaySelected = false
  151. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
  152. }
  153. }
  154. // Simple boxplot chart with improved visuals - broken down into components
  155. private var boxplotChart: some View {
  156. Chart {
  157. // First draw all the non-interactive elements
  158. ForEach(state.dailyGlucosePercentileStats) { day in
  159. if day.maximum > 0 { // Check if we have valid data
  160. // Add background components for each day
  161. spacerBarMark(for: day)
  162. percentileBarMark(
  163. for: day,
  164. startValue: day.minimum.asUnit(units),
  165. endValue: day.percentile10.asUnit(units),
  166. rangeName: "0-100%"
  167. )
  168. percentileBarMark(
  169. for: day,
  170. startValue: day.percentile10.asUnit(units),
  171. endValue: day.percentile25.asUnit(units),
  172. rangeName: "10-90%"
  173. )
  174. percentileBarMark(
  175. for: day,
  176. startValue: day.percentile25.asUnit(units),
  177. endValue: day.percentile75.asUnit(units),
  178. rangeName: "25-75%"
  179. )
  180. percentileBarMark(
  181. for: day,
  182. startValue: day.percentile75.asUnit(units),
  183. endValue: day.percentile90.asUnit(units),
  184. rangeName: "10-90%"
  185. )
  186. percentileBarMark(
  187. for: day,
  188. startValue: day.percentile90.asUnit(units),
  189. endValue: day.maximum.asUnit(units),
  190. rangeName: "0-100%"
  191. )
  192. }
  193. }
  194. // Draw median marks - these should appear above the percentile bars but below the selected percentile
  195. ForEach(state.dailyGlucosePercentileStats) { day in
  196. if day.maximum > 0 {
  197. medianMark(for: day)
  198. }
  199. }
  200. // Draw the selected percentile elements LAST so they're on top
  201. if let selectedPercentile = selectedPercentile {
  202. ForEach(state.dailyGlucosePercentileStats) { day in
  203. if day.maximum > 0 {
  204. // Line connecting points
  205. LineMark(
  206. x: .value("SelectedDate", day.date, unit: .day),
  207. y: .value("SelectedValue", selectedPercentile.getValue(from: day).asUnit(units))
  208. )
  209. .foregroundStyle(Color.purple)
  210. .lineStyle(StrokeStyle(lineWidth: selectedInterval == .total ? 1 : 2))
  211. .zIndex(200) // Set very high z-index
  212. // Point marks
  213. PointMark(
  214. x: .value("SelectedDate", day.date, unit: .day),
  215. y: .value("SelectedValue", selectedPercentile.getValue(from: day).asUnit(units))
  216. )
  217. .symbolSize(selectedInterval == .total ? 10 : 30)
  218. .foregroundStyle(Color.purple)
  219. .zIndex(300) // Even higher z-index for points
  220. }
  221. }
  222. }
  223. // Threshold lines
  224. RuleMark(
  225. y: .value("Low Limit", Double(timeInRangeType.bottomThreshold).asUnit(units))
  226. )
  227. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  228. .foregroundStyle(by: .value("Range", "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))"))
  229. .zIndex(100)
  230. RuleMark(
  231. y: .value("Mid Limit", Double(timeInRangeType.topThreshold).asUnit(units))
  232. )
  233. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  234. .foregroundStyle(by: .value("Range", "\(timeInRangeType.topThreshold.formatted(withUnits: units))"))
  235. .zIndex(100)
  236. RuleMark(
  237. y: .value("High Limit", Double(highLimit.asUnit(units)))
  238. )
  239. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  240. .foregroundStyle(by: .value("Range", "\(highLimit.formatted(withUnits: units))"))
  241. .zIndex(100)
  242. }
  243. .chartYAxis {
  244. AxisMarks(values: .automatic) { value in
  245. AxisGridLine()
  246. AxisTick()
  247. AxisValueLabel {
  248. if let glucoseValue = value.as(Double.self) {
  249. Text(
  250. units == .mmolL ?
  251. glucoseValue.formatted(.number.precision(.fractionLength(1))) :
  252. glucoseValue.formatted(.number.precision(.fractionLength(0)))
  253. )
  254. .font(.caption)
  255. }
  256. }
  257. }
  258. }
  259. .chartXAxis {
  260. AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
  261. if let date = value.as(Date.self) {
  262. let calendar = Calendar.current
  263. switch selectedInterval {
  264. case .month:
  265. // Mark the first day of the week
  266. let weekday = calendar.component(.weekday, from: date)
  267. if weekday == calendar.firstWeekday {
  268. AxisValueLabel(format: .dateTime.day(), centered: true)
  269. .font(.footnote)
  270. AxisGridLine()
  271. }
  272. case .total:
  273. // Mark the start of the month
  274. let day = calendar.component(.day, from: date)
  275. if day == 1 {
  276. AxisValueLabel(format: .dateTime.month(.abbreviated), centered: true)
  277. .font(.footnote)
  278. AxisGridLine()
  279. }
  280. default:
  281. // Mark every day
  282. AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true)
  283. .font(.footnote)
  284. AxisGridLine()
  285. }
  286. }
  287. }
  288. }
  289. .chartYScale(domain: glucoseYScaleDomain())
  290. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  291. .onChange(of: selectedDate) { _, newValue in
  292. isDaySelected = newValue != nil
  293. // Clear percentile selection when a day is selected
  294. if newValue != nil {
  295. selectedPercentile = nil
  296. }
  297. }
  298. .chartForegroundStyleScale([
  299. "0-100%": .blue.opacity(0.15),
  300. "10-90%": .blue.opacity(0.3),
  301. "25-75%": .blue.opacity(0.5),
  302. "Median": .blue,
  303. "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))": .red,
  304. "\(timeInRangeType.topThreshold.formatted(withUnits: units))": .mint,
  305. "\(highLimit.formatted(withUnits: units))": .orange
  306. ])
  307. .chartScrollableAxes(.horizontal)
  308. .chartScrollPosition(x: $scrollPosition)
  309. .chartScrollTargetBehavior(
  310. .valueAligned(
  311. matching: DateComponents(hour: 0),
  312. majorAlignment: .matching(
  313. StatChartUtils.alignmentComponents(for: selectedInterval)
  314. )
  315. )
  316. )
  317. .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
  318. }
  319. // MARK: - Chart Components
  320. private func percentileBarMark(
  321. for day: GlucoseDailyPercentileStats,
  322. startValue: Double,
  323. endValue: Double,
  324. rangeName: String
  325. ) -> some ChartContent {
  326. BarMark(
  327. x: .value("Day", day.date, unit: .day),
  328. y: .value("Percentage", endValue - startValue)
  329. )
  330. .foregroundStyle(by: .value("Range", rangeName))
  331. .opacity(getOpacity(for: day))
  332. }
  333. // Median mark - a horizontal line at the median point
  334. private func medianMark(for day: GlucoseDailyPercentileStats) -> some ChartContent {
  335. let baseDate = Calendar.current.startOfDay(for: day.date)
  336. let startOffset = Int(0.15 * 24 * 60) // 15% of minutes in a day
  337. let endOffset = Int(0.85 * 24 * 60) // 85% of minutes in a day
  338. return RuleMark(
  339. xStart: .value("DayStart", Calendar.current.date(byAdding: .minute, value: startOffset, to: baseDate)!),
  340. xEnd: .value("DayEnd", Calendar.current.date(byAdding: .minute, value: endOffset, to: baseDate)!),
  341. y: .value("Median", day.median.asUnit(units))
  342. )
  343. .lineStyle(StrokeStyle(lineWidth: 2))
  344. .foregroundStyle(by: .value("Range", "Median"))
  345. .opacity(getOpacity(for: day))
  346. }
  347. // Helper function to determine opacity based on selections
  348. private func getOpacity(for day: GlucoseDailyPercentileStats) -> Double {
  349. selectedDate.map { date in
  350. StatChartUtils.isSameTimeUnit(day.date, date, for: .total) ? 1 : 0.3
  351. } ?? 1
  352. }
  353. // Spacer box for each day
  354. private func spacerBarMark(for day: GlucoseDailyPercentileStats) -> some ChartContent {
  355. BarMark(
  356. x: .value("Day", day.date, unit: .day),
  357. y: .value("Percentage", day.minimum.asUnit(units))
  358. )
  359. .foregroundStyle(Color.clear)
  360. }
  361. // Calculate an appropriate Y axis domain for the chart
  362. private func glucoseYScaleDomain() -> ClosedRange<Double> {
  363. let padding = units == .mgdL ? 20.0 : 1.0
  364. let bottomLimit = 40.0.asUnit(units)
  365. let topLimit = 400.0.asUnit(units)
  366. if visibleDailyStats.isEmpty {
  367. return bottomLimit ... topLimit
  368. }
  369. var allValues: [Double] = []
  370. for day in visibleDailyStats where day.minimum > 0 {
  371. allValues.append(day.maximum.asUnit(units))
  372. }
  373. guard !allValues.isEmpty else {
  374. return bottomLimit ... topLimit
  375. }
  376. let maxValue = allValues.max() ?? topLimit
  377. return bottomLimit ... max(Double(highLimit.asUnit(units)), maxValue + padding)
  378. }
  379. }