MainChartView.swift 13 KB

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