BGChartModel.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. // LoopFollow
  2. // BGChartModel.swift
  3. import Foundation
  4. import SwiftUI
  5. /// Interaction state shared between the main chart (which owns the gestures)
  6. /// and the small overview chart (which shows a viewport box and navigates on
  7. /// tap). Kept separate from BGChartModel so high-frequency pan/zoom writes
  8. /// don't invalidate views that only observe the data.
  9. final class BGChartInteraction: ObservableObject {
  10. /// Date at the leading (left) edge of the main chart's visible window.
  11. @Published var scrollPosition: Date
  12. /// Length of the visible x-axis window in seconds.
  13. @Published var visibleSeconds: TimeInterval
  14. /// True while the main chart should keep auto-scrolling to "now"; cleared
  15. /// when the user pans back into history, re-armed when they return to the edge.
  16. @Published var followLatest: Bool = true
  17. init() {
  18. let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value)
  19. visibleSeconds = seconds
  20. scrollPosition = Date().addingTimeInterval(-seconds * 0.7)
  21. }
  22. /// Maps the persisted zoom scale (24 h divided by the scale factor) to a
  23. /// visible-window length.
  24. static func visibleSeconds(forScale scale: Double) -> TimeInterval {
  25. guard scale > 0 else { return 6 * 3600 }
  26. return min(max(24 * 3600 / scale, 15 * 60), 24 * 3600)
  27. }
  28. /// Persists the current zoom back into the stored scale representation.
  29. func persistZoom() {
  30. Storage.shared.chartScaleX.value = 24 * 3600 / visibleSeconds
  31. }
  32. }
  33. final class BGChartModel: ObservableObject {
  34. struct BGPoint: Identifiable {
  35. let date: Date
  36. let value: Double
  37. let color: Color
  38. var id: Double { date.timeIntervalSince1970 }
  39. }
  40. struct TreatmentPoint: Identifiable {
  41. let date: Date
  42. let value: Double
  43. let sgv: Double
  44. let label: String
  45. let pillText: String
  46. /// Where the symbol is drawn. Equals `date` unless `spread` nudged it
  47. /// left to keep a crowded run of treatments from stacking up.
  48. var drawnDate: Date
  49. var id: Double { date.timeIntervalSince1970 }
  50. init(date: Date, value: Double, sgv: Double, label: String, pillText: String) {
  51. self.date = date
  52. self.value = value
  53. self.sgv = sgv
  54. self.label = label
  55. self.pillText = pillText
  56. drawnDate = date
  57. }
  58. }
  59. struct BasalStep: Identifiable {
  60. let start: Date
  61. let end: Date
  62. let rate: Double
  63. var id: TimeInterval { start.timeIntervalSince1970 }
  64. }
  65. struct ScheduledBasalPoint: Identifiable {
  66. let date: Date
  67. let rate: Double
  68. var id: TimeInterval { date.timeIntervalSince1970 }
  69. }
  70. struct BandRect: Identifiable {
  71. let start: Date
  72. let end: Date
  73. let yBottom: Double
  74. let yTop: Double
  75. let label: String
  76. let pillText: String
  77. var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
  78. }
  79. struct ConePoint: Identifiable {
  80. let date: Date
  81. let yMin: Double
  82. let yMax: Double
  83. var id: Double { date.timeIntervalSince1970 }
  84. }
  85. /// A maximal stretch of consecutive BG readings sharing one range color.
  86. /// Each run renders as a single LineMark series; runs share their boundary
  87. /// point so the line stays visually continuous across color changes.
  88. /// (One series per run — tens per day — instead of one per segment, which
  89. /// was a Swift Charts layout hotspot at hundreds of series.)
  90. struct BGRun: Identifiable {
  91. let id: Int
  92. let color: Color
  93. let points: [BGPoint]
  94. }
  95. @Published var bg: [BGPoint] = []
  96. @Published var bgRuns: [BGRun] = []
  97. @Published var yesterday: [BGPoint] = []
  98. @Published var prediction: [BGPoint] = []
  99. @Published var ztPrediction: [BGPoint] = []
  100. @Published var iobPrediction: [BGPoint] = []
  101. @Published var cobPrediction: [BGPoint] = []
  102. @Published var uamPrediction: [BGPoint] = []
  103. /// Prediction cone band (min/max envelope). Set by updateOpenAPSPredictionDisplay;
  104. /// preserved across rebuild() since it has no source array on the view controller.
  105. /// The didSet keeps the canvas generation in sync for call sites that assign the
  106. /// cone directly without triggering a rebuild.
  107. @Published var cone: [ConePoint] = [] {
  108. didSet { generation &+= 1 }
  109. }
  110. @Published var basal: [BasalStep] = []
  111. @Published var basalScheduled: [ScheduledBasalPoint] = []
  112. @Published var boluses: [TreatmentPoint] = []
  113. @Published var carbs: [TreatmentPoint] = []
  114. @Published var smbs: [TreatmentPoint] = []
  115. @Published var bgChecks: [TreatmentPoint] = []
  116. @Published var suspends: [TreatmentPoint] = []
  117. @Published var resumes: [TreatmentPoint] = []
  118. @Published var sensorStarts: [TreatmentPoint] = []
  119. @Published var notes: [TreatmentPoint] = []
  120. @Published var overrides: [BandRect] = []
  121. @Published var tempTargets: [BandRect] = []
  122. // Backend-aware band colors: Loop draws overrides green / temp targets purple,
  123. // Trio (and other OpenAPS backends) use the inverse. Mirrors TreatmentGraphColors.
  124. @Published var overrideColor: Color = .green
  125. @Published var tempTargetColor: Color = .purple
  126. @Published var maxBG: Double = 250
  127. @Published var maxBasal: Double = 5
  128. @Published var lowLine: Double = 70
  129. @Published var highLine: Double = 180
  130. @Published var domainStart: Date = .init(timeIntervalSince1970: 0)
  131. @Published var domainEnd: Date = .init(timeIntervalSince1970: 0)
  132. @Published var now: Date = .init()
  133. @Published var diaMarkers: [Date] = []
  134. @Published var midnightMarkers: [Date] = []
  135. @Published var thirtyMinMark: Date? = nil
  136. @Published var ninetyMinMark: Date? = nil
  137. /// Shared pan/zoom/follow state (see BGChartInteraction). A separate object so
  138. /// per-frame gesture writes don't invalidate views that only observe the data.
  139. let interaction = BGChartInteraction()
  140. /// Monotonic data version. Bumped whenever chart data changes; the chart
  141. /// canvases use it (instead of comparing arrays) to decide whether a
  142. /// re-layout is needed, so panning — which changes none of the data — can
  143. /// provably skip their bodies.
  144. private(set) var generation: Int = 0
  145. private var rebuildScheduled = false
  146. @Published var showLines: Bool = true
  147. @Published var showDots: Bool = true
  148. @Published var showDIA: Bool = true
  149. @Published var show30Min: Bool = false
  150. @Published var show90Min: Bool = false
  151. @Published var showMidnight: Bool = false
  152. @Published var smallGraphTreatments: Bool = true
  153. private static let doseFormatter: NumberFormatter = {
  154. let nf = NumberFormatter()
  155. nf.locale = .current
  156. nf.numberStyle = .decimal
  157. nf.usesGroupingSeparator = false
  158. nf.minimumIntegerDigits = 0
  159. nf.minimumFractionDigits = 0
  160. nf.maximumFractionDigits = 2
  161. return nf
  162. }()
  163. private func formatDose(_ value: Double) -> String {
  164. Self.doseFormatter.string(from: NSNumber(value: value)) ?? String(value)
  165. }
  166. /// Formatter for the time line at the bottom of every selection pill.
  167. /// Recreated on each rebuild so 12/24-hour and graph-time-zone settings apply.
  168. private var pillTimeFormatter = BGChartModel.makePillTimeFormatter()
  169. private static func makePillTimeFormatter() -> DateFormatter {
  170. let df = DateFormatter()
  171. df.setLocalizedDateFormatFromTemplate(dateTimeUtils.is24Hour() ? "HH:mm" : "hh:mm")
  172. if Storage.shared.graphTimeZoneEnabled.value,
  173. let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
  174. {
  175. df.timeZone = tz
  176. }
  177. return df
  178. }
  179. /// The pill's time line for a given point in time.
  180. func pillTimeString(for date: Date) -> String {
  181. pillTimeFormatter.string(from: date)
  182. }
  183. /// Nightscout remote-command error notes embed a JSON payload after
  184. /// the human-readable message ("Error text {\"bolus-entry\": 1.5, ...}").
  185. /// Returns the message plus a compact summary of the payload, or nil when
  186. /// the note contains no JSON.
  187. private static func extractMessage(from note: String) -> String? {
  188. guard let jsonStartIndex = note.range(of: "{\"")?.lowerBound else {
  189. return nil
  190. }
  191. let errorMessage = String(note[..<jsonStartIndex])
  192. .trimmingCharacters(in: .whitespacesAndNewlines)
  193. var actionContext = ""
  194. if let jsonData = String(note[jsonStartIndex...]).data(using: .utf8),
  195. let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
  196. {
  197. var actionParts: [String] = []
  198. if let bolusAmount = json["bolus-entry"] as? Double {
  199. actionParts.append("Bolus: \(bolusAmount) U")
  200. }
  201. if let carbsAmount = json["carbs-entry"] as? Double {
  202. actionParts.append("Carbs: \(carbsAmount) g")
  203. }
  204. if let absorptionTime = json["absorption-time"] as? Double {
  205. actionParts.append("Absorption: \(absorptionTime) hrs")
  206. }
  207. if let otp = json["otp"] as? String {
  208. actionParts.append("OTP: \(otp)")
  209. }
  210. if let enteredBy = json["entered-by"] as? String {
  211. actionParts.append("From: \(enteredBy)")
  212. }
  213. if !actionParts.isEmpty {
  214. actionContext = " [" + actionParts.joined(separator: ", ") + "]"
  215. }
  216. }
  217. let finalMessage = errorMessage + actionContext
  218. return finalMessage.isEmpty ? nil : finalMessage
  219. }
  220. private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color {
  221. if Double(sgv) >= thresholds.high {
  222. return .yellow
  223. } else if Double(sgv) <= thresholds.low {
  224. return .red
  225. } else {
  226. return .green
  227. }
  228. }
  229. /// Groups consecutive same-colored readings into line runs (see BGRun).
  230. /// The segment between two points takes the color of the earlier point.
  231. private static func makeRuns(_ points: [BGPoint]) -> [BGRun] {
  232. guard let first = points.first else { return [] }
  233. var runs: [BGRun] = []
  234. var runColor = first.color
  235. var runPoints: [BGPoint] = [first]
  236. for pt in points.dropFirst() {
  237. runPoints.append(pt)
  238. if pt.color != runColor {
  239. runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
  240. runPoints = [pt]
  241. runColor = pt.color
  242. }
  243. }
  244. if runPoints.count > 1 {
  245. runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
  246. }
  247. return runs
  248. }
  249. /// Minimum drawn spacing between two treatments of the same population, and
  250. /// the furthest a treatment may be moved from its true time to reach it.
  251. /// Boluses and SMBs share a y-anchor and symbol footprint, so they are
  252. /// decluttered as one population; carbs carry a wider "30 3h" label, so
  253. /// they need — and are allowed — more room.
  254. private enum Spread {
  255. static let bolusGap: TimeInterval = 240
  256. static let bolusShift: TimeInterval = 240
  257. static let carbGap: TimeInterval = 250
  258. static let carbShift: TimeInterval = 250
  259. }
  260. /// Nudges crowded treatments left so their symbols don't stack. The newest
  261. /// point in a run keeps its true time and earlier ones give way, never by
  262. /// more than `maxShift` — so a treatment is at most `maxShift` from where it
  263. /// really happened, and an isolated one never moves at all.
  264. private static func spread(_ points: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> [TreatmentPoint] {
  265. var out = points.sorted { $0.date < $1.date }
  266. spreadSorted(&out, minGap: minGap, maxShift: maxShift)
  267. return out
  268. }
  269. /// Spreads two treatment kinds as a single population — a bolus dot and an
  270. /// SMB triangle drawn at the same minute overlap just like two dots would —
  271. /// then hands each kind back its own points.
  272. private static func spreadTogether(_ first: [TreatmentPoint], _ second: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> ([TreatmentPoint], [TreatmentPoint]) {
  273. let tagged = (first.map { (isFirst: true, point: $0) } + second.map { (isFirst: false, point: $0) })
  274. .sorted { $0.point.date < $1.point.date }
  275. var points = tagged.map(\.point)
  276. spreadSorted(&points, minGap: minGap, maxShift: maxShift)
  277. var outFirst: [TreatmentPoint] = []
  278. var outSecond: [TreatmentPoint] = []
  279. for (tag, point) in zip(tagged, points) {
  280. if tag.isFirst {
  281. outFirst.append(point)
  282. } else {
  283. outSecond.append(point)
  284. }
  285. }
  286. return (outFirst, outSecond)
  287. }
  288. /// `points` must be sorted ascending by `date`.
  289. private static func spreadSorted(_ out: inout [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) {
  290. guard out.count > 1 else { return }
  291. // Walking left from the newest point, each point yields to its right
  292. // neighbor until it hits its own left bound (`date - maxShift`).
  293. var clamped = [Bool](repeating: false, count: out.count)
  294. for i in stride(from: out.count - 2, through: 0, by: -1) {
  295. let wanted = out[i + 1].drawnDate.addingTimeInterval(-minGap)
  296. guard out[i].drawnDate > wanted else { continue }
  297. let leftBound = out[i].date.addingTimeInterval(-maxShift)
  298. if wanted <= leftBound {
  299. out[i].drawnDate = leftBound
  300. clamped[i] = true
  301. } else {
  302. out[i].drawnDate = wanted
  303. }
  304. }
  305. // A chain of clamped points was squeezed against its left bound and may
  306. // have piled up there (several same-time treatments all land at
  307. // date - maxShift). Re-space each chain evenly between that bound and
  308. // the first point to its right that still had room.
  309. var i = 0
  310. while i < out.count - 1 {
  311. guard clamped[i] else {
  312. i += 1
  313. continue
  314. }
  315. var last = i
  316. while clamped[last + 1] {
  317. last += 1
  318. }
  319. let anchorIndex = last + 1
  320. let anchor = out[anchorIndex].drawnDate
  321. let leftBound = out[i].date.addingTimeInterval(-maxShift)
  322. let spacing = anchor.timeIntervalSince(leftBound) / Double(anchorIndex - i)
  323. for k in i ... last {
  324. let ideal = anchor.addingTimeInterval(-spacing * Double(anchorIndex - k))
  325. let boundK = out[k].date.addingTimeInterval(-maxShift)
  326. out[k].drawnDate = min(max(ideal, boundK), out[k].date)
  327. }
  328. i = anchorIndex + 1
  329. }
  330. }
  331. /// Schedules a rebuild, coalescing bursts: a refresh cycle calls a dozen
  332. /// update*Graph() entry points back-to-back, and rebuilding once per
  333. /// runloop turn is enough.
  334. func rebuild() {
  335. guard !rebuildScheduled else {
  336. return
  337. }
  338. rebuildScheduled = true
  339. DispatchQueue.main.async { [weak self] in
  340. guard let self else { return }
  341. self.rebuildScheduled = false
  342. self.performRebuild()
  343. }
  344. }
  345. private func performRebuild() {
  346. guard let vc = MainViewController.shared else { return }
  347. pillTimeFormatter = Self.makePillTimeFormatter()
  348. let maxBGValue = Double(vc.calculateMaxBgGraphValue())
  349. maxBG = max(maxBGValue, Storage.shared.minBGScale.value)
  350. // Same thresholds the stats and main header use: fixed 70–180 / 70–140
  351. // for the TIR/TITR range modes, the user's lines for custom mode.
  352. let thresholds = UnitSettingsStore.shared.effectiveThresholds()
  353. lowLine = thresholds.low
  354. highLine = thresholds.high
  355. showLines = Storage.shared.showLines.value
  356. showDots = Storage.shared.showDots.value
  357. showDIA = Storage.shared.showDIALines.value
  358. show30Min = Storage.shared.show30MinLine.value
  359. show90Min = Storage.shared.show90MinLine.value
  360. showMidnight = Storage.shared.showMidnightLines.value
  361. smallGraphTreatments = Storage.shared.smallGraphTreatments.value
  362. let isLoop = Storage.shared.device.value == "Loop"
  363. overrideColor = isLoop ? .green : .purple
  364. tempTargetColor = isLoop ? .purple : .green
  365. // Clamp plotted BG to the display range (see #600); color is still
  366. // keyed off the true reading.
  367. let minDisplay = globalVariables.minDisplayGlucose
  368. let maxDisplay = globalVariables.maxDisplayGlucose
  369. func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) }
  370. bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) }
  371. bgRuns = Self.makeRuns(bg)
  372. // Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots.
  373. if Storage.shared.showYesterdayLine.value {
  374. yesterday = vc.yesterdayBGData.map {
  375. BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: Color(.systemGray).opacity(0.4))
  376. }
  377. } else {
  378. yesterday = []
  379. }
  380. prediction = vc.predictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  381. ztPrediction = vc.ztPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  382. iobPrediction = vc.iobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  383. cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  384. uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  385. let bolusPoints = vc.bolusData.map {
  386. let dose = self.formatDose($0.value)
  387. return TreatmentPoint(
  388. date: Date(timeIntervalSince1970: $0.date),
  389. value: $0.value,
  390. sgv: Double($0.sgv),
  391. label: dose,
  392. pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  393. )
  394. }
  395. carbs = Self.spread(vc.carbData.map {
  396. let grams = Int($0.value)
  397. var label = "\(grams)"
  398. if $0.absorptionTime > 0, Storage.shared.showAbsorption.value {
  399. label += " \($0.absorptionTime / 60)h"
  400. }
  401. return TreatmentPoint(
  402. date: Date(timeIntervalSince1970: $0.date),
  403. value: $0.value,
  404. sgv: Double($0.sgv),
  405. label: label,
  406. pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  407. )
  408. }, minGap: Spread.carbGap, maxShift: Spread.carbShift)
  409. let smbPoints = vc.smbData.map {
  410. let dose = self.formatDose($0.value)
  411. return TreatmentPoint(
  412. date: Date(timeIntervalSince1970: $0.date),
  413. value: $0.value,
  414. sgv: Double($0.sgv),
  415. label: dose,
  416. pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  417. )
  418. }
  419. (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift)
  420. bgChecks = vc.bgCheckData.map {
  421. TreatmentPoint(
  422. date: Date(timeIntervalSince1970: $0.date),
  423. value: Double($0.sgv),
  424. sgv: Double($0.sgv),
  425. label: "",
  426. pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  427. )
  428. }
  429. suspends = vc.suspendGraphData.map {
  430. TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
  431. }
  432. resumes = vc.resumeGraphData.map {
  433. TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
  434. }
  435. sensorStarts = vc.sensorStartGraphData.map {
  436. TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Sensor Start\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
  437. }
  438. notes = vc.noteGraphData.map {
  439. TreatmentPoint(
  440. date: Date(timeIntervalSince1970: $0.date),
  441. value: Double($0.sgv),
  442. sgv: Double($0.sgv),
  443. label: $0.note,
  444. pillText: "\(Self.extractMessage(from: $0.note) ?? $0.note)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  445. )
  446. }
  447. basalScheduled = vc.basalScheduleData.map {
  448. ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate)
  449. }
  450. var steps: [BasalStep] = []
  451. let sortedBasal = vc.basalData.sorted { $0.date < $1.date }
  452. for i in 0 ..< sortedBasal.count {
  453. let start = sortedBasal[i].date
  454. let end = i + 1 < sortedBasal.count
  455. ? sortedBasal[i + 1].date
  456. : dateTimeUtils.getNowTimeIntervalUTC()
  457. guard end > start else { continue }
  458. steps.append(BasalStep(
  459. start: Date(timeIntervalSince1970: start),
  460. end: Date(timeIntervalSince1970: end),
  461. rate: sortedBasal[i].basalRate
  462. ))
  463. }
  464. basal = steps
  465. let computedMaxBasal = steps.map(\.rate).max() ?? 0
  466. maxBasal = max(computedMaxBasal, Storage.shared.minBasalScale.value)
  467. let yTop = maxBG - 5
  468. let yBottom = maxBG - 25
  469. overrides = vc.overrideGraphData.map {
  470. let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines)
  471. let displayName = overrideName.isEmpty ? "Override" : overrideName
  472. return BandRect(
  473. start: Date(timeIntervalSince1970: $0.date),
  474. end: Date(timeIntervalSince1970: $0.endDate),
  475. yBottom: yBottom,
  476. yTop: yTop,
  477. label: displayName,
  478. pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  479. )
  480. }
  481. tempTargets = vc.tempTargetGraphData.map {
  482. let target = $0.correctionRange.first.map { String($0) } ?? ""
  483. // Temp targets render at the BG level they target (±5 mg/dL);
  484. // only overrides live in the top strip.
  485. let yCenter = Double($0.correctionRange.first ?? 0)
  486. return BandRect(
  487. start: Date(timeIntervalSince1970: $0.date),
  488. end: Date(timeIntervalSince1970: $0.endDate),
  489. yBottom: yCenter - 5,
  490. yTop: yCenter + 5,
  491. label: "Temp Target",
  492. pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  493. )
  494. }
  495. let currentNow = Date(timeIntervalSince1970: dateTimeUtils.getNowTimeIntervalUTC())
  496. now = currentNow
  497. let hoursBack = TimeInterval(Storage.shared.downloadDays.value * 24 * 3600)
  498. domainStart = currentNow.addingTimeInterval(-hoursBack)
  499. // Everything drawn in the future (predictions, cone, override/temp-target
  500. // bands) is bounded by the "Hours of Prediction" setting, so the scale
  501. // domain ends there too. The 15-minute floor keeps room for follow
  502. // mode's right-side padding when predictions are set to 0 (and matches
  503. // the floor Overrides.swift uses for future bands).
  504. let hoursForward = max(Storage.shared.predictionToLoad.value, 0.25) * 3600
  505. domainEnd = currentNow.addingTimeInterval(hoursForward)
  506. // 30/90 min lookback markers
  507. thirtyMinMark = currentNow.addingTimeInterval(-1800)
  508. ninetyMinMark = currentNow.addingTimeInterval(-5400)
  509. // DIA markers: every hour going back for 6 hours
  510. var dia: [Date] = []
  511. for i in 1 ... 6 {
  512. dia.append(currentNow.addingTimeInterval(TimeInterval(-i * 3600)))
  513. }
  514. diaMarkers = dia
  515. // Midnight markers: every local midnight within domain
  516. var midnights: [Date] = []
  517. let calendar: Calendar = {
  518. var cal = Calendar(identifier: .gregorian)
  519. if Storage.shared.graphTimeZoneEnabled.value,
  520. let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
  521. {
  522. cal.timeZone = tz
  523. }
  524. return cal
  525. }()
  526. var cursor = calendar.startOfDay(for: domainStart)
  527. while cursor <= domainEnd {
  528. if cursor >= domainStart {
  529. midnights.append(cursor)
  530. }
  531. guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
  532. cursor = next
  533. }
  534. midnightMarkers = midnights
  535. generation &+= 1
  536. }
  537. }