ForeCastChart.swift 9.9 KB

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