| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595 |
- // LoopFollow
- // BGChartModel.swift
- import Foundation
- import SwiftUI
- /// Interaction state shared between the main chart (which owns the gestures)
- /// and the small overview chart (which shows a viewport box and navigates on
- /// tap). Kept separate from BGChartModel so high-frequency pan/zoom writes
- /// don't invalidate views that only observe the data.
- final class BGChartInteraction: ObservableObject {
- /// Date at the leading (left) edge of the main chart's visible window.
- @Published var scrollPosition: Date
- /// Length of the visible x-axis window in seconds.
- @Published var visibleSeconds: TimeInterval
- /// True while the main chart should keep auto-scrolling to "now"; cleared
- /// when the user pans back into history, re-armed when they return to the edge.
- @Published var followLatest: Bool = true
- init() {
- let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value)
- visibleSeconds = seconds
- scrollPosition = Date().addingTimeInterval(-seconds * 0.7)
- }
- /// Maps the persisted zoom scale (24 h divided by the scale factor) to a
- /// visible-window length.
- static func visibleSeconds(forScale scale: Double) -> TimeInterval {
- guard scale > 0 else { return 6 * 3600 }
- return min(max(24 * 3600 / scale, 15 * 60), 24 * 3600)
- }
- /// Persists the current zoom back into the stored scale representation.
- func persistZoom() {
- Storage.shared.chartScaleX.value = 24 * 3600 / visibleSeconds
- }
- }
- final class BGChartModel: ObservableObject {
- struct BGPoint: Identifiable {
- let date: Date
- let value: Double
- let color: Color
- var id: Double { date.timeIntervalSince1970 }
- }
- struct TreatmentPoint: Identifiable {
- let date: Date
- let value: Double
- let sgv: Double
- let label: String
- let pillText: String
- /// Where the symbol is drawn. Equals `date` unless `spread` nudged it
- /// left to keep a crowded run of treatments from stacking up.
- var drawnDate: Date
- var id: Double { date.timeIntervalSince1970 }
- init(date: Date, value: Double, sgv: Double, label: String, pillText: String) {
- self.date = date
- self.value = value
- self.sgv = sgv
- self.label = label
- self.pillText = pillText
- drawnDate = date
- }
- }
- struct BasalStep: Identifiable {
- let start: Date
- let end: Date
- let rate: Double
- var id: TimeInterval { start.timeIntervalSince1970 }
- }
- struct ScheduledBasalPoint: Identifiable {
- let date: Date
- let rate: Double
- var id: TimeInterval { date.timeIntervalSince1970 }
- }
- struct BandRect: Identifiable {
- let start: Date
- let end: Date
- let yBottom: Double
- let yTop: Double
- let label: String
- let pillText: String
- var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
- }
- struct ConePoint: Identifiable {
- let date: Date
- let yMin: Double
- let yMax: Double
- var id: Double { date.timeIntervalSince1970 }
- }
- /// A maximal stretch of consecutive BG readings sharing one range color.
- /// Each run renders as a single LineMark series; runs share their boundary
- /// point so the line stays visually continuous across color changes.
- /// (One series per run — tens per day — instead of one per segment, which
- /// was a Swift Charts layout hotspot at hundreds of series.)
- struct BGRun: Identifiable {
- let id: Int
- let color: Color
- let points: [BGPoint]
- }
- @Published var bg: [BGPoint] = []
- @Published var bgRuns: [BGRun] = []
- @Published var yesterday: [BGPoint] = []
- @Published var prediction: [BGPoint] = []
- @Published var ztPrediction: [BGPoint] = []
- @Published var iobPrediction: [BGPoint] = []
- @Published var cobPrediction: [BGPoint] = []
- @Published var uamPrediction: [BGPoint] = []
- /// Prediction cone band (min/max envelope). Set by updateOpenAPSPredictionDisplay;
- /// preserved across rebuild() since it has no source array on the view controller.
- /// The didSet keeps the canvas generation in sync for call sites that assign the
- /// cone directly without triggering a rebuild.
- @Published var cone: [ConePoint] = [] {
- didSet { generation &+= 1 }
- }
- @Published var basal: [BasalStep] = []
- @Published var basalScheduled: [ScheduledBasalPoint] = []
- @Published var boluses: [TreatmentPoint] = []
- @Published var carbs: [TreatmentPoint] = []
- @Published var smbs: [TreatmentPoint] = []
- @Published var bgChecks: [TreatmentPoint] = []
- @Published var suspends: [TreatmentPoint] = []
- @Published var resumes: [TreatmentPoint] = []
- @Published var sensorStarts: [TreatmentPoint] = []
- @Published var notes: [TreatmentPoint] = []
- @Published var overrides: [BandRect] = []
- @Published var tempTargets: [BandRect] = []
- // Backend-aware band colors: Loop draws overrides green / temp targets purple,
- // Trio (and other OpenAPS backends) use the inverse. Mirrors TreatmentGraphColors.
- @Published var overrideColor: Color = .green
- @Published var tempTargetColor: Color = .purple
- @Published var maxBG: Double = 250
- @Published var maxBasal: Double = 5
- @Published var lowLine: Double = 70
- @Published var highLine: Double = 180
- @Published var domainStart: Date = .init(timeIntervalSince1970: 0)
- @Published var domainEnd: Date = .init(timeIntervalSince1970: 0)
- @Published var now: Date = .init()
- @Published var diaMarkers: [Date] = []
- @Published var midnightMarkers: [Date] = []
- @Published var thirtyMinMark: Date? = nil
- @Published var ninetyMinMark: Date? = nil
- /// Shared pan/zoom/follow state (see BGChartInteraction). A separate object so
- /// per-frame gesture writes don't invalidate views that only observe the data.
- let interaction = BGChartInteraction()
- /// Monotonic data version. Bumped whenever chart data changes; the chart
- /// canvases use it (instead of comparing arrays) to decide whether a
- /// re-layout is needed, so panning — which changes none of the data — can
- /// provably skip their bodies.
- private(set) var generation: Int = 0
- private var rebuildScheduled = false
- @Published var showLines: Bool = true
- @Published var showDots: Bool = true
- @Published var showDIA: Bool = true
- @Published var show30Min: Bool = false
- @Published var show90Min: Bool = false
- @Published var showMidnight: Bool = false
- private static let doseFormatter: NumberFormatter = {
- let nf = NumberFormatter()
- nf.locale = .current
- nf.numberStyle = .decimal
- nf.usesGroupingSeparator = false
- nf.minimumIntegerDigits = 0
- nf.minimumFractionDigits = 0
- nf.maximumFractionDigits = 2
- return nf
- }()
- private func formatDose(_ value: Double) -> String {
- Self.doseFormatter.string(from: NSNumber(value: value)) ?? String(value)
- }
- /// Formatter for the time line at the bottom of every selection pill.
- /// Recreated on each rebuild so 12/24-hour and graph-time-zone settings apply.
- private var pillTimeFormatter = BGChartModel.makePillTimeFormatter()
- private static func makePillTimeFormatter() -> DateFormatter {
- let df = DateFormatter()
- df.setLocalizedDateFormatFromTemplate(dateTimeUtils.is24Hour() ? "HH:mm" : "hh:mm")
- if Storage.shared.graphTimeZoneEnabled.value,
- let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
- {
- df.timeZone = tz
- }
- return df
- }
- /// The pill's time line for a given point in time.
- func pillTimeString(for date: Date) -> String {
- pillTimeFormatter.string(from: date)
- }
- /// Nightscout remote-command error notes embed a JSON payload after
- /// the human-readable message ("Error text {\"bolus-entry\": 1.5, ...}").
- /// Returns the message plus a compact summary of the payload, or nil when
- /// the note contains no JSON.
- private static func extractMessage(from note: String) -> String? {
- guard let jsonStartIndex = note.range(of: "{\"")?.lowerBound else {
- return nil
- }
- let errorMessage = String(note[..<jsonStartIndex])
- .trimmingCharacters(in: .whitespacesAndNewlines)
- var actionContext = ""
- if let jsonData = String(note[jsonStartIndex...]).data(using: .utf8),
- let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
- {
- var actionParts: [String] = []
- if let bolusAmount = json["bolus-entry"] as? Double {
- actionParts.append("Bolus: \(bolusAmount) U")
- }
- if let carbsAmount = json["carbs-entry"] as? Double {
- actionParts.append("Carbs: \(carbsAmount) g")
- }
- if let absorptionTime = json["absorption-time"] as? Double {
- actionParts.append("Absorption: \(absorptionTime) hrs")
- }
- if let otp = json["otp"] as? String {
- actionParts.append("OTP: \(otp)")
- }
- if let enteredBy = json["entered-by"] as? String {
- actionParts.append("From: \(enteredBy)")
- }
- if !actionParts.isEmpty {
- actionContext = " [" + actionParts.joined(separator: ", ") + "]"
- }
- }
- let finalMessage = errorMessage + actionContext
- return finalMessage.isEmpty ? nil : finalMessage
- }
- private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color {
- if Double(sgv) >= thresholds.high {
- return .yellow
- } else if Double(sgv) <= thresholds.low {
- return .red
- } else {
- return .green
- }
- }
- /// Groups consecutive same-colored readings into line runs (see BGRun).
- /// The segment between two points takes the color of the earlier point.
- private static func makeRuns(_ points: [BGPoint]) -> [BGRun] {
- guard let first = points.first else { return [] }
- var runs: [BGRun] = []
- var runColor = first.color
- var runPoints: [BGPoint] = [first]
- for pt in points.dropFirst() {
- runPoints.append(pt)
- if pt.color != runColor {
- runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
- runPoints = [pt]
- runColor = pt.color
- }
- }
- if runPoints.count > 1 {
- runs.append(BGRun(id: runs.count, color: runColor, points: runPoints))
- }
- return runs
- }
- /// Minimum drawn spacing between two treatments of the same population, and
- /// the furthest a treatment may be moved from its true time to reach it.
- /// Boluses and SMBs share a y-anchor and symbol footprint, so they are
- /// decluttered as one population; carbs carry a wider "30 3h" label, so
- /// they need — and are allowed — more room.
- private enum Spread {
- static let bolusGap: TimeInterval = 240
- static let bolusShift: TimeInterval = 240
- static let carbGap: TimeInterval = 250
- static let carbShift: TimeInterval = 250
- }
- /// Nudges crowded treatments left so their symbols don't stack. The newest
- /// point in a run keeps its true time and earlier ones give way, never by
- /// more than `maxShift` — so a treatment is at most `maxShift` from where it
- /// really happened, and an isolated one never moves at all.
- private static func spread(_ points: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> [TreatmentPoint] {
- var out = points.sorted { $0.date < $1.date }
- spreadSorted(&out, minGap: minGap, maxShift: maxShift)
- return out
- }
- /// Spreads two treatment kinds as a single population — a bolus dot and an
- /// SMB triangle drawn at the same minute overlap just like two dots would —
- /// then hands each kind back its own points.
- private static func spreadTogether(_ first: [TreatmentPoint], _ second: [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) -> ([TreatmentPoint], [TreatmentPoint]) {
- let tagged = (first.map { (isFirst: true, point: $0) } + second.map { (isFirst: false, point: $0) })
- .sorted { $0.point.date < $1.point.date }
- var points = tagged.map(\.point)
- spreadSorted(&points, minGap: minGap, maxShift: maxShift)
- var outFirst: [TreatmentPoint] = []
- var outSecond: [TreatmentPoint] = []
- for (tag, point) in zip(tagged, points) {
- if tag.isFirst {
- outFirst.append(point)
- } else {
- outSecond.append(point)
- }
- }
- return (outFirst, outSecond)
- }
- /// `points` must be sorted ascending by `date`.
- private static func spreadSorted(_ out: inout [TreatmentPoint], minGap: TimeInterval, maxShift: TimeInterval) {
- guard out.count > 1 else { return }
- // Walking left from the newest point, each point yields to its right
- // neighbor until it hits its own left bound (`date - maxShift`).
- var clamped = [Bool](repeating: false, count: out.count)
- for i in stride(from: out.count - 2, through: 0, by: -1) {
- let wanted = out[i + 1].drawnDate.addingTimeInterval(-minGap)
- guard out[i].drawnDate > wanted else { continue }
- let leftBound = out[i].date.addingTimeInterval(-maxShift)
- if wanted <= leftBound {
- out[i].drawnDate = leftBound
- clamped[i] = true
- } else {
- out[i].drawnDate = wanted
- }
- }
- // A chain of clamped points was squeezed against its left bound and may
- // have piled up there (several same-time treatments all land at
- // date - maxShift). Re-space each chain evenly between that bound and
- // the first point to its right that still had room.
- var i = 0
- while i < out.count - 1 {
- guard clamped[i] else {
- i += 1
- continue
- }
- var last = i
- while clamped[last + 1] {
- last += 1
- }
- let anchorIndex = last + 1
- let anchor = out[anchorIndex].drawnDate
- let leftBound = out[i].date.addingTimeInterval(-maxShift)
- let spacing = anchor.timeIntervalSince(leftBound) / Double(anchorIndex - i)
- for k in i ... last {
- let ideal = anchor.addingTimeInterval(-spacing * Double(anchorIndex - k))
- let boundK = out[k].date.addingTimeInterval(-maxShift)
- out[k].drawnDate = min(max(ideal, boundK), out[k].date)
- }
- i = anchorIndex + 1
- }
- }
- /// Schedules a rebuild, coalescing bursts: a refresh cycle calls a dozen
- /// update*Graph() entry points back-to-back, and rebuilding once per
- /// runloop turn is enough.
- func rebuild() {
- guard !rebuildScheduled else {
- return
- }
- rebuildScheduled = true
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- self.rebuildScheduled = false
- self.performRebuild()
- }
- }
- private func performRebuild() {
- guard let vc = MainViewController.shared else { return }
- pillTimeFormatter = Self.makePillTimeFormatter()
- let maxBGValue = Double(vc.calculateMaxBgGraphValue())
- maxBG = max(maxBGValue, Storage.shared.minBGScale.value)
- // Same thresholds the stats and main header use: fixed 70–180 / 70–140
- // for the TIR/TITR range modes, the user's lines for custom mode.
- let thresholds = UnitSettingsStore.shared.effectiveThresholds()
- lowLine = thresholds.low
- highLine = thresholds.high
- showLines = Storage.shared.showLines.value
- showDots = Storage.shared.showDots.value
- showDIA = Storage.shared.showDIALines.value
- show30Min = Storage.shared.show30MinLine.value
- show90Min = Storage.shared.show90MinLine.value
- showMidnight = Storage.shared.showMidnightLines.value
- let isLoop = Storage.shared.device.value == "Loop"
- overrideColor = isLoop ? .green : .purple
- tempTargetColor = isLoop ? .purple : .green
- // Clamp plotted BG to the display range (see #600); color is still
- // keyed off the true reading.
- let minDisplay = globalVariables.minDisplayGlucose
- let maxDisplay = globalVariables.maxDisplayGlucose
- func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) }
- bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) }
- bgRuns = Self.makeRuns(bg)
- // Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots.
- if Storage.shared.showYesterdayLine.value {
- yesterday = vc.yesterdayBGData.map {
- BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: Color(.systemGray).opacity(0.4))
- }
- } else {
- yesterday = []
- }
- prediction = vc.predictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
- ztPrediction = vc.ztPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
- iobPrediction = vc.iobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
- cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
- uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) }
- let bolusPoints = vc.bolusData.map {
- let dose = self.formatDose($0.value)
- return TreatmentPoint(
- date: Date(timeIntervalSince1970: $0.date),
- value: $0.value,
- sgv: Double($0.sgv),
- label: dose,
- pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }
- carbs = Self.spread(vc.carbData.map {
- let grams = Int($0.value)
- var label = "\(grams)"
- if $0.absorptionTime > 0, Storage.shared.showAbsorption.value {
- label += " \($0.absorptionTime / 60)h"
- }
- return TreatmentPoint(
- date: Date(timeIntervalSince1970: $0.date),
- value: $0.value,
- sgv: Double($0.sgv),
- label: label,
- pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }, minGap: Spread.carbGap, maxShift: Spread.carbShift)
- let smbPoints = vc.smbData.map {
- let dose = self.formatDose($0.value)
- return TreatmentPoint(
- date: Date(timeIntervalSince1970: $0.date),
- value: $0.value,
- sgv: Double($0.sgv),
- label: dose,
- pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }
- (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift)
- bgChecks = vc.bgCheckData.map {
- TreatmentPoint(
- date: Date(timeIntervalSince1970: $0.date),
- value: Double($0.sgv),
- sgv: Double($0.sgv),
- label: "",
- pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }
- suspends = vc.suspendGraphData.map {
- TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
- }
- resumes = vc.resumeGraphData.map {
- TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
- }
- sensorStarts = vc.sensorStartGraphData.map {
- 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)))")
- }
- notes = vc.noteGraphData.map {
- TreatmentPoint(
- date: Date(timeIntervalSince1970: $0.date),
- value: Double($0.sgv),
- sgv: Double($0.sgv),
- label: $0.note,
- pillText: "\(Self.extractMessage(from: $0.note) ?? $0.note)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }
- basalScheduled = vc.basalScheduleData.map {
- ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate)
- }
- var steps: [BasalStep] = []
- let sortedBasal = vc.basalData.sorted { $0.date < $1.date }
- for i in 0 ..< sortedBasal.count {
- let start = sortedBasal[i].date
- let end = i + 1 < sortedBasal.count
- ? sortedBasal[i + 1].date
- : dateTimeUtils.getNowTimeIntervalUTC()
- guard end > start else { continue }
- steps.append(BasalStep(
- start: Date(timeIntervalSince1970: start),
- end: Date(timeIntervalSince1970: end),
- rate: sortedBasal[i].basalRate
- ))
- }
- basal = steps
- let computedMaxBasal = steps.map(\.rate).max() ?? 0
- maxBasal = max(computedMaxBasal, Storage.shared.minBasalScale.value)
- let yTop = maxBG - 5
- let yBottom = maxBG - 25
- overrides = vc.overrideGraphData.map {
- let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines)
- let displayName = overrideName.isEmpty ? "Override" : overrideName
- return BandRect(
- start: Date(timeIntervalSince1970: $0.date),
- end: Date(timeIntervalSince1970: $0.endDate),
- yBottom: yBottom,
- yTop: yTop,
- label: displayName,
- pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }
- tempTargets = vc.tempTargetGraphData.map {
- let target = $0.correctionRange.first.map { String($0) } ?? ""
- return BandRect(
- start: Date(timeIntervalSince1970: $0.date),
- end: Date(timeIntervalSince1970: $0.endDate),
- yBottom: yBottom,
- yTop: yTop,
- label: "Temp Target",
- pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
- )
- }
- let currentNow = Date(timeIntervalSince1970: dateTimeUtils.getNowTimeIntervalUTC())
- now = currentNow
- let hoursBack = TimeInterval(Storage.shared.downloadDays.value * 24 * 3600)
- domainStart = currentNow.addingTimeInterval(-hoursBack)
- // Everything drawn in the future (predictions, cone, override/temp-target
- // bands) is bounded by the "Hours of Prediction" setting, so the scale
- // domain ends there too. The 15-minute floor keeps room for follow
- // mode's right-side padding when predictions are set to 0 (and matches
- // the floor Overrides.swift uses for future bands).
- let hoursForward = max(Storage.shared.predictionToLoad.value, 0.25) * 3600
- domainEnd = currentNow.addingTimeInterval(hoursForward)
- // 30/90 min lookback markers
- thirtyMinMark = currentNow.addingTimeInterval(-1800)
- ninetyMinMark = currentNow.addingTimeInterval(-5400)
- // DIA markers: every hour going back for 6 hours
- var dia: [Date] = []
- for i in 1 ... 6 {
- dia.append(currentNow.addingTimeInterval(TimeInterval(-i * 3600)))
- }
- diaMarkers = dia
- // Midnight markers: every local midnight within domain
- var midnights: [Date] = []
- let calendar: Calendar = {
- var cal = Calendar(identifier: .gregorian)
- if Storage.shared.graphTimeZoneEnabled.value,
- let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
- {
- cal.timeZone = tz
- }
- return cal
- }()
- var cursor = calendar.startOfDay(for: domainStart)
- while cursor <= domainEnd {
- if cursor >= domainStart {
- midnights.append(cursor)
- }
- guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
- cursor = next
- }
- midnightMarkers = midnights
- generation &+= 1
- }
- }
|