Ivan Valkou 5 лет назад
Родитель
Сommit
1b3cd012d5

+ 6 - 2
FreeAPS.xcodeproj/project.pbxproj

@@ -151,6 +151,7 @@
 		388E5A6025B6F2310019842D /* Autosens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388E5A5F25B6F2310019842D /* Autosens.swift */; };
 		389442CB25F65F7100FA1F27 /* NightscoutTreatment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389442CA25F65F7100FA1F27 /* NightscoutTreatment.swift */; };
 		3895E4C625B9E00D00214B37 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3895E4C525B9E00D00214B37 /* Preferences.swift */; };
+		389A572026079BAA00BC102F /* Interpolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389A571F26079BAA00BC102F /* Interpolation.swift */; };
 		389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389ECDFD2601061500D86C4F /* View+Snapshot.swift */; };
 		389ECE052601144100D86C4F /* ConcurrentMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389ECE042601144100D86C4F /* ConcurrentMap.swift */; };
 		38A00B1F25FC00F7006BC0B0 /* Autotune.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A00B1E25FC00F7006BC0B0 /* Autotune.swift */; };
@@ -423,6 +424,7 @@
 		388E5A5F25B6F2310019842D /* Autosens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Autosens.swift; sourceTree = "<group>"; };
 		389442CA25F65F7100FA1F27 /* NightscoutTreatment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutTreatment.swift; sourceTree = "<group>"; };
 		3895E4C525B9E00D00214B37 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
+		389A571F26079BAA00BC102F /* Interpolation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Interpolation.swift; sourceTree = "<group>"; };
 		389ECDFD2601061500D86C4F /* View+Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Snapshot.swift"; sourceTree = "<group>"; };
 		389ECE042601144100D86C4F /* ConcurrentMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentMap.swift; sourceTree = "<group>"; };
 		38A00B1E25FC00F7006BC0B0 /* Autotune.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Autotune.swift; sourceTree = "<group>"; };
@@ -1039,12 +1041,14 @@
 		388E5A5A25B6F05F0019842D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
-				38A00B2225FC2B55006BC0B0 /* LRUCache.swift */,
+				389ECE042601144100D86C4F /* ConcurrentMap.swift */,
 				3871F39E25ED895A0013ECB5 /* Decimal+Extensions.swift */,
 				38C4D33625E9A1A200D30B77 /* DispatchQueue+Extensions.swift */,
 				3811DE5425C9D4D500A708ED /* Formatters.swift */,
 				38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */,
+				389A571F26079BAA00BC102F /* Interpolation.swift */,
 				388E5A5B25B6F0770019842D /* JSON.swift */,
+				38A00B2225FC2B55006BC0B0 /* LRUCache.swift */,
 				38FCF3D525E8FDF40078B0D1 /* MD5.swift */,
 				38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */,
 				38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */,
@@ -1052,7 +1056,6 @@
 				3811DE5525C9D4D500A708ED /* Publisher.swift */,
 				38E98A3625F5509500C0CED0 /* String+Extensions.swift */,
 				3811DEE325CA063400A708ED /* PropertyWrappers */,
-				389ECE042601144100D86C4F /* ConcurrentMap.swift */,
 			);
 			path = Helpers;
 			sourceTree = "<group>";
@@ -1609,6 +1612,7 @@
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
+				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
 				3811DEB625C9D88300A708ED /* UnlockManager.swift in Sources */,
 				38A13D3225E28B4B00EAA382 /* PumpHistoryEvent.swift in Sources */,

+ 32 - 0
FreeAPS/Sources/Helpers/Interpolation.swift

