MainChartView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import Algorithms
  2. import SwiftDate
  3. import SwiftUI
  4. private enum PredictionType: Hashable {
  5. case iob
  6. case cob
  7. case zt
  8. case uam
  9. }
  10. struct MainChartView: View {
  11. private enum Config {
  12. static let screenHours = 6
  13. static let basalHeight: CGFloat = 60
  14. static let topYPadding: CGFloat = 20
  15. static let bottomYPadding: CGFloat = 50
  16. static let maxGlucose = 450
  17. static let yLinesCount = 5
  18. }
  19. @Binding var glucose: [BloodGlucose]
  20. @Binding var suggestion: Suggestion?
  21. @Binding var basals: [PumpHistoryEvent]
  22. @Binding var hours: Int
  23. let units: GlucoseUnits
  24. @State var didAppearTrigger = false
  25. @State private var glucoseDots: [CGRect] = []
  26. @State private var predictionDots: [PredictionType: [CGRect]] = [:]
  27. private var dateDormatter: DateFormatter {
  28. let formatter = DateFormatter()
  29. formatter.timeStyle = .short
  30. return formatter
  31. }
  32. private var glucoseFormatter: NumberFormatter {
  33. let formatter = NumberFormatter()
  34. formatter.numberStyle = .decimal
  35. formatter.maximumFractionDigits = 1
  36. return formatter
  37. }
  38. // MARK: - Views
  39. var body: some View {
  40. GeometryReader { geo in
  41. ZStack(alignment: .leading) {
  42. // Y grid
  43. Path { path in
  44. let range = glucoseYRange(fullSize: geo.size)
  45. let step = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
  46. for line in 0 ... Config.yLinesCount {
  47. path.move(to: CGPoint(x: 0, y: range.minY + CGFloat(line) * step))
  48. path.addLine(to: CGPoint(x: geo.size.width, y: range.minY + CGFloat(line) * step))
  49. }
  50. }.stroke(Color.secondary, lineWidth: 0.2)
  51. ScrollView(.horizontal, showsIndicators: false) {
  52. ScrollViewReader { scroll in
  53. ZStack(alignment: .top) {
  54. basalChart(fullSize: geo.size)
  55. mainChart(fullSize: geo.size)
  56. .onChange(of: glucose) { _ in
  57. scroll.scrollTo("End")
  58. }
  59. .onAppear {
  60. scroll.scrollTo("End")
  61. // add trigger to the end of main queue
  62. DispatchQueue.main.async {
  63. didAppearTrigger = true
  64. }
  65. }
  66. }
  67. }
  68. }
  69. // Y glucose labels
  70. ForEach(0 ..< Config.yLinesCount) { line -> AnyView in
  71. let range = glucoseYRange(fullSize: geo.size)
  72. let yStep = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
  73. let valueStep = Double(range.maxValue - range.minValue) / Double(Config.yLinesCount)
  74. let value = round(Double(range.maxValue) - Double(line) * valueStep) *
  75. (units == .mmolL ? Double(GlucoseUnits.exchangeRate) : 1)
  76. return Text(glucoseFormatter.string(from: value as NSNumber)!)
  77. .position(CGPoint(x: geo.size.width - 12, y: range.minY + CGFloat(line) * yStep))
  78. .font(.caption2)
  79. .asAny()
  80. }
  81. }
  82. }
  83. }
  84. private func basalChart(fullSize: CGSize) -> some View {
  85. Group {
  86. EmptyView()
  87. }
  88. .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
  89. .frame(maxHeight: Config.basalHeight).background(Color.secondary.opacity(0.1))
  90. }
  91. private func mainChart(fullSize: CGSize) -> some View {
  92. Group {
  93. VStack {
  94. ZStack {
  95. // X grid
  96. Path { path in
  97. for hour in 0 ..< hours + hours {
  98. let x = firstHourPosition(viewWidth: fullSize.width) +
  99. oneSecondStep(viewWidth: fullSize.width) *
  100. CGFloat(hour) * CGFloat(1.hours.timeInterval)
  101. path.move(to: CGPoint(x: x, y: 0))
  102. path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
  103. }
  104. }
  105. .stroke(Color.secondary, lineWidth: 0.2)
  106. glucosePath(fullSize: fullSize)
  107. predictions(fullSize: fullSize).id("End")
  108. }
  109. ZStack {
  110. // X time labels
  111. ForEach(0 ..< hours + hours) { hour in
  112. Text(dateDormatter.string(from: firstHourDate().addingTimeInterval(hour.hours.timeInterval)))
  113. .font(.caption)
  114. .position(
  115. x: firstHourPosition(viewWidth: fullSize.width) +
  116. oneSecondStep(viewWidth: fullSize.width) *
  117. CGFloat(hour) * CGFloat(1.hours.timeInterval),
  118. y: 10.0
  119. )
  120. .foregroundColor(.secondary)
  121. }
  122. }.frame(maxHeight: 20)
  123. }
  124. }
  125. .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
  126. }
  127. private func glucosePath(fullSize: CGSize) -> some View {
  128. Path { path in
  129. for rect in glucoseDots {
  130. path.addEllipse(in: rect)
  131. }
  132. }
  133. .fill(Color.green)
  134. .onChange(of: glucose) { _ in
  135. calculateGlucoseDots(fullSize: fullSize)
  136. }
  137. .onChange(of: didAppearTrigger) { _ in
  138. calculateGlucoseDots(fullSize: fullSize)
  139. }
  140. }
  141. private func predictions(fullSize: CGSize) -> some View {
  142. Group {
  143. Path { path in
  144. for rect in predictionDots[.iob] ?? [] {
  145. path.addEllipse(in: rect)
  146. }
  147. }.stroke(Color.blue)
  148. Path { path in
  149. for rect in predictionDots[.cob] ?? [] {
  150. path.addEllipse(in: rect)
  151. }
  152. }.stroke(Color.yellow)
  153. Path { path in
  154. for rect in predictionDots[.zt] ?? [] {
  155. path.addEllipse(in: rect)
  156. }
  157. }.stroke(Color.purple)
  158. Path { path in
  159. for rect in predictionDots[.uam] ?? [] {
  160. path.addEllipse(in: rect)
  161. }
  162. }.stroke(Color.orange)
  163. }
  164. .onChange(of: suggestion) { _ in
  165. calculatePredictionDots(fullSize: fullSize, type: .iob)
  166. calculatePredictionDots(fullSize: fullSize, type: .cob)
  167. calculatePredictionDots(fullSize: fullSize, type: .zt)
  168. calculatePredictionDots(fullSize: fullSize, type: .uam)
  169. }
  170. .onChange(of: didAppearTrigger) { _ in
  171. calculatePredictionDots(fullSize: fullSize, type: .iob)
  172. calculatePredictionDots(fullSize: fullSize, type: .cob)
  173. calculatePredictionDots(fullSize: fullSize, type: .zt)
  174. calculatePredictionDots(fullSize: fullSize, type: .uam)
  175. }
  176. }
  177. // MARK: - Calculations
  178. private func calculateGlucoseDots(fullSize: CGSize) {
  179. glucoseDots = glucose.concurrentMap { value -> CGRect in
  180. let position = glucoseToCoordinate(value, fullSize: fullSize)
  181. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  182. }
  183. }
  184. private func calculatePredictionDots(fullSize: CGSize, type: PredictionType) {
  185. let values: [Int] = { () -> [Int] in
  186. switch type {
  187. case .iob:
  188. return suggestion?.predictions?.iob ?? []
  189. case .cob:
  190. return suggestion?.predictions?.cob ?? []
  191. case .zt:
  192. return suggestion?.predictions?.zt ?? []
  193. case .uam:
  194. return suggestion?.predictions?.uam ?? []
  195. }
  196. }()
  197. var index = 0
  198. predictionDots[type] = values.map { value -> CGRect in
  199. let position = predictionToCoordinate(value, fullSize: fullSize, index: index)
  200. index += 1
  201. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  202. }
  203. }
  204. private func calculateBasalPoints(fullSize _: CGSize) {}
  205. private func fullGlucoseWidth(viewWidth: CGFloat) -> CGFloat {
  206. viewWidth * CGFloat(hours) / CGFloat(Config.screenHours)
  207. }
  208. private func additionalWidth(viewWidth: CGFloat) -> CGFloat {
  209. guard let predictions = suggestion?.predictions,
  210. let deliveredAt = suggestion?.deliverAt,
  211. let last = glucose.last
  212. else {
  213. return 0
  214. }
  215. let iob = predictions.iob?.count ?? 0
  216. let zt = predictions.zt?.count ?? 0
  217. let cob = predictions.cob?.count ?? 0
  218. let uam = predictions.uam?.count ?? 0
  219. let max = [iob, zt, cob, uam].max() ?? 0
  220. let lastDeltaTime = last.dateString.timeIntervalSince(deliveredAt)
  221. let additionalTime = CGFloat(TimeInterval(max) * 5.minutes.timeInterval - lastDeltaTime)
  222. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  223. return additionalTime * oneSecondWidth
  224. }
  225. private func oneSecondStep(viewWidth: CGFloat) -> CGFloat {
  226. viewWidth / (CGFloat(Config.screenHours) * CGFloat(1.hours.timeInterval))
  227. }
  228. private func maxPredValue() -> Int {
  229. [
  230. suggestion?.predictions?.cob ?? [],
  231. suggestion?.predictions?.iob ?? [],
  232. suggestion?.predictions?.zt ?? [],
  233. suggestion?.predictions?.uam ?? []
  234. ]
  235. .flatMap { $0 }
  236. .max() ?? Config.maxGlucose
  237. }
  238. private func minPredValue() -> Int {
  239. [
  240. suggestion?.predictions?.cob ?? [],
  241. suggestion?.predictions?.iob ?? [],
  242. suggestion?.predictions?.zt ?? [],
  243. suggestion?.predictions?.uam ?? []
  244. ]
  245. .flatMap { $0 }
  246. .min() ?? 0
  247. }
  248. private func glucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
  249. let x = timeToXCoordinate(glucoseEntry.dateString.timeIntervalSince1970, fullSize: fullSize)
  250. let y = glucoseToYCoordinate(glucoseEntry.glucose ?? 0, fullSize: fullSize)
  251. return CGPoint(x: x, y: y)
  252. }
  253. private func predictionToCoordinate(_ pred: Int, fullSize: CGSize, index: Int) -> CGPoint {
  254. guard let deliveredAt = suggestion?.deliverAt else {
  255. return .zero
  256. }
  257. let predTime = deliveredAt.timeIntervalSince1970 + TimeInterval(index) * 5.minutes.timeInterval
  258. let x = timeToXCoordinate(predTime, fullSize: fullSize)
  259. let y = glucoseToYCoordinate(pred, fullSize: fullSize)
  260. return CGPoint(x: x, y: y)
  261. }
  262. private func timeToXCoordinate(_ time: TimeInterval, fullSize: CGSize) -> CGFloat {
  263. let xOffset = -(
  264. glucose.first?.dateString.timeIntervalSince1970 ?? Date()
  265. .addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  266. )
  267. let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
  268. let x = CGFloat(time + xOffset) * stepXFraction
  269. return x
  270. }
  271. private func glucoseToYCoordinate(_ glucoseValue: Int, fullSize: CGSize) -> CGFloat {
  272. let topYPaddint = Config.topYPadding + Config.basalHeight
  273. let bottomYPadding = Config.bottomYPadding
  274. let maxValue = max(glucose.compactMap(\.glucose).max() ?? Config.maxGlucose, maxPredValue())
  275. let minValue = min(glucose.compactMap(\.glucose).min() ?? 0, minPredValue())
  276. let stepYFraction = (fullSize.height - topYPaddint - bottomYPadding) / CGFloat(maxValue - minValue)
  277. let yOffset = CGFloat(minValue) * stepYFraction
  278. let y = fullSize.height - CGFloat(glucoseValue) * stepYFraction + yOffset - bottomYPadding
  279. return y
  280. }
  281. private func glucoseYRange(fullSize: CGSize) -> (minValue: Int, minY: CGFloat, maxValue: Int, maxY: CGFloat) {
  282. let topYPaddint = Config.topYPadding + Config.basalHeight
  283. let bottomYPadding = Config.bottomYPadding
  284. let maxValue = max(glucose.compactMap(\.glucose).max() ?? Config.maxGlucose, maxPredValue())
  285. let minValue = min(glucose.compactMap(\.glucose).min() ?? 0, minPredValue())
  286. let stepYFraction = (fullSize.height - topYPaddint - bottomYPadding) / CGFloat(maxValue - minValue)
  287. let yOffset = CGFloat(minValue) * stepYFraction
  288. let maxY = fullSize.height - CGFloat(minValue) * stepYFraction + yOffset - bottomYPadding
  289. let minY = fullSize.height - CGFloat(maxValue) * stepYFraction + yOffset - bottomYPadding
  290. return (minValue: minValue, minY: minY, maxValue: maxValue, maxY: maxY)
  291. }
  292. private func firstHourDate() -> Date {
  293. let firstDate = glucose.first?.dateString ?? Date()
  294. return firstDate.dateTruncated(from: .minute)!
  295. }
  296. private func firstHourPosition(viewWidth: CGFloat) -> CGFloat {
  297. let firstDate = glucose.first?.dateString ?? Date()
  298. let firstHour = firstHourDate()
  299. let lastDeltaTime = firstHour.timeIntervalSince(firstDate)
  300. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  301. return oneSecondWidth * CGFloat(lastDeltaTime)
  302. }
  303. }