// // PredictedGlucoseChart.swift // LoopUI // // Copyright © 2019 LoopKit Authors. All rights reserved. // import Foundation import LoopKit import SwiftCharts import HealthKit import UIKit public class PredictedGlucoseChart: GlucoseChart, ChartProviding { public private(set) var glucosePoints: [ChartPoint] = [] { didSet { if let lastDate = glucosePoints.last?.x as? ChartAxisValueDate { updateEndDate(lastDate.date) } } } /// The chart points for predicted glucose public private(set) var predictedGlucosePoints: [ChartPoint] = [] { didSet { if let lastDate = predictedGlucosePoints.last?.x as? ChartAxisValueDate { updateEndDate(lastDate.date) } } } /// The chart points for alternate predicted glucose public private(set) var alternatePredictedGlucosePoints: [ChartPoint]? public var targetGlucoseSchedule: GlucoseRangeSchedule? { didSet { targetGlucosePoints = [] } } public var preMealOverride: TemporaryScheduleOverride? { didSet { preMealOverrideDurationPoints = [] } } public var scheduleOverride: TemporaryScheduleOverride? { didSet { targetOverrideDurationPoints = [] } } private var targetGlucosePoints = [TargetChartBar]() private var preMealOverrideDurationPoints: [ChartPoint] = [] private var targetOverrideDurationPoints: [ChartPoint] = [] private var glucoseChartCache: ChartPointsTouchHighlightLayerViewCache? public private(set) var endDate: Date? private var predictedGlucoseSoftBounds: PredictedGlucoseBounds? private let yAxisStepSizeMGDLOverride: Double? private var maxYAxisSegmentCount: Double { // when a glucose value is below the predicted glucose minimum soft bound, allow for more y-axis segments return glucoseValueBelowSoftBoundsMinimum() ? 5 : 4 } private func updateEndDate(_ date: Date) { if endDate == nil || date > endDate! { self.endDate = date } } public init(predictedGlucoseBounds: PredictedGlucoseBounds? = nil, yAxisStepSizeMGDLOverride: Double? = nil) { self.predictedGlucoseSoftBounds = predictedGlucoseBounds self.yAxisStepSizeMGDLOverride = yAxisStepSizeMGDLOverride super.init() } } extension PredictedGlucoseChart { public func didReceiveMemoryWarning() { glucosePoints = [] predictedGlucosePoints = [] alternatePredictedGlucosePoints = nil targetGlucosePoints = [TargetChartBar]() targetOverrideDurationPoints = [] glucoseChartCache = nil } public func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart { if targetGlucosePoints.isEmpty, xAxisValues.count > 1, let schedule = targetGlucoseSchedule { // TODO: This only considers one override: pre-meal or an active override. ChartPoint.barsForGlucoseRangeSchedule needs to accept list of overridden ranges. let potentialOverride = (preMealOverride?.isActive() ?? false) ? preMealOverride : (scheduleOverride?.isActive() ?? false) ? scheduleOverride : nil targetGlucosePoints = ChartPoint.barsForGlucoseRangeSchedule(schedule, unit: glucoseUnit, xAxisValues: xAxisValues, considering: potentialOverride) var displayedScheduleOverride = scheduleOverride if let preMealOverride = preMealOverride, preMealOverride.isActive() { preMealOverrideDurationPoints = ChartPoint.pointsForGlucoseRangeScheduleOverride(preMealOverride, unit: glucoseUnit, xAxisValues: xAxisValues) if displayedScheduleOverride != nil { if displayedScheduleOverride!.scheduledEndDate > preMealOverride.scheduledEndDate { let start = max(displayedScheduleOverride!.startDate, preMealOverride.scheduledEndDate) displayedScheduleOverride!.scheduledInterval = DateInterval(start: start, end: displayedScheduleOverride!.scheduledEndDate) } else { displayedScheduleOverride = nil } } } else { preMealOverrideDurationPoints = [] } if let override = displayedScheduleOverride, override.isActive() || override.startDate > Date() { targetOverrideDurationPoints = ChartPoint.pointsForGlucoseRangeScheduleOverride(override, unit: glucoseUnit, xAxisValues: xAxisValues) } else { targetOverrideDurationPoints = [] } } let yAxisValues = determineYAxisValues(axisLabelSettings: axisLabelSettings) let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY)) let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel) let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame) // The glucose targets let targetFill = colors.glucoseTint.withAlphaComponent(0.2) let overrideFill: UIColor = colors.glucoseTint.withAlphaComponent(0.45) let fills = targetGlucosePoints.map { if $0.isOverride { return ChartPointsFill( chartPoints: $0.points, fillColor: overrideFill, createContainerPoints: false) } else { return ChartPointsFill( chartPoints: $0.points, fillColor: targetFill, createContainerPoints: false) } } + [ ChartPointsFill( chartPoints: preMealOverrideDurationPoints, fillColor: overrideFill, createContainerPoints: false ), ChartPointsFill( chartPoints: targetOverrideDurationPoints, fillColor: overrideFill, createContainerPoints: false )] let targetsLayer = ChartPointsFillsLayer( xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: fills ) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) let circles = ChartPointsScatterCirclesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: glucosePoints, displayDelay: 0, itemSize: CGSize(width: 4, height: 4), itemFillColor: colors.glucoseTint, optimized: true) var alternatePrediction: ChartLayer? if let altPoints = alternatePredictedGlucosePoints, altPoints.count > 1 { let lineModel = ChartLineModel.predictionLine(points: altPoints, color: colors.glucoseTint, width: 2) alternatePrediction = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) } var prediction: ChartLayer? if predictedGlucosePoints.count > 1 { let lineColor = (alternatePrediction == nil) ? colors.glucoseTint : UIColor.secondaryLabel let lineModel = ChartLineModel.predictionLine( points: predictedGlucosePoints, color: lineColor, width: 1 ) prediction = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) } if gestureRecognizer != nil { glucoseChartCache = ChartPointsTouchHighlightLayerViewCache( xAxisLayer: xAxisLayer, yAxisLayer: yAxisLayer, axisLabelSettings: axisLabelSettings, chartPoints: glucosePoints + (alternatePredictedGlucosePoints ?? predictedGlucosePoints), tintColor: colors.glucoseTint, gestureRecognizer: gestureRecognizer ) } let layers: [ChartLayer?] = [ gridLayer, targetsLayer, xAxisLayer, yAxisLayer, glucoseChartCache?.highlightLayer, prediction, alternatePrediction, circles ] return Chart( frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 } ) } private func determineYAxisValues(axisLabelSettings: ChartLabelSettings? = nil) -> [ChartAxisValue] { let points = [ glucosePoints, predictedGlucosePoints, preMealOverrideDurationPoints, targetOverrideDurationPoints, targetGlucosePoints.flatMap { $0.points }, glucoseDisplayRangePoints ].flatMap { $0 } let axisValueGenerator: ChartAxisValueStaticGenerator if let axisLabelSettings = axisLabelSettings { axisValueGenerator = { ChartAxisValueDouble($0, labelSettings: axisLabelSettings) } } else { axisValueGenerator = { ChartAxisValueDouble($0) } } let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesUsingLinearSegmentStep(chartPoints: points, minSegmentCount: 2, maxSegmentCount: maxYAxisSegmentCount, multiple: glucoseUnit == .milligramsPerDeciliter ? (yAxisStepSizeMGDLOverride ?? 25) : 1, axisValueGenerator: axisValueGenerator, addPaddingSegmentIfEdge: false ) return yAxisValues } } extension PredictedGlucoseChart { public func setGlucoseValues(_ glucoseValues: [GlucoseValue]) { glucosePoints = glucosePointsFromValues(glucoseValues) } public func setPredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) { let clampedPredicatedGlucoseValues = clampPredictedGlucoseValues(glucoseValues) predictedGlucosePoints = glucosePointsFromValues(clampedPredicatedGlucoseValues) } public func setAlternatePredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) { alternatePredictedGlucosePoints = glucosePointsFromValues(glucoseValues) } } // MARK: - Clamping the predicted glucose values extension PredictedGlucoseChart { var chartMaximumValue: HKQuantity? { guard let glucosePointMaximum = glucosePoints.max(by: { point1, point2 in point1.y.scalar < point2.y.scalar }) else { return nil } let yAxisValues = determineYAxisValues() if let maxYAxisValue = yAxisValues.last, maxYAxisValue.scalar > glucosePointMaximum.y.scalar { return HKQuantity(unit: glucoseUnit, doubleValue: maxYAxisValue.scalar) } return HKQuantity(unit: glucoseUnit, doubleValue: glucosePointMaximum.y.scalar) } var chartMinimumValue: HKQuantity? { guard let glucosePointMinimum = glucosePoints.min(by: { point1, point2 in point1.y.scalar < point2.y.scalar }) else { return nil } let yAxisValues = determineYAxisValues() if let minYAxisValue = yAxisValues.first, minYAxisValue.scalar < glucosePointMinimum.y.scalar { return HKQuantity(unit: glucoseUnit, doubleValue: minYAxisValue.scalar) } return HKQuantity(unit: glucoseUnit, doubleValue: glucosePointMinimum.y.scalar) } func clampPredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) -> [GlucoseValue] { guard let predictedGlucoseBounds = predictedGlucoseSoftBounds else { return glucoseValues } let predictedGlucoseValueMaximum = chartMaximumValue != nil ? max(predictedGlucoseBounds.maximum, chartMaximumValue!) : predictedGlucoseBounds.maximum let predictedGlucoseValueMinimum = chartMinimumValue != nil ? min(predictedGlucoseBounds.minimum, chartMinimumValue!) : predictedGlucoseBounds.minimum return glucoseValues.map { if $0.quantity > predictedGlucoseValueMaximum { return PredictedGlucoseValue(startDate: $0.startDate, quantity: predictedGlucoseValueMaximum) } else if $0.quantity < predictedGlucoseValueMinimum { return PredictedGlucoseValue(startDate: $0.startDate, quantity: predictedGlucoseValueMinimum) } else { return $0 } } } var chartedGlucoseValueMinimum: HKQuantity? { guard let glucosePointMinimum = glucosePoints.min(by: { point1, point2 in point1.y.scalar < point2.y.scalar }) else { return nil } return HKQuantity(unit: glucoseUnit, doubleValue: glucosePointMinimum.y.scalar) } func glucoseValueBelowSoftBoundsMinimum() -> Bool { guard let predictedGlucoseSoftBounds = predictedGlucoseSoftBounds, let chartedGlucoseValueMinimum = chartedGlucoseValueMinimum else { return false } return chartedGlucoseValueMinimum < predictedGlucoseSoftBounds.minimum } public struct PredictedGlucoseBounds { var minimum: HKQuantity var maximum: HKQuantity public static var `default`: PredictedGlucoseBounds { return PredictedGlucoseBounds(minimum: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40), maximum: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 400)) } } }