BGChartModel.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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. private static let doseFormatter: NumberFormatter = {
  153. let nf = NumberFormatter()
  154. nf.locale = .current
  155. nf.numberStyle = .decimal
  156. nf.usesGroupingSeparator = false
  157. nf.minimumIntegerDigits = 0
  158. nf.minimumFractionDigits = 0
  159. nf.maximumFractionDigits = 2
  160. return nf
  161. }()
  162. private func formatDose(_ value: Double) -> String {
  163. Self.doseFormatter.string(from: NSNumber(value: value)) ?? String(value)
  164. }
  165. /// Formatter for the time line at the bottom of every selection pill.
  166. /// Recreated on each rebuild so 12/24-hour and graph-time-zone settings apply.
  167. private var pillTimeFormatter = BGChartModel.makePillTimeFormatter()
  168. private static func makePillTimeFormatter() -> DateFormatter {
  169. let df = DateFormatter()
  170. df.setLocalizedDateFormatFromTemplate(dateTimeUtils.is24Hour() ? "HH:mm" : "hh:mm")
  171. if Storage.shared.graphTimeZoneEnabled.value,
  172. let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
  173. {
  174. df.timeZone = tz
  175. }
  176. return df
  177. }
  178. /// The pill's time line for a given point in time.
  179. func pillTimeString(for date: Date) -> String {
  180. pillTimeFormatter.string(from: date)
  181. }
  182. /// Nightscout remote-command error notes embed a JSON payload after
  183. /// the human-readable message ("Error text {\"bolus-entry\": 1.5, ...}").
  184. /// Returns the message plus a compact summary of the payload, or nil when
  185. /// the note contains no JSON.
  186. private static func extractMessage(from note: String) -> String? {
  187. guard let jsonStartIndex = note.range(of: "{\"")?.lowerBound else {
  188. return nil
  189. }
  190. let errorMessage = String(note[..<jsonStartIndex])
  191. .trimmingCharacters(in: .whitespacesAndNewlines)
  192. var actionContext = ""
  193. if let jsonData = String(note[jsonStartIndex...]).data(using: .utf8),
  194. let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
  195. {
  196. var actionParts: [String] = []
  197. if let bolusAmount = json["bolus-entry"] as? Double {
  198. actionParts.append("Bolus: \(bolusAmount) U")
  199. }
  200. if let carbsAmount = json["carbs-entry"] as? Double {
  201. actionParts.append("Carbs: \(carbsAmount) g")
  202. }
  203. if let absorptionTime = json["absorption-time"] as? Double {
  204. actionParts.append("Absorption: \(absorptionTime) hrs")
  205. }
  206. if let otp = json["otp"] as? String {
  207. actionParts.append("OTP: \(otp)")
  208. }
  209. if let enteredBy = json["entered-by"] as? String {
  210. actionParts.append("From: \(enteredBy)")
  211. }
  212. if !actionParts.isEmpty {
  213. actionContext = " [" + actionParts.joined(separator: ", ") + "]"
  214. }
  215. }
  216. let finalMessage = errorMessage + actionContext
  217. return finalMessage.isEmpty ? nil : finalMessage
  218. }
  219. private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color {
  220. if Double(sgv) >= thresholds.high {
  221. return .yellow
  222. } else if Double(sgv) <= thresholds.low {
  223. return .red
  224. } else {
  225. return .green
  226. }
  227. }
  228. /// Groups consecutive same-colored readings into line runs (see BGRun).
  229. /// The segment between two points takes the color of the earlier point.
  230. private static func makeRuns(_ points: [BGPoint]) -> [BGRun] {
  231. guard let first = points.first else { return [] }
  232. var runs: [BGRun] = []
  233. var runColor = first.color
  234. var runPoints: [BGPoint] = [first]
  235. for pt in points.dropFirst() {
  236. runPoints.append(pt)
  237. if pt.color != runColor {
  238. runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
  239. runPoints = [pt]
  240. runColor = pt.color
  241. }
  242. }
  243. if runPoints.count > 1 {
  244. runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
  245. }
  246. return runs
  247. }
  248. /// Minimum drawn spacing between two treatments of the same population, and
  249. /// the furthest a treatment may be moved from its true time to reach it.
  250. /// Boluses and SMBs share a y-anchor and symbol footprint, so they are
  251. /// decluttered as one population; carbs carry a wider "30 3h" label, so
  252. /// they need — and are allowed — more room.
  253. private enum Spread {
  254. static let bolusGap: TimeInterval = 240
  255. static let bolusShift: TimeInterval = 240
  256. static let carbGap: TimeInterval = 250
  257. static let carbShift: TimeInterval = 250
  258. }
  259. /// Nudges crowded treatments left so their symbols don't stack. The newest
  260. /// point in a run keeps its true time and earlier ones give way, never by
  261. /// more than `maxShift` — so a treatment is at most `maxShift` from where it
  262. /// really happened, and an isolated one never moves at all.
  263. private static func spread(_ points: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> [TreatmentPoint] {
  264. var out = points.sorted { $0.date < $1.date }
  265. spreadSorted(&out, minGap: minGap, maxShift: maxShift)
  266. return out
  267. }
  268. /// Spreads two treatment kinds as a single population — a bolus dot and an
  269. /// SMB triangle drawn at the same minute overlap just like two dots would —
  270. /// then hands each kind back its own points.
  271. private static func spreadTogether(_ first: [TreatmentPoint], _ second: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> ([TreatmentPoint], [TreatmentPoint]) {
  272. let tagged = (first.map { (isFirst: true, point: $0) } + second.map { (isFirst: false, point: $0) })
  273. .sorted { $0.point.date < $1.point.date }
  274. var points = tagged.map(\.point)
  275. spreadSorted(&points, minGap: minGap, maxShift: maxShift)
  276. var outFirst: [TreatmentPoint] = []
  277. var outSecond: [TreatmentPoint] = []
  278. for (tag, point) in zip(tagged, points) {
  279. if tag.isFirst {
  280. outFirst.append(point)
  281. } else {
  282. outSecond.append(point)
  283. }
  284. }
  285. return (outFirst, outSecond)
  286. }
  287. /// `points` must be sorted ascending by `date`.
  288. private static func spreadSorted(_ out: inout [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) {
  289. guard out.count > 1 else { return }
  290. // Walking left from the newest point, each point yields to its right
  291. // neighbor until it hits its own left bound (`date - maxShift`).
  292. var clamped = [Bool](repeating: false, count: out.count)
  293. for i in stride(from: out.count - 2, through: 0, by: -1) {
  294. let wanted = out[i + 1].drawnDate.addingTimeInterval(-minGap)
  295. guard out[i].drawnDate > wanted else { continue }
  296. let leftBound = out[i].date.addingTimeInterval(-maxShift)
  297. if wanted <= leftBound {
  298. out[i].drawnDate = leftBound
  299. clamped[i] = true
  300. } else {
  301. out[i].drawnDate = wanted
  302. }
  303. }
  304. // A chain of clamped points was squeezed against its left bound and may
  305. // have piled up there (several same-time treatments all land at
  306. // date - maxShift). Re-space each chain evenly between that bound and
  307. // the first point to its right that still had room.
  308. var i = 0
  309. while i < out.count - 1 {
  310. guard clamped[i] else {
  311. i += 1
  312. continue
  313. }
  314. var last = i
  315. while clamped[last + 1] {
  316. last += 1
  317. }
  318. let anchorIndex = last + 1
  319. let anchor = out[anchorIndex].drawnDate
  320. let leftBound = out[i].date.addingTimeInterval(-maxShift)
  321. let spacing = anchor.timeIntervalSince(leftBound) / Double(anchorIndex - i)
  322. for k in i ... last {
  323. let ideal = anchor.addingTimeInterval(-spacing * Double(anchorIndex - k))
  324. let boundK = out[k].date.addingTimeInterval(-maxShift)
  325. out[k].drawnDate = min(max(ideal, boundK), out[k].date)
  326. }
  327. i = anchorIndex + 1
  328. }
  329. }
  330. /// Schedules a rebuild, coalescing bursts: a refresh cycle calls a dozen
  331. /// update*Graph() entry points back-to-back, and rebuilding once per
  332. /// runloop turn is enough.
  333. func rebuild() {
  334. guard !rebuildScheduled else {
  335. return
  336. }
  337. rebuildScheduled = true
  338. DispatchQueue.main.async { [weak self] in
  339. guard let self else { return }
  340. self.rebuildScheduled = false
  341. self.performRebuild()
  342. }
  343. }
  344. private func performRebuild() {
  345. guard let vc = MainViewController.shared else { return }
  346. pillTimeFormatter = Self.makePillTimeFormatter()
  347. let maxBGValue = Double(vc.calculateMaxBgGraphValue())
  348. maxBG = max(maxBGValue, Storage.shared.minBGScale.value)
  349. // Same thresholds the stats and main header use: fixed 70–180 / 70–140
  350. // for the TIR/TITR range modes, the user's lines for custom mode.
  351. let thresholds = UnitSettingsStore.shared.effectiveThresholds()
  352. lowLine = thresholds.low
  353. highLine = thresholds.high
  354. showLines = Storage.shared.showLines.value
  355. showDots = Storage.shared.showDots.value
  356. showDIA = Storage.shared.showDIALines.value
  357. show30Min = Storage.shared.show30MinLine.value
  358. show90Min = Storage.shared.show90MinLine.value
  359. showMidnight = Storage.shared.showMidnightLines.value
  360. let isLoop = Storage.shared.device.value == "Loop"
  361. overrideColor = isLoop ? .green : .purple
  362. tempTargetColor = isLoop ? .purple : .green
  363. // Clamp plotted BG to the display range (see #600); color is still
  364. // keyed off the true reading.
  365. let minDisplay = globalVariables.minDisplayGlucose
  366. let maxDisplay = globalVariables.maxDisplayGlucose
  367. func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) }
  368. bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) }
  369. bgRuns = Self.makeRuns(bg)
  370. // Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots.
  371. if Storage.shared.showYesterdayLine.value {
  372. yesterday = vc.yesterdayBGData.map {
  373. BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: Color(.systemGray).opacity(0.4))
  374. }
  375. } else {
  376. yesterday = []
  377. }
  378. prediction = vc.predictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  379. ztPrediction = vc.ztPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  380. iobPrediction = vc.iobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  381. cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  382. uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
  383. let bolusPoints = vc.bolusData.map {
  384. let dose = self.formatDose($0.value)
  385. return TreatmentPoint(
  386. date: Date(timeIntervalSince1970: $0.date),
  387. value: $0.value,
  388. sgv: Double($0.sgv),
  389. label: dose,
  390. pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  391. )
  392. }
  393. carbs = Self.spread(vc.carbData.map {
  394. let grams = Int($0.value)
  395. var label = "\(grams)"
  396. if $0.absorptionTime > 0, Storage.shared.showAbsorption.value {
  397. label += " \($0.absorptionTime / 60)h"
  398. }
  399. return TreatmentPoint(
  400. date: Date(timeIntervalSince1970: $0.date),
  401. value: $0.value,
  402. sgv: Double($0.sgv),
  403. label: label,
  404. pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  405. )
  406. }, minGap: Spread.carbGap, maxShift: Spread.carbShift)
  407. let smbPoints = vc.smbData.map {
  408. let dose = self.formatDose($0.value)
  409. return TreatmentPoint(
  410. date: Date(timeIntervalSince1970: $0.date),
  411. value: $0.value,
  412. sgv: Double($0.sgv),
  413. label: dose,
  414. pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  415. )
  416. }
  417. (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift)
  418. bgChecks = vc.bgCheckData.map {
  419. TreatmentPoint(
  420. date: Date(timeIntervalSince1970: $0.date),
  421. value: Double($0.sgv),
  422. sgv: Double($0.sgv),
  423. label: "",
  424. pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  425. )
  426. }
  427. suspends = vc.suspendGraphData.map {
  428. TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
  429. }
  430. resumes = vc.resumeGraphData.map {
  431. TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
  432. }
  433. sensorStarts = vc.sensorStartGraphData.map {
  434. 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)))")
  435. }
  436. notes = vc.noteGraphData.map {
  437. TreatmentPoint(
  438. date: Date(timeIntervalSince1970: $0.date),
  439. value: Double($0.sgv),
  440. sgv: Double($0.sgv),
  441. label: $0.note,
  442. pillText: "\(Self.extractMessage(from: $0.note) ?? $0.note)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  443. )
  444. }
  445. basalScheduled = vc.basalScheduleData.map {
  446. ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate)
  447. }
  448. var steps: [BasalStep] = []
  449. let sortedBasal = vc.basalData.sorted { $0.date < $1.date }
  450. for i in 0 ..< sortedBasal.count {
  451. let start = sortedBasal[i].date
  452. let end = i + 1 < sortedBasal.count
  453. ? sortedBasal[i + 1].date
  454. : dateTimeUtils.getNowTimeIntervalUTC()
  455. guard end > start else { continue }
  456. steps.append(BasalStep(
  457. start: Date(timeIntervalSince1970: start),
  458. end: Date(timeIntervalSince1970: end),
  459. rate: sortedBasal[i].basalRate
  460. ))
  461. }
  462. basal = steps
  463. let computedMaxBasal = steps.map(\.rate).max() ?? 0
  464. maxBasal = max(computedMaxBasal, Storage.shared.minBasalScale.value)
  465. let yTop = maxBG - 5
  466. let yBottom = maxBG - 25
  467. overrides = vc.overrideGraphData.map {
  468. let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines)
  469. let displayName = overrideName.isEmpty ? "Override" : overrideName
  470. return BandRect(
  471. start: Date(timeIntervalSince1970: $0.date),
  472. end: Date(timeIntervalSince1970: $0.endDate),
  473. yBottom: yBottom,
  474. yTop: yTop,
  475. label: displayName,
  476. pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  477. )
  478. }
  479. tempTargets = vc.tempTargetGraphData.map {
  480. let target = $0.correctionRange.first.map { String($0) } ?? ""
  481. return BandRect(
  482. start: Date(timeIntervalSince1970: $0.date),
  483. end: Date(timeIntervalSince1970: $0.endDate),
  484. yBottom: yBottom,
  485. yTop: yTop,
  486. label: "Temp Target",
  487. pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
  488. )
  489. }
  490. let currentNow = Date(timeIntervalSince1970: dateTimeUtils.getNowTimeIntervalUTC())
  491. now = currentNow
  492. let hoursBack = TimeInterval(Storage.shared.downloadDays.value * 24 * 3600)
  493. domainStart = currentNow.addingTimeInterval(-hoursBack)
  494. // Everything drawn in the future (predictions, cone, override/temp-target
  495. // bands) is bounded by the "Hours of Prediction" setting, so the scale
  496. // domain ends there too. The 15-minute floor keeps room for follow
  497. // mode's right-side padding when predictions are set to 0 (and matches
  498. // the floor Overrides.swift uses for future bands).
  499. let hoursForward = max(Storage.shared.predictionToLoad.value, 0.25) * 3600
  500. domainEnd = currentNow.addingTimeInterval(hoursForward)
  501. // 30/90 min lookback markers
  502. thirtyMinMark = currentNow.addingTimeInterval(-1800)
  503. ninetyMinMark = currentNow.addingTimeInterval(-5400)
  504. // DIA markers: every hour going back for 6 hours
  505. var dia: [Date] = []
  506. for i in 1 ... 6 {
  507. dia.append(currentNow.addingTimeInterval(TimeInterval(-i * 3600)))
  508. }
  509. diaMarkers = dia
  510. // Midnight markers: every local midnight within domain
  511. var midnights: [Date] = []
  512. let calendar: Calendar = {
  513. var cal = Calendar(identifier: .gregorian)
  514. if Storage.shared.graphTimeZoneEnabled.value,
  515. let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
  516. {
  517. cal.timeZone = tz
  518. }
  519. return cal
  520. }()
  521. var cursor = calendar.startOfDay(for: domainStart)
  522. while cursor <= domainEnd {
  523. if cursor >= domainStart {
  524. midnights.append(cursor)
  525. }
  526. guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
  527. cursor = next
  528. }
  529. midnightMarkers = midnights
  530. generation &+= 1
  531. }
  532. }