LoopStatsView.swift 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import Charts
  2. import SwiftUI
  3. struct LoopStatsView: View {
  4. let loopStatRecords: [LoopStatRecord]
  5. let selectedDuration: Stat.StateModel.Duration
  6. let groupedStats: [LoopStatsByPeriod]
  7. private let calendar = Calendar.current
  8. private var medianLoopDuration: Double {
  9. groupedStats.first?.medianDuration ?? 0
  10. }
  11. var body: some View {
  12. VStack(spacing: 20) {
  13. loopDurationChart
  14. Divider()
  15. loopStatsChart
  16. }
  17. }
  18. private var loopDurationChart: some View {
  19. Chart {
  20. ForEach(loopStatRecords, id: \.id) { record in
  21. LineMark(
  22. x: .value("Time", record.start ?? Date(), unit: .hour),
  23. y: .value("Duration", record.duration / 1000)
  24. )
  25. .interpolationMethod(.catmullRom)
  26. .foregroundStyle(.blue.opacity(0.6))
  27. }
  28. RuleMark(
  29. y: .value("Median", medianLoopDuration / 1000)
  30. )
  31. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  32. .foregroundStyle(.orange)
  33. .annotation(position: .top, alignment: .trailing) {
  34. Text("\((medianLoopDuration / 1000).formatted(.number.precision(.fractionLength(1)))) s")
  35. .font(.caption)
  36. .foregroundStyle(.orange)
  37. }
  38. }
  39. .chartYAxis {
  40. loopDurationChartYAxisMarks
  41. }
  42. .chartYAxisLabel(alignment: .leading) {
  43. Text("Loop duration")
  44. .foregroundStyle(.primary)
  45. .font(.caption)
  46. .padding(.vertical, 3)
  47. }
  48. .chartXAxis {
  49. loopDurationAxisMarks
  50. }
  51. .frame(height: 200)
  52. .padding(.horizontal)
  53. }
  54. private var loopDurationAxisMarks: some AxisContent {
  55. AxisMarks { value in
  56. if let date = value.as(Date.self) {
  57. AxisValueLabel {
  58. switch selectedDuration {
  59. case .Day,
  60. .Today:
  61. Text(date, format: .dateTime.hour(.defaultDigits(amPM: .abbreviated)))
  62. case .Week:
  63. Text(date, format: .dateTime.weekday(.abbreviated))
  64. case .Month,
  65. .Total:
  66. Text(date, format: .dateTime.day().month(.defaultDigits))
  67. }
  68. }
  69. AxisGridLine()
  70. }
  71. }
  72. }
  73. private var loopDurationChartYAxisMarks: some AxisContent {
  74. AxisMarks(position: .leading) { value in
  75. if let duration = value.as(Double.self) {
  76. AxisValueLabel {
  77. Text("\(duration.formatted(.number.precision(.fractionLength(1)))) s")
  78. .font(.caption)
  79. }
  80. AxisGridLine()
  81. }
  82. }
  83. }
  84. private var loopStatsChart: some View {
  85. Chart {
  86. ForEach(groupedStats) { stat in
  87. // Stacked Bar Chart first (will be in background)
  88. // Succeeded Loops
  89. BarMark(
  90. x: .value("Time", stat.period, unit: .day),
  91. y: .value("Successful", stat.successPercentage)
  92. )
  93. .foregroundStyle(Color.green.opacity(0.9))
  94. .foregroundStyle(by: .value("Type", "Success"))
  95. // .zIndex(1)
  96. // Failed Loops
  97. BarMark(
  98. x: .value("Time", stat.period, unit: .day),
  99. y: .value("Failed", stat.failurePercentage)
  100. )
  101. .foregroundStyle(Color.red.opacity(0.9))
  102. .foregroundStyle(by: .value("Type", "Failed"))
  103. // .zIndex(1)
  104. // Dotted Line Mark showing the daily Glucose counts (will overlay the bars)
  105. LineMark(
  106. x: .value("Time", stat.period, unit: .day),
  107. y: .value("Glucose Count", Double(stat.glucoseCount) / 288.0 * 100)
  108. )
  109. .foregroundStyle(Color.blue)
  110. .lineStyle(StrokeStyle(lineWidth: 2))
  111. .foregroundStyle(by: .value("Type", "Glucose Count"))
  112. // .zIndex(2)
  113. PointMark(
  114. x: .value("Time", stat.period, unit: .day),
  115. y: .value("Glucose Count", Double(stat.glucoseCount) / 288.0 * 100)
  116. )
  117. .foregroundStyle(Color.blue)
  118. .symbolSize(50)
  119. .foregroundStyle(by: .value("Type", "Glucose Count"))
  120. // .zIndex(3)
  121. }
  122. }
  123. .chartForegroundStyleScale([
  124. "Success": Color.green,
  125. "Failed": Color.red,
  126. "Glucose Count": Color.blue
  127. ])
  128. .chartYAxis {
  129. AxisMarks(position: .leading) { value in
  130. if let percent = value.as(Double.self) {
  131. AxisValueLabel {
  132. Text("\(percent.formatted(.number.precision(.fractionLength(0))))%")
  133. .font(.caption)
  134. }
  135. AxisGridLine()
  136. }
  137. }
  138. let maxPossibleReadings = 288.0
  139. let strideBy = 4.0
  140. let defaultStride = Array(stride(from: 0, to: 100, by: 100 / strideBy))
  141. let glucoseStride = Array(stride(from: 0, through: maxPossibleReadings, by: maxPossibleReadings / strideBy))
  142. AxisMarks(position: .trailing, values: defaultStride) { axis in
  143. let value = glucoseStride[axis.index]
  144. AxisValueLabel("\(Int(value))", centered: true)
  145. .font(.caption)
  146. }
  147. }
  148. .chartYAxisLabel(alignment: .leading) {
  149. Text("Loop Success Rate")
  150. .foregroundStyle(.primary)
  151. .font(.caption)
  152. .padding(.vertical, 3)
  153. }
  154. .chartXAxis {
  155. statsAxisMarks
  156. }
  157. .frame(height: 200)
  158. .padding(.horizontal)
  159. }
  160. private var statsAxisMarks: some AxisContent {
  161. AxisMarks { value in
  162. if let date = value.as(Date.self) {
  163. AxisValueLabel {
  164. switch selectedDuration {
  165. case .Day,
  166. .Today,
  167. .Week:
  168. Text(date, format: .dateTime.weekday(.abbreviated))
  169. case .Month,
  170. .Total:
  171. Text(date, format: .dateTime.day().month(.defaultDigits))
  172. }
  173. }
  174. AxisGridLine()
  175. }
  176. }
  177. }
  178. }