MainChartView.swift 9.4 KB

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