| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- //
- // 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))
- }
- }
- }
|