MainChartView.swift 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
  1. import Charts
  2. import SwiftUI
  3. let screenSize: CGRect = UIScreen.main.bounds
  4. let calendar = Calendar.current
  5. private struct BasalProfile: Hashable {
  6. let amount: Double
  7. var isOverwritten: Bool
  8. let startDate: Date
  9. let endDate: Date?
  10. init(amount: Double, isOverwritten: Bool, startDate: Date, endDate: Date? = nil) {
  11. self.amount = amount
  12. self.isOverwritten = isOverwritten
  13. self.startDate = startDate
  14. self.endDate = endDate
  15. }
  16. }
  17. private struct Prediction: Hashable {
  18. let amount: Int
  19. let timestamp: Date
  20. let type: PredictionType
  21. }
  22. private struct ChartTempTarget: Hashable {
  23. let amount: Decimal
  24. let start: Date
  25. let end: Date
  26. }
  27. private enum PredictionType: Hashable {
  28. case iob
  29. case cob
  30. case zt
  31. case uam
  32. }
  33. struct MainChartView: View {
  34. private enum Config {
  35. static let bolusSize: CGFloat = 5
  36. static let bolusScale: CGFloat = 1
  37. static let carbsSize: CGFloat = 5
  38. static let carbsScale: CGFloat = 0.3
  39. static let fpuSize: CGFloat = 10
  40. static let maxGlucose = 270
  41. static let minGlucose = 45
  42. }
  43. @Binding var glucose: [BloodGlucose]
  44. @Binding var manualGlucose: [BloodGlucose]
  45. @Binding var fpusForChart: [CarbsEntry]
  46. @Binding var units: GlucoseUnits
  47. @Binding var eventualBG: Int?
  48. @Binding var suggestion: Suggestion?
  49. @Binding var tempBasals: [PumpHistoryEvent]
  50. @Binding var boluses: [PumpHistoryEvent]
  51. @Binding var suspensions: [PumpHistoryEvent]
  52. @Binding var announcement: [Announcement]
  53. @Binding var hours: Int
  54. @Binding var maxBasal: Decimal
  55. @Binding var autotunedBasalProfile: [BasalProfileEntry]
  56. @Binding var basalProfile: [BasalProfileEntry]
  57. @Binding var tempTargets: [TempTarget]
  58. @Binding var smooth: Bool
  59. @Binding var highGlucose: Decimal
  60. @Binding var lowGlucose: Decimal
  61. @Binding var screenHours: Int16
  62. @Binding var displayXgridLines: Bool
  63. @Binding var displayYgridLines: Bool
  64. @Binding var thresholdLines: Bool
  65. @Binding var isTempTargetActive: Bool
  66. @StateObject var state = Home.StateModel()
  67. @State var didAppearTrigger = false
  68. @State private var BasalProfiles: [BasalProfile] = []
  69. @State private var TempBasals: [PumpHistoryEvent] = []
  70. @State private var ChartTempTargets: [ChartTempTarget] = []
  71. @State private var Predictions: [Prediction] = []
  72. @State private var count: Decimal = 1
  73. @State private var startMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 - 86400))
  74. @State private var endMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 + 10800))
  75. @State private var minValue: Decimal = 45
  76. @State private var maxValue: Decimal = 270
  77. @State private var selection: Date? = nil
  78. @Environment(\.colorScheme) var colorScheme
  79. @Environment(\.calendar) var calendar
  80. // MARK: - Core Data Fetch Requests
  81. @FetchRequest(
  82. entity: MealsStored.entity(),
  83. sortDescriptors: [NSSortDescriptor(keyPath: \MealsStored.date, ascending: true)]
  84. ) var carbsFromPersistence: FetchedResults<MealsStored>
  85. private var bolusFormatter: NumberFormatter {
  86. let formatter = NumberFormatter()
  87. formatter.numberStyle = .decimal
  88. formatter.minimumIntegerDigits = 0
  89. formatter.maximumFractionDigits = 2
  90. formatter.decimalSeparator = "."
  91. return formatter
  92. }
  93. private var carbsFormatter: NumberFormatter {
  94. let formatter = NumberFormatter()
  95. formatter.numberStyle = .decimal
  96. formatter.maximumFractionDigits = 0
  97. return formatter
  98. }
  99. private var conversionFactor: Decimal {
  100. units == .mmolL ? 0.0555 : 1
  101. }
  102. private var upperLimit: Decimal {
  103. units == .mgdL ? 400 : 22.2
  104. }
  105. private var defaultBolusPosition: Int {
  106. units == .mgdL ? 120 : 7
  107. }
  108. private var bolusOffset: Decimal {
  109. units == .mgdL ? 30 : 1.66
  110. }
  111. private var selectedGlucose: BloodGlucose? {
  112. if let selection = selection {
  113. let lowerBound = selection.addingTimeInterval(-120)
  114. let upperBound = selection.addingTimeInterval(120)
  115. return glucose.first { $0.dateString >= lowerBound && $0.dateString <= upperBound }
  116. } else {
  117. return nil
  118. }
  119. }
  120. var body: some View {
  121. VStack {
  122. ScrollViewReader { scroller in
  123. ScrollView(.horizontal, showsIndicators: false) {
  124. VStack(spacing: 0) {
  125. mainChart
  126. basalChart
  127. }.onChange(of: screenHours) { _ in
  128. updateStartEndMarkers()
  129. yAxisChartData()
  130. scroller.scrollTo("MainChart", anchor: .trailing)
  131. }.onChange(of: glucose) { _ in
  132. updateStartEndMarkers()
  133. yAxisChartData()
  134. scroller.scrollTo("MainChart", anchor: .trailing)
  135. }
  136. .onChange(of: suggestion) { _ in
  137. updateStartEndMarkers()
  138. scroller.scrollTo("MainChart", anchor: .trailing)
  139. }
  140. .onChange(of: tempBasals) { _ in
  141. updateStartEndMarkers()
  142. scroller.scrollTo("MainChart", anchor: .trailing)
  143. }
  144. .onChange(of: units) { _ in
  145. yAxisChartData()
  146. }
  147. .onAppear {
  148. updateStartEndMarkers()
  149. scroller.scrollTo("MainChart", anchor: .trailing)
  150. }
  151. }
  152. }
  153. legendPanel.padding(.top, 8)
  154. }
  155. }
  156. }
  157. // MARK: - Components
  158. struct Backport<Content: View> {
  159. let content: Content
  160. }
  161. extension View {
  162. var backport: Backport<Self> { Backport(content: self) }
  163. }
  164. extension Backport {
  165. @ViewBuilder func chartXSelection(value: Binding<Date?>) -> some View {
  166. if #available(iOS 17, *) {
  167. content.chartXSelection(value: value)
  168. } else {
  169. content
  170. }
  171. }
  172. }
  173. extension MainChartView {
  174. private var mainChart: some View {
  175. VStack {
  176. Chart {
  177. drawStartRuleMark()
  178. drawEndRuleMark()
  179. drawCurrentTimeMarker()
  180. drawCarbs()
  181. drawFpus()
  182. drawBoluses()
  183. drawTempTargets()
  184. drawPredictions()
  185. drawGlucose()
  186. drawManualGlucose()
  187. /// high and low treshold lines
  188. if thresholdLines {
  189. RuleMark(y: .value("High", highGlucose * conversionFactor)).foregroundStyle(Color.loopYellow)
  190. .lineStyle(.init(lineWidth: 1, dash: [5]))
  191. RuleMark(y: .value("Low", lowGlucose * conversionFactor)).foregroundStyle(Color.loopRed)
  192. .lineStyle(.init(lineWidth: 1, dash: [5]))
  193. }
  194. /// show glucose value when hovering over it
  195. if let selectedGlucose {
  196. RuleMark(x: .value("Selection", selectedGlucose.dateString, unit: .minute))
  197. .foregroundStyle(Color.tabBar)
  198. .offset(yStart: 70)
  199. .lineStyle(.init(lineWidth: 2, dash: [5]))
  200. .annotation(position: .top) {
  201. selectionPopover
  202. }
  203. }
  204. }
  205. .id("MainChart")
  206. .onChange(of: glucose) { _ in
  207. calculatePredictions()
  208. }
  209. .onChange(of: boluses) { _ in
  210. state.roundedTotalBolus = state.calculateTINS()
  211. }
  212. .onChange(of: tempTargets) { _ in
  213. calculateTTs()
  214. }
  215. .onChange(of: didAppearTrigger) { _ in
  216. calculatePredictions()
  217. calculateTTs()
  218. }.onChange(of: suggestion) { _ in
  219. calculatePredictions()
  220. }
  221. .onReceive(
  222. Foundation.NotificationCenter.default
  223. .publisher(for: UIApplication.willEnterForegroundNotification)
  224. ) { _ in
  225. calculatePredictions()
  226. }
  227. .frame(minHeight: UIScreen.main.bounds.height * 0.2)
  228. .frame(width: fullWidth(viewWidth: screenSize.width))
  229. .chartXScale(domain: startMarker ... endMarker)
  230. .chartXAxis { mainChartXAxis }
  231. // .chartXAxis(.hidden)
  232. .chartYAxis { mainChartYAxis }
  233. .chartYScale(domain: minValue ... maxValue)
  234. .backport.chartXSelection(value: $selection)
  235. }
  236. }
  237. @ViewBuilder var selectionPopover: some View {
  238. if let sgv = selectedGlucose?.sgv {
  239. let glucoseToShow = Decimal(sgv) * conversionFactor
  240. VStack {
  241. Text(selectedGlucose?.dateString.formatted(.dateTime.hour().minute(.twoDigits)) ?? "")
  242. HStack {
  243. Text(glucoseToShow.formatted(.number.precision(units == .mmolL ? .fractionLength(1) : .fractionLength(0))))
  244. .fontWeight(.bold)
  245. .foregroundStyle(
  246. Decimal(sgv) < lowGlucose ? Color
  247. .red : (Decimal(sgv) > highGlucose ? Color.orange : Color.primary)
  248. )
  249. Text(units.rawValue).foregroundColor(.secondary)
  250. }
  251. }
  252. .padding(6)
  253. .background {
  254. RoundedRectangle(cornerRadius: 4)
  255. .fill(Color.gray.opacity(0.1))
  256. .shadow(color: .blue, radius: 2)
  257. }
  258. }
  259. }
  260. private var basalChart: some View {
  261. VStack {
  262. Chart {
  263. drawStartRuleMark()
  264. drawEndRuleMark()
  265. drawCurrentTimeMarker()
  266. drawTempBasals()
  267. drawBasalProfile()
  268. drawSuspensions()
  269. }.onChange(of: tempBasals) { _ in
  270. calculateBasals()
  271. calculateTempBasals()
  272. }
  273. .onChange(of: maxBasal) { _ in
  274. calculateBasals()
  275. calculateTempBasals()
  276. }
  277. .onChange(of: autotunedBasalProfile) { _ in
  278. calculateBasals()
  279. calculateTempBasals()
  280. }
  281. .onChange(of: didAppearTrigger) { _ in
  282. calculateBasals()
  283. calculateTempBasals()
  284. }.onChange(of: basalProfile) { _ in
  285. calculateTempBasals()
  286. }
  287. .frame(height: UIScreen.main.bounds.height * 0.08)
  288. .frame(width: fullWidth(viewWidth: screenSize.width))
  289. .chartXScale(domain: startMarker ... endMarker)
  290. .chartXAxis { basalChartXAxis }
  291. .chartYAxis { basalChartYAxis }
  292. }
  293. }
  294. var legendPanel: some View {
  295. HStack(spacing: 10) {
  296. Spacer()
  297. LegendItem(color: .loopGreen, label: "BG")
  298. LegendItem(color: .insulin, label: "IOB")
  299. LegendItem(color: .zt, label: "ZT")
  300. LegendItem(color: .loopYellow, label: "COB")
  301. LegendItem(color: .uam, label: "UAM")
  302. Spacer()
  303. }
  304. .padding(.horizontal, 10)
  305. .frame(maxWidth: .infinity)
  306. }
  307. }
  308. // MARK: - Calculations
  309. extension MainChartView {
  310. private func drawBoluses() -> some ChartContent {
  311. /// smbs in triangle form
  312. ForEach(boluses) { bolus in
  313. let bolusAmount = bolus.amount ?? 0
  314. let glucose = timeToNearestGlucose(time: bolus.timestamp.timeIntervalSince1970)
  315. let yPosition = (Decimal(glucose.sgv ?? defaultBolusPosition) * conversionFactor) + bolusOffset
  316. let size = (Config.bolusSize + CGFloat(bolusAmount) * Config.bolusScale) * 1.8
  317. return PointMark(
  318. x: .value("Time", bolus.timestamp, unit: .second),
  319. y: .value("Value", yPosition)
  320. )
  321. .symbol {
  322. Image(systemName: "arrowtriangle.down.fill").font(.system(size: size)).foregroundStyle(Color.insulin)
  323. }
  324. .annotation(position: .top) {
  325. Text(bolusFormatter.string(from: bolusAmount as NSNumber)!).font(.caption2)
  326. .foregroundStyle(Color.insulin)
  327. }
  328. }
  329. }
  330. private func drawCarbs() -> some ChartContent {
  331. /// carbs
  332. ForEach(carbsFromPersistence) { carb in
  333. let carbAmount = carb.carbs
  334. let yPosition = units == .mgdL ? 60 : 3.33
  335. PointMark(
  336. x: .value("Time", carb.date ?? Date(), unit: .second),
  337. y: .value("Value", yPosition)
  338. )
  339. .symbolSize((Config.carbsSize + CGFloat(carbAmount) * Config.carbsScale) * 10)
  340. .foregroundStyle(Color.orange)
  341. .annotation(position: .bottom) {
  342. Text(carbsFormatter.string(from: carbAmount as NSNumber)!).font(.caption2)
  343. .foregroundStyle(Color.orange)
  344. }
  345. }
  346. }
  347. private func drawFpus() -> some ChartContent {
  348. /// fpus
  349. ForEach(fpusForChart) { fpu in
  350. let fpuAmount = fpu.carbs
  351. let size = (Config.fpuSize + CGFloat(fpuAmount) * Config.carbsScale) * 1.8
  352. let yPosition = units == .mgdL ? 60 : 3.33
  353. PointMark(
  354. x: .value("Time", fpu.actualDate ?? Date(), unit: .second),
  355. y: .value("Value", yPosition)
  356. )
  357. .symbolSize(size)
  358. .foregroundStyle(Color.brown)
  359. }
  360. }
  361. private func drawGlucose() -> some ChartContent {
  362. /// glucose point mark
  363. /// filtering for high and low bounds in settings
  364. ForEach(glucose) { item in
  365. if let sgv = item.sgv {
  366. let sgvLimited = max(sgv, 0)
  367. if smooth {
  368. if sgvLimited > Int(highGlucose) {
  369. PointMark(
  370. x: .value("Time", item.dateString, unit: .second),
  371. y: .value("Value", Decimal(sgvLimited) * conversionFactor)
  372. ).foregroundStyle(Color.orange.gradient).symbolSize(25).interpolationMethod(.cardinal)
  373. } else if sgvLimited < Int(lowGlucose) {
  374. PointMark(
  375. x: .value("Time", item.dateString, unit: .second),
  376. y: .value("Value", Decimal(sgvLimited) * conversionFactor)
  377. ).foregroundStyle(Color.red.gradient).symbolSize(25).interpolationMethod(.cardinal)
  378. } else {
  379. PointMark(
  380. x: .value("Time", item.dateString, unit: .second),
  381. y: .value("Value", Decimal(sgvLimited) * conversionFactor)
  382. ).foregroundStyle(Color.green.gradient).symbolSize(25).interpolationMethod(.cardinal)
  383. }
  384. } else {
  385. if sgvLimited > Int(highGlucose) {
  386. PointMark(
  387. x: .value("Time", item.dateString, unit: .second),
  388. y: .value("Value", Decimal(sgvLimited) * conversionFactor)
  389. ).foregroundStyle(Color.orange.gradient).symbolSize(25)
  390. } else if sgvLimited < Int(lowGlucose) {
  391. PointMark(
  392. x: .value("Time", item.dateString, unit: .second),
  393. y: .value("Value", Decimal(sgvLimited) * conversionFactor)
  394. ).foregroundStyle(Color.red.gradient).symbolSize(25)
  395. } else {
  396. PointMark(
  397. x: .value("Time", item.dateString, unit: .second),
  398. y: .value("Value", Decimal(sgvLimited) * conversionFactor)
  399. ).foregroundStyle(Color.green.gradient).symbolSize(25)
  400. }
  401. }
  402. }
  403. }
  404. }
  405. private func drawPredictions() -> some ChartContent {
  406. /// predictions
  407. ForEach(Predictions, id: \.self) { info in
  408. let y = max(info.amount, 0)
  409. if info.type == .uam {
  410. LineMark(
  411. x: .value("Time", info.timestamp, unit: .second),
  412. y: .value("Value", Decimal(y) * conversionFactor),
  413. series: .value("uam", "uam")
  414. ).foregroundStyle(Color.uam).symbolSize(16)
  415. }
  416. if info.type == .cob {
  417. LineMark(
  418. x: .value("Time", info.timestamp, unit: .second),
  419. y: .value("Value", Decimal(y) * conversionFactor),
  420. series: .value("cob", "cob")
  421. ).foregroundStyle(Color.orange).symbolSize(16)
  422. }
  423. if info.type == .iob {
  424. LineMark(
  425. x: .value("Time", info.timestamp, unit: .second),
  426. y: .value("Value", Decimal(y) * conversionFactor),
  427. series: .value("iob", "iob")
  428. ).foregroundStyle(Color.insulin).symbolSize(16)
  429. }
  430. if info.type == .zt {
  431. LineMark(
  432. x: .value("Time", info.timestamp, unit: .second),
  433. y: .value("Value", Decimal(y) * conversionFactor),
  434. series: .value("zt", "zt")
  435. ).foregroundStyle(Color.zt).symbolSize(16)
  436. }
  437. }
  438. }
  439. private func drawCurrentTimeMarker() -> some ChartContent {
  440. RuleMark(
  441. x: .value(
  442. "",
  443. Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970)),
  444. unit: .second
  445. )
  446. ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
  447. }
  448. private func drawStartRuleMark() -> some ChartContent {
  449. RuleMark(
  450. x: .value(
  451. "",
  452. startMarker,
  453. unit: .second
  454. )
  455. ).foregroundStyle(Color.clear)
  456. }
  457. private func drawEndRuleMark() -> some ChartContent {
  458. RuleMark(
  459. x: .value(
  460. "",
  461. endMarker,
  462. unit: .second
  463. )
  464. ).foregroundStyle(Color.clear)
  465. }
  466. private func drawTempTargets() -> some ChartContent {
  467. /// temp targets
  468. ForEach(ChartTempTargets, id: \.self) { target in
  469. let targetLimited = min(max(target.amount, 0), upperLimit)
  470. RuleMark(
  471. xStart: .value("Start", target.start),
  472. xEnd: .value("End", target.end),
  473. y: .value("Value", targetLimited)
  474. )
  475. .foregroundStyle(Color.purple.opacity(0.5)).lineStyle(.init(lineWidth: 8))
  476. }
  477. }
  478. private func drawManualGlucose() -> some ChartContent {
  479. /// manual glucose mark
  480. ForEach(manualGlucose) { item in
  481. if let manualGlucose = item.glucose {
  482. PointMark(
  483. x: .value("Time", item.dateString, unit: .second),
  484. y: .value("Value", Decimal(manualGlucose) * conversionFactor)
  485. )
  486. .symbol {
  487. Image(systemName: "drop.fill").font(.system(size: 10)).symbolRenderingMode(.monochrome)
  488. .foregroundStyle(.red)
  489. }
  490. }
  491. }
  492. }
  493. private func drawSuspensions() -> some ChartContent {
  494. /// pump suspensions
  495. ForEach(suspensions) { suspension in
  496. let now = Date()
  497. if suspension.type == EventType.pumpSuspend {
  498. let suspensionStart = suspension.timestamp
  499. let suspensionEnd = min(
  500. suspensions
  501. .first(where: { $0.timestamp > suspension.timestamp && $0.type == EventType.pumpResume })?
  502. .timestamp ?? now,
  503. now
  504. )
  505. let basalProfileDuringSuspension = BasalProfiles.first(where: { $0.startDate <= suspensionStart })
  506. let suspensionMarkHeight = basalProfileDuringSuspension?.amount ?? 1
  507. RectangleMark(
  508. xStart: .value("start", suspensionStart),
  509. xEnd: .value("end", suspensionEnd),
  510. yStart: .value("suspend-start", 0),
  511. yEnd: .value("suspend-end", suspensionMarkHeight)
  512. )
  513. .foregroundStyle(Color.loopGray.opacity(colorScheme == .dark ? 0.3 : 0.8))
  514. }
  515. }
  516. }
  517. private func drawTempBasals() -> some ChartContent {
  518. /// temp basal rects
  519. ForEach(TempBasals) { temp in
  520. /// calculate end time of temp basal adding duration to start time
  521. let end = temp.timestamp + (temp.durationMin ?? 0).minutes.timeInterval
  522. let now = Date()
  523. /// ensure that temp basals that are set cannot exceed current date -> i.e. scheduled temp basals are not shown
  524. /// we could display scheduled temp basals with opacity etc... in the future
  525. let maxEndTime = min(end, now)
  526. /// set mark height to 0 when insulin delivery is suspended
  527. let isInsulinSuspended = suspensions
  528. .first(where: { $0.timestamp >= temp.timestamp && $0.timestamp <= maxEndTime }) != nil
  529. let rate = (temp.rate ?? 0) * (isInsulinSuspended ? 0 : 1)
  530. /// find next basal entry and if available set end of current entry to start of next entry
  531. if let nextTemp = TempBasals.first(where: { $0.timestamp > temp.timestamp }) {
  532. let nextTempStart = nextTemp.timestamp
  533. RectangleMark(
  534. xStart: .value("start", temp.timestamp),
  535. xEnd: .value("end", nextTempStart),
  536. yStart: .value("rate-start", 0),
  537. yEnd: .value("rate-end", rate)
  538. ).foregroundStyle(Color.insulin.opacity(0.2))
  539. LineMark(x: .value("Start Date", temp.timestamp), y: .value("Amount", rate))
  540. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  541. LineMark(x: .value("End Date", nextTempStart), y: .value("Amount", rate))
  542. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  543. } else {
  544. RectangleMark(
  545. xStart: .value("start", temp.timestamp),
  546. xEnd: .value("end", maxEndTime),
  547. yStart: .value("rate-start", 0),
  548. yEnd: .value("rate-end", rate)
  549. ).foregroundStyle(Color.insulin.opacity(0.2))
  550. LineMark(x: .value("Start Date", temp.timestamp), y: .value("Amount", rate))
  551. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  552. LineMark(x: .value("End Date", maxEndTime), y: .value("Amount", rate))
  553. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
  554. }
  555. }
  556. }
  557. private func drawBasalProfile() -> some ChartContent {
  558. /// dashed profile line
  559. ForEach(BasalProfiles, id: \.self) { profile in
  560. LineMark(
  561. x: .value("Start Date", profile.startDate),
  562. y: .value("Amount", profile.amount),
  563. series: .value("profile", "profile")
  564. ).lineStyle(.init(lineWidth: 2, dash: [2, 4])).foregroundStyle(Color.insulin)
  565. LineMark(
  566. x: .value("End Date", profile.endDate ?? endMarker),
  567. y: .value("Amount", profile.amount),
  568. series: .value("profile", "profile")
  569. ).lineStyle(.init(lineWidth: 2.5, dash: [2, 4])).foregroundStyle(Color.insulin)
  570. }
  571. }
  572. /// calculates the glucose value thats the nearest to parameter 'time'
  573. /// if time is later than all the arrays values return the last element of BloodGlucose
  574. private func timeToNearestGlucose(time: TimeInterval) -> BloodGlucose {
  575. /// If the glucose array is empty, return a default BloodGlucose object or handle it accordingly
  576. guard let lastGlucose = glucose.last else {
  577. return BloodGlucose(
  578. date: 0,
  579. dateString: Date(),
  580. unfiltered: nil,
  581. filtered: nil,
  582. noise: nil,
  583. type: nil
  584. )
  585. }
  586. /// If the last glucose entry is before the specified time, return the last entry
  587. if lastGlucose.dateString.timeIntervalSince1970 < time {
  588. return lastGlucose
  589. }
  590. /// Find the index of the first element in the array whose date is greater than the specified time
  591. if let nextIndex = glucose.firstIndex(where: { $0.dateString.timeIntervalSince1970 > time }) {
  592. return glucose[nextIndex]
  593. } else {
  594. /// If no such element is found, return the last element in the array
  595. return lastGlucose
  596. }
  597. }
  598. private func fullWidth(viewWidth: CGFloat) -> CGFloat {
  599. viewWidth * CGFloat(hours) / CGFloat(min(max(screenHours, 2), 24))
  600. }
  601. /// calculations for temp target bar mark
  602. private func calculateTTs() {
  603. var groupedPackages: [[TempTarget]] = []
  604. var currentPackage: [TempTarget] = []
  605. var calculatedTTs: [ChartTempTarget] = []
  606. for target in tempTargets {
  607. if target.duration > 0 {
  608. if !currentPackage.isEmpty {
  609. groupedPackages.append(currentPackage)
  610. currentPackage = []
  611. }
  612. currentPackage.append(target)
  613. } else {
  614. if let lastNonZeroTempTarget = currentPackage.last(where: { $0.duration > 0 }) {
  615. if target.createdAt >= lastNonZeroTempTarget.createdAt,
  616. target.createdAt <= lastNonZeroTempTarget.createdAt
  617. .addingTimeInterval(TimeInterval(lastNonZeroTempTarget.duration * 60))
  618. {
  619. currentPackage.append(target)
  620. }
  621. }
  622. }
  623. }
  624. // appends last package, if exists
  625. if !currentPackage.isEmpty {
  626. groupedPackages.append(currentPackage)
  627. }
  628. for package in groupedPackages {
  629. guard let firstNonZeroTarget = package.first(where: { $0.duration > 0 }) else {
  630. continue
  631. }
  632. var end = firstNonZeroTarget.createdAt.addingTimeInterval(TimeInterval(firstNonZeroTarget.duration * 60))
  633. let earliestCancelTarget = package.filter({ $0.duration == 0 }).min(by: { $0.createdAt < $1.createdAt })
  634. if let earliestCancelTarget = earliestCancelTarget {
  635. end = min(earliestCancelTarget.createdAt, end)
  636. }
  637. let now = Date()
  638. isTempTargetActive = firstNonZeroTarget.createdAt <= now && now <= end
  639. if firstNonZeroTarget.targetTop != nil {
  640. calculatedTTs
  641. .append(ChartTempTarget(
  642. amount: (firstNonZeroTarget.targetTop ?? 0) * conversionFactor,
  643. start: firstNonZeroTarget.createdAt,
  644. end: end
  645. ))
  646. }
  647. }
  648. ChartTempTargets = calculatedTTs
  649. }
  650. private func addPredictions(_ predictions: [Int], type: PredictionType, deliveredAt: Date, endMarker: Date) -> [Prediction] {
  651. var calculatedPredictions: [Prediction] = []
  652. predictions.indices.forEach { index in
  653. let predTime = Date(
  654. timeIntervalSince1970: deliveredAt.timeIntervalSince1970 + TimeInterval(index) * 5.minutes.timeInterval
  655. )
  656. if predTime.timeIntervalSince1970 < endMarker.timeIntervalSince1970 {
  657. calculatedPredictions.append(
  658. Prediction(amount: predictions[index], timestamp: predTime, type: type)
  659. )
  660. }
  661. }
  662. return calculatedPredictions
  663. }
  664. private func calculatePredictions() {
  665. guard let suggestion = suggestion, let deliveredAt = suggestion.deliverAt else { return }
  666. let uamPredictions = suggestion.predictions?.uam ?? []
  667. let iobPredictions = suggestion.predictions?.iob ?? []
  668. let cobPredictions = suggestion.predictions?.cob ?? []
  669. let ztPredictions = suggestion.predictions?.zt ?? []
  670. let uam = addPredictions(uamPredictions, type: .uam, deliveredAt: deliveredAt, endMarker: endMarker)
  671. let iob = addPredictions(iobPredictions, type: .iob, deliveredAt: deliveredAt, endMarker: endMarker)
  672. let cob = addPredictions(cobPredictions, type: .cob, deliveredAt: deliveredAt, endMarker: endMarker)
  673. let zt = addPredictions(ztPredictions, type: .zt, deliveredAt: deliveredAt, endMarker: endMarker)
  674. Predictions = uam + iob + cob + zt
  675. }
  676. private func calculateTempBasals() {
  677. let basals = tempBasals
  678. var returnTempBasalRates: [PumpHistoryEvent] = []
  679. var finished: [Int: Bool] = [:]
  680. basals.indices.forEach { i in
  681. basals.indices.forEach { j in
  682. if basals[i].timestamp == basals[j].timestamp, i != j, !(finished[i] ?? false), !(finished[j] ?? false) {
  683. let rate = basals[i].rate ?? basals[j].rate
  684. let durationMin = basals[i].durationMin ?? basals[j].durationMin
  685. finished[i] = true
  686. if rate != 0 || durationMin != 0 {
  687. returnTempBasalRates.append(
  688. PumpHistoryEvent(
  689. id: basals[i].id, type: FreeAPS.EventType.tempBasal,
  690. timestamp: basals[i].timestamp,
  691. durationMin: durationMin,
  692. rate: rate
  693. )
  694. )
  695. }
  696. }
  697. }
  698. }
  699. TempBasals = returnTempBasalRates
  700. }
  701. private func findRegularBasalPoints(
  702. timeBegin: TimeInterval,
  703. timeEnd: TimeInterval,
  704. autotuned: Bool
  705. ) -> [BasalProfile] {
  706. guard timeBegin < timeEnd else {
  707. return []
  708. }
  709. let beginDate = Date(timeIntervalSince1970: timeBegin)
  710. let calendar = Calendar.current
  711. let startOfDay = calendar.startOfDay(for: beginDate)
  712. let profile = autotuned ? autotunedBasalProfile : basalProfile
  713. let basalNormalized = profile.map {
  714. (
  715. time: startOfDay.addingTimeInterval($0.minutes.minutes.timeInterval).timeIntervalSince1970,
  716. rate: $0.rate
  717. )
  718. } + profile.map {
  719. (
  720. time: startOfDay.addingTimeInterval($0.minutes.minutes.timeInterval + 1.days.timeInterval)
  721. .timeIntervalSince1970,
  722. rate: $0.rate
  723. )
  724. } + profile.map {
  725. (
  726. time: startOfDay.addingTimeInterval($0.minutes.minutes.timeInterval + 2.days.timeInterval)
  727. .timeIntervalSince1970,
  728. rate: $0.rate
  729. )
  730. }
  731. let basalTruncatedPoints = basalNormalized.windows(ofCount: 2)
  732. .compactMap { window -> BasalProfile? in
  733. let window = Array(window)
  734. if window[0].time < timeBegin, window[1].time < timeBegin {
  735. return nil
  736. }
  737. if window[0].time < timeBegin, window[1].time >= timeBegin {
  738. let startDate = Date(timeIntervalSince1970: timeBegin)
  739. let rate = window[0].rate
  740. return BasalProfile(amount: Double(rate), isOverwritten: false, startDate: startDate)
  741. }
  742. if window[0].time >= timeBegin, window[0].time < timeEnd {
  743. let startDate = Date(timeIntervalSince1970: window[0].time)
  744. let rate = window[0].rate
  745. return BasalProfile(amount: Double(rate), isOverwritten: false, startDate: startDate)
  746. }
  747. return nil
  748. }
  749. return basalTruncatedPoints
  750. }
  751. /// update start and end marker to fix scroll update problem with x axis
  752. private func updateStartEndMarkers() {
  753. startMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 - 86400))
  754. endMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 + 10800))
  755. }
  756. private func calculateBasals() {
  757. let dayAgoTime = Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  758. let regularPoints = findRegularBasalPoints(
  759. timeBegin: dayAgoTime,
  760. timeEnd: endMarker.timeIntervalSince1970,
  761. autotuned: false
  762. )
  763. let autotunedBasalPoints = findRegularBasalPoints(
  764. timeBegin: dayAgoTime,
  765. timeEnd: endMarker.timeIntervalSince1970,
  766. autotuned: true
  767. )
  768. var totalBasal = regularPoints + autotunedBasalPoints
  769. totalBasal.sort {
  770. $0.startDate.timeIntervalSince1970 < $1.startDate.timeIntervalSince1970
  771. }
  772. var basals: [BasalProfile] = []
  773. totalBasal.indices.forEach { index in
  774. basals.append(BasalProfile(
  775. amount: totalBasal[index].amount,
  776. isOverwritten: totalBasal[index].isOverwritten,
  777. startDate: totalBasal[index].startDate,
  778. endDate: totalBasal.count > index + 1 ? totalBasal[index + 1].startDate : endMarker
  779. ))
  780. print(
  781. "Basal",
  782. totalBasal[index].startDate,
  783. totalBasal.count > index + 1 ? totalBasal[index + 1].startDate : endMarker,
  784. totalBasal[index].amount,
  785. totalBasal[index].isOverwritten
  786. )
  787. }
  788. BasalProfiles = basals
  789. }
  790. // MARK: - Chart formatting
  791. private func yAxisChartData() {
  792. let glucoseMapped = glucose.compactMap(\.glucose)
  793. guard let minGlucose = glucoseMapped.min(), let maxGlucose = glucoseMapped.max() else {
  794. // default values
  795. minValue = 45 * conversionFactor - 20 * conversionFactor
  796. maxValue = 270 * conversionFactor + 50 * conversionFactor
  797. return
  798. }
  799. minValue = Decimal(minGlucose) * conversionFactor - 20 * conversionFactor
  800. maxValue = Decimal(maxGlucose) * conversionFactor + 50 * conversionFactor
  801. debug(.default, "min \(minValue)")
  802. debug(.default, "max \(maxValue)")
  803. }
  804. private func basalChartPlotStyle(_ plotContent: ChartPlotContent) -> some View {
  805. plotContent
  806. .rotationEffect(.degrees(180))
  807. .scaleEffect(x: -1, y: 1)
  808. .chartXAxis(.hidden)
  809. }
  810. private var mainChartXAxis: some AxisContent {
  811. AxisMarks(values: .stride(by: .hour, count: screenHours == 24 ? 4 : 2)) { _ in
  812. if displayXgridLines {
  813. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  814. } else {
  815. AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
  816. }
  817. }
  818. }
  819. private var basalChartXAxis: some AxisContent {
  820. AxisMarks(values: .stride(by: .hour, count: screenHours == 24 ? 4 : 2)) { _ in
  821. if displayXgridLines {
  822. AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
  823. } else {
  824. AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
  825. }
  826. AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
  827. .font(.footnote)
  828. }
  829. }
  830. private var mainChartYAxis: some AxisContent {
  831. AxisMarks(position: .trailing) { value 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. if let glucoseValue = value.as(Double.self), glucoseValue > 0 {
  838. /// fix offset between the two charts...
  839. if units == .mmolL {
  840. AxisTick(length: 7, stroke: .init(lineWidth: 7)).foregroundStyle(Color.clear)
  841. }
  842. AxisValueLabel().font(.footnote)
  843. }
  844. }
  845. }
  846. private var basalChartYAxis: some AxisContent {
  847. AxisMarks(position: .trailing) { _ in
  848. AxisTick(length: units == .mmolL ? 25 : 27, stroke: .init(lineWidth: 4))
  849. .foregroundStyle(Color.clear).font(.footnote)
  850. }
  851. }
  852. }
  853. struct LegendItem: View {
  854. var color: Color
  855. var label: String
  856. var body: some View {
  857. Group {
  858. Circle().fill(color).frame(width: 8, height: 8)
  859. Text(label)
  860. .font(.system(size: 10, weight: .bold))
  861. .foregroundColor(color)
  862. }
  863. }
  864. }