MainChartView.swift 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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 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. .onChange(of: suggestion) { _ in
  125. calculatePredictionDots(fullSize: fullSize, type: .iob)
  126. }
  127. .onChange(of: didAppearTrigger) { _ in
  128. calculatePredictionDots(fullSize: fullSize, type: .iob)
  129. }
  130. Path { path in
  131. for rect in predictionDots[.cob] ?? [] {
  132. path.addEllipse(in: rect)
  133. }
  134. }.stroke(Color.yellow)
  135. .onChange(of: suggestion) { _ in
  136. calculatePredictionDots(fullSize: fullSize, type: .cob)
  137. }
  138. .onChange(of: didAppearTrigger) { _ in
  139. calculatePredictionDots(fullSize: fullSize, type: .cob)
  140. }
  141. Path { path in
  142. for rect in predictionDots[.zt] ?? [] {
  143. path.addEllipse(in: rect)
  144. }
  145. }.stroke(Color.purple)
  146. .onChange(of: suggestion) { _ in
  147. calculatePredictionDots(fullSize: fullSize, type: .zt)
  148. }
  149. .onChange(of: didAppearTrigger) { _ in
  150. calculatePredictionDots(fullSize: fullSize, type: .zt)
  151. }
  152. Path { path in
  153. for rect in predictionDots[.uam] ?? [] {
  154. path.addEllipse(in: rect)
  155. }
  156. }.stroke(Color.orange)
  157. .onChange(of: suggestion) { _ in
  158. calculatePredictionDots(fullSize: fullSize, type: .uam)
  159. }
  160. .onChange(of: didAppearTrigger) { _ in
  161. calculatePredictionDots(fullSize: fullSize, type: .uam)
  162. }
  163. }
  164. }
  165. private func fullGlucoseWidth(viewWidth: CGFloat) -> CGFloat {
  166. viewWidth * CGFloat(hours) / CGFloat(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(screenHours) * CGFloat(1.hours.timeInterval))
  187. }
  188. private func glucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
  189. guard let first = glucose.first else {
  190. return .zero
  191. }
  192. let yPadding: CGFloat = 30
  193. let maxValue = glucose.compactMap(\.glucose).max() ?? 0
  194. let minValue = glucose.compactMap(\.glucose).min() ?? 0
  195. let stepYFraction = (fullSize.height - yPadding * 2) / CGFloat(maxValue - minValue)
  196. let yOffset = CGFloat(minValue) * stepYFraction
  197. let xOffset = -first.dateString.timeIntervalSince1970
  198. let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
  199. let x = CGFloat(glucoseEntry.dateString.timeIntervalSince1970 + xOffset) * stepXFraction
  200. let y = fullSize.height - CGFloat(glucoseEntry.glucose ?? 0) * stepYFraction + yOffset - yPadding
  201. return CGPoint(x: x, y: y)
  202. }
  203. private func predictionToCoordinate(_ pred: Int, fullSize: CGSize, index: Int) -> CGPoint {
  204. guard let first = glucose.first, let deliveredAt = suggestion?.deliverAt else {
  205. return .zero
  206. }
  207. let yPadding: CGFloat = 30
  208. let maxValue = glucose.compactMap(\.glucose).max() ?? 0
  209. let minValue = glucose.compactMap(\.glucose).min() ?? 0
  210. let stepYFraction = (fullSize.height - yPadding * 2) / CGFloat(maxValue - minValue)
  211. let yOffset = CGFloat(minValue) * stepYFraction
  212. let xOffset = -first.dateString.timeIntervalSince1970
  213. let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
  214. let predTime = deliveredAt.timeIntervalSince1970 + TimeInterval(index) * 5.minutes.timeInterval
  215. let x = CGFloat(predTime + xOffset) * stepXFraction
  216. let y = fullSize.height - CGFloat(pred) * stepYFraction + yOffset - yPadding
  217. return CGPoint(x: x, y: y)
  218. }
  219. private func firstHourDate() -> Date {
  220. let firstDate = glucose.first?.dateString ?? Date()
  221. return firstDate.dateTruncated(from: .minute)!
  222. }
  223. private func firstHourPosition(viewWidth: CGFloat) -> CGFloat {
  224. let firstDate = glucose.first?.dateString ?? Date()
  225. let firstHour = firstHourDate()
  226. let lastDeltaTime = firstHour.timeIntervalSince(firstDate)
  227. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  228. return oneSecondWidth * CGFloat(lastDeltaTime)
  229. }
  230. }