| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- import Charts
- import CoreData
- import Foundation
- import SwiftUI
- struct ForecastChart: View {
- @StateObject var state: Bolus.StateModel
- @Environment(\.colorScheme) var colorScheme
- @Binding var units: GlucoseUnits
- @State private var startMarker = Date(timeIntervalSinceNow: -4 * 60 * 60)
- private var endMarker: Date {
- state
- .forecastDisplayType == .lines ? Date(timeIntervalSinceNow: TimeInterval(hours: 3)) :
- Date(timeIntervalSinceNow: TimeInterval(
- Int(1.5) * 5 * state
- .minCount * 60
- )) // min is 1.5h -> (1.5*1h = 1.5*(5*12*60))
- }
- private var glucoseFormatter: NumberFormatter {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- if units == .mmolL {
- formatter.maximumFractionDigits = 1
- formatter.minimumFractionDigits = 1
- formatter.roundingMode = .halfUp
- } else {
- formatter.maximumFractionDigits = 0
- }
- return formatter
- }
- var body: some View {
- VStack {
- forecastChartLabels
- .padding(.bottom, 8)
- forecastChart
- }
- }
- private var forecastChartLabels: some View {
- HStack {
- HStack {
- Image(systemName: "fork.knife")
- Text("\(state.carbs.description) g")
- }
- .font(.footnote)
- .foregroundStyle(.orange)
- .padding(8)
- .background {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.orange.opacity(0.2))
- }
- Spacer()
- HStack {
- Image(systemName: "syringe.fill")
- Text("\(state.amount.description) U")
- }
- .font(.footnote)
- .foregroundStyle(.blue)
- .padding(8)
- .background {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.blue.opacity(0.2))
- }
- Spacer()
- HStack {
- Image(systemName: "arrow.right.circle")
- if let simulatedDetermination = state.simulatedDetermination, let eventualBG = simulatedDetermination.eventualBG {
- HStack {
- Text(
- (units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL) + units.rawValue
- )
- }
- } else {
- Text("---")
- }
- }
- .font(.footnote)
- .foregroundStyle(.primary)
- .padding(8)
- .background {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.primary.opacity(0.2))
- }
- }
- }
- private var forecastChart: some View {
- Chart {
- drawGlucose()
- drawCurrentTimeMarker()
- if state.forecastDisplayType == .lines {
- drawForecastLines()
- } else {
- drawForecastsCone()
- }
- }
- .chartXAxis { forecastChartXAxis }
- .chartXScale(domain: startMarker ... endMarker)
- .chartYAxis { forecastChartYAxis }
- .chartYScale(domain: units == .mgdL ? 0 ... 300 : 0.asMmolL ... 300.asMmolL)
- .backport.chartForegroundStyleScale(state: state)
- }
- private func drawGlucose() -> some ChartContent {
- ForEach(state.glucoseFromPersistence) { item in
- let glucoseToDisplay = state.units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
- // low and high glucose is parsed in state to mmol/L; parse it back to mg/dl here for comparison
- let lowGlucose = units == .mgdL ? state.lowGlucose : state.lowGlucose.asMgdL
- let highGlucose = units == .mgdL ? state.highGlucose : state.highGlucose.asMgdL
- let targetGlucose = (state.determination.first?.currentTarget ?? state.currentBGTarget as NSDecimalNumber) as Decimal
- // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
- let hardCodedLow = Decimal(55)
- let hardCodedHigh = Decimal(220)
- let pointMarkColor: Color = FreeAPS.getDynamicGlucoseColor(
- glucoseValue: Decimal(item.glucose),
- highGlucoseColorValue: hardCodedHigh,
- lowGlucoseColorValue: hardCodedLow,
- targetGlucose: targetGlucose,
- glucoseColorScheme: state.glucoseColorScheme
- )
- if !state.isSmoothingEnabled {
- PointMark(
- x: .value("Time", item.date ?? Date(), unit: .second),
- y: .value("Value", glucoseToDisplay)
- )
- .foregroundStyle(pointMarkColor)
- .symbolSize(18)
- } else {
- PointMark(
- x: .value("Time", item.date ?? Date(), unit: .second),
- y: .value("Value", glucoseToDisplay)
- )
- .symbol {
- Image(systemName: "record.circle.fill")
- .font(.system(size: 6))
- .bold()
- .foregroundStyle(pointMarkColor)
- }
- }
- }
- }
- private func timeForIndex(_ index: Int32) -> Date {
- let currentTime = Date()
- let timeInterval = TimeInterval(index * 300)
- return currentTime.addingTimeInterval(timeInterval)
- }
- private func drawForecastsCone() -> some ChartContent {
- // Draw AreaMark for the forecast bounds
- ForEach(0 ..< max(state.minForecast.count, state.maxForecast.count), id: \.self) { index in
- if index < state.minForecast.count, index < state.maxForecast.count {
- let yMinMaxDelta = Decimal(state.minForecast[index] - state.maxForecast[index])
- let xValue = timeForIndex(Int32(index))
- // if distance between respective min and max is 0, provide a default range
- if yMinMaxDelta == 0 {
- let yMinValue = units == .mgdL ? Decimal(state.minForecast[index] - 1) :
- Decimal(state.minForecast[index] - 1)
- .asMmolL
- let yMaxValue = units == .mgdL ? Decimal(state.minForecast[index] + 1) :
- Decimal(state.minForecast[index] + 1)
- .asMmolL
- AreaMark(
- x: .value("Time", xValue <= endMarker ? xValue : endMarker),
- yStart: .value("Min Value", units == .mgdL ? yMinValue : yMinValue.asMmolL),
- yEnd: .value("Max Value", units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
- )
- .foregroundStyle(Color.blue.opacity(0.5))
- .interpolationMethod(.catmullRom)
- } else {
- let yMinValue = Decimal(state.minForecast[index]) <= 300 ? Decimal(state.minForecast[index]) : Decimal(300)
- let yMaxValue = Decimal(state.maxForecast[index]) <= 300 ? Decimal(state.maxForecast[index]) : Decimal(300)
- AreaMark(
- x: .value("Time", timeForIndex(Int32(index)) <= endMarker ? timeForIndex(Int32(index)) : endMarker),
- yStart: .value("Min Value", units == .mgdL ? yMinValue : yMinValue.asMmolL),
- yEnd: .value("Max Value", units == .mgdL ? yMaxValue : yMaxValue.asMmolL)
- )
- .foregroundStyle(Color.blue.opacity(0.5))
- .interpolationMethod(.catmullRom)
- }
- }
- }
- }
- private func drawForecastLines() -> some ChartContent {
- let predictions = state.predictionsForChart
- // Prepare the prediction data with only the first 36 values, i.e. 3 hours in the future
- let predictionData = [
- ("iob", predictions?.iob?.prefix(36)),
- ("zt", predictions?.zt?.prefix(36)),
- ("cob", predictions?.cob?.prefix(36)),
- ("uam", predictions?.uam?.prefix(36))
- ]
- return ForEach(predictionData, id: \.0) { name, values in
- if let values = values {
- ForEach(values.indices, id: \.self) { index in
- LineMark(
- x: .value("Time", timeForIndex(Int32(index))),
- y: .value("Value", units == .mgdL ? Decimal(values[index]) : Decimal(values[index]).asMmolL)
- )
- .foregroundStyle(by: .value("Prediction Type", name))
- }
- }
- }
- }
- private func drawCurrentTimeMarker() -> some ChartContent {
- RuleMark(
- x: .value(
- "",
- Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970)),
- unit: .second
- )
- ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
- }
- private var forecastChartXAxis: some AxisContent {
- AxisMarks(values: .stride(by: .hour, count: 2)) { _ in
- AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
- AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), anchor: .top)
- .font(.caption2)
- .foregroundStyle(Color.secondary)
- }
- }
- private var forecastChartYAxis: some AxisContent {
- AxisMarks(position: .trailing) { _ in
- AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
- AxisTick(length: 3, stroke: .init(lineWidth: 3)).foregroundStyle(Color.secondary)
- AxisValueLabel().font(.caption2).foregroundStyle(Color.secondary)
- }
- }
- }
|