// // CarbEffectChart.swift // LoopUI // // Copyright © 2019 LoopKit Authors. All rights reserved. // import Foundation import LoopKit import SwiftCharts import UIKit public class CarbEffectChart: GlucoseChart, ChartProviding { /// The chart points for expected carb effect velocity public private(set) var carbEffectPoints: [ChartPoint] = [] { didSet { // don't extend the end date for carb effects } } /// The chart points for observed insulin counteraction effect velocity public private(set) var insulinCounteractionEffectPoints: [ChartPoint] = [] { didSet { // Extend 1 hour past the seen effect to ensure some future prediction is displayed if let lastDate = insulinCounteractionEffectPoints.last?.x as? ChartAxisValueDate { endDate = lastDate.date.addingTimeInterval(.hours(1)) } } } /// The chart points used for selection in the carb effect chart public private(set) var allCarbEffectPoints: [ChartPoint] = [] public private(set) var endDate: Date? private lazy var dateFormatter = DateFormatter(timeStyle: .short) private lazy var decimalFormatter = NumberFormatter.dose private var carbEffectChartCache: ChartPointsTouchHighlightLayerViewCache? } extension CarbEffectChart { public func didReceiveMemoryWarning() { carbEffectPoints = [] insulinCounteractionEffectPoints = [] allCarbEffectPoints = [] carbEffectChartCache = 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 { /// The minimum range to display for carb effect values. let carbEffectDisplayRangePoints: [ChartPoint] = [0, glucoseUnit.chartableIncrement].map { return ChartPoint( x: ChartAxisValue(scalar: 0), y: ChartAxisValueDouble($0) ) } let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(carbEffectPoints + allCarbEffectPoints + carbEffectDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 4, multiple: glucoseUnit.chartableIncrement / 2, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: axisLabelSettings) }, addPaddingSegmentIfEdge: false ) 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) let carbFillColor = colors.carbTint.withAlphaComponent(0.5) let carbBlendMode: CGBlendMode switch traitCollection.userInterfaceStyle { case .dark: carbBlendMode = .plusLighter case .light, .unspecified: carbBlendMode = .plusDarker @unknown default: carbBlendMode = .plusDarker } // Carb effect let effectsLayer = ChartPointsFillsLayer( xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ ChartPointsFill(chartPoints: carbEffectPoints, fillColor: UIColor.secondaryLabel.withAlphaComponent(0.5)), ChartPointsFill(chartPoints: insulinCounteractionEffectPoints, fillColor: carbFillColor, blendMode: carbBlendMode) ] ) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer( xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues ) // 0-line let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0)) let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in let width: CGFloat = 1 let viewFrame = CGRect(x: chart.contentView.bounds.minX, y: chartPointModel.screenLoc.y - width / 2, width: chart.contentView.bounds.size.width, height: width) let v = UIView(frame: viewFrame) v.layer.backgroundColor = carbFillColor.cgColor return v }) if gestureRecognizer != nil { carbEffectChartCache = ChartPointsTouchHighlightLayerViewCache( xAxisLayer: xAxisLayer, yAxisLayer: yAxisLayer, axisLabelSettings: axisLabelSettings, chartPoints: allCarbEffectPoints, tintColor: colors.carbTint, gestureRecognizer: gestureRecognizer ) } let layers: [ChartLayer?] = [ gridLayer, xAxisLayer, yAxisLayer, zeroGuidelineLayer, carbEffectChartCache?.highlightLayer, effectsLayer ] return Chart( frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 } ) } } extension CarbEffectChart { /// Convert an array of GlucoseEffects (as glucose values) into glucose effect velocity (glucose/min) for charting /// /// - Parameter effects: A timeline of glucose values representing glucose change public func setCarbEffects(_ effects: [GlucoseEffect]) { let unit = glucoseUnit.unitDivided(by: .minute()) let unitString = unit.unitString var lastDate = effects.first?.endDate var lastValue = effects.first?.quantity.doubleValue(for: glucoseUnit) let minuteInterval = 5.0 var carbEffectPoints = [ChartPoint]() let zero = ChartAxisValueInt(0) for effect in effects.dropFirst() { let value = effect.quantity.doubleValue(for: glucoseUnit) let valuePerMinute = (value - lastValue!) / minuteInterval lastValue = value let startX = ChartAxisValueDate(date: lastDate!, formatter: dateFormatter) let endX = ChartAxisValueDate(date: effect.endDate, formatter: dateFormatter) lastDate = effect.endDate let valueY = ChartAxisValueDoubleUnit(valuePerMinute, unitString: unitString, formatter: decimalFormatter) carbEffectPoints += [ ChartPoint(x: startX, y: zero), ChartPoint(x: startX, y: valueY), ChartPoint(x: endX, y: valueY), ChartPoint(x: endX, y: zero) ] } self.carbEffectPoints = carbEffectPoints } /// Charts glucose effect velocity /// /// - Parameter effects: A timeline of glucose velocity values public func setInsulinCounteractionEffects(_ effects: [GlucoseEffectVelocity]) { let unit = glucoseUnit.unitDivided(by: .minute()) let unitString = String(format: NSLocalizedString("%1$@/min", comment: "Format string describing glucose units per minute (1: glucose unit string)"), glucoseUnit.shortLocalizedUnitString()) var insulinCounteractionEffectPoints: [ChartPoint] = [] var allCarbEffectPoints: [ChartPoint] = [] let zero = ChartAxisValueInt(0) for effect in effects { let startX = ChartAxisValueDate(date: effect.startDate, formatter: dateFormatter) let endX = ChartAxisValueDate(date: effect.endDate, formatter: dateFormatter) let value = ChartAxisValueDoubleUnit(effect.quantity.doubleValue(for: unit), unitString: unitString, formatter: decimalFormatter) guard value.scalar != 0 else { continue } let valuePoint = ChartPoint(x: endX, y: value) insulinCounteractionEffectPoints += [ ChartPoint(x: startX, y: zero), ChartPoint(x: startX, y: value), valuePoint, ChartPoint(x: endX, y: zero) ] allCarbEffectPoints.append(valuePoint) } self.insulinCounteractionEffectPoints = insulinCounteractionEffectPoints self.allCarbEffectPoints = allCarbEffectPoints } }