| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- import Charts
- import SwiftUI
- private enum PredictionType: Hashable {
- case iob
- case cob
- case zt
- case uam
- }
- struct DotInfo {
- let rect: CGRect
- let value: Decimal
- }
- struct AnnouncementDot {
- let rect: CGRect
- let value: Decimal
- let note: String
- }
- typealias GlucoseYRange = (minValue: Int, minY: CGFloat, maxValue: Int, maxY: CGFloat)
- struct MainChartView2: View {
- private enum Config {
- static let endID = "End"
- static let basalHeight: CGFloat = 80
- static let topYPadding: CGFloat = 20
- static let bottomYPadding: CGFloat = 80
- static let minAdditionalWidth: CGFloat = 150
- static let maxGlucose = 270
- static let minGlucose = 45
- static let yLinesCount = 5
- static let glucoseScale: CGFloat = 2 // default 2
- static let bolusSize: CGFloat = 8
- static let bolusScale: CGFloat = 2.5
- static let carbsSize: CGFloat = 10
- static let fpuSize: CGFloat = 5
- static let carbsScale: CGFloat = 0.3
- static let fpuScale: CGFloat = 1
- static let announcementSize: CGFloat = 8
- static let announcementScale: CGFloat = 2.5
- static let owlSeize: CGFloat = 25
- static let owlOffset: CGFloat = 80
- }
- // MARK: BINDINGS
- @Binding var glucose: [BloodGlucose]
- @Binding var isManual: [BloodGlucose]
- @Binding var suggestion: Suggestion?
- @Binding var tempBasals: [PumpHistoryEvent]
- @Binding var boluses: [PumpHistoryEvent]
- @Binding var suspensions: [PumpHistoryEvent]
- @Binding var announcement: [Announcement]
- @Binding var hours: Int
- @Binding var maxBasal: Decimal
- @Binding var autotunedBasalProfile: [BasalProfileEntry]
- @Binding var basalProfile: [BasalProfileEntry]
- @Binding var tempTargets: [TempTarget]
- @Binding var carbs: [CarbsEntry]
- @Binding var timerDate: Date
- @Binding var units: GlucoseUnits
- @Binding var smooth: Bool
- @Binding var highGlucose: Decimal
- @Binding var lowGlucose: Decimal
- @Binding var screenHours: Int16
- @Binding var displayXgridLines: Bool
- @Binding var displayYgridLines: Bool
- @Binding var thresholdLines: Bool
- // // MARK: STATEs
- //
- // @State private var glucoseDots: [CGRect] = []
- // @State private var glucoseYRange: Range<CGFloat> = 0 ..< 0
- @State var didAppearTrigger = false
- @State private var glucoseDots: [CGRect] = []
- @State private var manualGlucoseDots: [CGRect] = []
- @State private var announcementDots: [AnnouncementDot] = []
- @State private var announcementPath = Path()
- @State private var manualGlucoseDotsCenter: [CGRect] = []
- @State private var unSmoothedGlucoseDots: [CGRect] = []
- @State private var predictionDots: [PredictionType: [CGRect]] = [:]
- @State private var bolusDots: [DotInfo] = []
- @State private var bolusPath = Path()
- @State private var tempBasalPath = Path()
- @State private var regularBasalPath = Path()
- @State private var tempTargetsPath = Path()
- @State private var suspensionsPath = Path()
- @State private var carbsDots: [DotInfo] = []
- @State private var carbsPath = Path()
- @State private var fpuDots: [DotInfo] = []
- @State private var fpuPath = Path()
- @State private var glucoseYRange: GlucoseYRange = (0, 0, 0, 0)
- @State private var offset: CGFloat = 0
- @State private var cachedMaxBasalRate: Decimal?
- private var date24Formatter: DateFormatter {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.setLocalizedDateFormatFromTemplate("HH")
- return formatter
- }
- private var dateFormatter: DateFormatter {
- let formatter = DateFormatter()
- formatter.timeStyle = .short
- return formatter
- }
- var body: some View {
- NavigationStack {
- ScrollView {
- VStack {
- let filteredGlucose: [BloodGlucose] = filterGlucoseData(for: 6)
- Chart(filteredGlucose) {
- PointMark(
- x: .value("Time", $0.dateString),
- y: .value("Value", $0.value)
- )
- .foregroundStyle(Color.green.gradient)
- .cornerRadius(0)
- }
- .frame(height: 350)
- .chartXAxis {
- AxisMarks(values: filteredGlucose.map(\.dateString)) { _ in
- AxisValueLabel(format: .dateTime.hour())
- }
- }
- Legend()
- }
- .padding()
- }
- }
- }
- private func filterGlucoseData(for hours: Int) -> [BloodGlucose] {
- guard hours > 0 else {
- return glucose
- }
- let currentDate = Date()
- let startDate = Calendar.current.date(byAdding: .hour, value: -hours, to: currentDate) ?? currentDate
- return glucose.filter { $0.dateString >= startDate }
- }
- // // MARK: GLUCOSE FOR CHART
- //
- private func calculateGlucoseDots(fullSize: CGSize) {
- let dots = glucose.map { value -> CGRect in
- let position = glucoseToCoordinate(value, fullSize: fullSize)
- return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
- }
- let range = getGlucoseYRange(fullSize: fullSize)
- DispatchQueue.main.async {
- glucoseYRange = range
- glucoseDots = dots
- }
- }
- private func getGlucoseYRange(fullSize: CGSize) -> GlucoseYRange {
- let topYPaddint = Config.topYPadding + Config.basalHeight
- let bottomYPadding = Config.bottomYPadding
- let (minValue, maxValue) = minMaxYValues()
- let stepYFraction = (fullSize.height - topYPaddint - bottomYPadding) / CGFloat(maxValue - minValue)
- let yOffset = CGFloat(minValue) * stepYFraction
- let maxY = fullSize.height - CGFloat(minValue) * stepYFraction + yOffset - bottomYPadding
- let minY = fullSize.height - CGFloat(maxValue) * stepYFraction + yOffset - bottomYPadding
- return (minValue: minValue, minY: minY, maxValue: maxValue, maxY: maxY)
- }
- private func glucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
- let x = timeToXCoordinate(glucoseEntry.dateString.timeIntervalSince1970, fullSize: fullSize)
- let y = glucoseToYCoordinate(glucoseEntry.glucose ?? 0, fullSize: fullSize)
- return CGPoint(x: x, y: y)
- }
- private func glucoseToYCoordinate(_ glucoseValue: Int, fullSize: CGSize) -> CGFloat {
- let topYPaddint = Config.topYPadding + Config.basalHeight
- let bottomYPadding = Config.bottomYPadding
- let (minValue, maxValue) = minMaxYValues()
- let stepYFraction = (fullSize.height - topYPaddint - bottomYPadding) / CGFloat(maxValue - minValue)
- let yOffset = CGFloat(minValue) * stepYFraction
- let y = fullSize.height - CGFloat(glucoseValue) * stepYFraction + yOffset - bottomYPadding
- return y
- }
- private func timeToXCoordinate(_ time: TimeInterval, fullSize: CGSize) -> CGFloat {
- let xOffset = -Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
- let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
- let x = CGFloat(time + xOffset) * stepXFraction
- return x
- }
- private func fullGlucoseWidth(viewWidth: CGFloat) -> CGFloat {
- viewWidth * CGFloat(hours) / CGFloat(min(max(screenHours, 2), 24))
- }
- private func minMaxYValues() -> (min: Int, max: Int) {
- var maxValue = glucose.compactMap(\.glucose).max() ?? Config.maxGlucose
- if let maxPredValue = maxPredValue() {
- maxValue = max(maxValue, maxPredValue)
- }
- if let maxTargetValue = maxTargetValue() {
- maxValue = max(maxValue, maxTargetValue)
- }
- var minValue = glucose.compactMap(\.glucose).min() ?? Config.minGlucose
- if let minPredValue = minPredValue() {
- minValue = min(minValue, minPredValue)
- }
- if let minTargetValue = minTargetValue() {
- minValue = min(minValue, minTargetValue)
- }
- if minValue == maxValue {
- minValue = Config.minGlucose
- maxValue = Config.maxGlucose
- }
- // fix the grah y-axis as long as the min and max BG values are within set borders
- if minValue > Config.minGlucose {
- minValue = Config.minGlucose
- }
- if maxValue < Config.maxGlucose {
- maxValue = Config.maxGlucose
- }
- return (min: minValue, max: maxValue)
- }
- private func maxTargetValue() -> Int? {
- tempTargets.map { $0.targetTop ?? 0 }.filter { $0 > 0 }.max().map(Int.init)
- }
- private func minPredValue() -> Int? {
- [
- suggestion?.predictions?.cob ?? [],
- suggestion?.predictions?.iob ?? [],
- suggestion?.predictions?.zt ?? [],
- suggestion?.predictions?.uam ?? []
- ]
- .flatMap { $0 }
- .min()
- }
- private func minTargetValue() -> Int? {
- tempTargets.map { $0.targetBottom ?? 0 }.filter { $0 > 0 }.min().map(Int.init)
- }
- private func maxPredValue() -> Int? {
- [
- suggestion?.predictions?.cob ?? [],
- suggestion?.predictions?.iob ?? [],
- suggestion?.predictions?.zt ?? [],
- suggestion?.predictions?.uam ?? []
- ]
- .flatMap { $0 }
- .max()
- }
- }
- // MARK: LEGEND PANEL FOR CHART
- struct Legend: View {
- var body: some View {
- HStack {
- Image(systemName: "line.diagonal")
- .rotationEffect(Angle(degrees: 45))
- .foregroundColor(.green)
- Text("BG")
- .foregroundColor(.secondary)
- Spacer()
- Image(systemName: "line.diagonal")
- .rotationEffect(Angle(degrees: 45))
- .foregroundColor(.insulin)
- Text("IOB")
- .foregroundColor(.secondary)
- Spacer()
- Image(systemName: "line.diagonal")
- .rotationEffect(Angle(degrees: 45))
- .foregroundColor(.purple)
- Text("ZT")
- .foregroundColor(.secondary)
- Spacer()
- Image(systemName: "line.diagonal")
- .rotationEffect(Angle(degrees: 45))
- .foregroundColor(.loopYellow)
- Text("COB")
- .foregroundColor(.secondary)
- Spacer()
- Image(systemName: "line.diagonal")
- .rotationEffect(Angle(degrees: 45))
- .foregroundColor(.orange)
- Text("UAM")
- .foregroundColor(.secondary)
- }
- .font(.caption2)
- .padding(.horizontal, 4)
- .padding(.vertical, 10)
- }
- }
|