BasalChart.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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) {
  27. calculateBasals()
  28. calculateTempBasals()
  29. }
  30. .onChange(of: state.maxBasal) {
  31. calculateBasals()
  32. }
  33. // profile loads async after first appearance; redraw the dashed line
  34. .onChange(of: state.basalProfile) {
  35. calculateBasals()
  36. }
  37. .frame(minHeight: basalHeight)
  38. .frame(width: fullWidth(viewWidth: screenSize.width))
  39. .chartXScale(domain: state.startMarker ... state.endMarker)
  40. .chartXAxis { basalChartXAxis }
  41. .chartXAxis(.hidden)
  42. .chartYAxis(.hidden)
  43. .chartPlotStyle { basalChartPlotStyle($0) }
  44. }
  45. }
  46. }
  47. // MARK: - Draw functions
  48. extension MainChartView {
  49. func drawTempBasals(dummy: Bool) -> some ChartContent {
  50. ForEach(preparedTempBasals, id: \.rate) { basal in
  51. if dummy {
  52. RectangleMark(
  53. xStart: .value("start", basal.start),
  54. xEnd: .value("end", basal.end),
  55. yStart: .value("rate-start", 0),
  56. yEnd: .value("rate-end", basal.rate)
  57. ).foregroundStyle(Color.clear)
  58. LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
  59. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
  60. LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
  61. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
  62. } else {
  63. RectangleMark(
  64. xStart: .value("start", basal.start),
  65. xEnd: .value("end", basal.end),
  66. yStart: .value("rate-start", 0),
  67. yEnd: .value("rate-end", basal.rate)
  68. ).foregroundStyle(
  69. .linearGradient(
  70. colors: [
  71. Color.insulin.opacity(0.6),
  72. Color.insulin.opacity(0.1)
  73. ],
  74. startPoint: .bottom,
  75. endPoint: .top
  76. )
  77. ).alignsMarkStylesWithPlotArea()
  78. LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
  79. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  80. LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
  81. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  82. }
  83. }
  84. }
  85. func drawBasalProfile() -> some ChartContent {
  86. /// dashed profile line
  87. ForEach(basalProfiles, id: \.self) { profile in
  88. LineMark(
  89. x: .value("Start Date", profile.startDate),
  90. y: .value("Amount", profile.amount),
  91. series: .value("profile", "profile")
  92. ).lineStyle(.init(lineWidth: 2, dash: [2, 4])).foregroundStyle(Color.insulin)
  93. LineMark(
  94. x: .value("End Date", profile.endDate ?? state.endMarker),
  95. y: .value("Amount", profile.amount),
  96. series: .value("profile", "profile")
  97. ).lineStyle(.init(lineWidth: 2.5, dash: [2, 4])).foregroundStyle(Color.insulin)
  98. }
  99. }
  100. func drawSuspensions() -> some ChartContent {
  101. let suspensions = state.suspendAndResumeEvents
  102. return ForEach(suspensions) { suspension in
  103. let now = Date()
  104. if let type = suspension.type, type == EventType.pumpSuspend.rawValue, let suspensionStart = suspension.timestamp {
  105. let suspensionEnd = min(
  106. (
  107. suspensions
  108. .first(where: {
  109. $0.timestamp ?? now > suspensionStart && $0.type == EventType.pumpResume.rawValue })?
  110. .timestamp
  111. ) ?? now,
  112. now
  113. )
  114. let basalProfileDuringSuspension = basalProfiles.first(where: { $0.startDate <= suspensionStart })
  115. let suspensionMarkHeight = basalProfileDuringSuspension?.amount ?? 1
  116. RectangleMark(
  117. xStart: .value("start", suspensionStart),
  118. xEnd: .value("end", suspensionEnd),
  119. yStart: .value("suspend-start", 0),
  120. yEnd: .value("suspend-end", suspensionMarkHeight)
  121. )
  122. .foregroundStyle(Color.loopGray.opacity(colorScheme == .dark ? 0.3 : 0.8))
  123. }
  124. }
  125. }
  126. }
  127. // MARK: - Calculation
  128. extension MainChartView {
  129. @MainActor func calculateTempBasals() {
  130. let now = Date()
  131. let suspensionTimes = state.suspendAndResumeEvents.compactMap(\.timestamp)
  132. // Snapshot the managed-object fields once; plain values from here on.
  133. let events = state.tempBasals.map {
  134. (timestamp: $0.timestamp, duration: $0.tempBasal?.duration ?? 0, rate: $0.tempBasal?.rate)
  135. }
  136. var prepared = [(start: Date, end: Date, rate: Double)]()
  137. prepared.reserveCapacity(events.count)
  138. for (index, event) in events.enumerated() {
  139. let timestamp = event.timestamp ?? now
  140. let end = timestamp + event.duration.minutes
  141. let isInsulinSuspended = suspensionTimes.contains { $0 >= timestamp && $0 <= end }
  142. let rate = Double(truncating: event.rate ?? 0) * (isInsulinSuspended ? 0 : 1)
  143. // A bar ends where the next later-starting temp basal begins,
  144. // else at its own scheduled end.
  145. var next = index + 1
  146. while next < events.count {
  147. if let nextStart = events[next].timestamp, nextStart > timestamp { break }
  148. next += 1
  149. }
  150. if next < events.count, let nextStart = events[next].timestamp {
  151. prepared.append((timestamp, nextStart, rate))
  152. } else {
  153. prepared.append((timestamp, end, rate))
  154. }
  155. }
  156. preparedTempBasals = prepared
  157. }
  158. func findRegularBasalPoints(
  159. timeBegin: TimeInterval,
  160. timeEnd: TimeInterval
  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 = state.basalProfile
  166. var basalPoints: [BasalProfile] = []
  167. var lastEntryBeforeRange: (amount: Double, date: Date)?
  168. // Iterate over the next three days, multiplying the time intervals
  169. for dayOffset in 0 ..< 3 {
  170. let dayTimeOffset = TimeInterval(dayOffset * 24 * 60 * 60) // One Day in seconds
  171. for entry in profile {
  172. let basalTime = startOfDay.addingTimeInterval(entry.minutes.minutes.timeInterval + dayTimeOffset)
  173. let basalTimeInterval = basalTime.timeIntervalSince1970
  174. if basalTimeInterval < timeBegin {
  175. // Track the last profile entry before the visible range
  176. if lastEntryBeforeRange == nil || basalTime > lastEntryBeforeRange!.date {
  177. lastEntryBeforeRange = (amount: Double(entry.rate), date: basalTime)
  178. }
  179. } else if basalTimeInterval < timeEnd {
  180. basalPoints.append(BasalProfile(
  181. amount: Double(entry.rate),
  182. isOverwritten: false,
  183. startDate: basalTime
  184. ))
  185. }
  186. }
  187. }
  188. // Include the active profile entry at timeBegin so the line starts at the chart's left edge
  189. if let lastBefore = lastEntryBeforeRange {
  190. basalPoints.append(BasalProfile(
  191. amount: lastBefore.amount,
  192. isOverwritten: false,
  193. startDate: beginDate
  194. ))
  195. }
  196. return basalPoints
  197. }
  198. func calculateBasals() {
  199. Task {
  200. let dayAgoTime = Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  201. async let getRegularBasalPoints = findRegularBasalPoints(
  202. timeBegin: dayAgoTime,
  203. timeEnd: state.endMarker.timeIntervalSince1970
  204. )
  205. var regularPoints = await getRegularBasalPoints
  206. regularPoints.sort { $0.startDate < $1.startDate }
  207. var basals: [BasalProfile] = []
  208. // No basal data? Then there's nothing to draw
  209. if regularPoints.isEmpty {
  210. // basals stays empty; do nothing
  211. }
  212. // Exactly one data point?
  213. else if regularPoints.count == 1 {
  214. let single = regularPoints[0]
  215. // Make one BasalProfile that stretches entire marker area
  216. basals.append(
  217. BasalProfile(
  218. amount: single.amount,
  219. isOverwritten: single.isOverwritten,
  220. startDate: state.startMarker,
  221. endDate: state.endMarker
  222. )
  223. )
  224. }
  225. // Multiple data points: chain them so each point ends where the next begins
  226. else {
  227. for i in 0 ..< (regularPoints.count - 1) {
  228. basals.append(
  229. BasalProfile(
  230. amount: regularPoints[i].amount,
  231. isOverwritten: regularPoints[i].isOverwritten,
  232. startDate: regularPoints[i].startDate,
  233. endDate: regularPoints[i + 1].startDate
  234. )
  235. )
  236. }
  237. // The last item goes from its start to endMarker
  238. if let lastItem = regularPoints.last {
  239. basals.append(
  240. BasalProfile(
  241. amount: lastItem.amount,
  242. isOverwritten: lastItem.isOverwritten,
  243. startDate: lastItem.startDate,
  244. endDate: state.endMarker
  245. )
  246. )
  247. }
  248. }
  249. await MainActor.run {
  250. basalProfiles = basals
  251. }
  252. }
  253. }
  254. }