@@ -0,0 +1,32 @@
+import CoreGraphics
+
+func pointInLine(_ a: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint {
+    var c: CGPoint = .zero
+    c.x = a.x - ((a.x - b.x) * t)
+    c.y = a.y - ((a.y - b.y) * t)
+    return c
+}
+
+func pointInQuadCurve(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ t: CGFloat) -> CGPoint {
+    let a = pointInLine(p0, p1, t)
+    let b = pointInLine(p1, p2, t)
+    return pointInLine(a, b, t)
+}
+
+func pointInCubicCurve(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ t: CGFloat) -> CGPoint {
+    let a = pointInQuadCurve(p0, p1, p2, t)
+    let b = pointInQuadCurve(p1, p2, p3, t)
+    return pointInLine(a, b, t)
+}
+
+extension BinaryFloatingPoint {
+    func inCubicCurve(_ p1: CGPoint, _ p2: CGPoint) -> Self {
+        Self(pointInCubicCurve(.zero, p1, p2, CGPoint(x: 2, y: 1), CGFloat(self)).y)
+    }
+
+    func clamped(_ range: ClosedRange<Self> = 0 ... 1) -> Self {
+        guard self < range.upperBound else { return range.upperBound }
+        guard self > range.lowerBound else { return range.lowerBound }
+        return self
+    }
+}

+ 11 - 0
FreeAPS/Sources/Modules/Home/HomeViewModel.swift

@@ -13,6 +13,7 @@ extension Home {
         @Published var recentGlucose: BloodGlucose?
         @Published var glucoseDelta: Int?
         @Published var tempBasals: [PumpHistoryEvent] = []
+        @Published var boluses: [PumpHistoryEvent] = []
         @Published var maxBasal: Decimal = 2
         @Published var basalProfile: [BasalProfileEntry] = []
         @Published var tempTargets: [TempTarget] = []
@@ -23,6 +24,7 @@ extension Home {
         override func subscribe() {
             setupGlucose()
             setupBasals()
+            setupBoluses()
             setupPumpSettings()
             setupBasalProfile()
             setupTempTargets()
@@ -87,6 +89,14 @@ extension Home {
             }
         }
 
+        private func setupBoluses() {
+            DispatchQueue.main.async {
+                self.boluses = self.provider.pumpHistory(hours: self.filteredHours).filter {
+                    $0.type == .bolus
+                }
+            }
+        }
+
         private func setupPumpSettings() {
             DispatchQueue.main.async {
                 self.maxBasal = self.provider.pumpSettings().maxBasal
@@ -130,6 +140,7 @@ extension Home.ViewModel:
 
     func pumpHistoryDidUpdate(_: [PumpHistoryEvent]) {
         setupBasals()
+        setupBoluses()
     }
 
     func pumpSettingsDidChange(_: PumpSettings) {

+ 76 - 1
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -11,7 +11,7 @@ private enum PredictionType: Hashable {
 
 struct MainChartView: View {
     private enum Config {
-        static let screenHours = 6
+        static let screenHours = 5
         static let basalHeight: CGFloat = 60
         static let topYPadding: CGFloat = 20
         static let bottomYPadding: CGFloat = 50
@@ -19,11 +19,14 @@ struct MainChartView: View {
         static let maxGlucose = 450
         static let minGlucose = 70
         static let yLinesCount = 5
+        static let bolusSize: CGFloat = 8
+        static let bolusScale: CGFloat = 10
     }
 
     @Binding var glucose: [BloodGlucose]
     @Binding var suggestion: Suggestion?
     @Binding var tempBasals: [PumpHistoryEvent]
+    @Binding var boluses: [PumpHistoryEvent]
     @Binding var hours: Int
     @Binding var maxBasal: Decimal
     @Binding var basalProfile: [BasalProfileEntry]
@@ -33,6 +36,9 @@ struct MainChartView: View {
     @State var didAppearTrigger = false
     @State private var glucoseDots: [CGRect] = []
     @State private var predictionDots: [PredictionType: [CGRect]] = [:]
+    @State private var bolusDots: [CGRect] = []
+    @State private var bolusPath = Path()
+    @State private var bolusLabels = AnyView(EmptyView())
     @State private var tempBasalPath = Path()
     @State private var regularBasalPath = Path()
     @State private var tempTargetsPath = Path()
@@ -57,6 +63,15 @@ struct MainChartView: View {
         return formatter
     }
 
+    private var bolusFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.minimumIntegerDigits = 0
+        formatter.maximumFractionDigits = 2
+        formatter.decimalSeparator = "."
+        return formatter
+    }
+
     // MARK: - Views
 
     var body: some View {
@@ -157,6 +172,7 @@ struct MainChartView: View {
                         }
                     }
                     .stroke(Color.secondary, lineWidth: 0.2)
+                    bolusView(fullSize: fullSize)
                     glucosePath(fullSize: fullSize)
                     predictions(fullSize: fullSize)
                 }
@@ -194,6 +210,28 @@ struct MainChartView: View {
         }
     }
 
+    private func bolusView(fullSize: CGSize) -> some View {
+        ZStack {
+            bolusPath
+                .fill(Color.blue)
+            bolusPath
+                .stroke(Color.primary, lineWidth: 0.5)
+
+            ForEach(bolusDots.indexed(), id: \.1.minX) { index, rect -> AnyView in
+                let position = CGPoint(x: rect.midX, y: rect.maxY + 6)
+                return Text(bolusFormatter.string(from: (boluses[index].amount ?? 0) as NSNumber)!).font(.caption2)
+                    .position(position)
+                    .asAny()
+            }
+        }
+        .onChange(of: boluses) { _ in
+            calculateBolusDots(fullSize: fullSize)
+        }
+        .onChange(of: didAppearTrigger) { _ in
+            calculateBolusDots(fullSize: fullSize)
+        }
+    }
+
     private func tempTargetsView(fullSize: CGSize) -> some View {
         ZStack {
             tempTargetsPath
@@ -259,6 +297,19 @@ struct MainChartView: View {
         }
     }
 
+    private func calculateBolusDots(fullSize: CGSize) {
+        bolusDots = boluses.map { value -> CGRect in
+            let center = timeToInterpolatedPoint(value.timestamp.timeIntervalSince1970, fullSize: fullSize)
+            let size = Config.bolusSize + CGFloat(value.amount ?? 0) * Config.bolusScale
+            return CGRect(x: center.x - size / 2, y: center.y - size / 2, width: size, height: size)
+        }
+        bolusPath = Path { path in
+            for rect in bolusDots {
+                path.addEllipse(in: rect)
+            }
+        }
+    }
+
     private func calculatePredictionDots(fullSize: CGSize, type: PredictionType) {
         let values: [Int] = { () -> [Int] in
             switch type {
@@ -500,6 +551,30 @@ struct MainChartView: View {
         return y
     }
 
+    private func timeToInterpolatedPoint(_ time: TimeInterval, fullSize: CGSize) -> CGPoint {
+        var nextIndex = 0
+        for (index, value) in glucose.enumerated() {
+            if value.dateString.timeIntervalSince1970 > time {
+                nextIndex = index
+                break
+            }
+        }
+        let x = timeToXCoordinate(time, fullSize: fullSize)
+
+        guard nextIndex > 0 else {
+            return CGPoint(x: x, y: Config.topYPadding + Config.basalHeight)
+        }
+
+        let prevX = timeToXCoordinate(glucose[nextIndex - 1].dateString.timeIntervalSince1970, fullSize: fullSize)
+        let prevY = glucoseToYCoordinate(glucose[nextIndex - 1].glucose ?? 0, fullSize: fullSize)
+        let nextX = timeToXCoordinate(glucose[nextIndex].dateString.timeIntervalSince1970, fullSize: fullSize)
+        let nextY = glucoseToYCoordinate(glucose[nextIndex].glucose ?? 0, fullSize: fullSize)
+        let delta = nextX - prevX
+        let fraction = (x - prevX) / delta
+
+        return pointInLine(CGPoint(x: prevX, y: prevY), CGPoint(x: nextX, y: nextY), fraction)
+    }
+
     private func glucoseYRange(fullSize: CGSize) -> (minValue: Int, minY: CGFloat, maxValue: Int, maxY: CGFloat) {
         let topYPaddint = Config.topYPadding + Config.basalHeight
         let bottomYPadding = Config.bottomYPadding

+ 1 - 0
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -51,6 +51,7 @@ extension Home {
                         glucose: $viewModel.glucose,
                         suggestion: $viewModel.suggestion,
                         tempBasals: $viewModel.tempBasals,
+                        boluses: $viewModel.boluses,
                         hours: .constant(viewModel.filteredHours),
                         maxBasal: $viewModel.maxBasal,
                         basalProfile: $viewModel.basalProfile,