BasalChart.swift 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import Charts
  2. import Foundation
  3. import SwiftUI
  4. struct BasalProfile: Hashable {
  5. let amount: Double
  6. var isOverwritten: Bool
  7. let startDate: Date
  8. let endDate: Date?
  9. init(amount: Double, isOverwritten: Bool, startDate: Date, endDate: Date? = nil) {
  10. self.amount = amount
  11. self.isOverwritten = isOverwritten
  12. self.startDate = startDate
  13. self.endDate = endDate
  14. }
  15. }
  16. extension MainChartView {
  17. var basalChart: some View {
  18. VStack {
  19. Chart {
  20. drawStartRuleMark()
  21. drawEndRuleMark()
  22. drawCurrentTimeMarker()
  23. drawTempBasals(dummy: false)
  24. drawBasalProfile()
  25. drawSuspensions()
  26. }.onChange(of: state.tempBasals) { _ in
  27. calculateBasals()
  28. calculateTempBasalsInBackground()
  29. }
  30. .onChange(of: state.maxBasal) { _ in
  31. calculateBasals()
  32. }
  33. .onChange(of: state.autotunedBasalProfile) { _ in
  34. calculateBasals()
  35. }
  36. .onChange(of: state.basalProfile) { _ in
  37. calculateBasals()
  38. }
  39. .frame(minHeight: geo.size.height * 0.05)
  40. .frame(width: fullWidth(viewWidth: screenSize.width))
  41. .chartXScale(domain: startMarker ... endMarker)
  42. .chartXAxis { basalChartXAxis }
  43. .chartXAxis(.hidden)
  44. .chartYAxis(.hidden)
  45. .chartPlotStyle { basalChartPlotStyle($0) }
  46. }
  47. }
  48. }
  49. // MARK: - Draw functions
  50. extension MainChartView {
  51. func drawTempBasals(dummy: Bool) -> some ChartContent {
  52. ForEach(preparedTempBasals, id: \.rate) { basal in
  53. if dummy {
  54. RectangleMark(
  55. xStart: .value("start", basal.start),
  56. xEnd: .value("end", basal.end),
  57. yStart: .value("rate-start", 0),
  58. yEnd: .value("rate-end", basal.rate)
  59. ).foregroundStyle(Color.clear)
  60. LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
  61. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
  62. LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
  63. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
  64. } else {
  65. RectangleMark(
  66. xStart: .value("start", basal.start),
  67. xEnd: .value("end", basal.end),
  68. yStart: .value("rate-start", 0),
  69. yEnd: .value("rate-end", basal.rate)
  70. ).foregroundStyle(
  71. LinearGradient(
  72. gradient: Gradient(
  73. colors: [
  74. Color.insulin.opacity(0.6),
  75. Color.insulin.opacity(0.1)
  76. ]
  77. ),
  78. startPoint: .top,
  79. endPoint: .bottom
  80. )
  81. )
  82. LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
  83. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  84. LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
  85. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  86. }
  87. }
  88. }
  89. func drawBasalProfile() -> some ChartContent {
  90. /// dashed profile line
  91. ForEach(basalProfiles, id: \.self) { profile in
  92. LineMark(
  93. x: .value("Start Date", profile.startDate),
  94. y: .value("Amount", profile.amount),
  95. series: .value("profile", "profile")
  96. ).lineStyle(.init(lineWidth: 2, dash: [2, 4])).foregroundStyle(Color.insulin)
  97. LineMark(
  98. x: .value("End Date", profile.endDate ?? endMarker),
  99. y: .value("Amount", profile.amount),
  100. series: .value("profile", "profile")
  101. ).lineStyle(.init(lineWidth: 2.5, dash: [2, 4])).foregroundStyle(Color.insulin)
  102. }
  103. }
  104. func drawSuspensions() -> some ChartContent {
  105. let suspensions = state.suspensions
  106. return ForEach(suspensions) { suspension in
  107. let now = Date()
  108. if let type = suspension.type, type == EventType.pumpSuspend.rawValue, let suspensionStart = suspension.timestamp {
  109. let suspensionEnd = min(
  110. (
  111. suspensions
  112. .first(where: {
  113. $0.timestamp ?? now > suspensionStart && $0.type == EventType.pumpResume.rawValue })?
  114. .timestamp
  115. ) ?? now,
  116. now
  117. )
  118. let basalProfileDuringSuspension = basalProfiles.first(where: { $0.startDate <= suspensionStart })
  119. let suspensionMarkHeight = basalProfileDuringSuspension?.amount ?? 1
  120. RectangleMark(
  121. xStart: .value("start", suspensionStart),
  122. xEnd: .value("end", suspensionEnd),
  123. yStart: .value("suspend-start", 0),
  124. yEnd: .value("suspend-end", suspensionMarkHeight)
  125. )
  126. .foregroundStyle(Color.loopGray.opacity(colorScheme == .dark ? 0.3 : 0.8))
  127. }
  128. }
  129. }
  130. }
  131. // MARK: - Calculation
  132. extension MainChartView {
  133. func calculateTempBasalsInBackground() {
  134. Task {
  135. let basals = await prepareTempBasals()
  136. await MainActor.run {
  137. preparedTempBasals = basals
  138. }
  139. }
  140. }
  141. func prepareTempBasals() async -> [(start: Date, end: Date, rate: Double)] {
  142. let now = Date()
  143. let tempBasals = state.tempBasals
  144. return tempBasals.compactMap { temp -> (start: Date, end: Date, rate: Double)? in
  145. let duration = temp.tempBasal?.duration ?? 0
  146. let timestamp = temp.timestamp ?? Date()
  147. let end = min(timestamp + duration.minutes, now)
  148. let isInsulinSuspended = state.suspensions.contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
  149. let rate = Double(truncating: temp.tempBasal?.rate ?? Decimal.zero as NSDecimalNumber) * (isInsulinSuspended ? 0 : 1)
  150. // Check if there's a subsequent temp basal to determine the end time
  151. guard let nextTemp = state.tempBasals.first(where: { $0.timestamp ?? .distantPast > timestamp }) else {
  152. return (timestamp, end, rate)
  153. }
  154. return (timestamp, nextTemp.timestamp ?? Date(), rate)
  155. }
  156. }
  157. func findRegularBasalPoints(
  158. timeBegin: TimeInterval,
  159. timeEnd: TimeInterval,
  160. autotuned: Bool
  161. ) async -> [BasalProfile] {
  162. guard timeBegin < timeEnd else { return [] }
  163. let beginDate = Date(timeIntervalSince1970: timeBegin)
  164. let startOfDay = Calendar.current.startOfDay(for: beginDate)
  165. let profile = autotuned ? state.autotunedBasalProfile : state.basalProfile
  166. var basalPoints: [BasalProfile] = []
  167. // Iterate over the next three days, multiplying the time intervals
  168. for dayOffset in 0 ..< 3 {
  169. let dayTimeOffset = TimeInterval(dayOffset * 24 * 60 * 60) // One Day in seconds
  170. for entry in profile {
  171. let basalTime = startOfDay.addingTimeInterval(entry.minutes.minutes.timeInterval + dayTimeOffset)
  172. let basalTimeInterval = basalTime.timeIntervalSince1970
  173. // Only append points within the timeBegin and timeEnd range
  174. if basalTimeInterval >= timeBegin, basalTimeInterval < timeEnd {
  175. basalPoints.append(BasalProfile(
  176. amount: Double(entry.rate),
  177. isOverwritten: false,
  178. startDate: basalTime
  179. ))
  180. }
  181. }
  182. }
  183. return basalPoints
  184. }
  185. func calculateBasals() {
  186. Task {
  187. let dayAgoTime = Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  188. // Get Regular and Autotuned Basal parallel
  189. async let getRegularBasalPoints = findRegularBasalPoints(
  190. timeBegin: dayAgoTime,
  191. timeEnd: endMarker.timeIntervalSince1970,
  192. autotuned: false
  193. )
  194. async let getAutotunedBasalPoints = findRegularBasalPoints(
  195. timeBegin: dayAgoTime,
  196. timeEnd: endMarker.timeIntervalSince1970,
  197. autotuned: true
  198. )
  199. let (regularPoints, autotunedBasalPoints) = await (getRegularBasalPoints, getAutotunedBasalPoints)
  200. var totalBasal = regularPoints + autotunedBasalPoints
  201. totalBasal.sort {
  202. $0.startDate.timeIntervalSince1970 < $1.startDate.timeIntervalSince1970
  203. }
  204. var basals: [BasalProfile] = []
  205. totalBasal.indices.forEach { index in
  206. basals.append(BasalProfile(
  207. amount: totalBasal[index].amount,
  208. isOverwritten: totalBasal[index].isOverwritten,
  209. startDate: totalBasal[index].startDate,
  210. endDate: totalBasal.count > index + 1 ? totalBasal[index + 1].startDate : endMarker
  211. ))
  212. }
  213. await MainActor.run {
  214. basalProfiles = basals
  215. }
  216. }
  217. }
  218. }