ForeCastChart.swift 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import Charts
  2. import CoreData
  3. import Foundation
  4. import SwiftUI
  5. struct ForeCastChart: View {
  6. var state: Bolus.StateModel
  7. @Environment(\.colorScheme) var colorScheme
  8. @State private var startMarker = Date(timeIntervalSinceNow: -4 * 60 * 60)
  9. private var endMarker: Date {
  10. state
  11. .forecastDisplayType == .lines ? Date(timeIntervalSinceNow: TimeInterval(hours: 3)) :
  12. Date(timeIntervalSinceNow: TimeInterval(
  13. Int(1.5) * 5 * state
  14. .minCount * 60
  15. )) // min is 1.5h -> (1.5*1h = 1.5*(5*12*60))
  16. }
  17. private var glucoseFormatter: NumberFormatter {
  18. let formatter = NumberFormatter()
  19. formatter.numberStyle = .decimal
  20. if state.units == .mmolL {
  21. formatter.maximumFractionDigits = 1
  22. formatter.minimumFractionDigits = 1
  23. formatter.roundingMode = .halfUp
  24. } else {
  25. formatter.maximumFractionDigits = 0
  26. }
  27. return formatter
  28. }
  29. var body: some View {
  30. VStack {
  31. HStack {
  32. HStack {
  33. Text("Added carbs: ")
  34. .font(.footnote)
  35. .fontWeight(.bold)
  36. .foregroundStyle(.orange)
  37. Text("\(state.carbs.description) g")
  38. .font(.footnote)
  39. .foregroundStyle(.orange)
  40. }
  41. .padding(8)
  42. .background {
  43. RoundedRectangle(cornerRadius: 10)
  44. .fill(Color.orange.opacity(0.2))
  45. }
  46. Spacer()
  47. HStack {
  48. Text("Added insulin: ")
  49. .font(.footnote)
  50. .fontWeight(.bold)
  51. .foregroundStyle(.blue)
  52. Text("\(state.amount.description) U")
  53. .font(.footnote)
  54. .foregroundStyle(.blue)
  55. }
  56. .padding(8)
  57. .background {
  58. RoundedRectangle(cornerRadius: 10)
  59. .fill(Color.blue.opacity(0.2))
  60. }
  61. }
  62. forecastChart
  63. .padding(.vertical, 3)
  64. HStack {
  65. Spacer()
  66. Image(systemName: "arrow.right.circle")
  67. .font(.system(size: 16, weight: .bold))
  68. if let eventualBG = state.simulatedDetermination?.eventualBG {
  69. HStack {
  70. Text(
  71. state.units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL
  72. )
  73. .font(.footnote)
  74. .foregroundStyle(.primary)
  75. Text("\(state.units.rawValue)")
  76. .font(.footnote)
  77. .foregroundStyle(.secondary)
  78. }
  79. } else {
  80. Text("---")
  81. .font(.footnote)
  82. .foregroundStyle(.primary)
  83. Text("\(state.units.rawValue)")
  84. .font(.footnote)
  85. .foregroundStyle(.secondary)
  86. }
  87. }
  88. }
  89. }
  90. private var forecastChart: some View {
  91. Chart {
  92. drawGlucose()
  93. drawCurrentTimeMarker()
  94. if state.forecastDisplayType == .lines {
  95. drawForecastLines()
  96. } else {
  97. drawForecastsCone()
  98. }
  99. }
  100. .chartXAxis { forecastChartXAxis }
  101. .chartXScale(domain: startMarker ... endMarker)
  102. .chartYAxis { forecastChartYAxis }
  103. .chartYScale(domain: state.units == .mgdL ? 0 ... 300 : 0.asMmolL ... 300.asMmolL)
  104. .backport.chartForegroundStyleScale(state: state)
  105. }
  106. private func drawGlucose() -> some ChartContent {
  107. ForEach(state.glucoseFromPersistence) { item in
  108. let glucoseToDisplay = state.units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
  109. let pointMarkColor: Color = glucoseToDisplay > state.highGlucose ? Color.orange :
  110. glucoseToDisplay < state.lowGlucose ? Color.red :
  111. Color.green
  112. if !state.isSmoothingEnabled {
  113. PointMark(
  114. x: .value("Time", item.date ?? Date(), unit: .second),
  115. y: .value("Value", glucoseToDisplay)
  116. )
  117. .foregroundStyle(pointMarkColor)
  118. .symbolSize(20)
  119. } else {
  120. PointMark(
  121. x: .value("Time", item.date ?? Date(), unit: .second),
  122. y: .value("Value", glucoseToDisplay)
  123. )
  124. .symbol {
  125. Image(systemName: "record.circle.fill")
  126. .font(.system(size: 8))
  127. .bold()
  128. .foregroundStyle(pointMarkColor)
  129. }
  130. }
  131. }
  132. }
  133. private func timeForIndex(_ index: Int32) -> Date {
  134. let currentTime = Date()
  135. let timeInterval = TimeInterval(index * 300)
  136. return currentTime.addingTimeInterval(timeInterval)
  137. }
  138. private func drawForecastsCone() -> some ChartContent {
  139. // Draw AreaMark for the forecast bounds
  140. ForEach(0 ..< max(state.minForecast.count, state.maxForecast.count), id: \.self) { index in
  141. if index < state.minForecast.count, index < state.maxForecast.count {
  142. let yMinMaxDelta = Decimal(state.minForecast[index] - state.maxForecast[index])
  143. let xValue = timeForIndex(Int32(index))
  144. // if distance between respective min and max is 0, provide a default range
  145. if yMinMaxDelta == 0 {
  146. let yMinValue = state.units == .mgdL ? Decimal(state.minForecast[index] - 1) :
  147. Decimal(state.minForecast[index] - 1)
  148. .asMmolL
  149. let yMaxValue = state.units == .mgdL ? Decimal(state.minForecast[index] + 1) :
  150. Decimal(state.minForecast[index] + 1)
  151. .asMmolL
  152. AreaMark(
  153. x: .value("Time", xValue <= endMarker ? xValue : endMarker),
  154. yStart: .value("Min Value", state.units == .mgdL ? yMinValue : yMinValue.asMmolL),
  155. yEnd: .value("Max Value", state.units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
  156. )
  157. .foregroundStyle(Color.blue.opacity(0.5))
  158. .interpolationMethod(.catmullRom)
  159. } else {
  160. let yMinValue = Decimal(state.minForecast[index]) <= 300 ? Decimal(state.minForecast[index]) : Decimal(300)
  161. let yMaxValue = Decimal(state.maxForecast[index]) <= 300 ? Decimal(state.maxForecast[index]) : Decimal(300)
  162. AreaMark(
  163. x: .value("Time", timeForIndex(Int32(index)) <= endMarker ? timeForIndex(Int32(index)) : endMarker),
  164. yStart: .value("Min Value", state.units == .mgdL ? yMinValue : yMinValue.asMmolL),
  165. yEnd: .value("Max Value", state.units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
  166. )
  167. .foregroundStyle(Color.blue.opacity(0.5))
  168. .interpolationMethod(.catmullRom)
  169. }
  170. }
  171. }
  172. }
  173. private func drawForecastLines() -> some ChartContent {
  174. let predictions = state.predictionsForChart
  175. // Prepare the prediction data with only the first 36 values, i.e. 3 hours in the future
  176. let predictionData = [
  177. ("iob", predictions?.iob?.prefix(36)),
  178. ("zt", predictions?.zt?.prefix(36)),
  179. ("cob", predictions?.cob?.prefix(36)),
  180. ("uam", predictions?.uam?.prefix(36))
  181. ]
  182. return ForEach(predictionData, id: \.0) { name, values in
  183. if let values = values {
  184. ForEach(values.indices, id: \.self) { index in
  185. LineMark(
  186. x: .value("Time", timeForIndex(Int32(index))),
  187. y: .value("Value", state.units == .mgdL ? Decimal(values[index]) : Decimal(values[index]).asMmolL)
  188. )
  189. .foregroundStyle(by: .value("Prediction Type", name))
  190. }
  191. }
  192. }
  193. }
  194. private func drawCurrentTimeMarker() -> some ChartContent {
  195. RuleMark(
  196. x: .value(
  197. "",
  198. Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970)),
  199. unit: .second
  200. )
  201. ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
  202. }
  203. private var forecastChartXAxis: some AxisContent {
  204. AxisMarks(values: .stride(by: .hour, count: 2)) { _ in
  205. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  206. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
  207. .font(.footnote)
  208. .foregroundStyle(Color.primary)
  209. }
  210. }
  211. private var forecastChartYAxis: some AxisContent {
  212. AxisMarks(position: .trailing) { _ in
  213. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  214. AxisTick(length: 3, stroke: .init(lineWidth: 3)).foregroundStyle(Color.secondary)
  215. AxisValueLabel().font(.footnote).foregroundStyle(Color.primary)
  216. }
  217. }
  218. }