MainChartView.swift 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  1. import Charts
  2. import CoreData
  3. import SwiftUI
  4. let screenSize: CGRect = UIScreen.main.bounds
  5. let calendar = Calendar.current
  6. private struct BasalProfile: Hashable {
  7. let amount: Double
  8. var isOverwritten: Bool
  9. let startDate: Date
  10. let endDate: Date?
  11. init(amount: Double, isOverwritten: Bool, startDate: Date, endDate: Date? = nil) {
  12. self.amount = amount
  13. self.isOverwritten = isOverwritten
  14. self.startDate = startDate
  15. self.endDate = endDate
  16. }
  17. }
  18. private struct ChartTempTarget: Hashable {
  19. let amount: Decimal
  20. let start: Date
  21. let end: Date
  22. }
  23. struct MainChartView: View {
  24. var geo: GeometryProxy
  25. @Binding var units: GlucoseUnits
  26. @Binding var announcement: [Announcement]
  27. @Binding var hours: Int
  28. @Binding var maxBasal: Decimal
  29. @Binding var autotunedBasalProfile: [BasalProfileEntry]
  30. @Binding var basalProfile: [BasalProfileEntry]
  31. @Binding var tempTargets: [TempTarget]
  32. @Binding var smooth: Bool
  33. @Binding var highGlucose: Decimal
  34. @Binding var lowGlucose: Decimal
  35. @Binding var screenHours: Int16
  36. @Binding var displayXgridLines: Bool
  37. @Binding var displayYgridLines: Bool
  38. @Binding var thresholdLines: Bool
  39. @StateObject var state: Home.StateModel
  40. @State var didAppearTrigger = false
  41. @State private var basalProfiles: [BasalProfile] = []
  42. @State private var chartTempTargets: [ChartTempTarget] = []
  43. @State private var count: Decimal = 1
  44. @State private var startMarker =
  45. Date(timeIntervalSinceNow: TimeInterval(hours: -24))
  46. @State private var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
  47. @State private var minValue: Decimal = 45
  48. @State private var maxValue: Decimal = 270
  49. @State private var selection: Date? = nil
  50. @State private var minValueCobChart: Decimal = 0
  51. @State private var maxValueCobChart: Decimal = 20
  52. @State private var minValueIobChart: Decimal = 0
  53. @State private var maxValueIobChart: Decimal = 5
  54. @State private var mainChartHasInitialized = false
  55. private let now = Date.now
  56. private let context = CoreDataStack.shared.persistentContainer.viewContext
  57. @Environment(\.colorScheme) var colorScheme
  58. @Environment(\.calendar) var calendar
  59. private var upperLimit: Decimal {
  60. units == .mgdL ? 400 : 22.2
  61. }
  62. private var selectedGlucose: GlucoseStored? {
  63. if let selection = selection {
  64. let lowerBound = selection.addingTimeInterval(-150)
  65. let upperBound = selection.addingTimeInterval(150)
  66. return state.glucoseFromPersistence.first { $0.date ?? now >= lowerBound && $0.date ?? now <= upperBound }
  67. } else {
  68. return nil
  69. }
  70. }
  71. private var selectedCOBValue: OrefDetermination? {
  72. if let selection = selection {
  73. let lowerBound = selection.addingTimeInterval(-120)
  74. let upperBound = selection.addingTimeInterval(120)
  75. return state.enactedAndNonEnactedDeterminations.first {
  76. $0.deliverAt ?? now >= lowerBound && $0.deliverAt ?? now <= upperBound
  77. }
  78. } else {
  79. return nil
  80. }
  81. }
  82. private var selectedIOBValue: OrefDetermination? {
  83. if let selection = selection {
  84. let lowerBound = selection.addingTimeInterval(-120)
  85. let upperBound = selection.addingTimeInterval(120)
  86. return state.enactedAndNonEnactedDeterminations.first {
  87. $0.deliverAt ?? now >= lowerBound && $0.deliverAt ?? now <= upperBound
  88. }
  89. } else {
  90. return nil
  91. }
  92. }
  93. var body: some View {
  94. VStack {
  95. ZStack {
  96. VStack(spacing: 5) {
  97. dummyBasalChart
  98. staticYAxisChart
  99. Spacer()
  100. dummyCobChart
  101. }
  102. ScrollViewReader { scroller in
  103. ScrollView(.horizontal, showsIndicators: false) {
  104. VStack(spacing: 5) {
  105. basalChart
  106. mainChart
  107. Spacer()
  108. ZStack {
  109. cobChart
  110. iobChart
  111. }
  112. }.onChange(of: screenHours) { _ in
  113. scroller.scrollTo("MainChart", anchor: .trailing)
  114. }
  115. .onChange(of: state.glucoseFromPersistence.last?.glucose) { _ in
  116. updateStartEndMarkers()
  117. yAxisChartData()
  118. scroller.scrollTo("MainChart", anchor: .trailing)
  119. }
  120. .onChange(of: state.enactedAndNonEnactedDeterminations.first?.deliverAt) { _ in
  121. yAxisChartDataCobChart()
  122. yAxisChartDataIobChart()
  123. scroller.scrollTo("MainChart", anchor: .trailing)
  124. }
  125. .onChange(of: units) { _ in
  126. yAxisChartData()
  127. yAxisChartDataCobChart()
  128. yAxisChartDataIobChart()
  129. }
  130. .onAppear {
  131. if !mainChartHasInitialized {
  132. updateStartEndMarkers()
  133. yAxisChartData()
  134. yAxisChartDataCobChart()
  135. yAxisChartDataIobChart()
  136. mainChartHasInitialized = true
  137. scroller.scrollTo("MainChart", anchor: .trailing)
  138. }
  139. }
  140. }
  141. }
  142. }
  143. }
  144. }
  145. }
  146. // MARK: - Components
  147. extension MainChartView {
  148. /// empty chart that just shows the Y axis and Y grid lines. Created separately from `mainChart` to allow main chart to scroll horizontally while having a fixed Y axis
  149. private var staticYAxisChart: some View {
  150. Chart {
  151. /// high and low threshold lines
  152. if thresholdLines {
  153. RuleMark(y: .value("High", highGlucose)).foregroundStyle(Color.loopYellow)
  154. .lineStyle(.init(lineWidth: 1, dash: [5]))
  155. RuleMark(y: .value("Low", lowGlucose)).foregroundStyle(Color.loopRed)
  156. .lineStyle(.init(lineWidth: 1, dash: [5]))
  157. }
  158. }
  159. .id("DummyMainChart")
  160. .frame(minHeight: geo.size.height * 0.28)
  161. .frame(width: screenSize.width - 10)
  162. .chartXAxis { mainChartXAxis }
  163. .chartXScale(domain: startMarker ... endMarker)
  164. .chartXAxis(.hidden)
  165. .chartYAxis { mainChartYAxis }
  166. .chartYScale(domain: units == .mgdL ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
  167. .chartLegend(.hidden)
  168. }
  169. private var dummyBasalChart: some View {
  170. Chart {}
  171. .id("DummyBasalChart")
  172. .frame(minHeight: geo.size.height * 0.05)
  173. .frame(width: screenSize.width - 10)
  174. .chartXAxis { basalChartXAxis }
  175. .chartXAxis(.hidden)
  176. .chartYAxis(.hidden)
  177. .chartLegend(.hidden)
  178. }
  179. private var dummyCobChart: some View {
  180. Chart {
  181. drawCOB(dummy: true)
  182. }
  183. .id("DummyCobChart")
  184. .frame(minHeight: geo.size.height * 0.12)
  185. .frame(width: screenSize.width - 10)
  186. .chartXScale(domain: startMarker ... endMarker)
  187. .chartXAxis { basalChartXAxis }
  188. .chartXAxis(.hidden)
  189. .chartYAxis { cobChartYAxis }
  190. .chartYAxis(.hidden)
  191. .chartYScale(domain: minValueCobChart ... maxValueCobChart)
  192. .chartLegend(.hidden)
  193. }
  194. private var mainChart: some View {
  195. VStack {
  196. Chart {
  197. drawStartRuleMark()
  198. drawEndRuleMark()
  199. drawCurrentTimeMarker()
  200. drawTempTargets()
  201. GlucoseChartView(
  202. glucoseData: state.glucoseFromPersistence,
  203. manualGlucoseData: state.manualGlucoseFromPersistence,
  204. units: state.units,
  205. highGlucose: state.highGlucose,
  206. lowGlucose: state.lowGlucose,
  207. smooth: state.smooth,
  208. gradientStops: state.gradientStops
  209. )
  210. InsulinView(
  211. glucoseData: state.glucoseFromPersistence,
  212. insulinData: state.insulinFromPersistence,
  213. units: state.units
  214. )
  215. CarbView(
  216. glucoseData: state.glucoseFromPersistence,
  217. units: state.units,
  218. carbData: state.carbsFromPersistence,
  219. fpuData: state.fpusFromPersistence,
  220. minValue: minValue
  221. )
  222. OverrideView(
  223. overrides: state.overrides,
  224. overrideRunStored: state.overrideRunStored,
  225. units: state.units,
  226. viewContext: context
  227. )
  228. ForecastView(
  229. preprocessedData: state.preprocessedData,
  230. minForecast: state.minForecast,
  231. maxForecast: state.maxForecast,
  232. units: state.units,
  233. maxValue: maxValue,
  234. forecastDisplayType: state.forecastDisplayType
  235. )
  236. /// show glucose value when hovering over it
  237. if #available(iOS 17, *) {
  238. if let selectedGlucose {
  239. RuleMark(x: .value("Selection", selectedGlucose.date ?? now, unit: .minute))
  240. .foregroundStyle(Color.tabBar)
  241. .offset(yStart: 70)
  242. .lineStyle(.init(lineWidth: 2))
  243. .annotation(
  244. position: .top,
  245. alignment: .center,
  246. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  247. ) {
  248. selectionPopover
  249. }
  250. PointMark(
  251. x: .value("Time", selectedGlucose.date ?? now, unit: .minute),
  252. y: .value("Value", selectedGlucose.glucose)
  253. )
  254. .zIndex(-1)
  255. .symbolSize(CGSize(width: 15, height: 15))
  256. .foregroundStyle(
  257. Decimal(selectedGlucose.glucose) > highGlucose ? Color.orange
  258. .opacity(0.8) :
  259. (
  260. Decimal(selectedGlucose.glucose) < lowGlucose ? Color.red.opacity(0.8) : Color.green
  261. .opacity(0.8)
  262. )
  263. )
  264. PointMark(
  265. x: .value("Time", selectedGlucose.date ?? now, unit: .minute),
  266. y: .value("Value", selectedGlucose.glucose)
  267. )
  268. .zIndex(-1)
  269. .symbolSize(CGSize(width: 6, height: 6))
  270. .foregroundStyle(Color.primary)
  271. }
  272. }
  273. }
  274. .id("MainChart")
  275. .onChange(of: state.insulinFromPersistence) { _ in
  276. state.roundedTotalBolus = state.calculateTINS()
  277. }
  278. .onChange(of: tempTargets) { _ in
  279. Task {
  280. await calculateTTs()
  281. }
  282. }
  283. .onChange(of: didAppearTrigger) { _ in
  284. Task {
  285. await calculateTTs()
  286. }
  287. }
  288. .frame(minHeight: geo.size.height * 0.28)
  289. .frame(width: fullWidth(viewWidth: screenSize.width))
  290. .chartXScale(domain: startMarker ... endMarker)
  291. .chartXAxis { mainChartXAxis }
  292. .chartYAxis { mainChartYAxis }
  293. .chartYAxis(.hidden)
  294. .backport.chartXSelection(value: $selection)
  295. .chartYScale(domain: units == .mgdL ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
  296. .backport.chartForegroundStyleScale(state: state)
  297. }
  298. }
  299. @ViewBuilder var selectionPopover: some View {
  300. if let sgv = selectedGlucose?.glucose {
  301. let glucoseToShow = units == .mgdL ? Decimal(sgv) : Decimal(sgv).asMmolL
  302. VStack(alignment: .leading) {
  303. HStack {
  304. Image(systemName: "clock")
  305. Text(selectedGlucose?.date?.formatted(.dateTime.hour().minute(.twoDigits)) ?? "")
  306. .font(.body).bold()
  307. }.font(.body).padding(.bottom, 5)
  308. HStack {
  309. Text(units == .mgdL ? glucoseToShow.description : Decimal(sgv).formattedAsMmolL)
  310. .bold()
  311. + Text(" \(units.rawValue)")
  312. }.foregroundStyle(
  313. glucoseToShow < lowGlucose ? Color
  314. .red : (glucoseToShow > highGlucose ? Color.orange : Color.primary)
  315. ).font(.body)
  316. if let selectedIOBValue, let iob = selectedIOBValue.iob {
  317. HStack {
  318. Image(systemName: "syringe.fill").frame(width: 15)
  319. Text(MainChartHelper.bolusFormatter.string(from: iob) ?? "")
  320. .bold()
  321. + Text(NSLocalizedString(" U", comment: "Insulin unit"))
  322. }.foregroundStyle(Color.insulin).font(.body)
  323. }
  324. if let selectedCOBValue {
  325. HStack {
  326. Image(systemName: "fork.knife").frame(width: 15)
  327. Text(MainChartHelper.carbsFormatter.string(from: selectedCOBValue.cob as NSNumber) ?? "")
  328. .bold()
  329. + Text(NSLocalizedString(" g", comment: "gram of carbs"))
  330. }.foregroundStyle(Color.orange).font(.body)
  331. }
  332. }
  333. .padding()
  334. .background {
  335. RoundedRectangle(cornerRadius: 4)
  336. .fill(Color.chart.opacity(0.85))
  337. .shadow(color: Color.secondary, radius: 2)
  338. .overlay(
  339. RoundedRectangle(cornerRadius: 4)
  340. .stroke(Color.secondary, lineWidth: 2)
  341. )
  342. }
  343. }
  344. }
  345. private var basalChart: some View {
  346. VStack {
  347. Chart {
  348. drawStartRuleMark()
  349. drawEndRuleMark()
  350. drawCurrentTimeMarker()
  351. drawTempBasals(dummy: false)
  352. drawBasalProfile()
  353. drawSuspensions()
  354. }.onChange(of: state.tempBasals) { _ in
  355. calculateBasals()
  356. }
  357. .onChange(of: maxBasal) { _ in
  358. calculateBasals()
  359. }
  360. .onChange(of: autotunedBasalProfile) { _ in
  361. calculateBasals()
  362. }
  363. .onChange(of: didAppearTrigger) { _ in
  364. calculateBasals()
  365. }.onChange(of: basalProfile) { _ in
  366. calculateBasals()
  367. }
  368. .frame(minHeight: geo.size.height * 0.05)
  369. .frame(width: fullWidth(viewWidth: screenSize.width))
  370. .chartXScale(domain: startMarker ... endMarker)
  371. .chartXAxis { basalChartXAxis }
  372. .chartXAxis(.hidden)
  373. .chartYAxis(.hidden)
  374. .chartPlotStyle { basalChartPlotStyle($0) }
  375. }
  376. }
  377. private var iobChart: some View {
  378. VStack {
  379. Chart {
  380. drawIOB()
  381. if #available(iOS 17, *) {
  382. if let selectedIOBValue {
  383. PointMark(
  384. x: .value("Time", selectedIOBValue.deliverAt ?? now, unit: .minute),
  385. y: .value("Value", Int(truncating: selectedIOBValue.iob ?? 0))
  386. )
  387. .symbolSize(CGSize(width: 15, height: 15))
  388. .foregroundStyle(Color.darkerBlue.opacity(0.8))
  389. PointMark(
  390. x: .value("Time", selectedIOBValue.deliverAt ?? now, unit: .minute),
  391. y: .value("Value", Int(truncating: selectedIOBValue.iob ?? 0))
  392. )
  393. .symbolSize(CGSize(width: 6, height: 6))
  394. .foregroundStyle(Color.primary)
  395. }
  396. }
  397. }
  398. .frame(minHeight: geo.size.height * 0.12)
  399. .frame(width: fullWidth(viewWidth: screenSize.width))
  400. .chartXScale(domain: startMarker ... endMarker)
  401. .backport.chartXSelection(value: $selection)
  402. .chartXAxis { basalChartXAxis }
  403. .chartYAxis { cobChartYAxis }
  404. .chartYScale(domain: minValueIobChart ... maxValueIobChart)
  405. .chartYAxis(.hidden)
  406. }
  407. }
  408. private var cobChart: some View {
  409. Chart {
  410. drawCurrentTimeMarker()
  411. drawCOB(dummy: false)
  412. if #available(iOS 17, *) {
  413. if let selectedCOBValue {
  414. PointMark(
  415. x: .value("Time", selectedCOBValue.deliverAt ?? now, unit: .minute),
  416. y: .value("Value", selectedCOBValue.cob)
  417. )
  418. .symbolSize(CGSize(width: 15, height: 15))
  419. .foregroundStyle(Color.orange.opacity(0.8))
  420. PointMark(
  421. x: .value("Time", selectedCOBValue.deliverAt ?? now, unit: .minute),
  422. y: .value("Value", selectedCOBValue.cob)
  423. )
  424. .symbolSize(CGSize(width: 6, height: 6))
  425. .foregroundStyle(Color.primary)
  426. }
  427. }
  428. }
  429. .frame(minHeight: geo.size.height * 0.12)
  430. .frame(width: fullWidth(viewWidth: screenSize.width))
  431. .chartXScale(domain: startMarker ... endMarker)
  432. .backport.chartXSelection(value: $selection)
  433. .chartXAxis { basalChartXAxis }
  434. .chartYAxis { cobChartYAxis }
  435. .chartYScale(domain: minValueCobChart ... maxValueCobChart)
  436. }
  437. }
  438. // MARK: - Calculations
  439. extension MainChartView {
  440. private func drawCurrentTimeMarker() -> some ChartContent {
  441. RuleMark(
  442. x: .value(
  443. "",
  444. Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970)),
  445. unit: .second
  446. )
  447. ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
  448. }
  449. private func drawStartRuleMark() -> some ChartContent {
  450. RuleMark(
  451. x: .value(
  452. "",
  453. startMarker,
  454. unit: .second
  455. )
  456. ).foregroundStyle(Color.clear)
  457. }
  458. private func drawEndRuleMark() -> some ChartContent {
  459. RuleMark(
  460. x: .value(
  461. "",
  462. endMarker,
  463. unit: .second
  464. )
  465. ).foregroundStyle(Color.clear)
  466. }
  467. private func drawTempTargets() -> some ChartContent {
  468. /// temp targets
  469. ForEach(chartTempTargets, id: \.self) { target in
  470. let targetLimited = min(max(target.amount, 0), upperLimit)
  471. RuleMark(
  472. xStart: .value("Start", target.start),
  473. xEnd: .value("End", target.end),
  474. y: .value("Value", targetLimited)
  475. )
  476. .foregroundStyle(Color.purple.opacity(0.75)).lineStyle(.init(lineWidth: 8))
  477. }
  478. }
  479. private func drawSuspensions() -> some ChartContent {
  480. let suspensions = state.suspensions
  481. return ForEach(suspensions) { suspension in
  482. let now = Date()
  483. if let type = suspension.type, type == EventType.pumpSuspend.rawValue, let suspensionStart = suspension.timestamp {
  484. let suspensionEnd = min(
  485. (
  486. suspensions
  487. .first(where: {
  488. $0.timestamp ?? now > suspensionStart && $0.type == EventType.pumpResume.rawValue })?
  489. .timestamp
  490. ) ?? now,
  491. now
  492. )
  493. let basalProfileDuringSuspension = basalProfiles.first(where: { $0.startDate <= suspensionStart })
  494. let suspensionMarkHeight = basalProfileDuringSuspension?.amount ?? 1
  495. RectangleMark(
  496. xStart: .value("start", suspensionStart),
  497. xEnd: .value("end", suspensionEnd),
  498. yStart: .value("suspend-start", 0),
  499. yEnd: .value("suspend-end", suspensionMarkHeight)
  500. )
  501. .foregroundStyle(Color.loopGray.opacity(colorScheme == .dark ? 0.3 : 0.8))
  502. }
  503. }
  504. }
  505. private func drawIOB() -> some ChartContent {
  506. ForEach(state.enactedAndNonEnactedDeterminations) { iob in
  507. let rawAmount = iob.iob?.doubleValue ?? 0
  508. let amount: Double = rawAmount > 0 ? rawAmount : rawAmount * 2 // weigh negative iob with factor 2
  509. let date: Date = iob.deliverAt ?? Date()
  510. LineMark(x: .value("Time", date), y: .value("Amount", amount))
  511. .foregroundStyle(Color.darkerBlue)
  512. AreaMark(x: .value("Time", date), y: .value("Amount", amount))
  513. .foregroundStyle(
  514. LinearGradient(
  515. gradient: Gradient(
  516. colors: [
  517. Color.darkerBlue.opacity(0.8),
  518. Color.darkerBlue.opacity(0.01)
  519. ]
  520. ),
  521. startPoint: .top,
  522. endPoint: .bottom
  523. )
  524. )
  525. }
  526. }
  527. private func drawCOB(dummy: Bool) -> some ChartContent {
  528. ForEach(state.enactedAndNonEnactedDeterminations) { cob in
  529. let amount = Int(cob.cob)
  530. let date: Date = cob.deliverAt ?? Date()
  531. if dummy {
  532. LineMark(x: .value("Time", date), y: .value("Value", amount))
  533. .foregroundStyle(Color.clear)
  534. AreaMark(x: .value("Time", date), y: .value("Value", amount)).foregroundStyle(
  535. Color.clear
  536. )
  537. } else {
  538. LineMark(x: .value("Time", date), y: .value("Value", amount))
  539. .foregroundStyle(Color.orange.gradient)
  540. AreaMark(x: .value("Time", date), y: .value("Value", amount)).foregroundStyle(
  541. LinearGradient(
  542. gradient: Gradient(
  543. colors: [
  544. Color.orange.opacity(0.8),
  545. Color.orange.opacity(0.01)
  546. ]
  547. ),
  548. startPoint: .top,
  549. endPoint: .bottom
  550. )
  551. )
  552. }
  553. }
  554. }
  555. private func prepareTempBasals() -> [(start: Date, end: Date, rate: Double)] {
  556. let now = Date()
  557. let tempBasals = state.tempBasals
  558. return tempBasals.compactMap { temp -> (start: Date, end: Date, rate: Double)? in
  559. let duration = temp.tempBasal?.duration ?? 0
  560. let timestamp = temp.timestamp ?? Date()
  561. let end = min(timestamp + duration.minutes, now)
  562. let isInsulinSuspended = state.suspensions.contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
  563. let rate = Double(truncating: temp.tempBasal?.rate ?? Decimal.zero as NSDecimalNumber) * (isInsulinSuspended ? 0 : 1)
  564. // Check if there's a subsequent temp basal to determine the end time
  565. guard let nextTemp = state.tempBasals.first(where: { $0.timestamp ?? .distantPast > timestamp }) else {
  566. return (timestamp, end, rate)
  567. }
  568. return (timestamp, nextTemp.timestamp ?? Date(), rate) // end defaults to current time
  569. }
  570. }
  571. private func drawTempBasals(dummy: Bool) -> some ChartContent {
  572. ForEach(prepareTempBasals(), id: \.rate) { basal in
  573. if dummy {
  574. RectangleMark(
  575. xStart: .value("start", basal.start),
  576. xEnd: .value("end", basal.end),
  577. yStart: .value("rate-start", 0),
  578. yEnd: .value("rate-end", basal.rate)
  579. ).foregroundStyle(Color.clear)
  580. LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
  581. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
  582. LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
  583. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
  584. } else {
  585. RectangleMark(
  586. xStart: .value("start", basal.start),
  587. xEnd: .value("end", basal.end),
  588. yStart: .value("rate-start", 0),
  589. yEnd: .value("rate-end", basal.rate)
  590. ).foregroundStyle(
  591. LinearGradient(
  592. gradient: Gradient(
  593. colors: [
  594. Color.insulin.opacity(0.6),
  595. Color.insulin.opacity(0.1)
  596. ]
  597. ),
  598. startPoint: .top,
  599. endPoint: .bottom
  600. )
  601. )
  602. LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
  603. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  604. LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
  605. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  606. }
  607. }
  608. }
  609. private func drawBasalProfile() -> some ChartContent {
  610. /// dashed profile line
  611. ForEach(basalProfiles, id: \.self) { profile in
  612. LineMark(
  613. x: .value("Start Date", profile.startDate),
  614. y: .value("Amount", profile.amount),
  615. series: .value("profile", "profile")
  616. ).lineStyle(.init(lineWidth: 2, dash: [2, 4])).foregroundStyle(Color.insulin)
  617. LineMark(
  618. x: .value("End Date", profile.endDate ?? endMarker),
  619. y: .value("Amount", profile.amount),
  620. series: .value("profile", "profile")
  621. ).lineStyle(.init(lineWidth: 2.5, dash: [2, 4])).foregroundStyle(Color.insulin)
  622. }
  623. }
  624. private func fullWidth(viewWidth: CGFloat) -> CGFloat {
  625. viewWidth * CGFloat(hours) / CGFloat(min(max(screenHours, 2), 24))
  626. }
  627. /// calculations for temp target bar mark
  628. private func calculateTTs() async {
  629. // Perform calculations off the main thread
  630. let calculatedTTs = await Task.detached { () -> [ChartTempTarget] in
  631. var groupedPackages: [[TempTarget]] = []
  632. var currentPackage: [TempTarget] = []
  633. var calculatedTTs: [ChartTempTarget] = []
  634. for target in await tempTargets {
  635. if target.duration > 0 {
  636. if !currentPackage.isEmpty {
  637. groupedPackages.append(currentPackage)
  638. currentPackage = []
  639. }
  640. currentPackage.append(target)
  641. } else if let lastNonZeroTempTarget = currentPackage.last(where: { $0.duration > 0 }) {
  642. // Ensure this cancel target is within the valid time range
  643. if target.createdAt >= lastNonZeroTempTarget.createdAt,
  644. target.createdAt <= lastNonZeroTempTarget.createdAt
  645. .addingTimeInterval(TimeInterval(lastNonZeroTempTarget.duration * 60))
  646. {
  647. currentPackage.append(target)
  648. }
  649. }
  650. }
  651. // Append the last group, if any
  652. if !currentPackage.isEmpty {
  653. groupedPackages.append(currentPackage)
  654. }
  655. for package in groupedPackages {
  656. guard let firstNonZeroTarget = package.first(where: { $0.duration > 0 }) else { continue }
  657. var end = firstNonZeroTarget.createdAt.addingTimeInterval(TimeInterval(firstNonZeroTarget.duration * 60))
  658. let earliestCancelTarget = package.filter({ $0.duration == 0 }).min(by: { $0.createdAt < $1.createdAt })
  659. if let earliestCancelTarget = earliestCancelTarget {
  660. end = min(earliestCancelTarget.createdAt, end)
  661. }
  662. if let targetTop = firstNonZeroTarget.targetTop {
  663. let adjustedTarget = await units == .mgdL ? targetTop : targetTop.asMmolL
  664. calculatedTTs
  665. .append(ChartTempTarget(amount: adjustedTarget, start: firstNonZeroTarget.createdAt, end: end))
  666. }
  667. }
  668. return calculatedTTs
  669. }.value
  670. // Update chartTempTargets on the main thread
  671. await MainActor.run {
  672. self.chartTempTargets = calculatedTTs
  673. }
  674. }
  675. private func findRegularBasalPoints(
  676. timeBegin: TimeInterval,
  677. timeEnd: TimeInterval,
  678. autotuned: Bool
  679. ) async -> [BasalProfile] {
  680. guard timeBegin < timeEnd else { return [] }
  681. let beginDate = Date(timeIntervalSince1970: timeBegin)
  682. let startOfDay = Calendar.current.startOfDay(for: beginDate)
  683. let profile = autotuned ? autotunedBasalProfile : basalProfile
  684. var basalPoints: [BasalProfile] = []
  685. // Iterate over the next three days, multiplying the time intervals
  686. for dayOffset in 0 ..< 3 {
  687. let dayTimeOffset = TimeInterval(dayOffset * 24 * 60 * 60) // One Day in seconds
  688. for entry in profile {
  689. let basalTime = startOfDay.addingTimeInterval(entry.minutes.minutes.timeInterval + dayTimeOffset)
  690. let basalTimeInterval = basalTime.timeIntervalSince1970
  691. // Only append points within the timeBegin and timeEnd range
  692. if basalTimeInterval >= timeBegin, basalTimeInterval < timeEnd {
  693. basalPoints.append(BasalProfile(
  694. amount: Double(entry.rate),
  695. isOverwritten: false,
  696. startDate: basalTime
  697. ))
  698. }
  699. }
  700. }
  701. return basalPoints
  702. }
  703. /// update start and end marker to fix scroll update problem with x axis
  704. private func updateStartEndMarkers() {
  705. startMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 - 86400))
  706. let threeHourSinceNow = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
  707. // min is 1.5h -> (1.5*1h = 1.5*(5*12*60))
  708. let dynamicFutureDateForCone = Date(timeIntervalSinceNow: TimeInterval(
  709. Int(1.5) * 5 * state
  710. .minCount * 60
  711. ))
  712. endMarker = state
  713. .forecastDisplayType == .lines ? threeHourSinceNow : dynamicFutureDateForCone <= threeHourSinceNow ?
  714. dynamicFutureDateForCone.addingTimeInterval(TimeInterval(minutes: 30)) : threeHourSinceNow
  715. }
  716. private func calculateBasals() {
  717. Task {
  718. let dayAgoTime = Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  719. // Get Regular and Autotuned Basal parallel
  720. async let getRegularBasalPoints = findRegularBasalPoints(
  721. timeBegin: dayAgoTime,
  722. timeEnd: endMarker.timeIntervalSince1970,
  723. autotuned: false
  724. )
  725. async let getAutotunedBasalPoints = findRegularBasalPoints(
  726. timeBegin: dayAgoTime,
  727. timeEnd: endMarker.timeIntervalSince1970,
  728. autotuned: true
  729. )
  730. let (regularPoints, autotunedBasalPoints) = await (getRegularBasalPoints, getAutotunedBasalPoints)
  731. var totalBasal = regularPoints + autotunedBasalPoints
  732. totalBasal.sort {
  733. $0.startDate.timeIntervalSince1970 < $1.startDate.timeIntervalSince1970
  734. }
  735. var basals: [BasalProfile] = []
  736. totalBasal.indices.forEach { index in
  737. basals.append(BasalProfile(
  738. amount: totalBasal[index].amount,
  739. isOverwritten: totalBasal[index].isOverwritten,
  740. startDate: totalBasal[index].startDate,
  741. endDate: totalBasal.count > index + 1 ? totalBasal[index + 1].startDate : endMarker
  742. ))
  743. }
  744. await MainActor.run {
  745. basalProfiles = basals
  746. }
  747. }
  748. }
  749. // MARK: - Chart formatting
  750. private func yAxisChartData() {
  751. Task {
  752. let (minGlucose, maxGlucose, minForecast, maxForecast) = await Task
  753. .detached { () -> (Decimal?, Decimal?, Decimal?, Decimal?) in
  754. let glucoseMapped = await state.glucoseFromPersistence.map { Decimal($0.glucose) }
  755. let forecastValues = await state.preprocessedData.map { Decimal($0.forecastValue.value) }
  756. // Calculate min and max values for glucose and forecast
  757. return (glucoseMapped.min(), glucoseMapped.max(), forecastValues.min(), forecastValues.max())
  758. }.value
  759. // Ensure all values exist, otherwise set default values
  760. guard let minGlucose = minGlucose, let maxGlucose = maxGlucose,
  761. let minForecast = minForecast, let maxForecast = maxForecast
  762. else {
  763. await updateChartBounds(minValue: 45 - 20, maxValue: 270 + 50)
  764. return
  765. }
  766. // Adjust max forecast to be no more than 100 over max glucose
  767. let adjustedMaxForecast = min(maxForecast, maxGlucose + 100)
  768. let minOverall = min(minGlucose, minForecast)
  769. let maxOverall = max(maxGlucose, adjustedMaxForecast)
  770. // Update the chart bounds on the main thread
  771. await updateChartBounds(minValue: minOverall - 50, maxValue: maxOverall + 80)
  772. }
  773. }
  774. @MainActor private func updateChartBounds(minValue: Decimal, maxValue: Decimal) async {
  775. self.minValue = minValue
  776. self.maxValue = maxValue
  777. }
  778. private func yAxisChartDataCobChart() {
  779. Task {
  780. let maxCob = await Task.detached { () -> Decimal? in
  781. let cobMapped = await state.enactedAndNonEnactedDeterminations.map { Decimal($0.cob) }
  782. return cobMapped.max()
  783. }.value
  784. // Ensure the result exists or set default values
  785. if let maxCob = maxCob {
  786. let calculatedMax = maxCob == 0 ? 20 : maxCob + 20
  787. await updateCobChartBounds(minValue: 0, maxValue: calculatedMax)
  788. } else {
  789. await updateCobChartBounds(minValue: 0, maxValue: 20)
  790. }
  791. }
  792. }
  793. @MainActor private func updateCobChartBounds(minValue: Decimal, maxValue: Decimal) async {
  794. minValueCobChart = minValue
  795. maxValueCobChart = maxValue
  796. }
  797. private func yAxisChartDataIobChart() {
  798. Task {
  799. let (minIob, maxIob) = await Task.detached { () -> (Decimal?, Decimal?) in
  800. let iobMapped = await state.enactedAndNonEnactedDeterminations.compactMap { $0.iob?.decimalValue }
  801. return (iobMapped.min(), iobMapped.max())
  802. }.value
  803. // Ensure min and max IOB values exist, or set defaults
  804. if let minIob = minIob, let maxIob = maxIob {
  805. let adjustedMin = minIob < 0 ? minIob - 2 : 0
  806. await updateIobChartBounds(minValue: adjustedMin, maxValue: maxIob + 2)
  807. } else {
  808. await updateIobChartBounds(minValue: 0, maxValue: 5)
  809. }
  810. }
  811. }
  812. @MainActor private func updateIobChartBounds(minValue: Decimal, maxValue: Decimal) async {
  813. minValueIobChart = minValue
  814. maxValueIobChart = maxValue
  815. }
  816. private func basalChartPlotStyle(_ plotContent: ChartPlotContent) -> some View {
  817. plotContent
  818. .rotationEffect(.degrees(180))
  819. .scaleEffect(x: -1, y: 1)
  820. }
  821. private var mainChartXAxis: some AxisContent {
  822. AxisMarks(values: .stride(by: .hour, count: screenHours > 6 ? (screenHours > 12 ? 4 : 2) : 1)) { _ in
  823. if displayXgridLines {
  824. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  825. } else {
  826. AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
  827. }
  828. }
  829. }
  830. private var basalChartXAxis: some AxisContent {
  831. AxisMarks(values: .stride(by: .hour, count: screenHours > 6 ? (screenHours > 12 ? 4 : 2) : 1)) { _ in
  832. if displayXgridLines {
  833. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  834. } else {
  835. AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
  836. }
  837. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
  838. .font(.footnote).foregroundStyle(Color.primary)
  839. }
  840. }
  841. private var mainChartYAxis: some AxisContent {
  842. AxisMarks(position: .trailing) { value in
  843. if displayYgridLines {
  844. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  845. } else {
  846. AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
  847. }
  848. if let glucoseValue = value.as(Double.self), glucoseValue > 0 {
  849. /// fix offset between the two charts...
  850. if units == .mmolL {
  851. AxisTick(length: 7, stroke: .init(lineWidth: 7)).foregroundStyle(Color.clear)
  852. }
  853. AxisValueLabel().font(.footnote).foregroundStyle(Color.primary)
  854. }
  855. }
  856. }
  857. private var cobChartYAxis: some AxisContent {
  858. AxisMarks(position: .trailing) { _ in
  859. if displayYgridLines {
  860. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  861. } else {
  862. AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
  863. }
  864. }
  865. }
  866. }
  867. struct LegendItem: View {
  868. var color: Color
  869. var label: String
  870. var body: some View {
  871. Group {
  872. Circle().fill(color).frame(width: 8, height: 8)
  873. Text(label)
  874. .font(.system(size: 10, weight: .bold))
  875. .foregroundColor(color)
  876. }
  877. }
  878. }
  879. extension Int16 {
  880. var minutes: TimeInterval {
  881. TimeInterval(self) * 60
  882. }
  883. }