10nas 2 лет назад
Родитель
Сommit
e1bcd94e4e

+ 155 - 123
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -86,20 +86,23 @@ struct MainChartView: View {
     @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 zoomScale: Double {
+        1.0 / Double(screenHours)
+    }
 
-    private let calculationQueue = DispatchQueue(label: "MainChartView.calculationQueue")
+    private let calculationQueue = DispatchQueue(
+        label: "MainChartView.calculationQueue",
+        qos: .userInteractive,
+        attributes: .concurrent
+    )
 
     private var dateFormatter: DateFormatter {
         let formatter = DateFormatter()
@@ -164,10 +167,6 @@ struct MainChartView: View {
             .onChange(of: vSizeClass) { _ in
                 update(fullSize: geo.size)
             }
-            .onChange(of: screenHours) { _ in
-                update(fullSize: geo.size)
-                // scroll.scrollTo(Config.endID, anchor: .trailing)
-            }
             .onReceive(
                 Foundation.NotificationCenter.default
                     .publisher(for: UIDevice.orientationDidChangeNotification)
@@ -178,34 +177,32 @@ struct MainChartView: View {
     }
 
     private func mainScrollView(fullSize: CGSize) -> some View {
-        ScrollView(.horizontal, showsIndicators: false) {
-            ScrollViewReader { scroll in
+        ScrollViewReader { scroll in
+            ScrollView(.horizontal, showsIndicators: false) {
                 ZStack(alignment: .top) {
                     tempTargetsView(fullSize: fullSize).drawingGroup()
                     basalView(fullSize: fullSize).drawingGroup()
-
                     mainView(fullSize: fullSize).id(Config.endID)
                         .drawingGroup()
-                        .onChange(of: glucose) { _ in
-                            scroll.scrollTo(Config.endID, anchor: .trailing)
-                        }
-                        .onChange(of: suggestion) { _ in
-                            scroll.scrollTo(Config.endID, anchor: .trailing)
-                        }
-                        .onChange(of: tempBasals) { _ in
-                            scroll.scrollTo(Config.endID, anchor: .trailing)
-                        }
-                        .onChange(of: screenHours) { _ in
-                            scroll.scrollTo(Config.endID, anchor: .trailing)
-                        }
-
-                        .onAppear {
-                            // add trigger to the end of main queue
-                            DispatchQueue.main.async {
-                                scroll.scrollTo(Config.endID, anchor: .trailing)
-                                didAppearTrigger = true
-                            }
-                        }
+                }
+            }
+            .onChange(of: glucose) { _ in
+                scroll.scrollTo(Config.endID, anchor: .trailing)
+            }
+            .onChange(of: suggestion) { _ in
+                scroll.scrollTo(Config.endID, anchor: .trailing)
+            }
+            .onChange(of: tempBasals) { _ in
+                scroll.scrollTo(Config.endID, anchor: .trailing)
+            }
+            .onChange(of: screenHours) { _ in
+                scroll.scrollTo(Config.endID, anchor: .trailing)
+            }
+            .onAppear {
+                // add trigger to the end of main queue
+                DispatchQueue.main.async {
+                    scroll.scrollTo(Config.endID, anchor: .trailing)
+                    didAppearTrigger = true
                 }
             }
         }
@@ -264,14 +261,16 @@ struct MainChartView: View {
 
     private func basalView(fullSize: CGSize) -> some View {
         ZStack {
-            tempBasalPath.fill(Color.basal.opacity(0.5))
-            tempBasalPath.stroke(Color.insulin, lineWidth: 1)
-            regularBasalPath.stroke(Color.insulin, style: StrokeStyle(lineWidth: 0.7, dash: [4]))
-            suspensionsPath.stroke(Color.loopGray.opacity(0.7), style: StrokeStyle(lineWidth: 0.7)).scaleEffect(x: 1, y: -1)
-            suspensionsPath.fill(Color.loopGray.opacity(0.2)).scaleEffect(x: 1, y: -1)
+            tempBasalPath.scale(x: zoomScale, anchor: .zero).fill(Color.basal.opacity(0.5))
+            tempBasalPath.scale(x: zoomScale, anchor: .zero).stroke(Color.insulin, lineWidth: 1)
+            regularBasalPath.scale(x: zoomScale, anchor: .zero)
+                .stroke(Color.insulin, style: StrokeStyle(lineWidth: 0.7, dash: [4]))
+            suspensionsPath.scale(x: zoomScale, anchor: .zero)
+                .stroke(Color.loopGray.opacity(0.7), style: StrokeStyle(lineWidth: 0.7)).scaleEffect(x: 1, y: -1)
+            suspensionsPath.scale(x: zoomScale, anchor: .zero).fill(Color.loopGray.opacity(0.2)).scaleEffect(x: 1, y: -1)
         }
         .scaleEffect(x: 1, y: -1)
-        .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
+        .frame(width: glucoseAndAdditionalWidth(fullSize: fullSize))
         .frame(maxHeight: Config.basalHeight)
         .background(Color.secondary.opacity(0.1))
         .onChange(of: tempBasals) { _ in
@@ -292,24 +291,51 @@ struct MainChartView: View {
     }
 
     private func mainView(fullSize: CGSize) -> some View {
-        Group {
-            VStack {
-                ZStack {
-                    xGridView(fullSize: fullSize)
-                    carbsView(fullSize: fullSize)
-                    fpuView(fullSize: fullSize)
-                    bolusView(fullSize: fullSize)
-                    if smooth { unSmoothedGlucoseView(fullSize: fullSize) }
-                    glucoseView(fullSize: fullSize)
-                    manualGlucoseView(fullSize: fullSize)
-                    manualGlucoseCenterView(fullSize: fullSize)
-                    announcementView(fullSize: fullSize)
-                    predictionsView(fullSize: fullSize)
-                }
-                timeLabelsView(fullSize: fullSize)
+        VStack {
+            ZStack {
+                xGridView(fullSize: fullSize)
+                carbsView(fullSize: fullSize)
+                fpuView(fullSize: fullSize)
+                bolusView(fullSize: fullSize)
+                if smooth { unSmoothedGlucoseView(fullSize: fullSize) }
+                glucoseView(fullSize: fullSize)
+                manualGlucoseView(fullSize: fullSize)
+                manualGlucoseCenterView(fullSize: fullSize)
+                announcementView(fullSize: fullSize)
+                predictionsView(fullSize: fullSize)
             }
+            timeLabelsView(fullSize: fullSize)
+        }
+        .frame(width: glucoseAndAdditionalWidth(fullSize: fullSize))
+    }
+
+    /// returns the width of the full chart view including predictions for the current `screenHours`
+    private func glucoseAndAdditionalWidth(fullSize: CGSize) -> CGFloat {
+        // fullGlucoseWidth returns the width scaled to 1h screen hours. Scale it down to screenHours
+        fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(screenHours)
+            + additionalWidthScaled(viewWidth: fullSize.width)
+    }
+
+    /// returns the additional width for predictions, scaled to `screenHours`
+    private func additionalWidthScaled(viewWidth: CGFloat) -> CGFloat {
+        guard let predictions = suggestion?.predictions,
+              let deliveredAt = suggestion?.deliverAt,
+              let last = glucose.last
+        else {
+            return Config.minAdditionalWidth
         }
-        .frame(width: fullGlucoseWidth(viewWidth: fullSize.width) + additionalWidth(viewWidth: fullSize.width))
+
+        let iob = predictions.iob?.count ?? 0
+        let zt = predictions.zt?.count ?? 0
+        let cob = predictions.cob?.count ?? 0
+        let uam = predictions.uam?.count ?? 0
+        let max = [iob, zt, cob, uam].max() ?? 0
+
+        let lastDeltaTime = last.dateString.timeIntervalSince(deliveredAt)
+        let additionalTime = CGFloat(TimeInterval(max) * 5.minutes.timeInterval - lastDeltaTime)
+        let oneSecondWidth = oneSecondStep(viewWidth: viewWidth) / CGFloat(screenHours)
+
+        return Swift.min(Swift.max(additionalTime * oneSecondWidth, Config.minAdditionalWidth), 275)
     }
 
     @Environment(\.colorScheme) var colorScheme
@@ -319,9 +345,11 @@ struct MainChartView: View {
         return ZStack {
             Path { path in
                 for hour in 0 ..< hours + hours {
-                    let x = firstHourPosition(viewWidth: fullSize.width) +
-                        oneSecondStep(viewWidth: fullSize.width) *
-                        CGFloat(hour) * CGFloat(1.hours.timeInterval)
+                    let x = (
+                        firstHourPosition(viewWidth: fullSize.width) +
+                            oneSecondStep(viewWidth: fullSize.width) *
+                            CGFloat(hour) * CGFloat(1.hours.timeInterval)
+                    ) * zoomScale
                     path.move(to: CGPoint(x: x, y: 0))
                     path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
                 }
@@ -329,7 +357,7 @@ struct MainChartView: View {
             .stroke(useColour, lineWidth: 0.15)
 
             Path { path in // vertical timeline
-                let x = timeToXCoordinate(timerDate.timeIntervalSince1970, fullSize: fullSize)
+                let x = timeToXCoordinate(timerDate.timeIntervalSince1970, fullSize: fullSize) * zoomScale
                 path.move(to: CGPoint(x: x, y: 0))
                 path.addLine(to: CGPoint(x: x, y: fullSize.height - 20))
             }
@@ -344,13 +372,15 @@ struct MainChartView: View {
         let format = screenHours > 6 ? date24Formatter : dateFormatter
         return ZStack {
             // X time labels
-            ForEach(0 ..< hours + hours) { hour in
+            ForEach(0 ..< hours + hours, id: \.self) { hour in
                 Text(format.string(from: firstHourDate().addingTimeInterval(hour.hours.timeInterval)))
                     .font(.caption)
                     .position(
-                        x: firstHourPosition(viewWidth: fullSize.width) +
-                            oneSecondStep(viewWidth: fullSize.width) *
-                            CGFloat(hour) * CGFloat(1.hours.timeInterval),
+                        x: (
+                            firstHourPosition(viewWidth: fullSize.width) +
+                                oneSecondStep(viewWidth: fullSize.width) *
+                                CGFloat(hour) * CGFloat(1.hours.timeInterval)
+                        ) * zoomScale,
                         y: 10.0
                     )
                     .foregroundColor(.secondary)
@@ -361,7 +391,7 @@ struct MainChartView: View {
     private func glucoseView(fullSize: CGSize) -> some View {
         Path { path in
             for rect in glucoseDots {
-                path.addEllipse(in: rect)
+                path.addEllipse(in: scaleCenter(rect: rect))
             }
         }
         .fill(Color.loopGreen)
@@ -379,7 +409,7 @@ struct MainChartView: View {
     private func manualGlucoseView(fullSize: CGSize) -> some View {
         Path { path in
             for rect in manualGlucoseDots {
-                path.addEllipse(in: rect)
+                path.addEllipse(in: scaleCenter(rect: rect))
             }
         }
         .fill(Color.gray)
@@ -397,7 +427,9 @@ struct MainChartView: View {
     private func announcementView(fullSize: CGSize) -> some View {
         ZStack {
             ForEach(announcementDots, id: \.rect.minX) { info -> AnyView in
-                let position = CGPoint(x: info.rect.midX + 5, y: info.rect.maxY - Config.owlOffset)
+                let scaledRect = scaleCenter(rect: info.rect)
+
+                let position = CGPoint(x: scaledRect.midX + 5, y: scaledRect.maxY - Config.owlOffset)
                 let type: String =
                     info.note.contains("true") ?
                     Command.open :
@@ -426,7 +458,7 @@ struct MainChartView: View {
     private func manualGlucoseCenterView(fullSize: CGSize) -> some View {
         Path { path in
             for rect in manualGlucoseDotsCenter {
-                path.addEllipse(in: rect)
+                path.addEllipse(in: scaleCenter(rect: rect))
             }
         }
         .fill(Color.red)
@@ -449,8 +481,9 @@ struct MainChartView: View {
         Path { path in
             var lines: [CGPoint] = []
             for rect in unSmoothedGlucoseDots {
-                lines.append(CGPoint(x: rect.midX, y: rect.midY))
-                path.addEllipse(in: rect)
+                let scaled = scaleCenter(rect: rect)
+                lines.append(CGPoint(x: scaled.midX, y: scaled.midY))
+                path.addEllipse(in: scaled)
             }
             path.addLines(lines)
         }
@@ -468,13 +501,21 @@ struct MainChartView: View {
 
     private func bolusView(fullSize: CGSize) -> some View {
         ZStack {
+            let bolusPath = Path { path in
+                for dot in bolusDots {
+                    path.addEllipse(in: scaleCenter(rect: dot.rect))
+                }
+            }
+
             bolusPath
                 .fill(Color.insulin)
             bolusPath
                 .stroke(Color.primary, lineWidth: 0.5)
 
             ForEach(bolusDots, id: \.rect.minX) { info -> AnyView in
-                let position = CGPoint(x: info.rect.midX, y: info.rect.maxY + 8)
+                let rect = scaleCenter(rect: info.rect)
+
+                let position = CGPoint(x: rect.midX, y: rect.maxY + 8)
                 return Text(bolusFormatter.string(from: info.value as NSNumber)!).font(.caption2)
                     .position(position)
                     .asAny()
@@ -490,13 +531,21 @@ struct MainChartView: View {
 
     private func carbsView(fullSize: CGSize) -> some View {
         ZStack {
+            let carbsPath = Path { path in
+                for dot in carbsDots {
+                    path.addEllipse(in: scaleCenter(rect: dot.rect))
+                }
+            }
+
             carbsPath
                 .fill(Color.loopYellow)
             carbsPath
                 .stroke(Color.primary, lineWidth: 0.5)
 
             ForEach(carbsDots, id: \.rect.minX) { info -> AnyView in
-                let position = CGPoint(x: info.rect.midX, y: info.rect.minY - 8)
+                let rect = scaleCenter(rect: info.rect)
+
+                let position = CGPoint(x: rect.midX, y: rect.minY - 8)
                 return Text(carbsFormatter.string(from: info.value as NSNumber)!).font(.caption2)
                     .position(position)
                     .asAny()
@@ -512,6 +561,12 @@ struct MainChartView: View {
 
     private func fpuView(fullSize: CGSize) -> some View {
         ZStack {
+            let fpuPath = Path { path in
+                for dot in fpuDots {
+                    path.addEllipse(in: scaleCenter(rect: dot.rect))
+                }
+            }
+
             fpuPath
                 .fill(.orange.opacity(0.5))
             fpuPath
@@ -528,8 +583,10 @@ struct MainChartView: View {
     private func tempTargetsView(fullSize: CGSize) -> some View {
         ZStack {
             tempTargetsPath
+                .scale(x: zoomScale, anchor: .zero)
                 .fill(Color.tempBasal.opacity(0.5))
             tempTargetsPath
+                .scale(x: zoomScale, anchor: .zero)
                 .stroke(Color.basal.opacity(0.5), lineWidth: 1)
         }
         .onChange(of: glucose) { _ in
@@ -543,29 +600,37 @@ struct MainChartView: View {
         }
     }
 
+    private func scale(rect: CGRect) -> CGRect {
+        CGRect(origin: CGPoint(x: rect.origin.x * zoomScale, y: rect.origin.y), size: rect.size)
+    }
+
+    private func scaleCenter(rect: CGRect) -> CGRect {
+        CGRect(origin: CGPoint(x: rect.midX * zoomScale - rect.width / 2, y: rect.origin.y), size: rect.size)
+    }
+
     private func predictionsView(fullSize: CGSize) -> some View {
         Group {
             Path { path in
                 for rect in predictionDots[.iob] ?? [] {
-                    path.addEllipse(in: rect)
+                    path.addEllipse(in: scaleCenter(rect: rect))
                 }
             }.fill(Color.insulin)
 
             Path { path in
                 for rect in predictionDots[.cob] ?? [] {
-                    path.addEllipse(in: rect)
+                    path.addEllipse(in: scaleCenter(rect: rect))
                 }
             }.fill(Color.loopYellow)
 
             Path { path in
                 for rect in predictionDots[.zt] ?? [] {
-                    path.addEllipse(in: rect)
+                    path.addEllipse(in: scaleCenter(rect: rect))
                 }
             }.fill(Color.zt)
 
             Path { path in
                 for rect in predictionDots[.uam] ?? [] {
-                    path.addEllipse(in: rect)
+                    path.addEllipse(in: scaleCenter(rect: rect))
                 }
             }.fill(Color.uam)
         }
@@ -577,6 +642,8 @@ struct MainChartView: View {
 
 // MARK: - Calculations
 
+/// some of the calculations done here can take quite long (100ms+) and are not able to update data at a fast rate
+/// therefore we stick to the 1h screen window for these calculations and scale the results as needed to `screenHours` which has little extra overhead and enables changing the screen hours with no lag
 extension MainChartView {
     private func update(fullSize: CGSize) {
         calculatePredictionDots(fullSize: fullSize, type: .iob)
@@ -693,15 +760,8 @@ extension MainChartView {
                 return DotInfo(rect: rect, value: value.amount ?? 0)
             }
 
-            let path = Path { path in
-                for dot in dots {
-                    path.addEllipse(in: dot.rect)
-                }
-            }
-
             DispatchQueue.main.async {
                 bolusDots = dots
-                bolusPath = path
             }
         }
     }
@@ -720,15 +780,8 @@ extension MainChartView {
                 return DotInfo(rect: rect, value: value.carbs)
             }
 
-            let path = Path { path in
-                for dot in dots {
-                    path.addEllipse(in: dot.rect)
-                }
-            }
-
             DispatchQueue.main.async {
                 carbsDots = dots
-                carbsPath = path
             }
         }
     }
@@ -747,15 +800,8 @@ extension MainChartView {
                 return DotInfo(rect: rect, value: value.carbs)
             }
 
-            let path = Path { path in
-                for dot in dots {
-                    path.addEllipse(in: dot.rect)
-                }
-            }
-
             DispatchQueue.main.async {
                 fpuDots = dots
-                fpuPath = path
             }
         }
     }
@@ -830,9 +876,8 @@ extension MainChartView {
                 path.addLine(to: CGPoint(x: lastPoint.x, y: Config.basalHeight))
                 path.addLine(to: CGPoint(x: 0, y: Config.basalHeight))
             }
-            let adjustForOptionalExtraHours = screenHours > 12 ? screenHours - 12 : 0
-            let endDateTime = dayAgoTime + min(max(Int(screenHours - adjustForOptionalExtraHours), 12), 24).hours
-                .timeInterval + min(max(Int(screenHours - adjustForOptionalExtraHours), 12), 24).hours
+            let endDateTime = dayAgoTime + 12.hours
+                .timeInterval + 12.hours
                 .timeInterval
             let autotunedBasalPoints = findRegularBasalPoints(
                 timeBegin: dayAgoTime,
@@ -892,8 +937,7 @@ extension MainChartView {
                     .map { self.timeToXCoordinate($0.timestamp.timeIntervalSince1970, fullSize: fullSize) }
                 let x0 = self.timeToXCoordinate(event.timestamp.timeIntervalSince1970, fullSize: fullSize)
 
-                let x1 = tbrTimeX ?? self.fullGlucoseWidth(viewWidth: fullSize.width) + self
-                    .additionalWidth(viewWidth: fullSize.width)
+                let x1 = tbrTimeX ?? self.fullGlucoseWidth(viewWidth: fullSize.width) + 275
 
                 return CGRect(x: x0, y: 0, width: x1 - x0, height: Config.basalHeight * 0.7)
             }
@@ -1039,32 +1083,11 @@ extension MainChartView {
     }
 
     private func fullGlucoseWidth(viewWidth: CGFloat) -> CGFloat {
-        viewWidth * CGFloat(hours) / CGFloat(min(max(screenHours, 2), 24))
-    }
-
-    private func additionalWidth(viewWidth: CGFloat) -> CGFloat {
-        guard let predictions = suggestion?.predictions,
-              let deliveredAt = suggestion?.deliverAt,
-              let last = glucose.last
-        else {
-            return Config.minAdditionalWidth
-        }
-
-        let iob = predictions.iob?.count ?? 0
-        let zt = predictions.zt?.count ?? 0
-        let cob = predictions.cob?.count ?? 0
-        let uam = predictions.uam?.count ?? 0
-        let max = [iob, zt, cob, uam].max() ?? 0
-
-        let lastDeltaTime = last.dateString.timeIntervalSince(deliveredAt)
-        let additionalTime = CGFloat(TimeInterval(max) * 5.minutes.timeInterval - lastDeltaTime)
-        let oneSecondWidth = oneSecondStep(viewWidth: viewWidth)
-
-        return Swift.min(Swift.max(additionalTime * oneSecondWidth, Config.minAdditionalWidth), 275)
+        viewWidth * CGFloat(hours)
     }
 
     private func oneSecondStep(viewWidth: CGFloat) -> CGFloat {
-        viewWidth / (CGFloat(min(max(screenHours, 2), 24)) * CGFloat(1.hours.timeInterval))
+        viewWidth / CGFloat(1.hours.timeInterval)
     }
 
     private func maxPredValue() -> Int? {
@@ -1131,6 +1154,15 @@ extension MainChartView {
         return x
     }
 
+    /// inverse of `timeToXCoordinate`
+    private func xCoordinateToTime(x: CGFloat, fullSize: CGSize) -> TimeInterval {
+        let stepXFraction = fullGlucoseWidth(viewWidth: fullSize.width) / CGFloat(hours.hours.timeInterval)
+        let xx = x / stepXFraction
+        let xOffset = -Date().addingTimeInterval(-1.days.timeInterval).timeIntervalSince1970
+        let time = xx - xOffset
+        return time
+    }
+
     private func glucoseToYCoordinate(_ glucoseValue: Int, fullSize: CGSize) -> CGFloat {
         let topYPaddint = Config.topYPadding + Config.basalHeight
         let bottomYPadding = Config.bottomYPadding

+ 4 - 2
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -349,11 +349,10 @@ extension Home {
         }
 
         var timeInterval: some View {
-            HStack {
+            HStack(alignment: .center) {
                 ForEach(timeButtons) { button in
                     Text(button.active ? NSLocalizedString(button.label, comment: "") : button.number).onTapGesture {
                         state.hours = button.hours
-                        highlightButtons()
                     }
                     .foregroundStyle(button.active ? .primary : .secondary)
                     .frame(maxHeight: 20).padding(.horizontal)
@@ -631,6 +630,9 @@ extension Home {
                 }
                 .edgesIgnoringSafeArea(.vertical)
             }
+            .onChange(of: state.hours) { _ in
+                highlightButtons()
+            }
             .onAppear {
                 configureView {
                     highlightButtons()