// // Chart.swift // Naterade // // Created by Nathan Racklyeft on 2/19/16. // Copyright © 2016 Nathan Racklyeft. All rights reserved. // import Foundation import HealthKit import LoopKit import SwiftCharts import UIKit open class ChartsManager { private lazy var timeFormatter: DateFormatter = { let formatter = DateFormatter() let dateFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: Locale.current)! let isAmPmTimeFormat = dateFormat.firstIndex(of: "a") != nil formatter.dateFormat = isAmPmTimeFormat ? "h a" : "H:mm" return formatter }() public init( colors: ChartColorPalette, settings: ChartSettings, axisLabelFont: UIFont = .systemFont(ofSize: 14), // caption1, but hard-coded until axis can scale with type preference charts: [ChartProviding], traitCollection: UITraitCollection ) { self.colors = colors self.chartSettings = settings self.charts = charts self.traitCollection = traitCollection self.chartsCache = Array(repeating: nil, count: charts.count) axisLabelSettings = ChartLabelSettings(font: axisLabelFont, fontColor: colors.axisLabel) guideLinesLayerSettings = ChartGuideLinesLayerSettings(linesColor: colors.grid) } // MARK: - Configuration private let colors: ChartColorPalette private let chartSettings: ChartSettings private let labelsWidthY: CGFloat = 30 public let charts: [ChartProviding] /// The amount of horizontal space reserved for fixed margins public var fixedHorizontalMargin: CGFloat { return chartSettings.leading + chartSettings.trailing + labelsWidthY + chartSettings.labelsToAxisSpacingY } private let axisLabelSettings: ChartLabelSettings private let guideLinesLayerSettings: ChartGuideLinesLayerSettings public var gestureRecognizer: UIGestureRecognizer? // MARK: - UITraitEnvironment public var traitCollection: UITraitCollection public func didReceiveMemoryWarning() { for chart in charts { chart.didReceiveMemoryWarning() } xAxisValues = nil } // MARK: - Data /// The earliest date on the X-axis public var startDate = Date() { didSet { if startDate != oldValue { xAxisValues = nil // Set a new minimum end date endDate = startDate.addingTimeInterval(.hours(3)) } } } /// The latest date on the X-axis private var endDate = Date() { didSet { if endDate != oldValue { xAxisValues = nil } } } /// The latest allowed date on the X-axis public var maxEndDate = Date.distantFuture { didSet { endDate = min(endDate, maxEndDate) } } /// Updates the endDate using a new candidate date /// /// Dates are rounded up to the next hour. /// /// - Parameter date: The new candidate date public func updateEndDate(_ date: Date) { if date > endDate { let components = DateComponents(minute: 0) endDate = min( maxEndDate, Calendar.current.nextDate( after: date, matching: components, matchingPolicy: .strict, direction: .forward ) ?? date ) } } // MARK: - State private var xAxisValues: [ChartAxisValue]? { didSet { if let xAxisValues = xAxisValues, xAxisValues.count > 1 { xAxisModel = ChartAxisModel(axisValues: xAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(20)) } else { xAxisModel = nil } chartsCache.replaceAllElements(with: nil) } } private var xAxisModel: ChartAxisModel? private var chartsCache: [Chart?] // MARK: - Generators public func chart(atIndex index: Int, frame: CGRect) -> Chart? { if let chart = chartsCache[index], chart.frame != frame { chartsCache[index] = nil } if chartsCache[index] == nil, let xAxisModel = xAxisModel, let xAxisValues = xAxisValues { chartsCache[index] = charts[index].generate(withFrame: frame, xAxisModel: xAxisModel, xAxisValues: xAxisValues, axisLabelSettings: axisLabelSettings, guideLinesLayerSettings: guideLinesLayerSettings, colors: colors, chartSettings: chartSettings, labelsWidthY: labelsWidthY, gestureRecognizer: gestureRecognizer, traitCollection: traitCollection) } return chartsCache[index] } public func invalidateChart(atIndex index: Int) { chartsCache[index] = nil } // MARK: - Shared Axis private func generateXAxisValues() { if let endDate = charts.compactMap({ $0.endDate }).max() { updateEndDate(endDate) } let points = [ ChartPoint( x: ChartAxisValueDate(date: startDate, formatter: timeFormatter), y: ChartAxisValue(scalar: 0) ), ChartPoint( x: ChartAxisValueDate(date: endDate, formatter: timeFormatter), y: ChartAxisValue(scalar: 0) ) ] let segments = ceil(endDate.timeIntervalSince(startDate).hours) let xAxisValues = ChartAxisValuesStaticGenerator.generateXAxisValuesWithChartPoints(points, minSegmentCount: segments - 1, maxSegmentCount: segments + 1, multiple: TimeInterval(hours: 1), axisValueGenerator: { ChartAxisValueDate( date: ChartAxisValueDate.dateFromScalar($0), formatter: timeFormatter, labelSettings: self.axisLabelSettings ) }, addPaddingSegmentIfEdge: false ) xAxisValues.first?.hidden = true xAxisValues.last?.hidden = true self.xAxisValues = xAxisValues } /// Runs any necessary steps before rendering charts public func prerender() { if xAxisValues == nil { generateXAxisValues() } } } fileprivate extension Array { mutating func replaceAllElements(with element: Element) { self = Array(repeating: element, count: count) } } public protocol ChartProviding { /// Instructs the chart to clear its non-critical resources like caches func didReceiveMemoryWarning() /// The last date represented in the chart data var endDate: Date? { get } /// Creates a chart from the current data /// /// - Returns: A new chart object func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection ) -> Chart }