// // DoseChart.swift // LoopUI // // Copyright © 2019 LoopKit Authors. All rights reserved. // import Foundation import LoopKit import SwiftCharts import UIKit fileprivate struct DosePointsCache { let basal: [ChartPoint] let basalFill: [ChartPoint] let bolus: [ChartPoint] let highlight: [ChartPoint] } public class DoseChart: ChartProviding { public init() { doseEntries = [] } public var doseEntries: [DoseEntry] { didSet { pointsCache = nil } } private var pointsCache: DosePointsCache? { didSet { if let pointsCache = pointsCache { if let lastDate = pointsCache.highlight.last?.x as? ChartAxisValueDate { endDate = lastDate.date } } } } /// The minimum range to display for insulin values. private let doseDisplayRangePoints: [ChartPoint] = [0, 1].map { return ChartPoint( x: ChartAxisValue(scalar: 0), y: ChartAxisValueInt($0) ) } public private(set) var endDate: Date? private var doseChartCache: ChartPointsTouchHighlightLayerViewCache? } public extension DoseChart { func didReceiveMemoryWarning() { pointsCache = nil doseChartCache = nil } func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart { let integerFormatter = NumberFormatter.integer let startDate = ChartAxisValueDate.dateFromScalar(xAxisValues.first!.scalar) let points = generateDosePoints(startDate: startDate) let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesUsingLinearSegmentStep( chartPoints: points.basal + points.bolus + doseDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: log(2) / 2, axisValueGenerator: { ChartAxisValueDoubleLog(screenLocDouble: $0, formatter: integerFormatter, labelSettings: axisLabelSettings) }, addPaddingSegmentIfEdge: true) 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 dose area let lineModel = ChartLineModel(chartPoints: points.basal, lineColor: colors.insulinTint, lineWidth: 2, animDuration: 0, animDelay: 0) let doseLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) let doseArea = ChartPointsFillsLayer( xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ChartPointsFill( chartPoints: points.basalFill, fillColor: colors.insulinTint.withAlphaComponent(0.5), createContainerPoints: false )] ) // bolus points let bolusPointSize: Double = 12 let bolusLayer: ChartPointsScatterDownTrianglesLayer? if points.bolus.count > 0 { bolusLayer = ChartPointsScatterDownTrianglesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: points.bolus, displayDelay: 0, itemSize: CGSize(width: bolusPointSize, height: bolusPointSize), itemFillColor: colors.insulinTint) } else { bolusLayer = nil } // 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 = colors.insulinTint.cgColor return v }) if gestureRecognizer != nil { doseChartCache = ChartPointsTouchHighlightLayerViewCache( xAxisLayer: xAxisLayer, yAxisLayer: yAxisLayer, axisLabelSettings: axisLabelSettings, chartPoints: points.highlight, tintColor: colors.insulinTint, gestureRecognizer: gestureRecognizer ) } let layers: [ChartLayer?] = [ gridLayer, xAxisLayer, yAxisLayer, zeroGuidelineLayer, doseChartCache?.highlightLayer, doseArea, doseLine, bolusLayer ] let chart = Chart(frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 }) // the bolus points are drawn in the chart's drawersContentView. Update the drawersContentView frame to allow the bolus points to be drawn without clipping var frame = chart.drawersContentView.frame frame.size.height = frame.height+CGFloat(bolusPointSize/2) chart.drawersContentView.frame = frame.offsetBy(dx: 0, dy: -CGFloat(bolusPointSize/2)) return chart } private func generateDosePoints(startDate: Date) -> DosePointsCache { guard pointsCache == nil else { return pointsCache! } let dateFormatter = DateFormatter(timeStyle: .short) let doseFormatter = NumberFormatter.dose var basalPoints = [ChartPoint]() var basalFillPoints = [ChartPoint]() var bolusPoints = [ChartPoint]() var highlightPoints = [ChartPoint]() for entry in doseEntries { let time = entry.endDate.timeIntervalSince(entry.startDate) if entry.type == .bolus && entry.netBasalUnits > 0 { let x = ChartAxisValueDate(date: entry.startDate, formatter: dateFormatter) let y = ChartAxisValueDoubleLog(actualDouble: entry.unitsInDeliverableIncrements, unitString: "U", formatter: doseFormatter) let point = ChartPoint(x: x, y: y) bolusPoints.append(point) highlightPoints.append(point) } else if time > 0 { // TODO: Display the DateInterval let startX = ChartAxisValueDate(date: max(startDate, entry.startDate), formatter: dateFormatter) let endX = ChartAxisValueDate(date: entry.endDate, formatter: dateFormatter) let zero = ChartAxisValueInt(0) let rate = entry.netBasalUnitsPerHour let value = ChartAxisValueDoubleLog(actualDouble: rate, unitString: "U/hour", formatter: doseFormatter) let valuePoints: [ChartPoint] if abs(rate) > .ulpOfOne { valuePoints = [ ChartPoint(x: startX, y: value), ChartPoint(x: endX, y: value) ] } else { valuePoints = [] } basalFillPoints += [ChartPoint(x: startX, y: zero)] + valuePoints + [ChartPoint(x: endX, y: zero)] if entry.startDate > startDate { basalPoints += [ChartPoint(x: startX, y: zero)] } basalPoints += valuePoints + [ChartPoint(x: endX, y: zero)] highlightPoints += valuePoints } } let pointsCache = DosePointsCache(basal: basalPoints, basalFill: basalFillPoints, bolus: bolusPoints, highlight: highlightPoints) self.pointsCache = pointsCache return pointsCache } }