GlucosePercentileChart.swift 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import Charts
  2. import SwiftUI
  3. struct GlucosePercentileChart: View {
  4. let glucose: [GlucoseStored]
  5. let highLimit: Decimal
  6. let lowLimit: Decimal
  7. let isTodayOrLast24h: Bool
  8. let units: GlucoseUnits
  9. let hourlyStats: [HourlyStats]
  10. var body: some View {
  11. VStack(alignment: .leading, spacing: 8) {
  12. Text("Ambulatory Glucose Profile (AGP)")
  13. .font(.headline)
  14. Chart {
  15. // if isTodayOrLast24h {
  16. // // Single day line chart
  17. // ForEach(glucose.sorted(by: { ($0.date ?? Date()) < ($1.date ?? Date()) }), id: \.id) { reading in
  18. // LineMark(
  19. // x: .value("Time", reading.date ?? Date()),
  20. // y: .value("Glucose", Double(reading.glucose))
  21. // )
  22. // .lineStyle(StrokeStyle(lineWidth: 2))
  23. // .foregroundStyle(.blue)
  24. // }
  25. // } else {
  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. // }
  60. // High/Low limit lines
  61. RuleMark(y: .value("High Limit", Double(highLimit)))
  62. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  63. .foregroundStyle(.orange)
  64. RuleMark(y: .value("Low Limit", Double(lowLimit)))
  65. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  66. .foregroundStyle(.red)
  67. }
  68. // .chartYScale(domain: 40 ... 400)
  69. .chartYAxis {
  70. AxisMarks(position: .leading)
  71. }
  72. .chartYAxisLabel(alignment: .leading) {
  73. Text("\(units.rawValue)")
  74. .foregroundStyle(.primary)
  75. .font(.caption)
  76. .padding(.vertical, 3)
  77. }
  78. .chartXAxis {
  79. AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
  80. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
  81. AxisGridLine()
  82. }
  83. }
  84. .frame(height: 200)
  85. // Legend
  86. // if !isTodayOrLast24h {
  87. legend
  88. // }
  89. }
  90. }
  91. private var legend: some View {
  92. HStack(spacing: 20) {
  93. VStack {
  94. // 10-90 Percentile
  95. HStack(spacing: 8) {
  96. Rectangle()
  97. .frame(width: 20, height: 8)
  98. .foregroundStyle(.blue.opacity(0.2))
  99. Text("10% - 90%")
  100. .font(.caption)
  101. .foregroundStyle(.secondary)
  102. }
  103. // 25-75 Percentile
  104. HStack(spacing: 8) {
  105. Rectangle()
  106. .frame(width: 20, height: 8)
  107. .foregroundStyle(.blue.opacity(0.3))
  108. Text("25% - 75%")
  109. .font(.caption)
  110. .foregroundStyle(.secondary)
  111. }
  112. }
  113. // Median
  114. HStack(spacing: 8) {
  115. Rectangle()
  116. .frame(width: 20, height: 2)
  117. .foregroundStyle(.blue)
  118. Text("Median")
  119. .font(.caption)
  120. .foregroundStyle(.secondary)
  121. }
  122. VStack {
  123. // High Limit
  124. HStack(spacing: 8) {
  125. Rectangle()
  126. .frame(width: 20, height: 1)
  127. .foregroundStyle(.orange)
  128. Text("High Limit")
  129. .font(.caption)
  130. .foregroundStyle(.secondary)
  131. }
  132. // Low Limit
  133. HStack(spacing: 8) {
  134. Rectangle()
  135. .frame(width: 20, height: 1)
  136. .foregroundStyle(.red)
  137. Text("Low Limit")
  138. .font(.caption)
  139. .foregroundStyle(.secondary)
  140. }
  141. }
  142. }
  143. .padding(.horizontal)
  144. }
  145. }
  146. private extension Calendar {
  147. func startOfHour(for date: Date) -> Date {
  148. let components = dateComponents([.year, .month, .day, .hour], from: date)
  149. return self.date(from: components) ?? date
  150. }
  151. }