MainChartView.swift 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046
  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 DotInfo {
  11. let rect: CGRect
  12. let value: Decimal
  13. }
  14. typealias GlucoseYRange = (minValue: Int, minY: CGFloat, maxValue: Int, maxY: CGFloat)
  15. struct MainChartView: View {
  16. private enum Config {
  17. static let endID = "End"
  18. static let basalHeight: CGFloat = 80
  19. static let topYPadding: CGFloat = 20
  20. static let bottomYPadding: CGFloat = 80
  21. static let minAdditionalWidth: CGFloat = 150
  22. static let maxGlucose = 270
  23. static let minGlucose = 45
  24. static let yLinesCount = 5
  25. static let glucoseScale: CGFloat = 2 // default 2
  26. static let bolusSize: CGFloat = 8
  27. static let bolusScale: CGFloat = 2.5
  28. static let carbsSize: CGFloat = 10
  29. static let carbsScale: CGFloat = 0.3
  30. }
  31. @Binding var glucose: [BloodGlucose]
  32. @Binding var suggestion: Suggestion?
  33. @Binding var tempBasals: [PumpHistoryEvent]
  34. @Binding var boluses: [PumpHistoryEvent]
  35. @Binding var suspensions: [PumpHistoryEvent]
  36. @Binding var hours: Int
  37. @Binding var maxBasal: Decimal
  38. @Binding var autotunedBasalProfile: [BasalProfileEntry]
  39. @Binding var basalProfile: [BasalProfileEntry]
  40. @Binding var tempTargets: [TempTarget]
  41. @Binding var carbs: [CarbsEntry]
  42. @Binding var timerDate: Date
  43. @Binding var units: GlucoseUnits
  44. @Binding var smooth: Bool
  45. @Binding var highGlucose: Decimal
  46. @Binding var lowGlucose: Decimal
  47. @Binding var screenHours: Int
  48. @Binding var displayXgridLines: Bool
  49. @Binding var displayYgridLines: Bool
  50. @State var didAppearTrigger = false
  51. @State private var glucoseDots: [CGRect] = []
  52. @State private var unSmoothedGlucoseDots: [CGRect] = []
  53. @State private var predictionDots: [PredictionType: [CGRect]] = [:]
  54. @State private var bolusDots: [DotInfo] = []
  55. @State private var bolusPath = Path()
  56. @State private var tempBasalPath = Path()
  57. @State private var regularBasalPath = Path()
  58. @State private var tempTargetsPath = Path()
  59. @State private var suspensionsPath = Path()
  60. @State private var carbsDots: [DotInfo] = []
  61. @State private var carbsPath = Path()
  62. @State private var fpuDots: [DotInfo] = []
  63. @State private var fpuPath = Path()
  64. @State private var glucoseYRange: GlucoseYRange = (0, 0, 0, 0)
  65. @State private var offset: CGFloat = 0
  66. @State private var cachedMaxBasalRate: Decimal?
  67. private let calculationQueue = DispatchQueue(label: "MainChartView.calculationQueue")
  68. private var dateFormatter: DateFormatter {
  69. let formatter = DateFormatter()
  70. formatter.timeStyle = .short
  71. return formatter
  72. }
  73. private var glucoseFormatter: NumberFormatter {
  74. let formatter = NumberFormatter()
  75. formatter.numberStyle = .decimal
  76. formatter.maximumFractionDigits = 1
  77. return formatter
  78. }
  79. private var bolusFormatter: NumberFormatter {
  80. let formatter = NumberFormatter()
  81. formatter.numberStyle = .decimal
  82. formatter.minimumIntegerDigits = 0
  83. formatter.maximumFractionDigits = 2
  84. formatter.decimalSeparator = "."
  85. return formatter
  86. }
  87. private var carbsFormatter: NumberFormatter {
  88. let formatter = NumberFormatter()
  89. formatter.numberStyle = .decimal
  90. formatter.maximumFractionDigits = 0
  91. return formatter
  92. }
  93. private var fpuFormatter: NumberFormatter {
  94. let formatter = NumberFormatter()
  95. formatter.numberStyle = .decimal
  96. formatter.maximumFractionDigits = 1
  97. formatter.decimalSeparator = "."
  98. formatter.minimumIntegerDigits = 0
  99. return formatter
  100. }
  101. @Environment(\.horizontalSizeClass) var hSizeClass
  102. @Environment(\.verticalSizeClass) var vSizeClass
  103. // MARK: - Views
  104. var body: some View {
  105. GeometryReader { geo in
  106. ZStack(alignment: .leading) {
  107. yGridView(fullSize: geo.size)
  108. mainScrollView(fullSize: geo.size)
  109. glucoseLabelsView(fullSize: geo.size)
  110. }
  111. .onChange(of: hSizeClass) { _ in
  112. update(fullSize: geo.size)
  113. }
  114. .onChange(of: vSizeClass) { _ in
  115. update(fullSize: geo.size)
  116. }
  117. .onReceive(
  118. Foundation.NotificationCenter.default
  119. .publisher(for: UIDevice.orientationDidChangeNotification)
  120. ) { _ in
  121. update(fullSize: geo.size)
  122. }
  123. }
  124. }
  125. private func mainScrollView(fullSize: CGSize) -> some View {
  126. ScrollView(.horizontal, showsIndicators: false) {
  127. ScrollViewReader { scroll in
  128. ZStack(alignment: .top) {
  129. tempTargetsView(fullSize: fullSize).drawingGroup()
  130. basalView(fullSize: fullSize).drawingGroup()
  131. mainView(fullSize: fullSize).id(Config.endID)
  132. .drawingGroup()
  133. .onChange(of: glucose) { _ in
  134. scroll.scrollTo(Config.endID, anchor: .trailing)
  135. }
  136. .onChange(of: suggestion) { _ in
  137. scroll.scrollTo(Config.endID, anchor: .trailing)
  138. }
  139. .onChange(of: tempBasals) { _ in
  140. scroll.scrollTo(Config.endID, anchor: .trailing)
  141. }
  142. .onAppear {
  143. // add trigger to the end of main queue
  144. DispatchQueue.main.async {
  145. scroll.scrollTo(Config.endID, anchor: .trailing)
  146. didAppearTrigger = true
  147. }
  148. }
  149. }
  150. }
  151. }
  152. }
  153. private func yGridView(fullSize: CGSize) -> some View {
  154. let useColour = displayYgridLines ? Color.secondary : Color.clear
  155. return ZStack {
  156. Path { path in
  157. let range = glucoseYRange
  158. let step = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
  159. for line in 0 ... Config.yLinesCount {
  160. path.move(to: CGPoint(x: 0, y: range.minY + CGFloat(line) * step))
  161. path.addLine(to: CGPoint(x: fullSize.width, y: range.minY + CGFloat(line) * step))
  162. }
  163. }.stroke(useColour, lineWidth: 0.15)
  164. // horizontal limits
  165. let range = glucoseYRange
  166. let topstep = (range.maxY - range.minY) / CGFloat(range.maxValue - range.minValue) *
  167. (CGFloat(range.maxValue) - CGFloat(highGlucose))
  168. if CGFloat(range.maxValue) > CGFloat(highGlucose) {
  169. Path { path in
  170. path.move(to: CGPoint(x: 0, y: range.minY + topstep))
  171. path.addLine(to: CGPoint(x: fullSize.width, y: range.minY + topstep))
  172. }.stroke(Color.loopYellow, lineWidth: 0.5) // .StrokeStyle(lineWidth: 0.5, dash: [5])
  173. }
  174. let yrange = glucoseYRange
  175. let bottomstep = (yrange.maxY - yrange.minY) / CGFloat(yrange.maxValue - yrange.minValue) *
  176. (CGFloat(yrange.maxValue) - CGFloat(lowGlucose))
  177. if CGFloat(yrange.minValue) < CGFloat(lowGlucose) {
  178. Path { path in
  179. path.move(to: CGPoint(x: 0, y: yrange.minY + bottomstep))
  180. path.addLine(to: CGPoint(x: fullSize.width, y: yrange.minY + bottomstep))
  181. }.stroke(Color.loopRed, lineWidth: 0.5)
  182. }
  183. }
  184. }
  185. private func glucoseLabelsView(fullSize: CGSize) -> some View {
  186. ForEach(0 ..< Config.yLinesCount + 1, id: \.self) { line -> AnyView in
  187. let range = glucoseYRange
  188. let yStep = (range.maxY - range.minY) / CGFloat(Config.yLinesCount)
  189. let valueStep = Double(range.maxValue - range.minValue) / Double(Config.yLinesCount)
  190. let value = round(Double(range.maxValue) - Double(line) * valueStep) *
  191. (units == .mmolL ? Double(GlucoseUnits.exchangeRate) : 1)
  192. return Text(glucoseFormatter.string(from: value as NSNumber)!)
  193. .position(CGPoint(x: fullSize.width - 12, y: range.minY + CGFloat(line) * yStep))
  194. .font(.caption2)
  195. .asAny()
  196. }
  197. }
  198. private func basalView(fullSize: CGSize) -> some View {
  199. ZStack {
  200. tempBasalPath.fill(Color.basal.opacity(0.5))
  201. tempBasalPath.stroke(Color.insulin, lineWidth: 1)
  202. regularBasalPath.stroke(Color.insulin, style: StrokeStyle(lineWidth: 0.7, dash: [4]))
  203. suspensionsPath.stroke(Color.loopGray.opacity(0.7), style: StrokeStyle(lineWidth: 0.7)).scaleEffect(x: 1, y: -1)
  204. suspensionsPath.fill(Color.loopGray.opacity(0.2)).scaleEffect(x: 1, y: -1)
  205. }
  206. .scaleEffect(x: 1, y: -1)
  207. .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
  208. .frame(maxHeight: Config.basalHeight)
  209. .background(Color.secondary.opacity(0.1))
  210. .onChange(of: tempBasals) { _ in
  211. calculateBasalPoints(fullSize: fullSize)
  212. }
  213. .onChange(of: suspensions) { _ in
  214. calculateSuspensions(fullSize: fullSize)
  215. }
  216. .onChange(of: maxBasal) { _ in
  217. calculateBasalPoints(fullSize: fullSize)
  218. }
  219. .onChange(of: autotunedBasalProfile) { _ in
  220. calculateBasalPoints(fullSize: fullSize)
  221. }
  222. .onChange(of: didAppearTrigger) { _ in
  223. calculateBasalPoints(fullSize: fullSize)
  224. }
  225. }
  226. private func mainView(fullSize: CGSize) -> some View {
  227. Group {
  228. VStack {
  229. ZStack {
  230. xGridView(fullSize: fullSize)
  231. carbsView(fullSize: fullSize)
  232. fpuView(fullSize: fullSize)
  233. bolusView(fullSize: fullSize)
  234. if smooth { unSmoothedGlucoseView(fullSize: fullSize) }
  235. glucoseView(fullSize: fullSize)
  236. predictionsView(fullSize: fullSize)
  237. }
  238. timeLabelsView(fullSize: fullSize)
  239. }
  240. }
  241. .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
  242. }
  243. @Environment(\.colorScheme) var colorScheme
  244. private func xGridView(fullSize: CGSize) -> some View {
  245. let useColour = displayXgridLines ? Color.secondary : Color.clear
  246. return ZStack {
  247. Path { path in
  248. for hour in 0 ..< hours + hours {
  249. let x = firstHourPosition(viewWidth: fullSize.width) +
  250. oneSecondStep(viewWidth: fullSize.width) *
  251. CGFloat(hour) * CGFloat(1.hours.timeInterval)
  252. path.move(to: CGPoint(x: x, y: 0))
  253. path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
  254. }
  255. }
  256. .stroke(useColour, lineWidth: 0.15)
  257. // .stroke(Color.secondary, lineWidth: 0.2)
  258. Path { path in // vertical timeline
  259. let x = timeToXCoordinate(timerDate.timeIntervalSince1970, fullSize: fullSize)
  260. path.move(to: CGPoint(x: x, y: 0))
  261. path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
  262. }
  263. .stroke(
  264. colorScheme == .dark ? Color.white : Color.black,
  265. style: StrokeStyle(lineWidth: 0.5, dash: [5])
  266. )
  267. }
  268. }
  269. private func timeLabelsView(fullSize: CGSize) -> some View {
  270. ZStack {
  271. // X time labels
  272. ForEach(0 ..< hours + hours) { hour in
  273. Text(dateFormatter.string(from: firstHourDate().addingTimeInterval(hour.hours.timeInterval)))
  274. .font(.caption)
  275. .position(
  276. x: firstHourPosition(viewWidth: fullSize.width) +
  277. oneSecondStep(viewWidth: fullSize.width) *
  278. CGFloat(hour) * CGFloat(1.hours.timeInterval),
  279. y: 10.0
  280. )
  281. .foregroundColor(.secondary)
  282. }
  283. }.frame(maxHeight: 20)
  284. }
  285. private func glucoseView(fullSize: CGSize) -> some View {
  286. Path { path in
  287. for rect in glucoseDots {
  288. path.addEllipse(in: rect)
  289. }
  290. }
  291. .fill(Color.loopGreen)
  292. .onChange(of: glucose) { _ in
  293. update(fullSize: fullSize)
  294. }
  295. .onChange(of: didAppearTrigger) { _ in
  296. update(fullSize: fullSize)
  297. }
  298. .onReceive(Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
  299. update(fullSize: fullSize)
  300. }
  301. }
  302. private func unSmoothedGlucoseView(fullSize: CGSize) -> some View {
  303. Path { path in
  304. var lines: [CGPoint] = []
  305. for rect in unSmoothedGlucoseDots {
  306. lines.append(CGPoint(x: rect.midX, y: rect.midY))
  307. path.addEllipse(in: rect)
  308. }
  309. path.addLines(lines)
  310. }
  311. .stroke(Color.loopGray, lineWidth: 0.5)
  312. .onChange(of: glucose) { _ in
  313. update(fullSize: fullSize)
  314. }
  315. .onChange(of: didAppearTrigger) { _ in
  316. update(fullSize: fullSize)
  317. }
  318. .onReceive(Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
  319. update(fullSize: fullSize)
  320. }
  321. }
  322. private func bolusView(fullSize: CGSize) -> some View {
  323. ZStack {
  324. bolusPath
  325. .fill(Color.insulin)
  326. bolusPath
  327. .stroke(Color.primary, lineWidth: 0.5)
  328. ForEach(bolusDots, id: \.rect.minX) { info -> AnyView in
  329. let position = CGPoint(x: info.rect.midX, y: info.rect.maxY + 8)
  330. return Text(bolusFormatter.string(from: info.value as NSNumber)!).font(.caption2)
  331. .position(position)
  332. .asAny()
  333. }
  334. }
  335. .onChange(of: boluses) { _ in
  336. calculateBolusDots(fullSize: fullSize)
  337. }
  338. .onChange(of: didAppearTrigger) { _ in
  339. calculateBolusDots(fullSize: fullSize)
  340. }
  341. }
  342. private func carbsView(fullSize: CGSize) -> some View {
  343. ZStack {
  344. carbsPath
  345. .fill(Color.loopYellow)
  346. carbsPath
  347. .stroke(Color.primary, lineWidth: 0.5)
  348. ForEach(carbsDots, id: \.rect.minX) { info -> AnyView in
  349. let position = CGPoint(x: info.rect.midX, y: info.rect.minY - 8)
  350. return Text(carbsFormatter.string(from: info.value as NSNumber)!).font(.caption2)
  351. .position(position)
  352. .asAny()
  353. }
  354. }
  355. .onChange(of: carbs) { _ in
  356. calculateCarbsDots(fullSize: fullSize)
  357. }
  358. .onChange(of: didAppearTrigger) { _ in
  359. calculateCarbsDots(fullSize: fullSize)
  360. }
  361. }
  362. private func fpuView(fullSize: CGSize) -> some View {
  363. ZStack {
  364. fpuPath
  365. .fill(Color.red)
  366. fpuPath
  367. .stroke(Color.primary, lineWidth: 0.5)
  368. ForEach(fpuDots, id: \.rect.minX) { info -> AnyView in
  369. let position = CGPoint(x: info.rect.midX, y: info.rect.minY - 8)
  370. return Text(fpuFormatter.string(from: info.value as NSNumber)!).font(.caption2)
  371. .position(position)
  372. .asAny()
  373. }
  374. }
  375. .onChange(of: carbs) { _ in
  376. calculateFPUsDots(fullSize: fullSize)
  377. }
  378. .onChange(of: didAppearTrigger) { _ in
  379. calculateFPUsDots(fullSize: fullSize)
  380. }
  381. }
  382. private func tempTargetsView(fullSize: CGSize) -> some View {
  383. ZStack {
  384. tempTargetsPath
  385. .fill(Color.tempBasal.opacity(0.5))
  386. tempTargetsPath
  387. .stroke(Color.basal.opacity(0.5), lineWidth: 1)
  388. }
  389. .onChange(of: glucose) { _ in
  390. calculateTempTargetsRects(fullSize: fullSize)
  391. }
  392. .onChange(of: tempTargets) { _ in
  393. calculateTempTargetsRects(fullSize: fullSize)
  394. }
  395. .onChange(of: didAppearTrigger) { _ in
  396. calculateTempTargetsRects(fullSize: fullSize)
  397. }
  398. }
  399. private func predictionsView(fullSize: CGSize) -> some View {
  400. Group {
  401. Path { path in
  402. for rect in predictionDots[.iob] ?? [] {
  403. path.addEllipse(in: rect)
  404. }
  405. }.fill(Color.insulin)
  406. Path { path in
  407. for rect in predictionDots[.cob] ?? [] {
  408. path.addEllipse(in: rect)
  409. }
  410. }.fill(Color.loopYellow)
  411. Path { path in
  412. for rect in predictionDots[.zt] ?? [] {
  413. path.addEllipse(in: rect)
  414. }
  415. }.fill(Color.zt)
  416. Path { path in
  417. for rect in predictionDots[.uam] ?? [] {
  418. path.addEllipse(in: rect)
  419. }
  420. }.fill(Color.uam)
  421. }
  422. .onChange(of: suggestion) { _ in
  423. update(fullSize: fullSize)
  424. }
  425. }
  426. }
  427. // MARK: - Calculations
  428. extension MainChartView {
  429. private func update(fullSize: CGSize) {
  430. calculatePredictionDots(fullSize: fullSize, type: .iob)
  431. calculatePredictionDots(fullSize: fullSize, type: .cob)
  432. calculatePredictionDots(fullSize: fullSize, type: .zt)
  433. calculatePredictionDots(fullSize: fullSize, type: .uam)
  434. calculateGlucoseDots(fullSize: fullSize)
  435. calculateUnSmoothedGlucoseDots(fullSize: fullSize)
  436. calculateBolusDots(fullSize: fullSize)
  437. calculateCarbsDots(fullSize: fullSize)
  438. calculateFPUsDots(fullSize: fullSize)
  439. calculateTempTargetsRects(fullSize: fullSize)
  440. calculateBasalPoints(fullSize: fullSize)
  441. calculateSuspensions(fullSize: fullSize)
  442. }
  443. private func calculateGlucoseDots(fullSize: CGSize) {
  444. calculationQueue.async {
  445. let dots = glucose.concurrentMap { value -> CGRect in
  446. let position = glucoseToCoordinate(value, fullSize: fullSize)
  447. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  448. }
  449. let range = self.getGlucoseYRange(fullSize: fullSize)
  450. DispatchQueue.main.async {
  451. glucoseYRange = range
  452. glucoseDots = dots
  453. }
  454. }
  455. }
  456. private func calculateUnSmoothedGlucoseDots(fullSize: CGSize) {
  457. calculationQueue.async {
  458. let dots = glucose.concurrentMap { value -> CGRect in
  459. let position = UnSmoothedGlucoseToCoordinate(value, fullSize: fullSize)
  460. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  461. }
  462. let range = self.getGlucoseYRange(fullSize: fullSize)
  463. DispatchQueue.main.async {
  464. glucoseYRange = range
  465. unSmoothedGlucoseDots = dots
  466. }
  467. }
  468. }
  469. private func calculateBolusDots(fullSize: CGSize) {
  470. calculationQueue.async {
  471. let dots = boluses.map { value -> DotInfo in
  472. let center = timeToInterpolatedPoint(value.timestamp.timeIntervalSince1970, fullSize: fullSize)
  473. let size = Config.bolusSize + CGFloat(value.amount ?? 0) * Config.bolusScale
  474. let rect = CGRect(x: center.x - size / 2, y: center.y - size / 2, width: size, height: size)
  475. return DotInfo(rect: rect, value: value.amount ?? 0)
  476. }
  477. let path = Path { path in
  478. for dot in dots {
  479. path.addEllipse(in: dot.rect)
  480. }
  481. }
  482. DispatchQueue.main.async {
  483. bolusDots = dots
  484. bolusPath = path
  485. }
  486. }
  487. }
  488. private func calculateCarbsDots(fullSize: CGSize) {
  489. calculationQueue.async {
  490. let realCarbs = carbs.filter { !($0.isFPU ?? false) }
  491. let dots = realCarbs.map { value -> DotInfo in
  492. let center = timeToInterpolatedPoint(value.createdAt.timeIntervalSince1970, fullSize: fullSize)
  493. let size = Config.carbsSize + CGFloat(value.carbs) * Config.carbsScale
  494. let rect = CGRect(x: center.x - size / 2, y: center.y - size / 2, width: size, height: size)
  495. return DotInfo(rect: rect, value: value.carbs)
  496. }
  497. let path = Path { path in
  498. for dot in dots {
  499. path.addEllipse(in: dot.rect)
  500. }
  501. }
  502. DispatchQueue.main.async {
  503. carbsDots = dots
  504. carbsPath = path
  505. }
  506. }
  507. }
  508. private func calculateFPUsDots(fullSize: CGSize) {
  509. calculationQueue.async {
  510. let fpus = carbs.filter { $0.isFPU ?? false }
  511. let dots = fpus.map { value -> DotInfo in
  512. let center = timeToInterpolatedPoint(value.createdAt.timeIntervalSince1970, fullSize: fullSize)
  513. let size = Config.carbsSize + CGFloat(value.carbs) * Config.carbsScale
  514. let rect = CGRect(x: center.x - size / 2, y: center.y - size / 2, width: size, height: size)
  515. return DotInfo(rect: rect, value: value.carbs)
  516. }
  517. let path = Path { path in
  518. for dot in dots {
  519. path.addEllipse(in: dot.rect)
  520. }
  521. }
  522. DispatchQueue.main.async {
  523. fpuDots = dots
  524. fpuPath = path
  525. }
  526. }
  527. }
  528. private func calculatePredictionDots(fullSize: CGSize, type: PredictionType) {
  529. calculationQueue.async {
  530. let values: [Int] = { () -> [Int] in
  531. switch type {
  532. case .iob:
  533. return suggestion?.predictions?.iob ?? []
  534. case .cob:
  535. return suggestion?.predictions?.cob ?? []
  536. case .zt:
  537. return suggestion?.predictions?.zt ?? []
  538. case .uam:
  539. return suggestion?.predictions?.uam ?? []
  540. }
  541. }()
  542. var index = 0
  543. let dots = values.map { value -> CGRect in
  544. let position = predictionToCoordinate(value, fullSize: fullSize, index: index)
  545. index += 1
  546. return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
  547. }
  548. DispatchQueue.main.async {
  549. predictionDots[type] = dots
  550. }
  551. }
  552. }
  553. private func calculateBasalPoints(fullSize: CGSize) {
  554. calculationQueue.async {
  555. self.cachedMaxBasalRate = nil
  556. let dayAgoTime = Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  557. let firstTempTime = (tempBasals.first?.timestamp ?? Date()).timeIntervalSince1970
  558. var lastTimeEnd = firstTempTime
  559. let firstRegularBasalPoints = findRegularBasalPoints(
  560. timeBegin: dayAgoTime,
  561. timeEnd: firstTempTime,
  562. fullSize: fullSize,
  563. autotuned: false
  564. )
  565. let tempBasalPoints = firstRegularBasalPoints + tempBasals.chunks(ofCount: 2).map { chunk -> [CGPoint] in
  566. let chunk = Array(chunk)
  567. guard chunk.count == 2, chunk[0].type == .tempBasal, chunk[1].type == .tempBasalDuration else { return [] }
  568. let timeBegin = chunk[0].timestamp.timeIntervalSince1970
  569. let timeEnd = timeBegin + (chunk[1].durationMin ?? 0).minutes.timeInterval
  570. let rateCost = Config.basalHeight / CGFloat(maxBasalRate())
  571. let x0 = timeToXCoordinate(timeBegin, fullSize: fullSize)
  572. let y0 = Config.basalHeight - CGFloat(chunk[0].rate ?? 0) * rateCost
  573. let regularPoints = findRegularBasalPoints(
  574. timeBegin: lastTimeEnd,
  575. timeEnd: timeBegin,
  576. fullSize: fullSize,
  577. autotuned: false
  578. )
  579. lastTimeEnd = timeEnd
  580. return regularPoints + [CGPoint(x: x0, y: y0)]
  581. }.flatMap { $0 }
  582. let tempBasalPath = Path { path in
  583. var yPoint: CGFloat = Config.basalHeight
  584. path.move(to: CGPoint(x: 0, y: yPoint))
  585. for point in tempBasalPoints {
  586. path.addLine(to: CGPoint(x: point.x, y: yPoint))
  587. path.addLine(to: point)
  588. yPoint = point.y
  589. }
  590. let lastPoint = lastBasalPoint(fullSize: fullSize)
  591. path.addLine(to: CGPoint(x: lastPoint.x, y: yPoint))
  592. path.addLine(to: CGPoint(x: lastPoint.x, y: Config.basalHeight))
  593. path.addLine(to: CGPoint(x: 0, y: Config.basalHeight))
  594. }
  595. let endDateTime = dayAgoTime + 1.days.timeInterval + 6.hours.timeInterval
  596. let autotunedBasalPoints = findRegularBasalPoints(
  597. timeBegin: dayAgoTime,
  598. timeEnd: endDateTime,
  599. fullSize: fullSize,
  600. autotuned: true
  601. )
  602. let autotunedBasalPath = Path { path in
  603. var yPoint: CGFloat = Config.basalHeight
  604. path.move(to: CGPoint(x: -50, y: yPoint))
  605. for point in autotunedBasalPoints {
  606. path.addLine(to: CGPoint(x: point.x, y: yPoint))
  607. path.addLine(to: point)
  608. yPoint = point.y
  609. }
  610. path.addLine(to: CGPoint(x: timeToXCoordinate(endDateTime, fullSize: fullSize), y: yPoint))
  611. }
  612. DispatchQueue.main.async {
  613. self.tempBasalPath = tempBasalPath
  614. self.regularBasalPath = autotunedBasalPath
  615. }
  616. }
  617. }
  618. private func calculateSuspensions(fullSize: CGSize) {
  619. calculationQueue.async {
  620. var rects = suspensions.windows(ofCount: 2).map { window -> CGRect? in
  621. let window = Array(window)
  622. guard window[0].type == .pumpSuspend, window[1].type == .pumpResume else { return nil }
  623. let x0 = self.timeToXCoordinate(window[0].timestamp.timeIntervalSince1970, fullSize: fullSize)
  624. let x1 = self.timeToXCoordinate(window[1].timestamp.timeIntervalSince1970, fullSize: fullSize)
  625. return CGRect(x: x0, y: 0, width: x1 - x0, height: Config.basalHeight * 0.7)
  626. }
  627. let firstRec = self.suspensions.first.flatMap { event -> CGRect? in
  628. guard event.type == .pumpResume else { return nil }
  629. let tbrTime = self.tempBasals.last { $0.timestamp < event.timestamp }
  630. .map { $0.timestamp.timeIntervalSince1970 + TimeInterval($0.durationMin ?? 0) * 60 } ?? Date()
  631. .addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  632. let x0 = self.timeToXCoordinate(tbrTime, fullSize: fullSize)
  633. let x1 = self.timeToXCoordinate(event.timestamp.timeIntervalSince1970, fullSize: fullSize)
  634. return CGRect(
  635. x: x0,
  636. y: 0,
  637. width: x1 - x0,
  638. height: Config.basalHeight * 0.7
  639. )
  640. }
  641. let lastRec = self.suspensions.last.flatMap { event -> CGRect? in
  642. guard event.type == .pumpSuspend else { return nil }
  643. let tbrTimeX = self.tempBasals.first { $0.timestamp > event.timestamp }
  644. .map { self.timeToXCoordinate($0.timestamp.timeIntervalSince1970, fullSize: fullSize) }
  645. let x0 = self.timeToXCoordinate(event.timestamp.timeIntervalSince1970, fullSize: fullSize)
  646. let x1 = tbrTimeX ?? self.fullGlucoseWidth(viewWidth: fullSize.width) + self
  647. .additionalWidth(viewWidth: fullSize.width)
  648. return CGRect(x: x0, y: 0, width: x1 - x0, height: Config.basalHeight * 0.7)
  649. }
  650. rects.append(firstRec)
  651. rects.append(lastRec)
  652. let path = Path { path in
  653. path.addRects(rects.compactMap { $0 })
  654. }
  655. DispatchQueue.main.async {
  656. suspensionsPath = path
  657. }
  658. }
  659. }
  660. private func maxBasalRate() -> Decimal {
  661. if let cached = cachedMaxBasalRate {
  662. return cached
  663. }
  664. let maxRegularBasalRate = max(
  665. basalProfile.map(\.rate).max() ?? maxBasal,
  666. autotunedBasalProfile.map(\.rate).max() ?? maxBasal
  667. )
  668. var maxTempBasalRate = tempBasals.compactMap(\.rate).max() ?? maxRegularBasalRate
  669. if maxTempBasalRate == 0 {
  670. maxTempBasalRate = maxRegularBasalRate
  671. }
  672. cachedMaxBasalRate = max(maxTempBasalRate, maxRegularBasalRate)
  673. return cachedMaxBasalRate ?? maxBasal
  674. }
  675. private func calculateTempTargetsRects(fullSize: CGSize) {
  676. calculationQueue.async {
  677. var rects = tempTargets.map { tempTarget -> CGRect in
  678. let x0 = timeToXCoordinate(tempTarget.createdAt.timeIntervalSince1970, fullSize: fullSize)
  679. let y0 = glucoseToYCoordinate(Int(tempTarget.targetTop ?? 0), fullSize: fullSize)
  680. let x1 = timeToXCoordinate(
  681. tempTarget.createdAt.timeIntervalSince1970 + Int(tempTarget.duration).minutes.timeInterval,
  682. fullSize: fullSize
  683. )
  684. let y1 = glucoseToYCoordinate(Int(tempTarget.targetBottom ?? 0), fullSize: fullSize)
  685. return CGRect(
  686. x: x0,
  687. y: y0 - 3,
  688. width: x1 - x0,
  689. height: y1 - y0 + 6
  690. )
  691. }
  692. if rects.count > 1 {
  693. rects = rects.reduce([]) { result, rect -> [CGRect] in
  694. guard var last = result.last else { return [rect] }
  695. if last.origin.x + last.width > rect.origin.x {
  696. last.size.width = rect.origin.x - last.origin.x
  697. }
  698. var res = Array(result.dropLast())
  699. res.append(contentsOf: [last, rect])
  700. return res
  701. }
  702. }
  703. let path = Path { path in
  704. path.addRects(rects)
  705. }
  706. DispatchQueue.main.async {
  707. tempTargetsPath = path
  708. }
  709. }
  710. }
  711. private func findRegularBasalPoints(
  712. timeBegin: TimeInterval,
  713. timeEnd: TimeInterval,
  714. fullSize: CGSize,
  715. autotuned: Bool
  716. ) -> [CGPoint] {
  717. guard timeBegin < timeEnd else {
  718. return []
  719. }
  720. let beginDate = Date(timeIntervalSince1970: timeBegin)
  721. let calendar = Calendar.current
  722. let startOfDay = calendar.startOfDay(for: beginDate)
  723. let profile = autotuned ? autotunedBasalProfile : basalProfile
  724. let basalNormalized = profile.map {
  725. (
  726. time: startOfDay.addingTimeInterval($0.minutes.minutes.timeInterval).timeIntervalSince1970,
  727. rate: $0.rate
  728. )
  729. } + profile.map {
  730. (
  731. time: startOfDay.addingTimeInterval($0.minutes.minutes.timeInterval + 1.days.timeInterval).timeIntervalSince1970,
  732. rate: $0.rate
  733. )
  734. } + profile.map {
  735. (
  736. time: startOfDay.addingTimeInterval($0.minutes.minutes.timeInterval + 2.days.timeInterval).timeIntervalSince1970,
  737. rate: $0.rate
  738. )
  739. }
  740. let basalTruncatedPoints = basalNormalized.windows(ofCount: 2)
  741. .compactMap { window -> CGPoint? in
  742. let window = Array(window)
  743. if window[0].time < timeBegin, window[1].time < timeBegin {
  744. return nil
  745. }
  746. let rateCost = Config.basalHeight / CGFloat(maxBasalRate())
  747. if window[0].time < timeBegin, window[1].time >= timeBegin {
  748. let x = timeToXCoordinate(timeBegin, fullSize: fullSize)
  749. let y = Config.basalHeight - CGFloat(window[0].rate) * rateCost
  750. return CGPoint(x: x, y: y)
  751. }
  752. if window[0].time >= timeBegin, window[0].time < timeEnd {
  753. let x = timeToXCoordinate(window[0].time, fullSize: fullSize)
  754. let y = Config.basalHeight - CGFloat(window[0].rate) * rateCost
  755. return CGPoint(x: x, y: y)
  756. }
  757. return nil
  758. }
  759. return basalTruncatedPoints
  760. }
  761. private func lastBasalPoint(fullSize: CGSize) -> CGPoint {
  762. let lastBasal = Array(tempBasals.suffix(2))
  763. guard lastBasal.count == 2 else {
  764. return CGPoint(x: timeToXCoordinate(Date().timeIntervalSince1970, fullSize: fullSize), y: Config.basalHeight)
  765. }
  766. let endBasalTime = lastBasal[0].timestamp.timeIntervalSince1970 + (lastBasal[1].durationMin?.minutes.timeInterval ?? 0)
  767. let rateCost = Config.basalHeight / CGFloat(maxBasalRate())
  768. let x = timeToXCoordinate(endBasalTime, fullSize: fullSize)
  769. let y = Config.basalHeight - CGFloat(lastBasal[0].rate ?? 0) * rateCost
  770. return CGPoint(x: x, y: y)
  771. }
  772. private func fullGlucoseWidth(viewWidth: CGFloat) -> CGFloat {
  773. viewWidth * CGFloat(hours) / CGFloat(max(screenHours, 6))
  774. }
  775. private func additionalWidth(viewWidth: CGFloat) -> CGFloat {
  776. guard let predictions = suggestion?.predictions,
  777. let deliveredAt = suggestion?.deliverAt,
  778. let last = glucose.last
  779. else {
  780. return Config.minAdditionalWidth
  781. }
  782. let iob = predictions.iob?.count ?? 0
  783. let zt = predictions.zt?.count ?? 0
  784. let cob = predictions.cob?.count ?? 0
  785. let uam = predictions.uam?.count ?? 0
  786. let max = [iob, zt, cob, uam].max() ?? 0
  787. let lastDeltaTime = last.dateString.timeIntervalSince(deliveredAt)
  788. let additionalTime = CGFloat(TimeInterval(max) * 5.minutes.timeInterval - lastDeltaTime)
  789. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  790. return Swift.max(additionalTime * oneSecondWidth, Config.minAdditionalWidth)
  791. }
  792. private func oneSecondStep(viewWidth: CGFloat) -> CGFloat {
  793. viewWidth / (CGFloat(max(screenHours, 6)) * CGFloat(1.hours.timeInterval))
  794. }
  795. private func maxPredValue() -> Int? {
  796. [
  797. suggestion?.predictions?.cob ?? [],
  798. suggestion?.predictions?.iob ?? [],
  799. suggestion?.predictions?.zt ?? [],
  800. suggestion?.predictions?.uam ?? []
  801. ]
  802. .flatMap { $0 }
  803. .max()
  804. }
  805. private func minPredValue() -> Int? {
  806. [
  807. suggestion?.predictions?.cob ?? [],
  808. suggestion?.predictions?.iob ?? [],
  809. suggestion?.predictions?.zt ?? [],
  810. suggestion?.predictions?.uam ?? []
  811. ]
  812. .flatMap { $0 }
  813. .min()
  814. }
  815. private func maxTargetValue() -> Int? {
  816. tempTargets.map { $0.targetTop ?? 0 }.filter { $0 > 0 }.max().map(Int.init)
  817. }
  818. private func minTargetValue() -> Int? {
  819. tempTargets.map { $0.targetBottom ?? 0 }.filter { $0 > 0 }.min().map(Int.init)
  820. }
  821. private func glucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
  822. let x = timeToXCoordinate(glucoseEntry.dateString.timeIntervalSince1970, fullSize: fullSize)
  823. let y = glucoseToYCoordinate(glucoseEntry.glucose ?? 0, fullSize: fullSize)
  824. return CGPoint(x: x, y: y)
  825. }
  826. private func UnSmoothedGlucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
  827. let x = timeToXCoordinate(glucoseEntry.dateString.timeIntervalSince1970, fullSize: fullSize)
  828. let glucoseValue: Decimal = glucoseEntry.unfiltered ?? Decimal(glucoseEntry.glucose ?? 0)
  829. let y = glucoseToYCoordinate(Int(glucoseValue), fullSize: fullSize)
  830. return CGPoint(x: x, y: y)
  831. }
  832. private func predictionToCoordinate(_ pred: Int, fullSize: CGSize, index: Int) -> CGPoint {
  833. guard let deliveredAt = suggestion?.deliverAt else {
  834. return .zero
  835. }
  836. let predTime = deliveredAt.timeIntervalSince1970 + TimeInterval(index) * 5.minutes.timeInterval
  837. let x = timeToXCoordinate(predTime, fullSize: fullSize)
  838. let y = glucoseToYCoordinate(pred, fullSize: fullSize)
  839. return CGPoint(x: x, y: y)
  840. }
  841. private func timeToXCoordinate(_ time: TimeInterval, fullSize: CGSize) -> CGFloat {
  842. let xOffset = -Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
  843. let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
  844. let x = CGFloat(time + xOffset) * stepXFraction
  845. return x
  846. }
  847. private func glucoseToYCoordinate(_ glucoseValue: Int, fullSize: CGSize) -> CGFloat {
  848. let topYPaddint = Config.topYPadding + Config.basalHeight
  849. let bottomYPadding = Config.bottomYPadding
  850. let (minValue, maxValue) = minMaxYValues()
  851. let stepYFraction = (fullSize.height - topYPaddint - bottomYPadding) / CGFloat(maxValue - minValue)
  852. let yOffset = CGFloat(minValue) * stepYFraction
  853. let y = fullSize.height - CGFloat(glucoseValue) * stepYFraction + yOffset - bottomYPadding
  854. return y
  855. }
  856. private func timeToInterpolatedPoint(_ time: TimeInterval, fullSize: CGSize) -> CGPoint {
  857. var nextIndex = 0
  858. for (index, value) in glucose.enumerated() {
  859. if value.dateString.timeIntervalSince1970 > time {
  860. nextIndex = index
  861. break
  862. }
  863. }
  864. let x = timeToXCoordinate(time, fullSize: fullSize)
  865. guard nextIndex > 0 else {
  866. let lastY = glucoseToYCoordinate(glucose.last?.glucose ?? 0, fullSize: fullSize)
  867. return CGPoint(x: x, y: lastY)
  868. }
  869. let prevX = timeToXCoordinate(glucose[nextIndex - 1].dateString.timeIntervalSince1970, fullSize: fullSize)
  870. let prevY = glucoseToYCoordinate(glucose[nextIndex - 1].glucose ?? 0, fullSize: fullSize)
  871. let nextX = timeToXCoordinate(glucose[nextIndex].dateString.timeIntervalSince1970, fullSize: fullSize)
  872. let nextY = glucoseToYCoordinate(glucose[nextIndex].glucose ?? 0, fullSize: fullSize)
  873. let delta = nextX - prevX
  874. let fraction = (x - prevX) / delta
  875. return pointInLine(CGPoint(x: prevX, y: prevY), CGPoint(x: nextX, y: nextY), fraction)
  876. }
  877. private func minMaxYValues() -> (min: Int, max: Int) {
  878. var maxValue = glucose.compactMap(\.glucose).max() ?? Config.maxGlucose
  879. if let maxPredValue = maxPredValue() {
  880. maxValue = max(maxValue, maxPredValue)
  881. }
  882. if let maxTargetValue = maxTargetValue() {
  883. maxValue = max(maxValue, maxTargetValue)
  884. }
  885. var minValue = glucose.compactMap(\.glucose).min() ?? Config.minGlucose
  886. if let minPredValue = minPredValue() {
  887. minValue = min(minValue, minPredValue)
  888. }
  889. if let minTargetValue = minTargetValue() {
  890. minValue = min(minValue, minTargetValue)
  891. }
  892. if minValue == maxValue {
  893. minValue = Config.minGlucose
  894. maxValue = Config.maxGlucose
  895. }
  896. // fix the grah y-axis as long as the min and max BG values are within set borders
  897. if minValue > Config.minGlucose {
  898. minValue = Config.minGlucose
  899. }
  900. if maxValue < Config.maxGlucose {
  901. maxValue = Config.maxGlucose
  902. }
  903. return (min: minValue, max: maxValue)
  904. }
  905. private func getGlucoseYRange(fullSize: CGSize) -> GlucoseYRange {
  906. let topYPaddint = Config.topYPadding + Config.basalHeight
  907. let bottomYPadding = Config.bottomYPadding
  908. let (minValue, maxValue) = minMaxYValues()
  909. let stepYFraction = (fullSize.height - topYPaddint - bottomYPadding) / CGFloat(maxValue - minValue)
  910. let yOffset = CGFloat(minValue) * stepYFraction
  911. let maxY = fullSize.height - CGFloat(minValue) * stepYFraction + yOffset - bottomYPadding
  912. let minY = fullSize.height - CGFloat(maxValue) * stepYFraction + yOffset - bottomYPadding
  913. return (minValue: minValue, minY: minY, maxValue: maxValue, maxY: maxY)
  914. }
  915. private func firstHourDate() -> Date {
  916. let firstDate = Date().addingTimeInterval(-1.days.timeInterval)
  917. return firstDate.dateTruncated(from: .minute)!
  918. }
  919. private func firstHourPosition(viewWidth: CGFloat) -> CGFloat {
  920. let firstDate = Date().addingTimeInterval(-1.days.timeInterval)
  921. let firstHour = firstHourDate()
  922. let lastDeltaTime = firstHour.timeIntervalSince(firstDate)
  923. let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
  924. return oneSecondWidth * CGFloat(lastDeltaTime)
  925. }
  926. }