MainChartView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  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 = 80
  14. }
  15. @Binding var glucose: [BloodGlucose]
  16. @Binding var suggestion: Suggestion?
  17. @Binding var hours: Int
  18. @State var didAppearTrigger = false
  19. @State private var glucoseDots: [CGRect] = []
  20. @State private var predictionDots: [PredictionType: [CGRect]] = [:]
  21. private var dateDormatter: DateFormatter {
  22. let formatter = DateFormatter()
  23. formatter.timeStyle = .short
  24. return formatter
  25. }
  26. var body: some View {
  27. GeometryReader { geo in
  28. ScrollView(.horizontal, showsIndicators: false) {
  29. ScrollViewReader { scroll in
  30. VStack {
  31. mainChart(fullSize: geo.size)
  32. .drawingGroup(opaque: false, colorMode: .nonLinear)
  33. .onChange(of: glucose) { _ in
  34. scroll.scrollTo("End")
  35. }
  36. .onAppear {
  37. scroll.scrollTo("End")
  38. // add trigger to the end of main queue
  39. DispatchQueue.main.async {
  40. didAppearTrigger = true
  41. }
  42. }
  43. }
  44. }
  45. }
  46. }
  47. }
  48. private func basalChart(fullSize: CGSize) -> some View {
  49. Group {
  50. Text("test")
  51. }
  52. .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
  53. .frame(maxHeight: 80).background(Color.red)
  54. }
  55. private func mainChart(fullSize: CGSize) -> some View {
  56. Group {
  57. VStack {
  58. ZStack {
  59. Path { path in
  60. for hour in 0 ..< hours + hours {
  61. let x = firstHourPosition(viewWidth: fullSize.width) +
  62. oneSecondStep(viewWidth: fullSize.width) *
  63. CGFloat(hour) * CGFloat(1.hours.timeInterval)
  64. path.move(to: CGPoint(x: x, y: 0))
  65. path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
  66. }
  67. }
  68. .stroke(Color.secondary, lineWidth: 0.2)
  69. glucosePath(fullSize: fullSize)
  70. predictions(fullSize: fullSize).id("End")
  71. }
  72. ZStack {
  73. ForEach(0 ..< hours + hours) { hour in
  74. Text(dateDormatter.string(from: firstHourDate().addingTimeInterval(hour.hours.timeInterval)))
  75. .font(.caption)
  76. .position(
  77. x: firstHourPosition(viewWidth: fullSize.width) +
  78. oneSecondStep(viewWidth: fullSize.width) *
  79. CGFloat(hour) * CGFloat(1.hours.timeInterval),
  80. y: 10.0
  81. )
  82. .foregroundColor(.secondary)
  83. }
  84. }.frame(maxHeight: 20)
  85. }
  86. }
  87. .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
  88. }
  89. private func glucosePath(fullSize: CGSize) -> some View {
  90. Path { path in
  91. for rect in glucoseDots {
  92. path.addEllipse(in: rect)
  93. }
  94. }
  95. .fill(Color.green)
  96. .onChange(of: glucose) { _ in
  97. calculateGlucoseDots(fullSize: fullSize)
  98. }
  99. .onChange(of: didAppearTrigger) { _ in
  100. calculateGlucoseDots(fullSize: fullSize)
  101. }
  102. }
  103. private func calculateGlucoseDots(fullSize: CGSize) {
  104. glucoseDots = glucose.concurrentMap { value -> CGRect in
  105. let position = glucoseToCoordinate(value, fullSize: fullSize)
  106. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  107. }
  108. }
  109. private func calculatePredictionDots(fullSize: CGSize, type: PredictionType) {
  110. let values: [Int] = { () -> [Int] in
  111. switch type {
  112. case .iob:
  113. return suggestion?.predictions?.iob ?? []
  114. case .cob:
  115. return suggestion?.predictions?.cob ?? []
  116. case .zt:
  117. return suggestion?.predictions?.zt ?? []
  118. case .uam:
  119. return suggestion?.predictions?.uam ?? []
  120. }
  121. }()
  122. var index = 0
  123. predictionDots[type] = values.concurrentMap { value -> CGRect in
  124. let position = predictionToCoordinate(value, fullSize: fullSize, index: index)
  125. index += 1
  126. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  127. }
  128. }
  129. private func predictions(fullSize: CGSize) -> some View {
  130. Group {
  131. Path { path in
  132. for rect in predictionDots[.iob] ?? [] {
  133. path.addEllipse(in: rect)
  134. }
  135. }.stroke(Color.blue)
  136. Path { path in
  137. for rect in predictionDots[.cob] ?? [] {
  138. path.addEllipse(in: rect)
  139. }
  140. }.stroke(Color.yellow)
  141. Path { path in
  142. for rect in predictionDots[.zt] ?? [] {
  143. path.addEllipse(in: rect)
  144. }
  145. }.stroke(Color.purple)
  146. Path { path in
  147. for rect in predictionDots[.uam] ?? [] {
  148. path.addEllipse(in: rect)
  149. }
  150. }.stroke(Color.orange)
  151. }
  152. .onChange(of: suggestion) { _ in
  153. calculatePredictionDots(fullSize: fullSize, type: .iob)
  154. calculatePredictionDots(fullSize: fullSize, type: .cob)
  155. calculatePredictionDots(fullSize: fullSize, type: .zt)
  156. calculatePredictionDots(fullSize: fullSize, type: .uam)
  157. }
  158. .onChange(of: didAppearTrigger) { _ in
  159. calculatePredictionDots(fullSize: fullSize, type: .iob)
  160. calculatePredictionDots(fullSize: fullSize, type: .cob)
  161. calculatePredictionDots(fullSize: fullSize, type: .zt)
  162. calculatePredictionDots(fullSize: fullSize, type: .uam)
  163. }
  164. }
  165. private func fullGlucoseWidth(viewWidth: CGFloat) -> CGFloat {
  166. viewWidth * CGFloat(hours) / CGFloat(Config.screenHours)
  167. }
  168. private func additionalWidth(viewWidth: CGFloat) -> CGFloat {
  169. guard let predictions = suggestion?.predictions,
  170. let deliveredAt = suggestion?.deliverAt,
  171. let last = glucose.last
  172. else {
  173. return 0
  174. }
  175. let iob = predictions.iob?.count ?? 0
  176. let zt = predictions.zt?.count ?? 0
  177. let cob = predictions.cob?.count ?? 0
  178. let uam = predictions.uam?.count ?? 0
  179. let max = [iob, zt, cob, uam].max() ?? 0
  180. let lastDeltaTime = last.dateString.timeIntervalSince(deliveredAt)
  181. let additionalTime = CGFloat(TimeInterval(max) * 5.minutes.timeInterval - lastDeltaTime)
  182. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  183. return additionalTime * oneSecondWidth
  184. }
  185. private func oneSecondStep(viewWidth: CGFloat) -> CGFloat {
  186. viewWidth / (CGFloat(Config.screenHours) * CGFloat(1.hours.timeInterval))
  187. }
  188. private func maxPredValue() -> Int {
  189. [
  190. suggestion?.predictions?.cob ?? [],
  191. suggestion?.predictions?.iob ?? [],
  192. suggestion?.predictions?.zt ?? [],
  193. suggestion?.predictions?.uam ?? []
  194. ]
  195. .flatMap { $0 }
  196. .max() ?? 450
  197. }
  198. private func minPredValue() -> Int {
  199. [
  200. suggestion?.predictions?.cob ?? [],
  201. suggestion?.predictions?.iob ?? [],
  202. suggestion?.predictions?.zt ?? [],
  203. suggestion?.predictions?.uam ?? []
  204. ]
  205. .flatMap { $0 }
  206. .min() ?? 0
  207. }
  208. private func glucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
  209. guard let first = glucose.first else {
  210. return .zero
  211. }
  212. let yPadding: CGFloat = 30
  213. let maxValue = max(glucose.compactMap(\.glucose).max() ?? 450, maxPredValue())
  214. let minValue = min(glucose.compactMap(\.glucose).min() ?? 0, minPredValue())
  215. let stepYFraction = (fullSize.height - yPadding * 2) / CGFloat(maxValue - minValue)
  216. let yOffset = CGFloat(minValue) * stepYFraction
  217. let xOffset = -first.dateString.timeIntervalSince1970
  218. let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
  219. let x = CGFloat(glucoseEntry.dateString.timeIntervalSince1970 + xOffset) * stepXFraction
  220. let y = fullSize.height - CGFloat(glucoseEntry.glucose ?? 0) * stepYFraction + yOffset - yPadding
  221. return CGPoint(x: x, y: y)
  222. }
  223. private func predictionToCoordinate(_ pred: Int, fullSize: CGSize, index: Int) -> CGPoint {
  224. guard let first = glucose.first, let deliveredAt = suggestion?.deliverAt else {
  225. return .zero
  226. }
  227. let yPadding: CGFloat = 30
  228. let maxValue = max(glucose.compactMap(\.glucose).max() ?? 450, maxPredValue())
  229. let minValue = min(glucose.compactMap(\.glucose).min() ?? 0, minPredValue())
  230. let stepYFraction = (fullSize.height - yPadding * 2) / CGFloat(maxValue - minValue)
  231. let yOffset = CGFloat(minValue) * stepYFraction
  232. let xOffset = -first.dateString.timeIntervalSince1970
  233. let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
  234. let predTime = deliveredAt.timeIntervalSince1970 + TimeInterval(index) * 5.minutes.timeInterval
  235. let x = CGFloat(predTime + xOffset) * stepXFraction
  236. let y = fullSize.height - CGFloat(pred) * stepYFraction + yOffset - yPadding
  237. return CGPoint(x: x, y: y)
  238. }
  239. private func firstHourDate() -> Date {
  240. let firstDate = glucose.first?.dateString ?? Date()
  241. return firstDate.dateTruncated(from: .minute)!
  242. }
  243. private func firstHourPosition(viewWidth: CGFloat) -> CGFloat {
  244. let firstDate = glucose.first?.dateString ?? Date()
  245. let firstHour = firstHourDate()
  246. let lastDeltaTime = firstHour.timeIntervalSince(firstDate)
  247. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  248. return oneSecondWidth * CGFloat(lastDeltaTime)
  249. }
  250. }