Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,212 @@
//
// BaseChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
enum BaseConstants {
static let defaultRange: ClosedRange<CGFloat> = 0...1
static let minimumAxisYLabelsDistance: CGFloat = 85
static let monthDayDateFormatter = DateFormatter.utc(format: "MMM d")
static let timeDateFormatter = DateFormatter.utc(format: "HH:mm")
static let headerFullRangeFormatter: DateFormatter = {
let formatter = DateFormatter.utc()
formatter.calendar = Calendar.utc
formatter.dateStyle = .long
return formatter
}()
static let headerMediumRangeFormatter: DateFormatter = {
let formatter = DateFormatter.utc()
formatter.dateStyle = .medium
return formatter
}()
static let headerFullZoomedFormatter: DateFormatter = {
let formatter = DateFormatter.utc()
formatter.dateStyle = .full
return formatter
}()
static let verticalBaseAnchors: [CGFloat] = [8, 5, 2.5, 2, 1]
static let defaultVerticalBaseAnchor: CGFloat = 1
static let mainChartLineWidth: CGFloat = 2
static let previewChartLineWidth: CGFloat = 1
static let previewLinesChartOptimizationLevel: CGFloat = 1.5
static let linesChartOptimizationLevel: CGFloat = 1.0
static let barsChartOptimizationLevel: CGFloat = 0.75
static let defaultRangePresetLength = TimeInterval.day * 60
static let chartNumberFormatter: ScalesNumberFormatter = {
let numberFormatter = ScalesNumberFormatter()
numberFormatter.allowsFloats = true
numberFormatter.numberStyle = .decimal
numberFormatter.usesGroupingSeparator = true
numberFormatter.groupingSeparator = " "
numberFormatter.minimumIntegerDigits = 1
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
return numberFormatter
}()
static let tonNumberFormatter: NumberFormatter = {
let numberFormatter = TonNumberFormatter()
numberFormatter.allowsFloats = true
numberFormatter.numberStyle = .decimal
numberFormatter.usesGroupingSeparator = true
numberFormatter.groupingSeparator = " "
numberFormatter.minimumIntegerDigits = 1
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
return numberFormatter
}()
static let starNumberFormatter: NumberFormatter = {
let numberFormatter = NumberFormatter()
numberFormatter.allowsFloats = true
numberFormatter.numberStyle = .decimal
numberFormatter.usesGroupingSeparator = true
numberFormatter.groupingSeparator = " "
numberFormatter.minimumIntegerDigits = 1
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
return numberFormatter
}()
static let detailsNumberFormatter: NumberFormatter = {
let detailsNumberFormatter = NumberFormatter()
detailsNumberFormatter.allowsFloats = false
detailsNumberFormatter.numberStyle = .decimal
detailsNumberFormatter.usesGroupingSeparator = true
detailsNumberFormatter.groupingSeparator = " "
return detailsNumberFormatter
}()
}
public class BaseChartController: ChartThemeContainer {
//let performanceRenderer = PerformanceRenderer()
var initialChartsCollection: ChartsCollection
var isZoomed: Bool = false
public var isZoomable: Bool = true
var chartTitle: String = ""
public init(chartsCollection: ChartsCollection) {
self.initialChartsCollection = chartsCollection
}
public var mainChartRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
public var navigationRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
public var cartViewBounds: (() -> CGRect) = { fatalError() }
public var chartFrame: (() -> CGRect) = { fatalError() }
public func initializeChart() {
fatalError("Abstract")
}
public func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
fatalError("Abstract")
}
public func chartInteractionDidEnd() {
fatalError("Abstract")
}
public func cancelChartInteraction() {
fatalError("Abstract")
}
public func didTapZoomOut() {
fatalError("Abstract")
}
public func updateChartsVisibility(visibility: [Bool], animated: Bool) {
fatalError("Abstract")
}
public var currentHorizontalRange: ClosedRange<CGFloat> {
fatalError("Abstract")
}
public func height(for width: CGFloat) -> CGFloat {
var height: CGFloat = 308
let items = actualChartsCollection.chartValues.map { value in
return ChartVisibilityItem(title: value.name, color: value.color)
}
let frames = ChartVisibilityItem.generateItemsFrames(for: width, items: items)
guard let lastFrame = frames.last else { return height }
height += lastFrame.maxY
return height
}
public var isChartRangePagingEnabled: Bool = false
public var minimumSelectedChartRange: CGFloat = 0.085
public var chartRangePagingClosure: ((Bool, CGFloat) -> Void)? // isEnabled, PageSize
public func setChartRangePagingEnabled(isEnabled: Bool, minimumSelectionSize: CGFloat) {
isChartRangePagingEnabled = isEnabled
minimumSelectedChartRange = minimumSelectionSize
chartRangePagingClosure?(isChartRangePagingEnabled, minimumSelectedChartRange)
}
public var chartRangeUpdatedClosure: ((ClosedRange<CGFloat>, Bool) -> Void)?
public var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
fatalError("Abstract")
}
public func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
fatalError("Abstract")
}
public var actualChartVisibility: [Bool] {
fatalError("Abstract")
}
public var actualChartsCollection: ChartsCollection {
fatalError("Abstract")
}
public var drawChartVisibity: Bool {
return true
}
public var drawChartNavigation: Bool {
return true
}
public var setDetailsViewPositionClosure: ((CGFloat) -> Void)?
public var setDetailsChartVisibleClosure: ((Bool, Bool) -> Void)?
public var setDetailsViewModel: ((ChartDetailsViewModel, Bool, Bool) -> Void)?
public var getDetailsData: ((Date, @escaping (ChartsCollection?) -> Void) -> Void)?
public var setChartTitleClosure: ((String, Bool) -> Void)?
public var setBackButtonVisibilityClosure: ((Bool, Bool) -> Void)?
public var refreshChartToolsClosure: ((Bool) -> Void)?
public func didTapZoomIn(date: Date, pointIndex: Int) {
fatalError("Abstract")
}
public func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
}
}
@@ -0,0 +1,411 @@
//
// GeneralChartComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
enum GeneralChartComponentConstants {
static let defaultInitialRangeLength = CGFloat(TimeInterval.day * 60)
static let defaultZoomedRangeLength = CGFloat(TimeInterval.day)
}
class GeneralChartComponentController: ChartThemeContainer {
var chartsCollection: ChartsCollection = ChartsCollection.blank
var chartVisibility: [Bool] = []
var lastChartInteractionPoint: CGPoint = .zero
var isChartInteractionBegun: Bool = false
var isChartInteracting: Bool = false
var ignoreInteraction: Bool = false
let isZoomed: Bool
var isZoomable = true
var theme: ChartTheme = ChartTheme.defaultDayTheme
var strings: ChartStrings = ChartStrings.defaultStrings
var totalHorizontalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var totalVerticalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var initialHorizontalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var initialVerticalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var currency: GraphCurrency?
var conversionRate: Double = 1.0
public var cartViewBounds: (() -> CGRect) = { fatalError() }
public var chartFrame: (() -> CGRect) = { fatalError() }
init(isZoomed: Bool) {
self.isZoomed = isZoomed
}
func initialize(chartsCollection: ChartsCollection,
initialDate: Date,
totalHorizontalRange: ClosedRange<CGFloat>,
totalVerticalRange: ClosedRange<CGFloat>) {
self.chartsCollection = chartsCollection
self.chartVisibility = Array(repeating: true, count: chartsCollection.chartValues.count)
self.totalHorizontalRange = totalHorizontalRange
self.totalVerticalRange = totalVerticalRange
self.initialHorizontalRange = totalHorizontalRange
self.initialVerticalRange = totalVerticalRange
didLoad()
setupInitialChartRange(initialDate: initialDate)
}
func didLoad() {
hideDetailsView(animated: false)
}
func willAppear(animated: Bool) {
updateChartRangeTitle(animated: animated)
setupChartRangePaging()
}
func willDisappear(animated: Bool) {
}
func setupInitialChartRange(initialDate: Date) {
guard let first = chartsCollection.axisValues.first?.timeIntervalSince1970,
let last = chartsCollection.axisValues.last?.timeIntervalSince1970 else { return }
let rangeStart = CGFloat(first)
let rangeEnd = CGFloat(last)
if isZoomed {
let initalDate = CGFloat(initialDate.timeIntervalSince1970)
initialHorizontalRange = max(initalDate, rangeStart)...min(initalDate + GeneralChartComponentConstants.defaultZoomedRangeLength, rangeEnd)
initialVerticalRange = totalVerticalRange
} else {
initialHorizontalRange = max(rangeStart, rangeEnd - GeneralChartComponentConstants.defaultInitialRangeLength)...rangeEnd
initialVerticalRange = totalVerticalRange
}
}
func setupChartRangePaging() {
chartRangePagingClosure?(false, 0.05)
}
var visibleHorizontalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.verticalRange.current
}
var visibleVerticalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.verticalRange.current
}
var currentHorizontalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.horizontalRange.end
}
var currentVerticalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.verticalRange.end
}
var currentMainRangeRenderer: BaseChartRenderer {
fatalError("Abstract")
}
var visiblePreviewHorizontalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.verticalRange.current
}
var visiblePreviewVerticalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.verticalRange.current
}
var currentPreviewHorizontalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.horizontalRange.end
}
var currentPreviewVerticalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.verticalRange.end
}
var currentPreviewRangeRenderer: BaseChartRenderer {
fatalError("Abstract")
}
var mainChartRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
var previewRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
func updateChartsVisibility(visibility: [Bool], animated: Bool) {
self.chartVisibility = visibility
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint, manual: false)
}
}
var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalMainChartRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalMainChartRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
func chartRangeFractionDidUpdated(_ rangeFraction: ClosedRange<CGFloat>) {
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
chartRangeDidUpdated(horizontalRange)
updateChartRangeTitle(animated: true)
}
func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
hideDetailsView(animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
}
// MARK: - Details & Interaction
func findClosestDateTo(dateToFind: Date) -> (Date, Int)? {
guard chartsCollection.axisValues.count > 0 else { return nil }
var closestDate = chartsCollection.axisValues[0]
var minIndex = 0
for (index, date) in chartsCollection.axisValues.enumerated() {
if abs(dateToFind.timeIntervalSince(date)) < abs(dateToFind.timeIntervalSince(closestDate)) {
closestDate = date
minIndex = index
}
}
return (closestDate, minIndex)
}
var currentChartValue: CGFloat?
func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if manual && !isChartInteracting && detailsVisible {
self.hideDetailsView(animated: true)
ignoreInteraction = true
return
}
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let horizontalRange = currentHorizontalMainChartRange
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartWasInteracting = isChartInteractionBegun
lastChartInteractionPoint = point
isChartInteractionBegun = true
isChartInteracting = true
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
var chartValueUpdated = true
if chartValue == currentChartValue {
chartValueUpdated = false
}
currentChartValue = chartValue
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
showDetailsView(at: chartValue, detailsViewPosition: detailsViewPosition, dataIndex: minIndex, date: closestDate, animated: chartWasInteracting, feedback: chartWasInteracting && chartValueUpdated)
}
var detailsVisible = false
func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animated: Bool, feedback: Bool) {
setDetailsViewModel?(chartDetailsViewModel(closestDate: date, pointIndex: dataIndex, currency: self.currency, rate: self.conversionRate), animated, feedback)
setDetailsChartVisibleClosure?(true, true)
setDetailsViewPositionClosure?(detailsViewPosition)
detailsVisible = true
}
func chartInteractionDidEnd() {
isChartInteracting = false
ignoreInteraction = false
currentChartValue = nil
}
func hideDetailsView(animated: Bool) {
isChartInteractionBegun = false
setDetailsChartVisibleClosure?(false, animated)
detailsVisible = false
currentChartValue = nil
}
var visibleDetailsChartValues: [ChartsCollection.Chart] {
let visibleCharts: [ChartsCollection.Chart] = chartVisibility.enumerated().compactMap { args in
args.element ? chartsCollection.chartValues[args.offset] : nil
}
return visibleCharts
}
var updatePreviewRangeClosure: ((ClosedRange<CGFloat>, Bool) -> Void)?
var zoomInOnDateClosure: ((Date) -> Void)?
var setChartTitleClosure: ((String, Bool) -> Void)?
var setDetailsViewPositionClosure: ((CGFloat) -> Void)?
var setDetailsChartVisibleClosure: ((Bool, Bool) -> Void)?
var setDetailsViewModel: ((ChartDetailsViewModel, Bool, Bool) -> Void)?
var chartRangePagingClosure: ((Bool, CGFloat) -> Void)? // isEnabled, PageSize
func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
self.theme = theme
self.strings = strings
}
// MARK: - Helpers
var prevoiusHorizontalStrideInterval: Int = -1
func updateHorizontalLimitLabels(horizontalScalesRenderer: HorizontalScalesRenderer,
horizontalRange: ClosedRange<CGFloat>,
scaleType: ChartScaleType,
forceUpdate: Bool,
animated: Bool) {
let scaleTimeInterval: TimeInterval
if chartsCollection.axisValues.count >= 1 {
scaleTimeInterval = chartsCollection.axisValues[1].timeIntervalSince1970 - chartsCollection.axisValues[0].timeIntervalSince1970
} else {
scaleTimeInterval = scaleType.timeInterval
}
let numberOfItems = horizontalRange.distance / CGFloat(scaleTimeInterval)
let maximumNumberOfItems = chartFrame().width / scaleType.minimumAxisXDistance
let tempStride = max(1, Int((numberOfItems / maximumNumberOfItems).rounded(.up)))
var strideInterval = 1
while strideInterval < tempStride {
strideInterval *= 2
}
if forceUpdate || (strideInterval != prevoiusHorizontalStrideInterval && strideInterval > 0) {
var labels: [LinesChartLabel] = []
for index in stride(from: chartsCollection.axisValues.count - 1, to: -1, by: -strideInterval).reversed() {
let date = chartsCollection.axisValues[index]
let timestamp = date.timeIntervalSince1970
if timestamp <= 24 {
labels.append(LinesChartLabel(value: CGFloat(timestamp),
text: "\(Int(timestamp)):00"))
} else {
labels.append(LinesChartLabel(value: CGFloat(timestamp),
text: scaleType.dateFormatter.string(from: date)))
}
}
prevoiusHorizontalStrideInterval = strideInterval
horizontalScalesRenderer.setup(labels: labels, animated: animated)
}
}
var verticalLimitsNumberFormatter: NumberFormatter = BaseConstants.chartNumberFormatter
var detailsNumberFormatter: NumberFormatter = BaseConstants.detailsNumberFormatter
func verticalLimitsLabels(verticalRange: ClosedRange<CGFloat>) -> (ClosedRange<CGFloat>, [LinesChartLabel]) {
let ditance = verticalRange.distance
let chartHeight = chartFrame().height
guard ditance > 0, chartHeight > 0 else { return (BaseConstants.defaultRange, []) }
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
var numberOfOffsetsPerItem = ditance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
}
var base: CGFloat = BaseConstants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
base = base * multiplier / dividor
var verticalLabels: [LinesChartLabel] = []
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let lowerBound = verticalValue
let numberFormatter = self.verticalLimitsNumberFormatter
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
while verticalValue < verticalRange.upperBound {
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
verticalValue += base
}
let updatedRange = lowerBound...verticalValue
return (updatedRange, verticalLabels)
}
func chartDetailsViewModel(closestDate: Date, pointIndex: Int, currency: GraphCurrency? = nil, rate: Double = 1.0) -> ChartDetailsViewModel {
var values: [ChartDetailsViewModel.Value] = chartsCollection.chartValues.enumerated().map { arg in
let (index, component) = arg
return ChartDetailsViewModel.Value(prefix: nil,
title: component.name,
value: self.detailsNumberFormatter.string(from: NSNumber(value: component.values[pointIndex])) ?? "",
color: component.color,
visible: chartVisibility[index])
}
if let currency, let firstValue = values.first, let starColor = GColor(hexString: "#dda747") {
let updatedTitle: String
let color: GColor
switch currency {
case .ton:
updatedTitle = self.strings.revenueInTon
color = firstValue.color
case .xtr:
updatedTitle = self.strings.revenueInStars
color = starColor
}
values[0] = ChartDetailsViewModel.Value(
prefix: nil,
title: updatedTitle,
value: firstValue.value,
color: color,
visible: firstValue.visible
)
let convertedValue = (self.verticalLimitsNumberFormatter.number(from: firstValue.value) as? Double ?? 0.0) * rate
let convertedValueString: String
if convertedValue > 1.0 {
convertedValueString = String(format: "%0.1f", convertedValue)
} else {
convertedValueString = String(format: "%0.3f", convertedValue)
}
values.append(ChartDetailsViewModel.Value(
prefix: nil,
title: self.strings.revenueInUsd,
value: "~$\(convertedValueString)",
color: color,
visible: firstValue.visible
))
}
let dateString: String
if isZoomed {
dateString = BaseConstants.timeDateFormatter.string(from: closestDate)
} else {
dateString = BaseConstants.headerMediumRangeFormatter.string(from: closestDate)
}
let viewModel = ChartDetailsViewModel(title: dateString,
showArrow: self.isZoomable && !self.isZoomed,
showPrefixes: false,
isLoading: false,
values: values,
totalValue: nil,
tapAction: { [weak self] in
self?.zoomInOnDateClosure?(closestDate) },
hideAction: { [weak self] in
self?.setDetailsChartVisibleClosure?(false, true)
})
return viewModel
}
func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.lowerBound) + 1)
let toDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let string = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(string, animated)
} else {
let string = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(string, animated)
}
}
}
@@ -0,0 +1,275 @@
//
// BaseLinesChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
public class BaseLinesChartController: BaseChartController {
var chartVisibility: [Bool]
var zoomChartVisibility: [Bool]
var lastChartInteractionPoint: CGPoint = .zero
var isChartInteractionBegun: Bool = false
var isChartInteracting: Bool = false
var ignoreInteraction: Bool = false
var initialChartRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var zoomedChartRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
override public init(chartsCollection: ChartsCollection) {
self.chartVisibility = Array(repeating: true, count: chartsCollection.chartValues.count)
self.zoomChartVisibility = []
super.init(chartsCollection: chartsCollection)
}
func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
if animated {
TimeInterval.setDefaultDuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultDuration(.osXDuration)
}
}
self.initialChartsCollection = chartsCollection
self.isZoomed = isZoomed
self.setBackButtonVisibilityClosure?(isZoomed, animated)
updateChartRangeTitle(animated: animated)
}
func updateChartRangeTitle(animated: Bool) {
let range: ClosedRange<CGFloat>
if zoomedChartRange == BaseConstants.defaultRange {
range = initialChartRange
} else {
range = zoomedChartRange
}
let fromDate = Date(timeIntervalSince1970: TimeInterval(range.lowerBound) + .hour)
let toDate = Date(timeIntervalSince1970: TimeInterval(range.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
lastChartInteractionPoint = point
isChartInteractionBegun = true
isChartInteracting = true
}
public override func chartInteractionDidEnd() {
isChartInteracting = false
ignoreInteraction = false
}
public override func cancelChartInteraction() {
isChartInteracting = false
isChartInteractionBegun = false
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
}
public override var actualChartVisibility: [Bool] {
return isZoomed ? zoomChartVisibility : chartVisibility
}
public override var actualChartsCollection: ChartsCollection {
return initialChartsCollection
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibleCharts: [ChartsCollection.Chart] = actualChartVisibility.enumerated().compactMap { args in
args.element ? initialChartsCollection.chartValues[args.offset] : nil
}
return visibleCharts
}
func chartDetailsViewModel(closestDate: Date, pointIndex: Int, loading: Bool) -> ChartDetailsViewModel {
let values: [ChartDetailsViewModel.Value] = actualChartsCollection.chartValues.enumerated().map { arg in
let (index, component) = arg
return ChartDetailsViewModel.Value(prefix: nil,
title: component.name,
value: BaseConstants.detailsNumberFormatter.string(from: component.values[pointIndex]),
color: component.color,
visible: actualChartVisibility[index])
}
let total = actualChartsCollection.chartValues.enumerated().map { $0.element.values[pointIndex] }.reduce(0, +)
let dateString: String
if isZoomed {
dateString = BaseConstants.timeDateFormatter.string(from: closestDate)
} else {
dateString = BaseConstants.headerMediumRangeFormatter.string(from: closestDate)
}
let viewModel = ChartDetailsViewModel(title: dateString,
showArrow: total > 0 && self.isZoomable && !self.isZoomed,
showPrefixes: false,
isLoading: loading,
values: values,
totalValue: nil,
tapAction: { [weak self] in
self?.didTapZoomIn(date: closestDate, pointIndex: pointIndex)
}, hideAction: { [weak self] in
self?.cancelChartInteraction()
})
return viewModel
}
public override func didTapZoomIn(date: Date, pointIndex: Int) {
guard !isZoomed, isZoomable else { return }
setDetailsViewModel?(chartDetailsViewModel(closestDate: date, pointIndex: pointIndex, loading: true), false, false)
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.cancelChartInteraction()
self.initialChartRange = self.currentHorizontalRange
if let startDate = updatedCollection.axisValues.first,
let endDate = updatedCollection.axisValues.last {
self.zoomedChartRange = CGFloat(max(date.timeIntervalSince1970, startDate.timeIntervalSince1970))...CGFloat(min(date.timeIntervalSince1970 + .day - .hour, endDate.timeIntervalSince1970))
} else {
self.zoomedChartRange = CGFloat(date.timeIntervalSince1970)...CGFloat(date.timeIntervalSince1970 + .day - 1)
}
self.setupChartCollection(chartsCollection: updatedCollection, animated: true, isZoomed: true)
}
})
}
func horizontalLimitsLabels(horizontalRange: ClosedRange<CGFloat>,
scaleType: ChartScaleType,
prevoiusHorizontalStrideInterval: Int) -> (Int, [LinesChartLabel])? {
let numberOfItems = horizontalRange.distance / CGFloat(scaleType.timeInterval)
let maximumNumberOfItems = chartFrame().width / scaleType.minimumAxisXDistance
let tempStride = max(1, Int((numberOfItems / maximumNumberOfItems).rounded(.up)))
var strideInterval = 1
while strideInterval < tempStride {
strideInterval *= 2
}
if strideInterval != prevoiusHorizontalStrideInterval && strideInterval > 0 {
var labels: [LinesChartLabel] = []
for index in stride(from: initialChartsCollection.axisValues.count - 1, to: -1, by: -strideInterval).reversed() {
let date = initialChartsCollection.axisValues[index]
let timestamp = date.timeIntervalSince1970
if timestamp <= 24 {
labels.append(LinesChartLabel(value: CGFloat(timestamp),
text: "\(Int(timestamp)):00"))
} else {
labels.append(LinesChartLabel(value: CGFloat(timestamp),
text: scaleType.dateFormatter.string(from: date)))
}
}
return (strideInterval, labels)
}
return nil
}
func findClosestDateTo(dateToFind: Date) -> (Date, Int)? {
guard initialChartsCollection.axisValues.count > 0 else { return nil }
var closestDate = initialChartsCollection.axisValues[0]
var minIndex = 0
for (index, date) in initialChartsCollection.axisValues.enumerated() {
if abs(dateToFind.timeIntervalSince(date)) < abs(dateToFind.timeIntervalSince(closestDate)) {
closestDate = date
minIndex = index
}
}
return (closestDate, minIndex)
}
func verticalLimitsLabels(verticalRange: ClosedRange<CGFloat>) -> (ClosedRange<CGFloat>, [LinesChartLabel]) {
let distance = verticalRange.distance
let chartHeight = chartFrame().height
guard distance > 0, chartHeight > 0 else { return (BaseConstants.defaultRange, []) }
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
var numberOfOffsetsPerItem = distance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
}
var base: CGFloat = BaseConstants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
base = base * multiplier / dividor
var verticalLabels: [LinesChartLabel] = []
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let lowerBound = verticalValue
let numberFormatter = BaseConstants.chartNumberFormatter
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
while verticalValue < verticalRange.upperBound {
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
verticalValue += base
}
let updatedRange = lowerBound...verticalValue
return (updatedRange, verticalLabels)
}
}
enum ChartScaleType {
case day
case hour
case minutes5
}
extension ChartScaleType {
var timeInterval: TimeInterval {
switch self {
case .day: return .day
case .hour: return .hour
case .minutes5: return .minute * 5
}
}
var minimumAxisXDistance: CGFloat {
switch self {
case .day: return 50
case .hour: return 40
case .minutes5: return 40
}
}
var dateFormatter: DateFormatter {
switch self {
case .day: return BaseConstants.monthDayDateFormatter
case .hour: return BaseConstants.timeDateFormatter
case .minutes5: return BaseConstants.timeDateFormatter
}
}
}
@@ -0,0 +1,259 @@
//
// LinesChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
private enum Constants {
static let defaultRange: ClosedRange<CGFloat> = 0...1
}
public class GeneralLinesChartController: BaseLinesChartController {
private let initialChartCollection: ChartsCollection
private let mainLinesRenderer = LinesChartRenderer()
private let horizontalScalesRenderer = HorizontalScalesRenderer()
private let verticalScalesRenderer = VerticalScalesRenderer()
private let verticalLineRenderer = VerticalLinesRenderer()
private let lineBulletsRenderer = LineBulletsRenderer()
private let previewLinesRenderer = LinesChartRenderer()
private var totalVerticalRange: ClosedRange<CGFloat> = Constants.defaultRange
private var totalHorizontalRange: ClosedRange<CGFloat> = Constants.defaultRange
private var prevoiusHorizontalStrideInterval: Int = 1
private(set) var chartLines: [LinesChartRenderer.LineData] = []
override public init(chartsCollection: ChartsCollection) {
self.initialChartCollection = chartsCollection
self.mainLinesRenderer.lineWidth = 2
self.mainLinesRenderer.optimizationLevel = BaseConstants.linesChartOptimizationLevel
self.previewLinesRenderer.optimizationLevel = BaseConstants.previewLinesChartOptimizationLevel
self.lineBulletsRenderer.isEnabled = false
super.init(chartsCollection: chartsCollection)
self.zoomChartVisibility = chartVisibility
}
override func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
super.setupChartCollection(chartsCollection: chartsCollection, animated: animated, isZoomed: isZoomed)
self.chartLines = chartsCollection.chartValues.map { chart in
let points = chart.values.enumerated().map({ (arg) -> CGPoint in
return CGPoint(x: chartsCollection.axisValues[arg.offset].timeIntervalSince1970,
y: arg.element)
})
return LinesChartRenderer.LineData(color: chart.color, points: points)
}
self.prevoiusHorizontalStrideInterval = -1
self.totalVerticalRange = LinesChartRenderer.LineData.verticalRange(lines: chartLines) ?? Constants.defaultRange
self.totalHorizontalRange = LinesChartRenderer.LineData.horizontalRange(lines: chartLines) ?? Constants.defaultRange
self.lineBulletsRenderer.bullets = self.chartLines.map { LineBulletsRenderer.Bullet(coordinate: $0.points.first ?? .zero, offset: .zero,
color: $0.color)}
let chartRange: ClosedRange<CGFloat>
if isZoomed {
chartRange = zoomedChartRange
} else {
chartRange = initialChartRange
}
self.previewLinesRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
self.previewLinesRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
self.mainLinesRenderer.setLines(lines: chartLines, animated: animated)
self.previewLinesRenderer.setLines(lines: chartLines, animated: animated)
updateHorizontalLimits(horizontalRange: chartRange, animated: animated)
updateMainChartHorizontalRange(range: chartRange, animated: animated)
updateVerticalLimitsAndRange(horizontalRange: chartRange, animated: animated)
self.chartRangeUpdatedClosure?(currentChartHorizontalRangeFraction, animated)
}
public override func initializeChart() {
if let first = initialChartCollection.axisValues.first?.timeIntervalSince1970,
let last = initialChartCollection.axisValues.last?.timeIntervalSince1970 {
initialChartRange = CGFloat(max(first, last - BaseConstants.defaultRangePresetLength))...CGFloat(last)
}
setupChartCollection(chartsCollection: initialChartCollection, animated: false, isZoomed: false)
}
public override var mainChartRenderers: [ChartViewRenderer] {
return [//performanceRenderer,
mainLinesRenderer,
horizontalScalesRenderer,
verticalScalesRenderer,
verticalLineRenderer,
lineBulletsRenderer
]
}
public override var navigationRenderers: [ChartViewRenderer] {
return [previewLinesRenderer]
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
chartVisibility = visibility
zoomChartVisibility = visibility
for (index, isVisible) in visibility.enumerated() {
mainLinesRenderer.setLineVisible(isVisible, at: index, animated: animated)
previewLinesRenderer.setLineVisible(isVisible, at: index, animated: animated)
lineBulletsRenderer.setLineVisible(isVisible, at: index, animated: animated)
}
updateVerticalLimitsAndRange(horizontalRange: currentHorizontalRange, animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint, manual: false)
}
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if manual && !isChartInteracting && !self.verticalLineRenderer.values.isEmpty {
self.cancelChartInteraction()
ignoreInteraction = true
return
}
let horizontalRange = mainLinesRenderer.horizontalRange.current
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartInteractionWasBegin = isChartInteractionBegun
super.chartInteractionDidBegin(point: point)
self.lineBulletsRenderer.bullets = chartLines.compactMap { chart in
return LineBulletsRenderer.Bullet(coordinate: chart.points[minIndex], offset: .zero, color: chart.color)
}
self.lineBulletsRenderer.isEnabled = true
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
var chartValueUpdated = true
if self.verticalLineRenderer.values == [chartValue] {
chartValueUpdated = false
}
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
self.setDetailsViewModel?(chartDetailsViewModel(closestDate: closestDate, pointIndex: minIndex, loading: false), chartInteractionWasBegin, chartInteractionWasBegin && chartValueUpdated)
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewPositionClosure?(detailsViewPosition)
self.verticalLineRenderer.values = [chartValue]
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
public override var currentHorizontalRange: ClosedRange<CGFloat> {
return mainLinesRenderer.horizontalRange.end
}
public override func cancelChartInteraction() {
super.cancelChartInteraction()
self.lineBulletsRenderer.isEnabled = false
self.setDetailsChartVisibleClosure?(false, true)
self.verticalLineRenderer.values = []
}
public override func didTapZoomOut() {
cancelChartInteraction()
self.setupChartCollection(chartsCollection: initialChartCollection, animated: true, isZoomed: false)
}
var visibleCharts: [LinesChartRenderer.LineData] {
let visibleCharts: [LinesChartRenderer.LineData] = chartVisibility.enumerated().compactMap { args in
args.element ? chartLines[args.offset] : nil
}
return visibleCharts
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
cancelChartInteraction()
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
zoomedChartRange = horizontalRange
updateChartRangeTitle(animated: animated)
updateMainChartHorizontalRange(range: horizontalRange, animated: false)
updateHorizontalLimits(horizontalRange: horizontalRange, animated: animated)
updateVerticalLimitsAndRange(horizontalRange: horizontalRange, animated: animated)
}
func updateMainChartHorizontalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(horizontalRange: range, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalLineRenderer.setup(horizontalRange: range, animated: animated)
lineBulletsRenderer.setup(horizontalRange: range, animated: animated)
}
func updateMainChartVerticalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(verticalRange: range, animated: animated)
horizontalScalesRenderer.setup(verticalRange: range, animated: animated)
verticalScalesRenderer.setup(verticalRange: range, animated: animated)
verticalLineRenderer.setup(verticalRange: range, animated: animated)
lineBulletsRenderer.setup(verticalRange: range, animated: animated)
}
func updateHorizontalLimits(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let (stride, labels) = horizontalLimitsLabels(horizontalRange: horizontalRange,
scaleType: isZoomed ? .hour : .day,
prevoiusHorizontalStrideInterval: prevoiusHorizontalStrideInterval) {
self.horizontalScalesRenderer.setup(labels: labels, animated: animated)
self.prevoiusHorizontalStrideInterval = stride
}
}
func updateVerticalLimitsAndRange(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let verticalRange = LinesChartRenderer.LineData.verticalRange(lines: visibleCharts,
calculatingRange: horizontalRange,
addBounds: true) {
let (range, labels) = verticalLimitsLabels(verticalRange: verticalRange)
if verticalScalesRenderer.verticalRange.end != range {
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
updateMainChartVerticalRange(range: range, animated: animated)
}
verticalScalesRenderer.setVisible(true, animated: animated)
} else {
verticalScalesRenderer.setVisible(false, animated: animated)
}
guard let previewVerticalRange = LinesChartRenderer.LineData.verticalRange(lines: visibleCharts) else { return }
if previewLinesRenderer.verticalRange.end != previewVerticalRange {
previewLinesRenderer.setup(verticalRange: previewVerticalRange, animated: animated)
}
}
override public func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
horizontalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.axisXColor = theme.chartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = theme.chartHelperLinesColor
lineBulletsRenderer.setInnerColor(theme.chartBackgroundColor, animated: animated)
verticalLineRenderer.linesColor = theme.chartStrongLinesColor
}
}
@@ -0,0 +1,323 @@
//
// TwoAxisLinesChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
private enum Constants {
static let verticalBaseAnchors: [CGFloat] = [8, 5, 4, 2.5, 2, 1]
static let defaultRange: ClosedRange<CGFloat> = 0...1
}
public class TwoAxisLinesChartController: BaseLinesChartController {
class GraphController {
let mainLinesRenderer = LinesChartRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
let lineBulletsRenderer = LineBulletsRenderer()
let previewLinesRenderer = LinesChartRenderer()
var chartLines: [LinesChartRenderer.LineData] = []
var totalVerticalRange: ClosedRange<CGFloat> = Constants.defaultRange
init() {
self.mainLinesRenderer.lineWidth = 2
self.previewLinesRenderer.lineWidth = 1
self.lineBulletsRenderer.isEnabled = false
self.mainLinesRenderer.optimizationLevel = BaseConstants.linesChartOptimizationLevel
self.previewLinesRenderer.optimizationLevel = BaseConstants.previewLinesChartOptimizationLevel
}
func updateMainChartVerticalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(verticalRange: range, animated: animated)
verticalScalesRenderer.setup(verticalRange: range, animated: animated)
lineBulletsRenderer.setup(verticalRange: range, animated: animated)
}
}
private var graphControllers: [GraphController] = []
private let verticalLineRenderer = VerticalLinesRenderer()
private let horizontalScalesRenderer = HorizontalScalesRenderer()
var totalHorizontalRange: ClosedRange<CGFloat> = Constants.defaultRange
private let initialChartCollection: ChartsCollection
private var prevoiusHorizontalStrideInterval: Int = 1
override public init(chartsCollection: ChartsCollection) {
self.initialChartCollection = chartsCollection
graphControllers = chartsCollection.chartValues.map { _ in GraphController() }
super.init(chartsCollection: chartsCollection)
self.zoomChartVisibility = chartVisibility
}
override func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
super.setupChartCollection(chartsCollection: chartsCollection, animated: animated, isZoomed: isZoomed)
for (index, controller) in self.graphControllers.enumerated() {
let chart = chartsCollection.chartValues[index]
let points = chart.values.enumerated().map({ (arg) -> CGPoint in
return CGPoint(x: chartsCollection.axisValues[arg.offset].timeIntervalSince1970,
y: arg.element)
})
let chartLines = [LinesChartRenderer.LineData(color: chart.color, points: points)]
controller.chartLines = [LinesChartRenderer.LineData(color: chart.color, points: points)]
controller.verticalScalesRenderer.labelsColor = chart.color
controller.totalVerticalRange = LinesChartRenderer.LineData.verticalRange(lines: chartLines) ?? Constants.defaultRange
self.totalHorizontalRange = LinesChartRenderer.LineData.horizontalRange(lines: chartLines) ?? Constants.defaultRange
controller.lineBulletsRenderer.bullets = chartLines.map { LineBulletsRenderer.Bullet(coordinate: $0.points.first ?? .zero, offset: .zero,
color: $0.color) }
controller.previewLinesRenderer.setup(horizontalRange: self.totalHorizontalRange, animated: animated)
controller.previewLinesRenderer.setup(verticalRange: controller.totalVerticalRange, animated: animated)
controller.mainLinesRenderer.setLines(lines: chartLines, animated: animated)
controller.previewLinesRenderer.setLines(lines: chartLines, animated: animated)
controller.verticalScalesRenderer.setHorizontalLinesVisible((index == 0), animated: animated)
controller.verticalScalesRenderer.isRightAligned = (index != 0)
}
self.prevoiusHorizontalStrideInterval = -1
let chartRange: ClosedRange<CGFloat>
if isZoomed {
chartRange = zoomedChartRange
} else {
chartRange = initialChartRange
}
updateHorizontalLimits(horizontalRange: chartRange, animated: animated)
updateMainChartHorizontalRange(range: chartRange, animated: animated)
updateVerticalLimitsAndRange(horizontalRange: chartRange, animated: animated)
self.chartRangeUpdatedClosure?(currentChartHorizontalRangeFraction, animated)
}
public override func initializeChart() {
if let first = initialChartCollection.axisValues.first?.timeIntervalSince1970,
let last = initialChartCollection.axisValues.last?.timeIntervalSince1970 {
initialChartRange = CGFloat(max(first, last - BaseConstants.defaultRangePresetLength))...CGFloat(last)
}
setupChartCollection(chartsCollection: initialChartCollection, animated: false, isZoomed: false)
}
public override var mainChartRenderers: [ChartViewRenderer] {
return graphControllers.map { $0.mainLinesRenderer } +
graphControllers.flatMap { [$0.verticalScalesRenderer, $0.lineBulletsRenderer] } +
[horizontalScalesRenderer, verticalLineRenderer,
// performanceRenderer
]
}
public override var navigationRenderers: [ChartViewRenderer] {
return graphControllers.map { $0.previewLinesRenderer }
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
chartVisibility = visibility
zoomChartVisibility = visibility
let firstIndex = visibility.firstIndex(where: { $0 })
for (index, isVisible) in visibility.enumerated() {
let graph = graphControllers[index]
for graphIndex in graph.chartLines.indices {
graph.mainLinesRenderer.setLineVisible(isVisible, at: graphIndex, animated: animated)
graph.previewLinesRenderer.setLineVisible(isVisible, at: graphIndex, animated: animated)
graph.lineBulletsRenderer.setLineVisible(isVisible, at: graphIndex, animated: animated)
}
graph.verticalScalesRenderer.setVisible(isVisible, animated: animated)
if let firstIndex = firstIndex {
graph.verticalScalesRenderer.setHorizontalLinesVisible(index == firstIndex, animated: animated)
}
}
updateVerticalLimitsAndRange(horizontalRange: currentHorizontalRange, animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint, manual: false)
}
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if manual && !isChartInteracting && !self.verticalLineRenderer.values.isEmpty {
self.cancelChartInteraction()
ignoreInteraction = true
return
}
let horizontalRange = currentHorizontalRange
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartInteractionWasBegin = isChartInteractionBegun
super.chartInteractionDidBegin(point: point)
for graphController in graphControllers {
graphController.lineBulletsRenderer.bullets = graphController.chartLines.map { chart in
LineBulletsRenderer.Bullet(coordinate: chart.points[minIndex], offset: .zero, color: chart.color)
}
graphController.lineBulletsRenderer.isEnabled = true
}
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
var chartValueUpdated = true
if self.verticalLineRenderer.values == [chartValue] {
chartValueUpdated = false
}
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
self.setDetailsViewModel?(chartDetailsViewModel(closestDate: closestDate, pointIndex: minIndex, loading: false), chartInteractionWasBegin, chartInteractionWasBegin && chartValueUpdated)
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewPositionClosure?(detailsViewPosition)
self.verticalLineRenderer.values = [chartValue]
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
public override var currentHorizontalRange: ClosedRange<CGFloat> {
return graphControllers.first?.mainLinesRenderer.horizontalRange.end ?? Constants.defaultRange
}
public override func cancelChartInteraction() {
super.cancelChartInteraction()
for graphController in graphControllers {
graphController.lineBulletsRenderer.isEnabled = false
}
self.setDetailsChartVisibleClosure?(false, true)
self.verticalLineRenderer.values = []
}
public override func didTapZoomOut() {
cancelChartInteraction()
self.setupChartCollection(chartsCollection: initialChartCollection, animated: true, isZoomed: false)
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
cancelChartInteraction()
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
zoomedChartRange = horizontalRange
updateChartRangeTitle(animated: true)
updateMainChartHorizontalRange(range: horizontalRange, animated: false)
updateHorizontalLimits(horizontalRange: horizontalRange, animated: true)
updateVerticalLimitsAndRange(horizontalRange: horizontalRange, animated: true)
}
func updateMainChartHorizontalRange(range: ClosedRange<CGFloat>, animated: Bool) {
for controller in graphControllers {
controller.mainLinesRenderer.setup(horizontalRange: range, animated: animated)
controller.verticalScalesRenderer.setup(horizontalRange: range, animated: animated)
controller.lineBulletsRenderer.setup(horizontalRange: range, animated: animated)
}
horizontalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalLineRenderer.setup(horizontalRange: range, animated: animated)
}
func updateHorizontalLimits(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let (stride, labels) = horizontalLimitsLabels(horizontalRange: horizontalRange,
scaleType: isZoomed ? .hour : .day,
prevoiusHorizontalStrideInterval: prevoiusHorizontalStrideInterval) {
self.horizontalScalesRenderer.setup(labels: labels, animated: animated)
self.prevoiusHorizontalStrideInterval = stride
}
}
func updateVerticalLimitsAndRange(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
let chartHeight = chartFrame().height
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
let dividorsAndMultiplers: [(startValue: CGFloat, base: CGFloat, count: Int, maximumNumberOfDecimals: Int)] = graphControllers.enumerated().map { arg in
let (index, controller) = arg
let verticalRange = LinesChartRenderer.LineData.verticalRange(lines: controller.chartLines,
calculatingRange: horizontalRange,
addBounds: true) ?? controller.totalVerticalRange
var numberOfOffsetsPerItem = verticalRange.distance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
}
let generalBase = Constants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
let base = generalBase * multiplier / dividor
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let startValue = verticalValue
var count = 0
if chartVisibility[index] {
while verticalValue < verticalRange.upperBound {
count += 1
verticalValue += base
}
}
return (startValue: startValue, base: base, count: count, maximumNumberOfDecimals: maximumNumberOfDecimals)
}
let totalCount = dividorsAndMultiplers.map { $0.count }.max() ?? 0
guard totalCount > 0 else { return }
let numberFormatter = BaseConstants.chartNumberFormatter
for (index, controller) in graphControllers.enumerated() {
let (startValue, base, _, maximumNumberOfDecimals) = dividorsAndMultiplers[index]
let updatedRange = startValue...(startValue + base * CGFloat(totalCount))
if controller.verticalScalesRenderer.verticalRange.end != updatedRange {
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
var verticalLabels: [LinesChartLabel] = []
for multipler in 0...(totalCount - 1) {
let verticalValue = startValue + base * CGFloat(multipler)
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
}
controller.verticalScalesRenderer.setup(verticalLimitsLabels: verticalLabels, animated: animated)
controller.updateMainChartVerticalRange(range: updatedRange, animated: animated)
}
}
}
public override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
horizontalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalLineRenderer.linesColor = theme.chartStrongLinesColor
for controller in graphControllers {
controller.verticalScalesRenderer.horizontalLinesColor = theme.chartHelperLinesColor
controller.lineBulletsRenderer.setInnerColor(theme.chartBackgroundColor, animated: animated)
controller.verticalScalesRenderer.axisXColor = theme.chartStrongLinesColor
}
}
}
@@ -0,0 +1,204 @@
//
// PercentChartComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class PercentChartComponentController: GeneralChartComponentController {
let mainPecentChartRenderer: PecentChartRenderer
let horizontalScalesRenderer: HorizontalScalesRenderer
let verticalScalesRenderer: VerticalScalesRenderer
let verticalLineRenderer: VerticalLinesRenderer
let previewPercentChartRenderer: PecentChartRenderer
var percentageData: PecentChartRenderer.PercentageData = .blank
init(isZoomed: Bool,
mainPecentChartRenderer: PecentChartRenderer,
horizontalScalesRenderer: HorizontalScalesRenderer,
verticalScalesRenderer: VerticalScalesRenderer,
verticalLineRenderer: VerticalLinesRenderer,
previewPercentChartRenderer: PecentChartRenderer) {
self.mainPecentChartRenderer = mainPecentChartRenderer
self.horizontalScalesRenderer = horizontalScalesRenderer
self.verticalScalesRenderer = verticalScalesRenderer
self.verticalLineRenderer = verticalLineRenderer
self.previewPercentChartRenderer = previewPercentChartRenderer
super.init(isZoomed: isZoomed)
}
override func initialize(chartsCollection: ChartsCollection, initialDate: Date, totalHorizontalRange _: ClosedRange<CGFloat>, totalVerticalRange _: ClosedRange<CGFloat>) {
let components = chartsCollection.chartValues.map { PecentChartRenderer.PercentageData.Component(color: $0.color,
values: $0.values.map { CGFloat($0) }) }
self.percentageData = PecentChartRenderer.PercentageData(locations: chartsCollection.axisValues.map { CGFloat($0.timeIntervalSince1970) },
components: components)
let totalHorizontalRange = PecentChartRenderer.PercentageData.horizontalRange(data: self.percentageData) ?? BaseConstants.defaultRange
let totalVerticalRange = BaseConstants.defaultRange
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: totalVerticalRange)
mainPecentChartRenderer.percentageData = self.percentageData
previewPercentChartRenderer.percentageData = self.percentageData
let axisValues: [CGFloat] = [0, 25, 50, 75, 100]
let labels: [LinesChartLabel] = axisValues.map { value in
return LinesChartLabel(value: value / 100, text: BaseConstants.detailsNumberFormatter.string(from: NSNumber(value: Double(value))) ?? "")
}
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: false)
setupMainChart(horizontalRange: initialHorizontalRange, animated: false)
setupMainChart(verticalRange: initialVerticalRange, animated: false)
previewPercentChartRenderer.setup(verticalRange: totalVerticalRange, animated: false)
previewPercentChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: false)
updateHorizontalLimitLabels(animated: false)
}
override func willAppear(animated: Bool) {
previewPercentChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewPercentChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
setConponentsVisible(visible: true, animated: true)
setupMainChart(verticalRange: initialVerticalRange, animated: animated)
setupMainChart(horizontalRange: initialHorizontalRange, animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
super.willAppear(animated: animated)
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
super.chartRangeDidUpdated(updatedRange)
initialHorizontalRange = updatedRange
setupMainChart(horizontalRange: updatedRange, animated: false)
updateHorizontalLimitLabels(animated: true)
}
func updateHorizontalLimitLabels(animated: Bool) {
updateHorizontalLimitLabels(horizontalScalesRenderer: horizontalScalesRenderer,
horizontalRange: initialHorizontalRange,
scaleType: isZoomed ? .hour : .day,
forceUpdate: false,
animated: animated)
}
func prepareAppearanceAnimation(horizontalRnage: ClosedRange<CGFloat>) {
setupMainChart(horizontalRange: horizontalRnage, animated: false)
setConponentsVisible(visible: false, animated: false)
}
func setConponentsVisible(visible: Bool, animated: Bool) {
mainPecentChartRenderer.setVisible(visible, animated: animated)
horizontalScalesRenderer.setVisible(visible, animated: animated)
verticalScalesRenderer.setVisible(visible, animated: animated)
verticalLineRenderer.setVisible(visible, animated: animated)
previewPercentChartRenderer.setVisible(visible, animated: animated)
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
mainPecentChartRenderer.setup(horizontalRange: horizontalRange, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalLineRenderer.setup(horizontalRange: horizontalRange, animated: animated)
}
func setupMainChart(verticalRange: ClosedRange<CGFloat>, animated: Bool) {
mainPecentChartRenderer.setup(verticalRange: verticalRange, animated: animated)
horizontalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalLineRenderer.setup(verticalRange: verticalRange, animated: animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
mainPecentChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
previewPercentChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
}
verticalScalesRenderer.setVisible(visibility.contains(true), animated: animated)
}
override func chartDetailsViewModel(closestDate: Date, pointIndex: Int, currency: GraphCurrency? = nil, rate: Double = 1.0) -> ChartDetailsViewModel {
let visibleValues = visibleDetailsChartValues
let total = visibleValues.map { $0.values[pointIndex] }.reduce(0, +)
let values: [ChartDetailsViewModel.Value] = chartsCollection.chartValues.enumerated().map { arg in
let (index, component) = arg
return ChartDetailsViewModel.Value(prefix: total > 0 ? PercentConstants.percentValueFormatter.string(from: component.values[pointIndex] / total * 100) : "0%",
title: component.name,
value: BaseConstants.detailsNumberFormatter.string(from: component.values[pointIndex]),
color: component.color,
visible: chartVisibility[index])
}
let dateString: String
if isZoomed {
dateString = BaseConstants.timeDateFormatter.string(from: closestDate)
} else {
dateString = BaseConstants.headerMediumRangeFormatter.string(from: closestDate)
}
let viewModel = ChartDetailsViewModel(title: dateString,
showArrow: total > 0 && self.isZoomable && !self.isZoomed,
showPrefixes: true,
isLoading: false,
values: values,
totalValue: nil,
tapAction: { [weak self] in
self?.hideDetailsView(animated: true)
self?.zoomInOnDateClosure?(closestDate) },
hideAction: { [weak self] in
self?.hideDetailsView(animated: true)
})
return viewModel
}
var currentlyVisiblePercentageData: PecentChartRenderer.PercentageData {
var currentPercentageData = percentageData
currentPercentageData.components = chartVisibility.enumerated().compactMap { $0.element ? currentPercentageData.components[$0.offset] : nil }
return currentPercentageData
}
override var currentMainRangeRenderer: BaseChartRenderer {
return mainPecentChartRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewPercentChartRenderer
}
override func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animated: Bool, feedback: Bool) {
super.showDetailsView(at: chartPosition, detailsViewPosition: detailsViewPosition, dataIndex: dataIndex, date: date, animated: animated, feedback: feedback)
verticalLineRenderer.values = [chartPosition]
verticalLineRenderer.isEnabled = true
}
override func hideDetailsView(animated: Bool) {
super.hideDetailsView(animated: animated)
verticalLineRenderer.values = []
verticalLineRenderer.isEnabled = false
}
override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
horizontalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.axisXColor = theme.barChartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = theme.barChartStrongLinesColor
verticalLineRenderer.linesColor = theme.chartStrongLinesColor
}
}
@@ -0,0 +1,307 @@
//
// PercentPieChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
enum PercentConstants {
static let percentValueFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.positiveSuffix = "%"
return formatter
}()
}
private enum Constants {
static let zoomedRange = 7
}
public class PercentPieChartController: BaseChartController {
let percentController: PercentChartComponentController
let pieController: PieChartComponentController
let transitionRenderer: PercentPieAnimationRenderer
var initiallyZoomed = false
public convenience init(chartsCollection: ChartsCollection, initiallyZoomed: Bool) {
self.init(chartsCollection: chartsCollection)
self.initiallyZoomed = initiallyZoomed
}
override public init(chartsCollection: ChartsCollection) {
transitionRenderer = PercentPieAnimationRenderer()
percentController = PercentChartComponentController(isZoomed: false,
mainPecentChartRenderer: PecentChartRenderer(),
horizontalScalesRenderer: HorizontalScalesRenderer(),
verticalScalesRenderer: VerticalScalesRenderer(),
verticalLineRenderer: VerticalLinesRenderer(),
previewPercentChartRenderer: PecentChartRenderer())
pieController = PieChartComponentController(isZoomed: true,
pieChartRenderer: PieChartRenderer(),
previewBarChartRenderer: BarChartRenderer())
super.init(chartsCollection: chartsCollection)
[percentController, pieController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date, pointIndex: 0)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?(title, animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated, feedback) in
self.setDetailsViewModel?(viewModel, animated, feedback)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
transitionRenderer.isEnabled = false
}
public override var mainChartRenderers: [ChartViewRenderer] {
return [percentController.mainPecentChartRenderer,
transitionRenderer,
percentController.horizontalScalesRenderer,
percentController.verticalScalesRenderer,
percentController.verticalLineRenderer,
pieController.pieChartRenderer,
// performanceRenderer
]
}
public override var navigationRenderers: [ChartViewRenderer] {
return [percentController.previewPercentChartRenderer,
pieController.previewBarChartRenderer]
}
public override func initializeChart() {
percentController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: percentController.chartsCollection, isZoomed: false, animated: false)
if let lastDate = initialChartsCollection.axisValues.last, self.initiallyZoomed {
TimeInterval.animationDurationMultipler = 0.00001
self.didTapZoomIn(date: lastDate, animated: false)
TimeInterval.animationDurationMultipler = 1.0
}
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultDuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultDuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = pieController.initialHorizontalRange
pieController.updateChartsVisibility(visibility: percentController.chartVisibility, animated: false)
pieController.pieChartRenderer.setup(horizontalRange: percentController.currentHorizontalMainChartRange, animated: false)
pieController.previewBarChartRenderer.setup(horizontalRange: percentController.currentPreviewHorizontalRange, animated: false)
pieController.pieChartRenderer.setVisible(false, animated: false)
pieController.previewBarChartRenderer.setVisible(true, animated: false)
pieController.willAppear(animated: animated)
percentController.willDisappear(animated: animated)
pieController.pieChartRenderer.drawPie = false
percentController.mainPecentChartRenderer.isEnabled = false
setupTransitionRenderer()
percentController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
percentController.previewPercentChartRenderer.setup(horizontalRange: toHorizontalRange, animated: animated)
percentController.setConponentsVisible(visible: false, animated: animated)
transitionRenderer.animate(fromDataToPie: true, animated: animated) { [weak self] in
self?.pieController.pieChartRenderer.drawPie = true
self?.percentController.mainPecentChartRenderer.isEnabled = true
}
} else {
if !pieController.chartsCollection.isBlank {
let fromHorizontalRange = pieController.currentHorizontalMainChartRange
let toHorizontalRange = percentController.initialHorizontalRange
pieController.pieChartRenderer.setup(horizontalRange: toHorizontalRange, animated: animated)
pieController.previewBarChartRenderer.setup(horizontalRange: toHorizontalRange, animated: animated)
pieController.pieChartRenderer.setVisible(false, animated: animated)
pieController.previewBarChartRenderer.setVisible(false, animated: animated)
percentController.updateChartsVisibility(visibility: pieController.chartVisibility, animated: false)
percentController.setupMainChart(horizontalRange: fromHorizontalRange, animated: false)
percentController.previewPercentChartRenderer.setup(horizontalRange: fromHorizontalRange, animated: false)
percentController.setConponentsVisible(visible: false, animated: false)
}
percentController.willAppear(animated: animated)
pieController.willDisappear(animated: animated)
if animated {
pieController.pieChartRenderer.drawPie = false
percentController.mainPecentChartRenderer.isEnabled = false
setupTransitionRenderer()
transitionRenderer.animate(fromDataToPie: false, animated: true) {
self.pieController.pieChartRenderer.drawPie = true
self.percentController.mainPecentChartRenderer.isEnabled = true
}
}
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
}
func setupTransitionRenderer() {
transitionRenderer.setup(verticalRange: percentController.currentVerticalMainChartRange, animated: false)
transitionRenderer.setup(horizontalRange: percentController.currentHorizontalMainChartRange, animated: false)
transitionRenderer.visiblePieComponents = pieController.visiblePieDataWithCurrentPreviewRange
transitionRenderer.visiblePercentageData = percentController.currentlyVisiblePercentageData
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
pieController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
percentController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? pieController.chartVisibility : percentController.chartVisibility
let collection = isZoomed ? pieController.chartsCollection : percentController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
public override var actualChartVisibility: [Bool] {
return isZoomed ? pieController.chartVisibility : percentController.chartVisibility
}
public override var actualChartsCollection: ChartsCollection {
let collection = isZoomed ? pieController.chartsCollection : percentController.chartsCollection
if collection.isBlank {
return self.initialChartsCollection
}
return collection
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if isZoomed {
pieController.chartInteractionDidBegin(point: point, manual: manual)
} else {
percentController.chartInteractionDidBegin(point: point, manual: manual)
}
}
public override func chartInteractionDidEnd() {
if isZoomed {
pieController.chartInteractionDidEnd()
} else {
percentController.chartInteractionDidEnd()
}
}
public override var drawChartVisibity: Bool {
return true
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return pieController.currentChartHorizontalRangeFraction
} else {
return percentController.currentChartHorizontalRangeFraction
}
}
public override func cancelChartInteraction() {
if isZoomed {
return pieController.hideDetailsView(animated: true)
} else {
return percentController.hideDetailsView(animated: true)
}
}
func didTapZoomIn(date: Date, animated: Bool) {
guard !isZoomed, isZoomable else { return }
cancelChartInteraction()
let currentCollection = percentController.chartsCollection
let range: Int = Constants.zoomedRange
guard let (foundDate, index) = percentController.findClosestDateTo(dateToFind: date) else { return }
var lowIndex = max(0, index - range / 2)
var highIndex = min(currentCollection.axisValues.count - 1, index + range / 2)
if lowIndex == 0 {
highIndex = min(currentCollection.axisValues.count - 1, lowIndex + (range - 1))
} else if highIndex == currentCollection.axisValues.count - 1 {
lowIndex = max(0, highIndex - (range - 1))
}
let newValues = currentCollection.chartValues.map { chart in
return ChartsCollection.Chart(color: chart.color,
name: chart.name,
values: Array(chart.values[(lowIndex...highIndex)]))
}
let newCollection = ChartsCollection(axisValues: Array(currentCollection.axisValues[(lowIndex...highIndex)]),
chartValues: newValues)
let selectedRange = CGFloat(foundDate.timeIntervalSince1970 - .day)...CGFloat(foundDate.timeIntervalSince1970)
pieController.initialize(chartsCollection: newCollection, initialDate: date, totalHorizontalRange: 0...1, totalVerticalRange: 0...1)
pieController.initialHorizontalRange = selectedRange
switchToChart(chartsCollection: newCollection, isZoomed: true, animated: true)
}
public override func didTapZoomIn(date: Date, pointIndex: Int) {
self.didTapZoomIn(date: date, animated: true)
}
public override func didTapZoomOut() {
self.pieController.deselectSegment(completion: { [weak self] in
guard let self = self else { return }
self.switchToChart(chartsCollection: self.percentController.chartsCollection, isZoomed: false, animated: true)
})
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
if isZoomed {
return pieController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return percentController.chartRangeFractionDidUpdated(rangeFraction)
}
}
public override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
pieController.apply(theme: theme, strings: strings, animated: animated)
percentController.apply(theme: theme, strings: strings, animated: animated)
transitionRenderer.backgroundColor = theme.chartBackgroundColor
}
}
@@ -0,0 +1,198 @@
//
// PieChartComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class PieChartComponentController: GeneralChartComponentController {
let pieChartRenderer: PieChartRenderer
let previewBarChartRenderer: BarChartRenderer
var barWidth: CGFloat = 1
var chartBars: BarChartRenderer.BarsData = .blank
init(isZoomed: Bool,
pieChartRenderer: PieChartRenderer,
previewBarChartRenderer: BarChartRenderer) {
self.pieChartRenderer = pieChartRenderer
self.previewBarChartRenderer = previewBarChartRenderer
super.init(isZoomed: isZoomed)
}
override func initialize(chartsCollection: ChartsCollection, initialDate: Date, totalHorizontalRange _: ClosedRange<CGFloat>, totalVerticalRange _: ClosedRange<CGFloat>) {
let (width, chartBars, totalHorizontalRange, _) = BarChartRenderer.BarsData.initialComponents(chartsCollection: chartsCollection)
self.barWidth = width
self.chartBars = chartBars
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: BaseConstants.defaultRange)
self.previewBarChartRenderer.bars = chartBars
self.previewBarChartRenderer.fillToTop = true
pieChartRenderer.valuesFormatter = PercentConstants.percentValueFormatter
pieChartRenderer.setup(horizontalRange: initialHorizontalRange, animated: false)
previewBarChartRenderer.setup(verticalRange: initialVerticalRange, animated: false)
previewBarChartRenderer.setup(horizontalRange: initialHorizontalRange, animated: false)
pieChartRenderer.updatePercentageData(pieDataWithCurrentPreviewRange, animated: false)
pieChartRenderer.selectSegmentAt(at: nil, animated: false)
}
private var pieDataWithCurrentPreviewRange: [PieChartRenderer.PieComponent] {
let range = currentHorizontalMainChartRange
var pieComponents = chartsCollection.chartValues.map { PieChartRenderer.PieComponent(color: $0.color,
value: 0) }
guard var valueIndex = chartsCollection.axisValues.firstIndex(where: { CGFloat($0.timeIntervalSince1970) > (range.lowerBound + 1)}) else {
return pieComponents
}
var count = 0
while valueIndex < chartsCollection.axisValues.count, CGFloat(chartsCollection.axisValues[valueIndex].timeIntervalSince1970) <= range.upperBound {
count += 1
for pieIndex in pieComponents.indices {
pieComponents[pieIndex].value += CGFloat(chartsCollection.chartValues[pieIndex].values[valueIndex])
}
valueIndex += 1
}
return pieComponents
}
var visiblePieDataWithCurrentPreviewRange: [PieChartRenderer.PieComponent] {
let currentData = pieDataWithCurrentPreviewRange
return chartVisibility.enumerated().compactMap { $0.element ? currentData[$0.offset] : nil }
}
override func willAppear(animated: Bool) {
pieChartRenderer.setup(horizontalRange: initialHorizontalRange, animated: animated)
pieChartRenderer.setVisible(true, animated: animated)
previewBarChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewBarChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
previewBarChartRenderer.setVisible(true, animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
pieChartRenderer.updatePercentageData(pieDataWithCurrentPreviewRange, animated: false)
super.willAppear(animated: animated)
}
override func setupChartRangePaging() {
let valuesCount = chartsCollection.axisValues.count
guard valuesCount > 0 else { return }
chartRangePagingClosure?(true, 1.0 / CGFloat(valuesCount))
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
initialHorizontalRange = updatedRange
setupMainChart(horizontalRange: updatedRange, animated: true)
updateSelectedDataLabelIfNeeded()
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
pieChartRenderer.setup(horizontalRange: horizontalRange, animated: animated)
pieChartRenderer.updatePercentageData(pieDataWithCurrentPreviewRange, animated: animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
pieChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
previewBarChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
}
if let segment = pieChartRenderer.selectedSegment {
if !visibility[segment] {
pieChartRenderer.selectSegmentAt(at: nil, animated: true)
}
}
updateSelectedDataLabelIfNeeded()
}
func deselectSegment(completion: @escaping () -> Void) {
if pieChartRenderer.hasSelectedSegments {
hideDetailsView(animated: true)
pieChartRenderer.selectSegmentAt(at: nil, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + .defaultDuration / 2) {
completion()
}
} else {
completion()
}
}
func updateSelectedDataLabelIfNeeded() {
if let segment = pieChartRenderer.selectedSegment {
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewModel?(chartDetailsViewModel(segmentInde: segment), false, false)
self.setDetailsViewPositionClosure?(chartFrame().width / 4)
} else {
self.setDetailsChartVisibleClosure?(false, true)
}
}
func chartDetailsViewModel(segmentInde: Int) -> ChartDetailsViewModel {
let pieItem = pieDataWithCurrentPreviewRange[segmentInde]
let title = chartsCollection.chartValues[segmentInde].name
let valueString = BaseConstants.detailsNumberFormatter.string(from: pieItem.value)
let viewModel = ChartDetailsViewModel(title: "",
showArrow: false,
showPrefixes: false,
isLoading: false,
values: [ChartDetailsViewModel.Value(prefix: nil,
title: title,
value: valueString,
color: pieItem.color,
visible: true)],
totalValue: nil,
tapAction: nil,
hideAction: { [weak self] in
self?.deselectSegment(completion: {})
})
return viewModel
}
override var currentMainRangeRenderer: BaseChartRenderer {
return pieChartRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewBarChartRenderer
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if let segment = pieChartRenderer.selectedItemIndex(at: point) {
pieChartRenderer.selectSegmentAt(at: segment, animated: true)
updateSelectedDataLabelIfNeeded()
}
}
override func hideDetailsView(animated: Bool) {
pieChartRenderer.selectSegmentAt(at: nil, animated: animated)
updateSelectedDataLabelIfNeeded()
}
override func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.lowerBound) + .day + 1)
let toDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
}
@@ -0,0 +1,322 @@
//
// BarsComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class BarsComponentController: GeneralChartComponentController {
let mainBarsRenderer: BarChartRenderer
let horizontalScalesRenderer: HorizontalScalesRenderer
let verticalScalesRenderer: VerticalScalesRenderer
let secondaryScalesRenderer: VerticalScalesRenderer?
let lineBulletsRenderer = LineBulletsRenderer()
let verticalLineRenderer = VerticalLinesRenderer()
let previewBarsChartRenderer: BarChartRenderer
private(set) var barsWidth: CGFloat = 1
private(set) var chartBars: BarChartRenderer.BarsData = .blank
private var step: Bool
init(isZoomed: Bool,
mainBarsRenderer: BarChartRenderer,
horizontalScalesRenderer: HorizontalScalesRenderer,
verticalScalesRenderer: VerticalScalesRenderer,
secondaryScalesRenderer: VerticalScalesRenderer? = nil,
previewBarsChartRenderer: BarChartRenderer,
step: Bool = false) {
self.mainBarsRenderer = mainBarsRenderer
self.horizontalScalesRenderer = horizontalScalesRenderer
self.verticalScalesRenderer = verticalScalesRenderer
self.secondaryScalesRenderer = secondaryScalesRenderer
self.previewBarsChartRenderer = previewBarsChartRenderer
self.step = step
self.lineBulletsRenderer.isEnabled = false
self.mainBarsRenderer.optimizationLevel = BaseConstants.barsChartOptimizationLevel
self.previewBarsChartRenderer.optimizationLevel = BaseConstants.barsChartOptimizationLevel
super.init(isZoomed: isZoomed)
}
override func initialize(chartsCollection: ChartsCollection, initialDate: Date, totalHorizontalRange _: ClosedRange<CGFloat>, totalVerticalRange _: ClosedRange<CGFloat>) {
let (width, chartBars, totalHorizontalRange, totalVerticalRange) = BarChartRenderer.BarsData.initialComponents(chartsCollection: chartsCollection, separate: self.step)
self.chartBars = chartBars
self.barsWidth = width
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: totalVerticalRange)
}
override func setupInitialChartRange(initialDate: Date) {
guard let first = chartsCollection.axisValues.first?.timeIntervalSince1970,
let last = chartsCollection.axisValues.last?.timeIntervalSince1970 else { return }
let rangeStart = CGFloat(first)
let rangeEnd = CGFloat(last)
if isZoomed {
let initalDate = CGFloat(initialDate.timeIntervalSince1970)
initialHorizontalRange = max(initalDate - barsWidth, rangeStart)...min(initalDate + GeneralChartComponentConstants.defaultZoomedRangeLength - barsWidth, rangeEnd)
initialVerticalRange = totalVerticalRange
} else {
super.setupInitialChartRange(initialDate: initialDate)
}
}
override func willAppear(animated: Bool) {
mainBarsRenderer.bars = self.chartBars
previewBarsChartRenderer.bars = self.chartBars
previewBarsChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewBarsChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
setupMainChart(verticalRange: initialVerticalRange, animated: animated)
setupMainChart(horizontalRange: initialHorizontalRange, animated: animated)
updateChartVerticalRanges(horizontalRange: initialHorizontalRange, animated: animated)
super.willAppear(animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
setComponentsVisible(visible: true, animated: animated)
updateHorizontalLimitLabels(animated: animated, forceUpdate: true)
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
super.chartRangeDidUpdated(updatedRange)
if !isZoomed {
initialHorizontalRange = updatedRange
}
setupMainChart(horizontalRange: updatedRange, animated: false)
updateHorizontalLimitLabels(animated: true, forceUpdate: false)
updateChartVerticalRanges(horizontalRange: updatedRange, animated: true)
}
func updateHorizontalLimitLabels(animated: Bool, forceUpdate: Bool) {
updateHorizontalLimitLabels(horizontalScalesRenderer: horizontalScalesRenderer,
horizontalRange: currentHorizontalMainChartRange,
scaleType: isZoomed ? .hour : .day,
forceUpdate: forceUpdate,
animated: animated)
}
func prepareAppearanceAnimation(horizontalRnage: ClosedRange<CGFloat>) {
setupMainChart(horizontalRange: horizontalRnage, animated: false)
setComponentsVisible(visible: false, animated: false)
}
func setComponentsVisible(visible: Bool, animated: Bool) {
mainBarsRenderer.setVisible(visible, animated: animated)
horizontalScalesRenderer.setVisible(visible, animated: animated)
verticalScalesRenderer.setVisible(visible, animated: animated)
secondaryScalesRenderer?.setVisible(visible, animated: animated)
previewBarsChartRenderer.setVisible(visible, animated: animated)
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
mainBarsRenderer.setup(horizontalRange: horizontalRange, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
secondaryScalesRenderer?.setup(horizontalRange: horizontalRange, animated: animated)
verticalLineRenderer.setup(horizontalRange: horizontalRange, animated: animated)
lineBulletsRenderer.setup(horizontalRange: horizontalRange, animated: animated)
}
var visibleBars: BarChartRenderer.BarsData {
let visibleComponents: [BarChartRenderer.BarsData.Component] = chartVisibility.enumerated().compactMap { args in
args.element ? chartBars.components[args.offset] : nil
}
return BarChartRenderer.BarsData(barWidth: chartBars.barWidth,
locations: chartBars.locations,
components: visibleComponents)
}
func verticalLimitsLabels(verticalRange: ClosedRange<CGFloat>, secondary: Bool) -> (ClosedRange<CGFloat>, [LinesChartLabel]) {
var (range, labels) = super.verticalLimitsLabels(verticalRange: verticalRange)
if secondary {
var updatedLabels: [LinesChartLabel] = []
for label in labels {
let convertedValue = (self.verticalLimitsNumberFormatter.number(from: label.text) as? Double ?? 0.0) * self.conversionRate
let text: String
if convertedValue > 1.0 {
text = String(format: "%0.1f", convertedValue)
} else {
text = String(format: "%0.3f", convertedValue)
}
updatedLabels.append(LinesChartLabel(value: label.value, text: "~$\(text)"))
}
labels = updatedLabels
}
return (range, labels)
}
func updateChartVerticalRanges(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let range = BarChartRenderer.BarsData.verticalRange(bars: visibleBars,
separate: self.step,
calculatingRange: horizontalRange,
addBounds: true) {
let (range, labels) = verticalLimitsLabels(verticalRange: range, secondary: false)
// if verticalScalesRenderer.verticalRange.end != range {
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
// }
verticalScalesRenderer.setVisible(true, animated: animated)
if let secondaryScalesRenderer = self.secondaryScalesRenderer {
let (_, labels) = verticalLimitsLabels(verticalRange: range, secondary: true)
// if secondaryScalesRenderer.verticalRange.end != range {
secondaryScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
// }
secondaryScalesRenderer.setVisible(true, animated: animated)
}
setupMainChart(verticalRange: range, animated: animated)
} else {
verticalScalesRenderer.setVisible(false, animated: animated)
secondaryScalesRenderer?.setVisible(false, animated: animated)
}
if let range = BarChartRenderer.BarsData.verticalRange(bars: visibleBars, separate: self.step) {
previewBarsChartRenderer.setup(verticalRange: range, animated: animated)
}
}
func setupMainChart(verticalRange: ClosedRange<CGFloat>, animated: Bool) {
mainBarsRenderer.setup(verticalRange: verticalRange, animated: animated)
horizontalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
secondaryScalesRenderer?.setup(verticalRange: verticalRange, animated: animated)
lineBulletsRenderer.setup(verticalRange: verticalRange, animated: animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
mainBarsRenderer.setComponentVisible(isVisible, at: index, animated: animated)
previewBarsChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
}
updateChartVerticalRanges(horizontalRange: currentHorizontalMainChartRange, animated: true)
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibleCharts: [ChartsCollection.Chart] = chartVisibility.enumerated().compactMap { args in
args.element ? chartsCollection.chartValues[args.offset] : nil
}
return visibleCharts
}
override func chartDetailsViewModel(closestDate: Date, pointIndex: Int, currency: GraphCurrency? = nil, rate: Double = 1.0) -> ChartDetailsViewModel {
var viewModel = super.chartDetailsViewModel(closestDate: closestDate, pointIndex: pointIndex, currency: currency, rate: rate)
let visibleChartValues = self.visibleChartValues
let totalSumm: CGFloat = visibleChartValues.map { CGFloat($0.values[pointIndex]) }.reduce(0, +)
viewModel.hideAction = { [weak self] in
self?.hideDetailsView(animated: true)
}
if !self.step {
viewModel.totalValue = ChartDetailsViewModel.Value(prefix: nil,
title: self.strings.total,
value: BaseConstants.detailsNumberFormatter.string(from: totalSumm),
color: .white,
visible: visibleChartValues.count > 1)
} else {
viewModel.title = "\(Int(closestDate.timeIntervalSince1970)):00"
}
return viewModel
}
override var currentMainRangeRenderer: BaseChartRenderer {
return mainBarsRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewBarsChartRenderer
}
override func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animated: Bool, feedback: Bool) {
super.showDetailsView(at: chartPosition, detailsViewPosition: detailsViewPosition, dataIndex: dataIndex, date: date, animated: animated, feedback: feedback)
mainBarsRenderer.setSelectedIndex(dataIndex, animated: true)
}
override func hideDetailsView(animated: Bool) {
super.hideDetailsView(animated: animated)
mainBarsRenderer.setSelectedIndex(nil, animated: animated)
}
override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
horizontalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.axisXColor = theme.barChartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = theme.barChartStrongLinesColor
secondaryScalesRenderer?.labelsColor = theme.chartLabelsColor
secondaryScalesRenderer?.axisXColor = .clear
secondaryScalesRenderer?.horizontalLinesColor = .clear
mainBarsRenderer.update(backgroundColor: theme.chartBackgroundColor, animated: false)
previewBarsChartRenderer.update(backgroundColor: theme.chartBackgroundColor, animated: false)
verticalLineRenderer.linesColor = theme.chartStrongLinesColor
}
override func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.lowerBound + barsWidth))
let toDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if manual && !isChartInteracting && detailsVisible {
self.hideDetailsView(animated: true)
ignoreInteraction = true
return
}
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let horizontalRange = currentHorizontalMainChartRange
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartWasInteracting = isChartInteractionBegun
lastChartInteractionPoint = point
isChartInteractionBegun = true
isChartInteracting = true
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
var chartValueUpdated = true
if chartValue == currentChartValue {
chartValueUpdated = false
}
currentChartValue = chartValue
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
showDetailsView(at: chartValue, detailsViewPosition: detailsViewPosition, dataIndex: minIndex, date: closestDate, animated: chartWasInteracting, feedback: chartWasInteracting && chartValueUpdated)
super.chartInteractionDidBegin(point: point)
self.verticalLineRenderer.values = [chartValue]
// self.verticalLineRenderer.offset = barOffset
}
}
@@ -0,0 +1,259 @@
//
// DailyBarsChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
public class DailyBarsChartController: BaseChartController {
let barsController: BarsComponentController
let linesController: LinesComponentController
override public init(chartsCollection: ChartsCollection) {
let horizontalScalesRenderer = HorizontalScalesRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
barsController = BarsComponentController(isZoomed: false,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer())
linesController = LinesComponentController(isZoomed: true,
userLinesTransitionAnimation: false,
mainLinesRenderer: LinesChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
verticalLineRenderer: VerticalLinesRenderer(),
lineBulletsRenderer: LineBulletsRenderer(),
previewLinesChartRenderer: LinesChartRenderer())
super.init(chartsCollection: chartsCollection)
[barsController, linesController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date, pointIndex: 0)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?(title, animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated, feedback) in
self.setDetailsViewModel?(viewModel, animated, feedback)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
}
public override var mainChartRenderers: [ChartViewRenderer] {
return [barsController.mainBarsRenderer,
linesController.mainLinesRenderer,
barsController.horizontalScalesRenderer,
barsController.verticalScalesRenderer,
linesController.verticalLineRenderer,
linesController.lineBulletsRenderer,
// performanceRenderer
]
}
public override var navigationRenderers: [ChartViewRenderer] {
return [barsController.previewBarsChartRenderer,
linesController.previewLinesChartRenderer]
}
public override func initializeChart() {
barsController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: false)
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultDuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultDuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = linesController.initialHorizontalRange
let destinationHorizontalRange = (toHorizontalRange.lowerBound - barsController.barsWidth)...(toHorizontalRange.upperBound - barsController.barsWidth)
let initialChartVerticalRange = lineProportionAnimationRange()
linesController.mainLinesRenderer.setup(horizontalRange: barsController.currentHorizontalMainChartRange, animated: false)
linesController.previewLinesChartRenderer.setup(horizontalRange: barsController.currentPreviewHorizontalRange, animated: false)
linesController.mainLinesRenderer.setup(verticalRange: initialChartVerticalRange, animated: false)
linesController.previewLinesChartRenderer.setup(verticalRange: initialChartVerticalRange, animated: false)
linesController.mainLinesRenderer.setVisible(false, animated: false)
linesController.previewLinesChartRenderer.setVisible(false, animated: false)
barsController.setupMainChart(horizontalRange: destinationHorizontalRange, animated: animated)
barsController.previewBarsChartRenderer.setup(horizontalRange: linesController.totalHorizontalRange, animated: animated)
barsController.mainBarsRenderer.setVisible(false, animated: animated)
barsController.previewBarsChartRenderer.setVisible(false, animated: animated)
linesController.willAppear(animated: animated)
barsController.willDisappear(animated: animated)
linesController.updateChartsVisibility(visibility: linesController.chartLines.map { _ in true }, animated: false)
} else {
if !linesController.chartsCollection.isBlank {
barsController.hideDetailsView(animated: false)
let visibleVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: barsController.visibleBars,
calculatingRange: barsController.initialHorizontalRange) ?? BaseConstants.defaultRange
barsController.mainBarsRenderer.setup(verticalRange: visibleVerticalRange, animated: false)
let toHorizontalRange = barsController.initialHorizontalRange
let destinationChartVerticalRange = lineProportionAnimationRange()
linesController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
linesController.mainLinesRenderer.setup(verticalRange: destinationChartVerticalRange, animated: animated)
linesController.previewLinesChartRenderer.setup(verticalRange: destinationChartVerticalRange, animated: animated)
linesController.previewLinesChartRenderer.setup(horizontalRange: barsController.totalHorizontalRange, animated: animated)
linesController.mainLinesRenderer.setVisible(false, animated: animated)
linesController.previewLinesChartRenderer.setVisible(false, animated: animated)
}
barsController.willAppear(animated: animated)
linesController.willDisappear(animated: animated)
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
self.refreshChartToolsClosure?(animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
linesController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
barsController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? linesController.chartVisibility : barsController.chartVisibility
let collection = isZoomed ? linesController.chartsCollection : barsController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
public override var actualChartVisibility: [Bool] {
return isZoomed ? linesController.chartVisibility : barsController.chartVisibility
}
public override var actualChartsCollection: ChartsCollection {
let collection = isZoomed ? linesController.chartsCollection : barsController.chartsCollection
if collection.isBlank {
return self.initialChartsCollection
}
return collection
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if isZoomed {
linesController.chartInteractionDidBegin(point: point, manual: manual)
} else {
barsController.chartInteractionDidBegin(point: point, manual: manual)
}
}
public override func chartInteractionDidEnd() {
if isZoomed {
linesController.chartInteractionDidEnd()
} else {
barsController.chartInteractionDidEnd()
}
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return linesController.currentChartHorizontalRangeFraction
} else {
return barsController.currentChartHorizontalRangeFraction
}
}
public override func cancelChartInteraction() {
if isZoomed {
return linesController.hideDetailsView(animated: true)
} else {
return barsController.hideDetailsView(animated: true)
}
}
public override func didTapZoomIn(date: Date, pointIndex: Int) {
guard !isZoomed, isZoomable else { return }
if isZoomed {
return linesController.hideDetailsView(animated: true)
}
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.linesController.initialize(chartsCollection: updatedCollection,
initialDate: date,
totalHorizontalRange: 0...1,
totalVerticalRange: 0...1)
self.switchToChart(chartsCollection: updatedCollection, isZoomed: true, animated: true)
}
})
}
func lineProportionAnimationRange() -> ClosedRange<CGFloat> {
let visibleLines = self.barsController.chartVisibility.enumerated().compactMap { $0.element ? self.linesController.chartLines[$0.offset] : nil }
let linesRange = LinesChartRenderer.LineData.verticalRange(lines: visibleLines) ?? BaseConstants.defaultRange
let barsRange = BarChartRenderer.BarsData.verticalRange(bars: self.barsController.visibleBars,
calculatingRange: self.linesController.totalHorizontalRange) ?? BaseConstants.defaultRange
let range = 0...(linesRange.upperBound / barsRange.distance * self.barsController.currentVerticalMainChartRange.distance)
return range
}
public override func didTapZoomOut() {
cancelChartInteraction()
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: true)
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
if isZoomed {
return linesController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return barsController.chartRangeFractionDidUpdated(rangeFraction)
}
}
override public func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
linesController.apply(theme: theme, strings: strings, animated: animated)
barsController.apply(theme: theme, strings: strings, animated: animated)
}
public override var drawChartVisibity: Bool {
return true
}
}
//TODO: Убрать Performance полоски сверзу чартов (Не забыть)
//TODO: Добавить ховеры на кнопки
@@ -0,0 +1,215 @@
//
// LinesComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class LinesComponentController: GeneralChartComponentController {
let mainLinesRenderer: LinesChartRenderer
let horizontalScalesRenderer: HorizontalScalesRenderer
let verticalScalesRenderer: VerticalScalesRenderer
let verticalLineRenderer: VerticalLinesRenderer
let lineBulletsRenderer: LineBulletsRenderer
let previewLinesChartRenderer: LinesChartRenderer
private let zoomedLinesRenderer = LinesChartRenderer()
private let zoomedPreviewLinesRenderer = LinesChartRenderer()
private let userLinesTransitionAnimation: Bool
private(set) var chartLines: [LinesChartRenderer.LineData] = []
init(isZoomed: Bool,
userLinesTransitionAnimation: Bool,
mainLinesRenderer: LinesChartRenderer,
horizontalScalesRenderer: HorizontalScalesRenderer,
verticalScalesRenderer: VerticalScalesRenderer,
verticalLineRenderer: VerticalLinesRenderer,
lineBulletsRenderer: LineBulletsRenderer,
previewLinesChartRenderer: LinesChartRenderer) {
self.mainLinesRenderer = mainLinesRenderer
self.horizontalScalesRenderer = horizontalScalesRenderer
self.verticalScalesRenderer = verticalScalesRenderer
self.verticalLineRenderer = verticalLineRenderer
self.lineBulletsRenderer = lineBulletsRenderer
self.previewLinesChartRenderer = previewLinesChartRenderer
self.userLinesTransitionAnimation = userLinesTransitionAnimation
super.init(isZoomed: isZoomed)
self.mainLinesRenderer.lineWidth = BaseConstants.mainChartLineWidth
self.mainLinesRenderer.optimizationLevel = BaseConstants.linesChartOptimizationLevel
self.previewLinesChartRenderer.lineWidth = BaseConstants.previewChartLineWidth
self.previewLinesChartRenderer.optimizationLevel = BaseConstants.previewLinesChartOptimizationLevel
self.lineBulletsRenderer.isEnabled = false
}
override func initialize(chartsCollection: ChartsCollection,
initialDate: Date,
totalHorizontalRange _: ClosedRange<CGFloat>,
totalVerticalRange _: ClosedRange<CGFloat>) {
let (chartLines, totalHorizontalRange, totalVerticalRange) = LinesChartRenderer.LineData.initialComponents(chartsCollection: chartsCollection)
self.chartLines = chartLines
self.lineBulletsRenderer.bullets = self.chartLines.map { LineBulletsRenderer.Bullet(coordinate: $0.points.first ?? .zero, offset: .zero,
color: $0.color)}
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: totalVerticalRange)
self.mainLinesRenderer.setup(verticalRange: totalVerticalRange, animated: true)
}
override func willAppear(animated: Bool) {
mainLinesRenderer.setLines(lines: self.chartLines, animated: animated && userLinesTransitionAnimation)
previewLinesChartRenderer.setLines(lines: self.chartLines, animated: animated && userLinesTransitionAnimation)
previewLinesChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewLinesChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
setupMainChart(verticalRange: initialVerticalRange, animated: animated)
setupMainChart(horizontalRange: initialHorizontalRange, animated: animated)
updateChartVerticalRanges(horizontalRange: initialHorizontalRange, animated: animated)
super.willAppear(animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
setConponentsVisible(visible: true, animated: animated)
updateHorizontalLimitLabels(animated: animated, forceUpdate: true)
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
super.chartRangeDidUpdated(updatedRange)
if !isZoomed {
initialHorizontalRange = updatedRange
}
setupMainChart(horizontalRange: updatedRange, animated: false)
updateHorizontalLimitLabels(animated: true, forceUpdate: false)
updateChartVerticalRanges(horizontalRange: updatedRange, animated: true)
}
func updateHorizontalLimitLabels(animated: Bool, forceUpdate: Bool) {
updateHorizontalLimitLabels(horizontalScalesRenderer: horizontalScalesRenderer,
horizontalRange: currentHorizontalMainChartRange,
scaleType: isZoomed ? .hour : .day,
forceUpdate: forceUpdate,
animated: animated)
}
func prepareAppearanceAnimation(horizontalRnage: ClosedRange<CGFloat>) {
setupMainChart(horizontalRange: horizontalRnage, animated: false)
setConponentsVisible(visible: false, animated: false)
}
func setConponentsVisible(visible: Bool, animated: Bool) {
mainLinesRenderer.setVisible(visible, animated: animated)
horizontalScalesRenderer.setVisible(visible, animated: animated)
verticalScalesRenderer.setVisible(visible, animated: animated)
verticalLineRenderer.setVisible(visible, animated: animated)
previewLinesChartRenderer.setVisible(visible, animated: animated)
lineBulletsRenderer.setVisible(visible, animated: animated)
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalLineRenderer.setup(horizontalRange: horizontalRange, animated: animated)
lineBulletsRenderer.setup(horizontalRange: horizontalRange, animated: animated)
}
var visibleLines: [LinesChartRenderer.LineData] {
return chartVisibility.enumerated().compactMap { $0.element ? chartLines[$0.offset] : nil }
}
func updateChartVerticalRanges(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let range = LinesChartRenderer.LineData.verticalRange(lines: visibleLines,
calculatingRange: horizontalRange,
addBounds: true) {
let (range, labels) = verticalLimitsLabels(verticalRange: range)
if verticalScalesRenderer.verticalRange.end != range {
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
}
setupMainChart(verticalRange: range, animated: animated)
verticalScalesRenderer.setVisible(true, animated: animated)
} else {
verticalScalesRenderer.setVisible(false, animated: animated)
}
if let range = LinesChartRenderer.LineData.verticalRange(lines: visibleLines) {
previewLinesChartRenderer.setup(verticalRange: range, animated: animated)
}
}
func setupMainChart(verticalRange: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(verticalRange: verticalRange, animated: animated)
horizontalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalLineRenderer.setup(verticalRange: verticalRange, animated: animated)
lineBulletsRenderer.setup(verticalRange: verticalRange, animated: animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
mainLinesRenderer.setLineVisible(isVisible, at: index, animated: animated)
previewLinesChartRenderer.setLineVisible(isVisible, at: index, animated: animated)
lineBulletsRenderer.setLineVisible(isVisible, at: index, animated: animated)
}
updateChartVerticalRanges(horizontalRange: currentHorizontalMainChartRange, animated: true)
}
override var currentMainRangeRenderer: BaseChartRenderer {
return mainLinesRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewLinesChartRenderer
}
override func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animated: Bool, feedback: Bool) {
super.showDetailsView(at: chartPosition, detailsViewPosition: detailsViewPosition, dataIndex: dataIndex, date: date, animated: animated, feedback: feedback)
verticalLineRenderer.values = [chartPosition]
verticalLineRenderer.isEnabled = true
lineBulletsRenderer.isEnabled = true
lineBulletsRenderer.setVisible(true, animated: animated)
lineBulletsRenderer.bullets = chartLines.compactMap { chart in
return LineBulletsRenderer.Bullet(coordinate: chart.points[dataIndex], offset: .zero, color: chart.color)
}
}
override func hideDetailsView(animated: Bool) {
super.hideDetailsView(animated: animated)
verticalLineRenderer.values = []
verticalLineRenderer.isEnabled = false
lineBulletsRenderer.isEnabled = false
}
override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
horizontalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalScalesRenderer.axisXColor = theme.chartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = theme.chartHelperLinesColor
lineBulletsRenderer.setInnerColor(theme.chartBackgroundColor, animated: animated)
verticalLineRenderer.linesColor = theme.chartStrongLinesColor
}
}
@@ -0,0 +1,289 @@
//
// StackedBarsChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
public enum GraphCurrency : String {
case xtr = "XTR"
case ton = "TON"
var formatter: NumberFormatter {
switch self {
case .xtr:
return BaseConstants.starNumberFormatter
case .ton:
return BaseConstants.tonNumberFormatter
}
}
}
public class StackedBarsChartController: BaseChartController {
let barsController: BarsComponentController
let zoomedBarsController: BarsComponentController
override public var isZoomable: Bool {
didSet {
barsController.isZoomable = self.isZoomable
}
}
public init(chartsCollection: ChartsCollection, currency: GraphCurrency? = nil, drawCurrency:((CGContext, UIColor, CGPoint)->Void)? = nil, rate: Double = 1.0) {
let horizontalScalesRenderer = HorizontalScalesRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
var secondaryScalesRenderer: VerticalScalesRenderer?
if let _ = currency {
verticalScalesRenderer.drawCurrency = drawCurrency
secondaryScalesRenderer = VerticalScalesRenderer()
secondaryScalesRenderer?.isRightAligned = true
}
barsController = BarsComponentController(isZoomed: false,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
secondaryScalesRenderer: secondaryScalesRenderer,
previewBarsChartRenderer: BarChartRenderer())
if let currency {
barsController.currency = currency
barsController.conversionRate = rate
barsController.verticalLimitsNumberFormatter = currency.formatter
barsController.detailsNumberFormatter = currency.formatter
}
zoomedBarsController = BarsComponentController(isZoomed: true,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer())
super.init(chartsCollection: chartsCollection)
[barsController, zoomedBarsController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date, pointIndex: 0)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?(title, animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated, feedback) in
self.setDetailsViewModel?(viewModel, animated, feedback)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
}
public override var mainChartRenderers: [ChartViewRenderer] {
var renderers = [barsController.mainBarsRenderer,
zoomedBarsController.mainBarsRenderer,
barsController.horizontalScalesRenderer,
barsController.verticalScalesRenderer,
// performanceRenderer
]
if let secondary = barsController.secondaryScalesRenderer {
renderers.append(secondary)
}
return renderers
}
public override var navigationRenderers: [ChartViewRenderer] {
return [barsController.previewBarsChartRenderer,
zoomedBarsController.previewBarsChartRenderer]
}
public override func initializeChart() {
barsController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: false)
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultDuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultDuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = zoomedBarsController.initialHorizontalRange
let destinationHorizontalRange = (toHorizontalRange.lowerBound - barsController.barsWidth)...(toHorizontalRange.upperBound - barsController.barsWidth)
let verticalVisibleRange = barsController.currentVerticalMainChartRange
let initialVerticalRange = verticalVisibleRange.lowerBound...(verticalVisibleRange.upperBound + verticalVisibleRange.distance * 10)
zoomedBarsController.mainBarsRenderer.setup(horizontalRange: barsController.currentHorizontalMainChartRange, animated: false)
zoomedBarsController.previewBarsChartRenderer.setup(horizontalRange: barsController.currentPreviewHorizontalRange, animated: false)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: initialVerticalRange, animated: false)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: initialVerticalRange, animated: false)
zoomedBarsController.mainBarsRenderer.setVisible(true, animated: false)
zoomedBarsController.previewBarsChartRenderer.setVisible(true, animated: false)
barsController.setupMainChart(horizontalRange: destinationHorizontalRange, animated: animated)
barsController.previewBarsChartRenderer.setup(horizontalRange: zoomedBarsController.totalHorizontalRange, animated: animated)
barsController.mainBarsRenderer.setVisible(false, animated: animated)
barsController.previewBarsChartRenderer.setVisible(false, animated: animated)
zoomedBarsController.willAppear(animated: animated)
barsController.willDisappear(animated: animated)
zoomedBarsController.updateChartsVisibility(visibility: barsController.chartVisibility, animated: false)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: zoomedBarsController.currentVerticalMainChartRange, animated: animated, timeFunction: .easeInOut)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: zoomedBarsController.currentPreviewVerticalRange, animated: animated, timeFunction: .easeInOut)
} else {
if !zoomedBarsController.chartsCollection.isBlank {
barsController.hideDetailsView(animated: false)
barsController.chartVisibility = zoomedBarsController.chartVisibility
let visibleVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: barsController.visibleBars,
calculatingRange: barsController.initialHorizontalRange) ?? BaseConstants.defaultRange
barsController.mainBarsRenderer.setup(verticalRange: visibleVerticalRange, animated: false)
let toHorizontalRange = barsController.initialHorizontalRange
let verticalVisibleRange = barsController.initialVerticalRange
let targetVerticalRange = verticalVisibleRange.lowerBound...(verticalVisibleRange.upperBound + verticalVisibleRange.distance * 10)
zoomedBarsController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: targetVerticalRange, animated: animated, timeFunction: .easeInOut)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: targetVerticalRange, animated: animated, timeFunction: .easeInOut)
zoomedBarsController.previewBarsChartRenderer.setup(horizontalRange: barsController.totalHorizontalRange, animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + .defaultDuration) { [weak self] in
self?.zoomedBarsController.mainBarsRenderer.setVisible(false, animated: false)
self?.zoomedBarsController.previewBarsChartRenderer.setVisible(false, animated: false)
}
}
barsController.willAppear(animated: animated)
zoomedBarsController.willDisappear(animated: animated)
if !zoomedBarsController.chartsCollection.isBlank {
barsController.updateChartsVisibility(visibility: zoomedBarsController.chartVisibility, animated: false)
}
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
zoomedBarsController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
barsController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? zoomedBarsController.chartVisibility : barsController.chartVisibility
let collection = isZoomed ? zoomedBarsController.chartsCollection : barsController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
public override var actualChartVisibility: [Bool] {
return isZoomed ? zoomedBarsController.chartVisibility : barsController.chartVisibility
}
public override var actualChartsCollection: ChartsCollection {
let collection = isZoomed ? zoomedBarsController.chartsCollection : barsController.chartsCollection
if collection.isBlank {
return self.initialChartsCollection
}
return collection
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if isZoomed {
zoomedBarsController.chartInteractionDidBegin(point: point, manual: manual)
} else {
barsController.chartInteractionDidBegin(point: point, manual: manual)
}
}
public override func chartInteractionDidEnd() {
if isZoomed {
zoomedBarsController.chartInteractionDidEnd()
} else {
barsController.chartInteractionDidEnd()
}
}
public override var drawChartVisibity: Bool {
return true
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return zoomedBarsController.currentChartHorizontalRangeFraction
} else {
return barsController.currentChartHorizontalRangeFraction
}
}
public override func cancelChartInteraction() {
if isZoomed {
return zoomedBarsController.hideDetailsView(animated: true)
} else {
return barsController.hideDetailsView(animated: true)
}
}
public override func didTapZoomIn(date: Date, pointIndex: Int) {
guard !isZoomed, isZoomable else { return }
if isZoomed {
return zoomedBarsController.hideDetailsView(animated: true)
}
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.zoomedBarsController.initialize(chartsCollection: updatedCollection,
initialDate: date,
totalHorizontalRange: 0...1,
totalVerticalRange: 0...1)
self.switchToChart(chartsCollection: updatedCollection, isZoomed: true, animated: true)
}
})
}
public override func didTapZoomOut() {
cancelChartInteraction()
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: true)
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
if isZoomed {
return zoomedBarsController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return barsController.chartRangeFractionDidUpdated(rangeFraction)
}
}
public override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
zoomedBarsController.apply(theme: theme, strings: strings, animated: animated)
barsController.apply(theme: theme, strings: strings, animated: animated)
}
}
@@ -0,0 +1,271 @@
//
// StackedBarsChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
public class StepBarsChartController: BaseChartController {
let barsController: BarsComponentController
let zoomedBarsController: BarsComponentController
override public var isZoomable: Bool {
didSet {
barsController.isZoomable = self.isZoomable
}
}
override public init(chartsCollection: ChartsCollection) {
let horizontalScalesRenderer = HorizontalScalesRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
barsController = BarsComponentController(isZoomed: false,
mainBarsRenderer: BarChartRenderer(step: true),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer(step: true), step: true)
zoomedBarsController = BarsComponentController(isZoomed: true,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer(), step: true)
super.init(chartsCollection: chartsCollection)
[barsController, zoomedBarsController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date, pointIndex: 0)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?("", animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated, feedback) in
self.setDetailsViewModel?(viewModel, animated, feedback)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
}
public var hourly: Bool = false
public convenience init(chartsCollection: ChartsCollection, hourly: Bool) {
self.init(chartsCollection: chartsCollection)
self.hourly = hourly
}
public override var mainChartRenderers: [ChartViewRenderer] {
return [barsController.mainBarsRenderer,
zoomedBarsController.mainBarsRenderer,
barsController.horizontalScalesRenderer,
barsController.verticalScalesRenderer,
barsController.lineBulletsRenderer,
barsController.verticalLineRenderer
// performanceRenderer
]
}
public override var navigationRenderers: [ChartViewRenderer] {
return [barsController.previewBarsChartRenderer,
zoomedBarsController.previewBarsChartRenderer]
}
public override func initializeChart() {
barsController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: false)
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultDuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultDuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = zoomedBarsController.initialHorizontalRange
let destinationHorizontalRange = (toHorizontalRange.lowerBound - barsController.barsWidth)...(toHorizontalRange.upperBound - barsController.barsWidth)
let verticalVisibleRange = barsController.currentVerticalMainChartRange
let initialVerticalRange = verticalVisibleRange.lowerBound...(verticalVisibleRange.upperBound + verticalVisibleRange.distance * 10)
zoomedBarsController.mainBarsRenderer.setup(horizontalRange: barsController.currentHorizontalMainChartRange, animated: false)
zoomedBarsController.previewBarsChartRenderer.setup(horizontalRange: barsController.currentPreviewHorizontalRange, animated: false)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: initialVerticalRange, animated: false)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: initialVerticalRange, animated: false)
zoomedBarsController.mainBarsRenderer.setVisible(true, animated: false)
zoomedBarsController.previewBarsChartRenderer.setVisible(true, animated: false)
barsController.setupMainChart(horizontalRange: destinationHorizontalRange, animated: animated)
barsController.previewBarsChartRenderer.setup(horizontalRange: zoomedBarsController.totalHorizontalRange, animated: animated)
barsController.mainBarsRenderer.setVisible(false, animated: animated)
barsController.previewBarsChartRenderer.setVisible(false, animated: animated)
zoomedBarsController.willAppear(animated: animated)
barsController.willDisappear(animated: animated)
zoomedBarsController.updateChartsVisibility(visibility: barsController.chartVisibility, animated: false)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: zoomedBarsController.currentVerticalMainChartRange, animated: animated, timeFunction: .easeInOut)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: zoomedBarsController.currentPreviewVerticalRange, animated: animated, timeFunction: .easeInOut)
} else {
if !zoomedBarsController.chartsCollection.isBlank {
barsController.hideDetailsView(animated: false)
barsController.chartVisibility = zoomedBarsController.chartVisibility
let visibleVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: barsController.visibleBars,
separate: true,
calculatingRange: barsController.initialHorizontalRange) ?? BaseConstants.defaultRange
barsController.mainBarsRenderer.setup(verticalRange: visibleVerticalRange, animated: false)
let toHorizontalRange = barsController.initialHorizontalRange
let verticalVisibleRange = barsController.initialVerticalRange
let targetVerticalRange = verticalVisibleRange.lowerBound...(verticalVisibleRange.upperBound + verticalVisibleRange.distance * 10)
zoomedBarsController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: targetVerticalRange, animated: animated, timeFunction: .easeInOut)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: targetVerticalRange, animated: animated, timeFunction: .easeInOut)
zoomedBarsController.previewBarsChartRenderer.setup(horizontalRange: barsController.totalHorizontalRange, animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + .defaultDuration) { [weak self] in
self?.zoomedBarsController.mainBarsRenderer.setVisible(false, animated: false)
self?.zoomedBarsController.previewBarsChartRenderer.setVisible(false, animated: false)
}
}
barsController.willAppear(animated: animated)
zoomedBarsController.willDisappear(animated: animated)
if !zoomedBarsController.chartsCollection.isBlank {
barsController.updateChartsVisibility(visibility: zoomedBarsController.chartVisibility, animated: false)
}
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
zoomedBarsController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
barsController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? zoomedBarsController.chartVisibility : barsController.chartVisibility
let collection = isZoomed ? zoomedBarsController.chartsCollection : barsController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
public override var actualChartVisibility: [Bool] {
return isZoomed ? zoomedBarsController.chartVisibility : barsController.chartVisibility
}
public override var actualChartsCollection: ChartsCollection {
let collection = isZoomed ? zoomedBarsController.chartsCollection : barsController.chartsCollection
if collection.isBlank {
return self.initialChartsCollection
}
return collection
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if isZoomed {
zoomedBarsController.chartInteractionDidBegin(point: point, manual: manual)
} else {
barsController.chartInteractionDidBegin(point: point, manual: manual)
}
}
public override func chartInteractionDidEnd() {
if isZoomed {
zoomedBarsController.chartInteractionDidEnd()
} else {
barsController.chartInteractionDidEnd()
}
}
public override var drawChartVisibity: Bool {
return true
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return zoomedBarsController.currentChartHorizontalRangeFraction
} else {
return barsController.currentChartHorizontalRangeFraction
}
}
public override func cancelChartInteraction() {
self.barsController.lineBulletsRenderer.isEnabled = false
self.barsController.verticalLineRenderer.values = []
if isZoomed {
return zoomedBarsController.hideDetailsView(animated: true)
} else {
return barsController.hideDetailsView(animated: true)
}
}
public override func didTapZoomIn(date: Date, pointIndex: Int) {
guard !isZoomed, isZoomable else { return }
if isZoomed {
return zoomedBarsController.hideDetailsView(animated: true)
}
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.zoomedBarsController.initialize(chartsCollection: updatedCollection,
initialDate: date,
totalHorizontalRange: 0...1,
totalVerticalRange: 0...1)
self.switchToChart(chartsCollection: updatedCollection, isZoomed: true, animated: true)
}
})
}
public override func didTapZoomOut() {
cancelChartInteraction()
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: true)
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
if isZoomed {
return zoomedBarsController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return barsController.chartRangeFractionDidUpdated(rangeFraction)
}
}
public override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
super.apply(theme: theme, strings: strings, animated: animated)
zoomedBarsController.apply(theme: theme, strings: strings, animated: animated)
barsController.apply(theme: theme, strings: strings, animated: animated)
}
}
@@ -0,0 +1,353 @@
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
private enum Constants {
static let verticalBaseAnchors: [CGFloat] = [8, 5, 4, 2.5, 2, 1]
}
public class TwoAxisStepBarsChartController: BaseLinesChartController {
class GraphController {
let mainBarsRenderer = BarChartRenderer(step: true)
let verticalScalesRenderer = VerticalScalesRenderer()
let lineBulletsRenderer = LineBulletsRenderer()
let previewBarsRenderer = BarChartRenderer(step: true, lineWidth: 1.0)
var chartBars: BarChartRenderer.BarsData = .blank
var barsWidth: CGFloat = 1
var totalVerticalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
init() {
self.lineBulletsRenderer.isEnabled = false
self.mainBarsRenderer.optimizationLevel = BaseConstants.barsChartOptimizationLevel
self.previewBarsRenderer.optimizationLevel = BaseConstants.barsChartOptimizationLevel
}
func updateMainChartVerticalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainBarsRenderer.setup(verticalRange: range, animated: animated)
verticalScalesRenderer.setup(verticalRange: range, animated: animated)
lineBulletsRenderer.setup(verticalRange: range, animated: animated)
}
}
private var graphControllers: [GraphController] = []
private let verticalLineRenderer = VerticalLinesRenderer()
private let horizontalScalesRenderer = HorizontalScalesRenderer()
var totalHorizontalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
private let initialChartCollection: ChartsCollection
private var prevoiusHorizontalStrideInterval: Int = 1
public var hourly: Bool = false
public var min5: Bool = false
override public init(chartsCollection: ChartsCollection) {
self.initialChartCollection = chartsCollection
graphControllers = chartsCollection.chartValues.map { _ in GraphController() }
super.init(chartsCollection: chartsCollection)
self.zoomChartVisibility = chartVisibility
}
override func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
super.setupChartCollection(chartsCollection: chartsCollection, animated: animated, isZoomed: isZoomed)
for (index, controller) in self.graphControllers.enumerated() {
if index < chartsCollection.chartValues.count {
let chart = chartsCollection.chartValues[index]
let initialComponents = [BarChartRenderer.BarsData.Component(color: chart.color,
values: chart.values.map { CGFloat($0) })]
let (width, chartBars, totalHorizontalRange, totalVerticalRange) = BarChartRenderer.BarsData.initialComponents(chartsCollection: chartsCollection, separate: true, initialComponents: initialComponents)
controller.chartBars = chartBars
controller.verticalScalesRenderer.labelsColor = chart.color
controller.barsWidth = width
controller.totalVerticalRange = totalVerticalRange
self.totalHorizontalRange = totalHorizontalRange
var bullets: [LineBulletsRenderer.Bullet] = []
if let component = chartBars.components.first {
for i in 0 ..< chartBars.locations.count {
let location = chartBars.locations[i]
let value = component.values[i]
bullets.append(LineBulletsRenderer.Bullet(coordinate: CGPoint(x: location, y: value), offset: .zero, color: component.color))
}
}
controller.lineBulletsRenderer.bullets = bullets
controller.previewBarsRenderer.setup(horizontalRange: self.totalHorizontalRange, animated: animated)
controller.previewBarsRenderer.setup(verticalRange: controller.totalVerticalRange, animated: animated)
controller.mainBarsRenderer.bars = chartBars
controller.previewBarsRenderer.bars = chartBars
controller.verticalScalesRenderer.setHorizontalLinesVisible((index == 0), animated: animated)
controller.verticalScalesRenderer.isRightAligned = (index != 0)
controller.verticalScalesRenderer.isEnabled = true
} else {
let emptyBars = BarChartRenderer.BarsData(barWidth: 0.0, locations: [], components: [])
controller.chartBars = emptyBars
controller.barsWidth = emptyBars.barWidth
controller.mainBarsRenderer.bars = emptyBars
controller.previewBarsRenderer.bars = emptyBars
}
}
self.prevoiusHorizontalStrideInterval = -1
let chartRange: ClosedRange<CGFloat>
if isZoomed {
chartRange = zoomedChartRange
} else {
chartRange = initialChartRange
}
updateHorizontalLimits(horizontalRange: chartRange, animated: animated)
updateMainChartHorizontalRange(range: chartRange, animated: animated)
updateVerticalLimitsAndRange(horizontalRange: chartRange, animated: animated)
self.chartRangeUpdatedClosure?(currentChartHorizontalRangeFraction, animated)
}
public override func initializeChart() {
if let first = initialChartCollection.axisValues.first?.timeIntervalSince1970,
let last = initialChartCollection.axisValues.last?.timeIntervalSince1970 {
initialChartRange = CGFloat(max(first, last - BaseConstants.defaultRangePresetLength))...CGFloat(last)
}
setupChartCollection(chartsCollection: initialChartCollection, animated: false, isZoomed: false)
}
public override var mainChartRenderers: [ChartViewRenderer] {
return graphControllers.map { $0.mainBarsRenderer } +
graphControllers.flatMap { [$0.verticalScalesRenderer, $0.lineBulletsRenderer] } +
[horizontalScalesRenderer, verticalLineRenderer]
}
public override var navigationRenderers: [ChartViewRenderer] {
return graphControllers.map { $0.previewBarsRenderer }
}
public override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
chartVisibility = visibility
zoomChartVisibility = visibility
let firstIndex = visibility.firstIndex(where: { $0 })
for (index, isVisible) in visibility.enumerated() {
let graph = graphControllers[index]
graph.mainBarsRenderer.setVisible(isVisible, animated: animated)
graph.previewBarsRenderer.setVisible(isVisible, animated: animated)
graph.lineBulletsRenderer.setLineVisible(isVisible, at: 0, animated: animated)
graph.verticalScalesRenderer.setVisible(isVisible, animated: animated)
if let firstIndex = firstIndex {
graph.verticalScalesRenderer.setHorizontalLinesVisible(index == firstIndex, animated: animated)
}
}
updateVerticalLimitsAndRange(horizontalRange: currentHorizontalRange, animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint, manual: false)
}
}
public override func chartInteractionDidBegin(point: CGPoint, manual: Bool = true) {
if manual && !isChartInteracting && !self.verticalLineRenderer.values.isEmpty {
self.cancelChartInteraction()
ignoreInteraction = true
return
}
let horizontalRange = currentHorizontalRange
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let barsWidth = graphControllers.first?.barsWidth ?? 0.0
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound + barsWidth / 2.0))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartInteractionWasBegin = isChartInteractionBegun
super.chartInteractionDidBegin(point: point)
var barOffset: CGFloat = 0.0
for (index, graphController) in graphControllers.enumerated() {
var bullets: [LineBulletsRenderer.Bullet] = []
if let component = graphController.chartBars.components.first {
let location = graphController.chartBars.locations[minIndex]
let value = component.values[minIndex]
let offset = -(graphController.mainBarsRenderer.transform(toChartCoordinateHorizontal: horizontalRange.lowerBound + graphController.barsWidth, chartFrame: chartFrame) - chartFrame.minX) / 2.0
barOffset = offset
bullets.append(LineBulletsRenderer.Bullet(coordinate: CGPoint(x: location, y: value), offset: CGPoint(x: offset, y: 0.0), color: component.color))
}
let isVisible = chartVisibility[index]
graphController.lineBulletsRenderer.bullets = bullets
graphController.lineBulletsRenderer.isEnabled = true
graphController.lineBulletsRenderer.setLineVisible(isVisible, at: 0, animated: false)
}
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
var chartValueUpdated = true
if self.verticalLineRenderer.values == [chartValue] {
chartValueUpdated = false
}
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX + barOffset
self.setDetailsViewModel?(chartDetailsViewModel(closestDate: closestDate, pointIndex: minIndex, loading: false), chartInteractionWasBegin, chartInteractionWasBegin && chartValueUpdated)
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewPositionClosure?(detailsViewPosition)
self.verticalLineRenderer.values = [chartValue]
self.verticalLineRenderer.offset = barOffset
}
public override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
public override var currentHorizontalRange: ClosedRange<CGFloat> {
return graphControllers.first?.mainBarsRenderer.horizontalRange.end ?? BaseConstants.defaultRange
}
public override func cancelChartInteraction() {
super.cancelChartInteraction()
for graphController in graphControllers {
graphController.lineBulletsRenderer.isEnabled = false
}
self.setDetailsChartVisibleClosure?(false, true)
self.verticalLineRenderer.values = []
}
public override func didTapZoomOut() {
cancelChartInteraction()
self.setupChartCollection(chartsCollection: initialChartCollection, animated: true, isZoomed: false)
}
public override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>, animated: Bool = true) {
cancelChartInteraction()
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
zoomedChartRange = horizontalRange
updateChartRangeTitle(animated: true)
updateMainChartHorizontalRange(range: horizontalRange, animated: false)
updateHorizontalLimits(horizontalRange: horizontalRange, animated: true)
updateVerticalLimitsAndRange(horizontalRange: horizontalRange, animated: true)
}
func updateMainChartHorizontalRange(range: ClosedRange<CGFloat>, animated: Bool) {
for controller in graphControllers {
controller.mainBarsRenderer.setup(horizontalRange: range, animated: animated)
controller.verticalScalesRenderer.setup(horizontalRange: range, animated: animated)
controller.lineBulletsRenderer.setup(horizontalRange: range, animated: animated)
}
horizontalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalLineRenderer.setup(horizontalRange: range, animated: animated)
}
func updateHorizontalLimits(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
var scaleType: ChartScaleType = .day
if isZoomed {
scaleType = .minutes5
} else {
if self.hourly {
scaleType = .hour
} else if self.min5 {
scaleType = .minutes5
}
}
if let (stride, labels) = horizontalLimitsLabels(horizontalRange: horizontalRange,
scaleType: scaleType,
prevoiusHorizontalStrideInterval: prevoiusHorizontalStrideInterval) {
self.horizontalScalesRenderer.setup(labels: labels, animated: animated)
self.prevoiusHorizontalStrideInterval = stride
}
}
func updateVerticalLimitsAndRange(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
let chartHeight = chartFrame().height
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
let dividorsAndMultiplers: [(startValue: CGFloat, base: CGFloat, count: Int, maximumNumberOfDecimals: Int)] = graphControllers.enumerated().map { arg in
let (index, controller) = arg
let verticalRange = BarChartRenderer.BarsData.verticalRange(bars: controller.chartBars, separate: true, calculatingRange: horizontalRange, addBounds: true) ?? controller.totalVerticalRange
var numberOfOffsetsPerItem = verticalRange.distance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
if numberOfOffsetsPerItem > 0 {
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
}
let generalBase = Constants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
let base = generalBase * multiplier / dividor
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let startValue = verticalValue
var count = 0
if chartVisibility[index] {
while verticalValue < verticalRange.upperBound {
count += 1
verticalValue += base
}
}
return (startValue: startValue, base: base, count: count, maximumNumberOfDecimals: maximumNumberOfDecimals)
}
let totalCount = dividorsAndMultiplers.map { $0.count }.max() ?? 0
guard totalCount > 0 else { return }
let numberFormatter = BaseConstants.chartNumberFormatter
for (index, controller) in graphControllers.enumerated() {
let (startValue, base, _, maximumNumberOfDecimals) = dividorsAndMultiplers[index]
let updatedRange = startValue...(startValue + base * CGFloat(totalCount))
if controller.verticalScalesRenderer.verticalRange.end != updatedRange {
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
var verticalLabels: [LinesChartLabel] = []
for multipler in 0...(totalCount - 1) {
let verticalValue = startValue + base * CGFloat(multipler)
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
}
controller.verticalScalesRenderer.setup(verticalLimitsLabels: verticalLabels, animated: animated)
controller.updateMainChartVerticalRange(range: updatedRange, animated: animated)
}
}
}
public override func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
horizontalScalesRenderer.labelsColor = theme.chartLabelsColor
verticalLineRenderer.linesColor = theme.chartStrongLinesColor
for controller in graphControllers {
controller.verticalScalesRenderer.horizontalLinesColor = theme.chartHelperLinesColor
controller.lineBulletsRenderer.setInnerColor(theme.chartBackgroundColor, animated: animated)
controller.verticalScalesRenderer.axisXColor = theme.chartStrongLinesColor
}
}
}
@@ -0,0 +1,382 @@
//
// BarChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class BarChartRenderer: BaseChartRenderer {
struct BarsData {
static let blank = BarsData(barWidth: 1, locations: [], components: [])
var barWidth: CGFloat
var locations: [CGFloat]
var components: [Component]
struct Component {
var color: GColor
var values: [CGFloat]
}
}
private var step = false
private var lineWidth: CGFloat = 2.0
init(step: Bool = false, lineWidth: CGFloat = 2.0) {
self.step = step
self.lineWidth = lineWidth
super.init()
}
var fillToTop: Bool = false
private(set) lazy var selectedIndexAnimator: AnimationController<CGFloat> = {
return AnimationController(current: 0, refreshClosure: self.refreshClosure)
}()
func setSelectedIndex(_ index: Int?, animated: Bool) {
let destinationValue: CGFloat = (index == nil) ? 0 : 1
if animated {
if index != nil {
selectedBarIndex = index
}
self.selectedIndexAnimator.completionClosure = {
self.selectedBarIndex = index
}
guard self.selectedIndexAnimator.end != destinationValue else { return }
self.selectedIndexAnimator.animate(to: destinationValue, duration: .defaultDuration)
} else {
self.selectedIndexAnimator.set(current: destinationValue)
self.selectedBarIndex = index
}
}
private var selectedBarIndex: Int? {
didSet {
setNeedsDisplay()
}
}
var generalUnselectedAlpha: CGFloat = 0.5
private var componentsAnimators: [AnimationController<CGFloat>] = []
var bars: BarsData = BarsData(barWidth: 1, locations: [], components: []) {
willSet {
if bars.components.count != newValue.components.count {
componentsAnimators = newValue.components.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
}
}
didSet {
setNeedsDisplay()
}
}
func setComponentVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
componentsAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
private lazy var backgroundColorAnimator = AnimationController(current: NSColorContainer(color: .white), refreshClosure: refreshClosure)
func update(backgroundColor: GColor, animated: Bool) {
if animated {
backgroundColorAnimator.animate(to: NSColorContainer(color: backgroundColor), duration: .defaultDuration)
} else {
backgroundColorAnimator.set(current: NSColorContainer(color: backgroundColor))
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let chartsAlpha = chartAlphaAnimator.current
if chartsAlpha == 0 { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
var selectedPaths: [[CGRect]] = bars.components.map { _ in [] }
var unselectedPaths: [[CGRect]] = bars.components.map { _ in [] }
if var barIndex = bars.locations.firstIndex(where: { $0 >= range.lowerBound }) {
if fillToTop {
barIndex = max(0, barIndex - 1)
while barIndex < bars.locations.count {
let currentLocation = bars.locations[barIndex]
let right = transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame).roundedUpToPixelGrid()
let left = transform(toChartCoordinateHorizontal: currentLocation - bars.barWidth, chartFrame: chartFrame).roundedUpToPixelGrid()
var summ: CGFloat = 0
for (index, component) in bars.components.enumerated() {
summ += componentsAnimators[index].current * component.values[barIndex]
}
guard summ > 0 else {
barIndex += 1
continue
}
var stackedValue: CGFloat = 0
for (index, component) in bars.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
let bottomFraction = stackedValue
let topFraction = stackedValue + ((component.values[barIndex] * visibilityPercent) / summ)
let rect = CGRect(x: left,
y: chartFrame.maxY - chartFrame.height * topFraction,
width: right - left,
height: chartFrame.height * (topFraction - bottomFraction))
if selectedBarIndex == barIndex {
selectedPaths[index].append(rect)
} else {
unselectedPaths[index].append(rect)
}
stackedValue = topFraction
}
if currentLocation > range.upperBound {
break
}
barIndex += 1
}
for (index, component) in bars.components.enumerated() {
context.saveGState()
context.setFillColor(component.color.withAlphaComponent(chartsAlpha * component.color.alphaValue).cgColor)
context.fill(selectedPaths[index])
let resultAlpha: CGFloat = 1.0 - (1.0 - generalUnselectedAlpha) * selectedIndexAnimator.current
context.setFillColor(component.color.withAlphaComponent(chartsAlpha * component.color.alphaValue * resultAlpha).cgColor)
context.fill(unselectedPaths[index])
context.restoreGState()
}
} else {
if self.step {
var selectedPaths: [[CGRect]] = bars.components.map { _ in [] }
barIndex = max(0, barIndex - 1)
var currentLocation = bars.locations[barIndex]
var leftX = transform(toChartCoordinateHorizontal: currentLocation - bars.barWidth, chartFrame: chartFrame)
var rightX: CGFloat = 0
var backgroundPaths: [[CGPoint]] = bars.components.map { _ in Array() }
let itemsCount = ((bars.locations.count - barIndex) * 2) + 4
for path in backgroundPaths.indices {
backgroundPaths[path].reserveCapacity(itemsCount)
}
var maxValues: [CGFloat] = bars.components.map { _ in 0 }
while barIndex < bars.locations.count {
currentLocation = bars.locations[barIndex]
rightX = transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame)
let bottomY: CGFloat = transform(toChartCoordinateVertical: 0.0, chartFrame: chartFrame)
for (index, component) in bars.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
let value = component.values[barIndex]
let height = value * visibilityPercent
let topY = transform(toChartCoordinateVertical: height, chartFrame: chartFrame)
let componentHeight = (bottomY - topY)
maxValues[index] = max(maxValues[index], componentHeight)
if selectedBarIndex == barIndex {
let rect = CGRect(x: leftX,
y: topY,
width: rightX - leftX,
height: componentHeight)
selectedPaths[index].append(rect)
}
backgroundPaths[index].append(CGPoint(x: leftX, y: topY))
backgroundPaths[index].append(CGPoint(x: rightX, y: topY))
}
if currentLocation > range.upperBound {
break
}
leftX = rightX
barIndex += 1
}
for (index, component) in bars.components.enumerated().reversed() {
if maxValues[index] < optimizationLevel {
continue
}
context.saveGState()
context.setLineWidth(self.lineWidth)
context.setStrokeColor(GColor.valueBetween(start: backgroundColorAnimator.current.color,
end: component.color,
offset: 1.0).cgColor)
context.beginPath()
context.addLines(between: backgroundPaths[index])
context.strokePath()
context.restoreGState()
}
} else {
var selectedPaths: [[CGRect]] = bars.components.map { _ in [] }
barIndex = max(0, barIndex - 1)
var currentLocation = bars.locations[barIndex]
var leftX = transform(toChartCoordinateHorizontal: currentLocation - bars.barWidth, chartFrame: chartFrame)
var rightX: CGFloat = 0
let startPoint = CGPoint(x: leftX,
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
var backgroundPaths: [[CGPoint]] = bars.components.map { _ in Array() }
let itemsCount = ((bars.locations.count - barIndex) * 2) + 4
for path in backgroundPaths.indices {
backgroundPaths[path].reserveCapacity(itemsCount)
backgroundPaths[path].append(startPoint)
}
var maxValues: [CGFloat] = bars.components.map { _ in 0 }
while barIndex < bars.locations.count {
currentLocation = bars.locations[barIndex]
rightX = transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame)
var stackedValue: CGFloat = 0
var bottomY: CGFloat = transform(toChartCoordinateVertical: stackedValue, chartFrame: chartFrame)
for (index, component) in bars.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
let height = component.values[barIndex] * visibilityPercent
stackedValue += height
let topY = transform(toChartCoordinateVertical: stackedValue, chartFrame: chartFrame)
let componentHeight = (bottomY - topY)
maxValues[index] = max(maxValues[index], componentHeight)
if selectedBarIndex == barIndex {
let rect = CGRect(x: leftX,
y: topY,
width: rightX - leftX,
height: componentHeight)
selectedPaths[index].append(rect)
}
backgroundPaths[index].append(CGPoint(x: leftX, y: topY))
backgroundPaths[index].append(CGPoint(x: rightX, y: topY))
bottomY = topY
}
if currentLocation > range.upperBound {
break
}
leftX = rightX
barIndex += 1
}
let endPoint = CGPoint(x: transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame).roundedUpToPixelGrid(),
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
let colorOffset = Double((1.0 - (1.0 - generalUnselectedAlpha) * selectedIndexAnimator.current) * chartsAlpha)
for (index, component) in bars.components.enumerated().reversed() {
if maxValues[index] < optimizationLevel {
continue
}
context.saveGState()
backgroundPaths[index].append(endPoint)
context.setFillColor(GColor.valueBetween(start: backgroundColorAnimator.current.color,
end: component.color,
offset: colorOffset).cgColor)
context.beginPath()
context.addLines(between: backgroundPaths[index])
context.closePath()
context.fillPath()
context.restoreGState()
}
for (index, component) in bars.components.enumerated().reversed() {
context.setFillColor(component.color.withAlphaComponent(chartsAlpha * component.color.alphaValue).cgColor)
context.fill(selectedPaths[index])
}
}
}
}
}
}
extension BarChartRenderer.BarsData {
static func initialComponents(chartsCollection: ChartsCollection, separate: Bool = false, initialComponents: [BarChartRenderer.BarsData.Component]? = nil) ->
(width: CGFloat,
chartBars: BarChartRenderer.BarsData,
totalHorizontalRange: ClosedRange<CGFloat>,
totalVerticalRange: ClosedRange<CGFloat>) {
let width: CGFloat
if chartsCollection.axisValues.count > 1 {
width = CGFloat(abs(chartsCollection.axisValues[1].timeIntervalSince1970 - chartsCollection.axisValues[0].timeIntervalSince1970))
} else {
width = 1
}
let components = initialComponents ?? chartsCollection.chartValues.map { BarChartRenderer.BarsData.Component(color: $0.color,
values: $0.values.map { CGFloat($0) }) }
let chartBars = BarChartRenderer.BarsData(barWidth: width,
locations: chartsCollection.axisValues.map { CGFloat($0.timeIntervalSince1970) },
components: components)
let totalVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: chartBars, separate: separate) ?? 0...1
let totalHorizontalRange = BarChartRenderer.BarsData.visibleHorizontalRange(bars: chartBars, width: width) ?? 0...1
return (width: width, chartBars: chartBars, totalHorizontalRange: totalHorizontalRange, totalVerticalRange: totalVerticalRange)
}
static func visibleHorizontalRange(bars: BarChartRenderer.BarsData, width: CGFloat) -> ClosedRange<CGFloat>? {
guard let firstPoint = bars.locations.first,
let lastPoint = bars.locations.last,
firstPoint <= lastPoint else {
return nil
}
return (firstPoint - width)...lastPoint
}
static func verticalRange(bars: BarChartRenderer.BarsData, separate: Bool = false, calculatingRange: ClosedRange<CGFloat>? = nil, addBounds: Bool = false) -> ClosedRange<CGFloat>? {
guard bars.components.count > 0 else {
return nil
}
if let calculatingRange = calculatingRange {
guard var index = bars.locations.firstIndex(where: { $0 >= calculatingRange.lowerBound && $0 <= calculatingRange.upperBound }) else {
return nil
}
var vMax: CGFloat = bars.components[0].values[index]
while index < bars.locations.count {
if separate {
for component in bars.components {
vMax = max(vMax, component.values[index])
}
} else {
var summ: CGFloat = 0
for component in bars.components {
summ += component.values[index]
}
vMax = max(vMax, summ)
}
if bars.locations[index] > calculatingRange.upperBound {
break
}
index += 1
}
return 0...vMax
} else {
var index = 0
var vMax: CGFloat = bars.components[0].values[index]
while index < bars.locations.count {
if separate {
for component in bars.components {
vMax = max(vMax, component.values[index])
}
} else {
var summ: CGFloat = 0
for component in bars.components {
summ += component.values[index]
}
vMax = max(vMax, summ)
}
index += 1
}
return 0...vMax
}
}
}
@@ -0,0 +1,140 @@
//
// BaseChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
public final class ContainerViewReference {
public weak var value: GView?
public init(value: GView) {
self.value = value
}
}
public protocol ChartViewRenderer: AnyObject {
var containerViews: [ContainerViewReference] { get set }
func render(context: CGContext, bounds: CGRect, chartFrame: CGRect)
}
private let exponentialAnimationTrashold: CGFloat = 100
class BaseChartRenderer: ChartViewRenderer {
var containerViews: [ContainerViewReference] = []
var optimizationLevel: CGFloat = 1 {
didSet {
setNeedsDisplay()
}
}
var isEnabled: Bool = true {
didSet {
setNeedsDisplay()
}
}
private(set) lazy var chartAlphaAnimator: AnimationController<CGFloat> = {
return AnimationController(current: 1, refreshClosure: self.refreshClosure)
}()
func setVisible(_ visible: Bool, animated: Bool) {
let destinationValue: CGFloat = visible ? 1 : 0
guard self.chartAlphaAnimator.end != destinationValue else { return }
if animated {
self.chartAlphaAnimator.animate(to: destinationValue, duration: .defaultDuration)
} else {
self.chartAlphaAnimator.set(current: destinationValue)
}
}
lazy var horizontalRange = AnimationController<ClosedRange<CGFloat>>(current: 0...1, refreshClosure: refreshClosure)
lazy var verticalRange = AnimationController<ClosedRange<CGFloat>>(current: 0...1, refreshClosure: refreshClosure)
func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
guard self.verticalRange.end != verticalRange else {
self.verticalRange.timeFunction = timeFunction ?? .linear
return
}
if animated {
let function: TimeFunction = .easeInOut
// if let timeFunction = timeFunction {
// function = timeFunction
// } else if self.verticalRange.current.distance > 0 && verticalRange.distance > 0 {
// if self.verticalRange.current.distance / verticalRange.distance > exponentialAnimationTrashold {
// function = .easeIn
// } else if verticalRange.distance / self.verticalRange.current.distance > exponentialAnimationTrashold {
// function = .easeOut
// } else {
// function = .linear
// }
// } else {
// function = .linear
// }
self.verticalRange.animate(to: verticalRange, duration: .defaultDuration, timeFunction: function)
} else {
self.verticalRange.set(current: verticalRange)
}
}
func setup(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
guard self.horizontalRange.end != horizontalRange else { return }
if animated {
let animationCurve: TimeFunction = self.horizontalRange.current.distance > horizontalRange.distance ? .easeOut : .easeIn
self.horizontalRange.animate(to: horizontalRange, duration: .defaultDuration, timeFunction: animationCurve)
} else {
self.horizontalRange.set(current: horizontalRange)
}
}
func transform(toChartCoordinateHorizontal x: CGFloat, chartFrame: CGRect) -> CGFloat {
return chartFrame.origin.x + (x - horizontalRange.current.lowerBound) / horizontalRange.current.distance * chartFrame.width
}
func transform(toChartCoordinateVertical y: CGFloat, chartFrame: CGRect) -> CGFloat {
return chartFrame.height + chartFrame.origin.y - (y - verticalRange.current.lowerBound) / verticalRange.current.distance * chartFrame.height
}
func transform(toChartCoordinate point: CGPoint, chartFrame: CGRect) -> CGPoint {
return CGPoint(x: transform(toChartCoordinateHorizontal: point.x, chartFrame: chartFrame),
y: transform(toChartCoordinateVertical: point.y, chartFrame: chartFrame))
}
func renderRange(bounds: CGRect, chartFrame: CGRect) -> ClosedRange<CGFloat> {
let lowerBound = horizontalRange.current.lowerBound - chartFrame.origin.x / chartFrame.width * horizontalRange.current.distance
let upperBound = horizontalRange.current.upperBound + (bounds.width - chartFrame.width - chartFrame.origin.x) / chartFrame.width * horizontalRange.current.distance
guard lowerBound <= upperBound else {
print("Error: Unexpecated bounds range!")
return 0...1
}
return lowerBound...upperBound
}
func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
fatalError("abstract")
}
func setNeedsDisplay() {
containerViews.forEach { containerView in
guard let value = containerView.value else {
return
}
value.setNeedsDisplay(value.bounds)
}
}
var refreshClosure: () -> Void {
return { [weak self] in
self?.setNeedsDisplay()
}
}
}
@@ -0,0 +1,153 @@
//
// ChartDetailsRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class ChartDetailsRenderer: BaseChartRenderer, ChartThemeContainer {
private lazy var colorAnimator = AnimationController<CGFloat>(current: 1, refreshClosure: refreshClosure)
private var fromTheme: ChartTheme = ChartTheme.defaultDayTheme
private var currentTheme: ChartTheme = ChartTheme.defaultDayTheme
func apply(theme: ChartTheme, strings: ChartStrings, animated: Bool) {
fromTheme = currentTheme
currentTheme = theme
colorAnimator.set(current: 1)
}
private var valuesAnimators: [AnimationController<CGFloat>] = []
func setValueVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
valuesAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
var detailsViewModel: ChartDetailsViewModel = .blank {
didSet {
if detailsViewModel.values.count != valuesAnimators.count {
valuesAnimators = detailsViewModel.values.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: refreshClosure) }
}
setNeedsDisplay()
}
}
var detailsViewPosition: CGFloat = 0 {
didSet {
setNeedsDisplay()
}
}
var detailViewPositionOffset: CGFloat = 10
var detailViewTopOffset: CGFloat = 10
private var iconWidth: CGFloat = 10
private var margins: CGFloat = 10
private let cornerRadius: CGFloat = 5
private var rowHeight: CGFloat = 20
private let titleFont = NSFont.systemFont(ofSize: 14, weight: .bold)
private let prefixFont = NSFont.systemFont(ofSize: 14, weight: .bold)
private let labelsFont = NSFont.systemFont(ofSize: 14, weight: .medium)
private let valuesFont = NSFont.systemFont(ofSize: 14, weight: .bold)
private let labelsColor: GColor = .black
private(set) var previousRenderBannerFrame: CGRect = .zero
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
previousRenderBannerFrame = .zero
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let generalAlpha = chartAlphaAnimator.current
if generalAlpha == 0 { return }
let widths: [(prefix: CGFloat, label: CGFloat, value: CGFloat)] = detailsViewModel.values.map { value in
var prefixWidth: CGFloat = 0
if let prefixText = value.prefix {
prefixWidth = (prefixText as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: prefixFont],
context: nil).width.rounded(.up) + margins
}
let labelWidth = (value.title as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: labelsFont],
context: nil).width.rounded(.up) + margins
let valueWidth = (value.value as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: valuesFont],
context: nil).width.rounded(.up)
return (prefixWidth, labelWidth, valueWidth)
}
let titleWidth = (detailsViewModel.title as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: titleFont],
context: nil).width
let prefixesWidth = widths.map { $0.prefix }.max() ?? 0
let labelsWidth = widths.map { $0.label }.max() ?? 0
let valuesWidth = widths.map { $0.value }.max() ?? 0
let totalWidth: CGFloat = max(prefixesWidth + labelsWidth + valuesWidth, titleWidth + iconWidth) + margins * 2
let totalHeight: CGFloat = CGFloat(detailsViewModel.values.count + 1) * rowHeight + margins * 2
let backgroundColor = GColor.valueBetween(start: fromTheme.chartDetailsViewColor,
end: currentTheme.chartDetailsViewColor,
offset: Double(colorAnimator.current))
let titleAndTextColor = GColor.valueBetween(start: fromTheme.chartDetailsTextColor,
end: currentTheme.chartDetailsTextColor,
offset: Double(colorAnimator.current))
let detailsViewFrame: CGRect
if totalWidth + detailViewTopOffset > detailsViewPosition {
detailsViewFrame = CGRect(x: detailsViewPosition + detailViewTopOffset,
y: detailViewTopOffset + chartFrame.minY,
width: totalWidth,
height: totalHeight)
} else {
detailsViewFrame = CGRect(x: detailsViewPosition - totalWidth - detailViewTopOffset,
y: detailViewTopOffset + chartFrame.minY,
width: totalWidth,
height: totalHeight)
}
previousRenderBannerFrame = detailsViewFrame
context.saveGState()
context.setFillColor(backgroundColor.cgColor)
context.beginPath()
context.addPath(CGPath(roundedRect: detailsViewFrame, cornerWidth: 5, cornerHeight: 5, transform: nil))
context.fillPath()
context.endPage()
context.restoreGState()
var drawY = detailsViewFrame.minY + margins + (rowHeight - titleFont.pointSize) / 2
let attributedString = NSAttributedString(string: detailsViewModel.title, attributes: [.foregroundColor: titleAndTextColor, .font: titleFont])
let textNode = LabelNode.layoutText(attributedString, bounds.size)
textNode.1.draw(CGRect(origin: CGPoint(x: detailsViewFrame.minX + margins, y: drawY), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
drawY += rowHeight
for (index, row) in widths.enumerated() {
let value = detailsViewModel.values[index]
if let prefixText = value.prefix {
let attributedString = NSAttributedString(string: prefixText, attributes: [.foregroundColor: titleAndTextColor, .font: prefixFont])
let textNode = LabelNode.layoutText(attributedString, bounds.size)
textNode.1.draw(CGRect(origin: CGPoint(x: detailsViewFrame.minX + prefixesWidth - row.prefix,
y: drawY), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
}
var attributedString = NSAttributedString(string: value.title, attributes: [.foregroundColor: titleAndTextColor, .font: labelsFont])
var textNode = LabelNode.layoutText(attributedString, bounds.size)
textNode.1.draw(CGRect(origin: CGPoint(x: detailsViewFrame.minX + prefixesWidth + margins,
y: drawY), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
attributedString = NSAttributedString(string: value.title, attributes: [.foregroundColor: value.color, .font: labelsFont])
textNode = LabelNode.layoutText(attributedString, bounds.size)
textNode.1.draw(CGRect(origin: CGPoint(x: detailsViewFrame.minX + prefixesWidth + labelsWidth + valuesWidth - row.value + margins, y: drawY), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
drawY += rowHeight
}
}
}
@@ -0,0 +1,102 @@
//
// HorizontalScalesRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class HorizontalScalesRenderer: BaseChartRenderer {
private var horizontalLabels: [LinesChartLabel] = []
private var animatedHorizontalLabels: [AnimatedLinesChartLabels] = []
var labelsVerticalOffset: CGFloat = 8
var labelsFont: NSFont = .systemFont(ofSize: 11)
var labelsColor: GColor = .gray
func setup(labels: [LinesChartLabel], animated: Bool) {
if animated {
var labelsToKeepVisible: [LinesChartLabel] = []
let labelsToHide: [LinesChartLabel]
var labelsToShow: [LinesChartLabel] = []
for label in labels {
if horizontalLabels.contains(label) {
labelsToKeepVisible.append(label)
} else {
labelsToShow.append(label)
}
}
labelsToHide = horizontalLabels.filter { !labels.contains($0) }
animatedHorizontalLabels.removeAll()
horizontalLabels = labelsToKeepVisible
let showAnimation = AnimatedLinesChartLabels(labels: labelsToShow, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
showAnimation.isAppearing = true
showAnimation.alphaAnimator.set(current: 0)
showAnimation.alphaAnimator.animate(to: 1, duration: .defaultDuration)
showAnimation.alphaAnimator.completionClosure = { [weak self, weak showAnimation] in
guard let self = self, let showAnimation = showAnimation else { return }
self.animatedHorizontalLabels.removeAll(where: { $0 === showAnimation })
self.horizontalLabels = labels
}
let hideAnimation = AnimatedLinesChartLabels(labels: labelsToHide, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
hideAnimation.isAppearing = false
hideAnimation.alphaAnimator.set(current: 1)
hideAnimation.alphaAnimator.animate(to: 0, duration: .defaultDuration)
hideAnimation.alphaAnimator.completionClosure = { [weak self, weak hideAnimation] in
guard let self = self, let hideAnimation = hideAnimation else { return }
self.animatedHorizontalLabels.removeAll(where: { $0 === hideAnimation })
}
animatedHorizontalLabels.append(showAnimation)
animatedHorizontalLabels.append(hideAnimation)
} else {
horizontalLabels = labels
animatedHorizontalLabels = []
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let itemsAlpha = chartAlphaAnimator.current
guard itemsAlpha > 0 else { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
func drawHorizontalLabels(_ labels: [LinesChartLabel], color: GColor) {
let y = chartFrame.origin.y + chartFrame.height + labelsVerticalOffset
if let start = labels.firstIndex(where: { $0.value > range.lowerBound }) {
for index in start..<labels.count {
let label = labels[index]
let x = transform(toChartCoordinateHorizontal: label.value, chartFrame: chartFrame)
let attributedString = NSAttributedString(string: label.text, attributes: [.foregroundColor: color, .font: labelsFont])
let textNode = LabelNode.layoutText(attributedString, bounds.size)
textNode.1.draw(CGRect(origin: CGPoint(x: x - textNode.0.size.width, y: y), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
if label.value > range.upperBound {
break
}
}
}
}
let labelColorAlpha = labelsColor.alphaValue * itemsAlpha
drawHorizontalLabels(horizontalLabels, color: labelsColor.withAlphaComponent(labelColorAlpha * itemsAlpha))
for animation in animatedHorizontalLabels {
let color = labelsColor.withAlphaComponent(animation.alphaAnimator.current * labelColorAlpha)
drawHorizontalLabels(animation.labels, color: color)
}
}
}
@@ -0,0 +1,75 @@
//
// LineBulletsRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class LineBulletsRenderer: BaseChartRenderer {
struct Bullet {
var coordinate: CGPoint
var offset: CGPoint
var color: GColor
}
var bullets: [Bullet] = [] {
willSet {
if alphaAnimators.count != newValue.count {
alphaAnimators = newValue.map { _ in AnimationController<CGFloat>(current: 1.0, refreshClosure: refreshClosure) }
}
}
didSet {
setNeedsDisplay()
}
}
private var alphaAnimators: [AnimationController<CGFloat>] = []
private lazy var innerColorAnimator = AnimationController(current: NSColorContainer(color: .white), refreshClosure: refreshClosure)
public func setInnerColor(_ color: GColor, animated: Bool) {
if animated {
innerColorAnimator.animate(to: NSColorContainer(color: color), duration: .defaultDuration)
} else {
innerColorAnimator.set(current: NSColorContainer(color: color))
}
}
var linesWidth: CGFloat = 2
var bulletRadius: CGFloat = 6
func setLineVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
if alphaAnimators.count > index {
alphaAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let generalAlpha = chartAlphaAnimator.current
if generalAlpha == 0 { return }
for (index, bullet) in bullets.enumerated() {
let alpha = alphaAnimators[index].current
if alpha == 0 { continue }
let centerX = transform(toChartCoordinateHorizontal: bullet.coordinate.x, chartFrame: chartFrame) + bullet.offset.x
let centerY = transform(toChartCoordinateVertical: bullet.coordinate.y, chartFrame: chartFrame) + bullet.offset.y
context.setFillColor(innerColorAnimator.current.color.withAlphaComponent(alpha).cgColor)
context.setStrokeColor(bullet.color.withAlphaComponent(alpha).cgColor)
context.setLineWidth(linesWidth)
let rect = CGRect(x: centerX - bulletRadius / 2,
y: centerY - bulletRadius / 2,
width: bulletRadius,
height: bulletRadius)
context.fillEllipse(in: rect)
context.strokeEllipse(in: rect)
}
}
}
@@ -0,0 +1,532 @@
//
// LinesChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class LinesChartRenderer: BaseChartRenderer {
struct LineData {
var color: GColor
var points: [CGPoint]
}
private var linesAlphaAnimators: [AnimationController<CGFloat>] = []
var lineWidth: CGFloat = 1 {
didSet {
setNeedsDisplay()
}
}
private lazy var linesShapeAnimator = AnimationController<Double>(current: 1, refreshClosure: self.refreshClosure)
private var fromLines: [LineData] = []
private var toLines: [LineData] = []
func setLines(lines: [LineData], animated: Bool) {
if toLines.count != lines.count {
linesAlphaAnimators = lines.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
}
if animated {
self.fromLines = self.toLines
self.toLines = lines
linesShapeAnimator.set(current: 1.0 - linesShapeAnimator.current)
linesShapeAnimator.completionClosure = {
self.fromLines = []
}
linesShapeAnimator.animate(to: 1, duration: .defaultDuration)
} else {
self.fromLines = []
self.toLines = lines
linesShapeAnimator.set(current: 1)
}
}
func setLineVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
if linesAlphaAnimators.count > index {
linesAlphaAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let chartsAlpha = chartAlphaAnimator.current
if chartsAlpha == 0 { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
let spacing: CGFloat = 1.0
context.clip(to: CGRect(origin: CGPoint(x: 0.0, y: chartFrame.minY - spacing), size: CGSize(width: chartFrame.width + chartFrame.origin.x * 2.0, height: chartFrame.height + spacing * 2.0)))
for (index, toLine) in toLines.enumerated() {
let alpha = linesAlphaAnimators[index].current * chartsAlpha
if alpha == 0 { continue }
context.setAlpha(alpha)
context.setStrokeColor(toLine.color.cgColor)
context.setLineWidth(lineWidth)
context.beginTransparencyLayer(auxiliaryInfo: nil)
if linesShapeAnimator.isAnimating {
let animationOffset = linesShapeAnimator.current
let fromPoints = fromLines.safeElement(at: index)?.points ?? []
let toPoints = toLines.safeElement(at: index)?.points ?? []
var fromIndex: Int? = fromPoints.firstIndex(where: { $0.x >= range.lowerBound })
var toIndex: Int? = toPoints.firstIndex(where: { $0.x >= range.lowerBound })
let fromRange = verticalRange.start
let currentRange = verticalRange.current
let toRange = verticalRange.end
func convertFromPoint(_ fromPoint: CGPoint) -> CGPoint {
return CGPoint(x: fromPoint.x,
y: (fromPoint.y - fromRange.lowerBound) / fromRange.distance * currentRange.distance + currentRange.lowerBound)
}
func convertToPoint(_ toPoint: CGPoint) -> CGPoint {
return CGPoint(x: toPoint.x,
y: (toPoint.y - toRange.lowerBound) / toRange.distance * currentRange.distance + currentRange.lowerBound)
}
var previousFromPoint: CGPoint
var previousToPoint: CGPoint
let startFromPoint: CGPoint?
let startToPoint: CGPoint?
if let validFrom = fromIndex {
previousFromPoint = convertFromPoint(fromPoints[max(0, validFrom - 1)])
startFromPoint = previousFromPoint
} else {
previousFromPoint = .zero
startFromPoint = nil
}
if let validTo = toIndex {
previousToPoint = convertToPoint(toPoints[max(0, validTo - 1)])
startToPoint = previousToPoint
} else {
previousToPoint = .zero
startToPoint = nil
}
var combinedPoints: [CGPoint] = []
func add(pointToDraw: CGPoint) {
if let startFromPoint = startFromPoint,
pointToDraw.x < startFromPoint.x {
let animatedPoint = CGPoint(x: pointToDraw.x,
y: CGFloat.valueBetween(start: startFromPoint.y, end: pointToDraw.y, offset: animationOffset))
combinedPoints.append(transform(toChartCoordinate: animatedPoint, chartFrame: chartFrame))
} else if let startToPoint = startToPoint,
pointToDraw.x < startToPoint.x {
let animatedPoint = CGPoint(x: pointToDraw.x,
y: CGFloat.valueBetween(start: startToPoint.y, end: pointToDraw.y, offset: 1 - animationOffset))
combinedPoints.append(transform(toChartCoordinate: animatedPoint, chartFrame: chartFrame))
} else {
combinedPoints.append(transform(toChartCoordinate: pointToDraw, chartFrame: chartFrame))
}
}
if previousToPoint != .zero && previousFromPoint != .zero {
add(pointToDraw: (previousToPoint.x < previousFromPoint.x ? previousToPoint : previousFromPoint))
} else if previousToPoint != .zero {
add(pointToDraw: previousToPoint)
} else if previousFromPoint != .zero {
add(pointToDraw: previousFromPoint)
}
while let validFromIndex = fromIndex,
let validToIndex = toIndex,
validFromIndex < fromPoints.count,
validToIndex < toPoints.count {
let currentFromPoint = convertFromPoint(fromPoints[validFromIndex])
let currentToPoint = convertToPoint(toPoints[validToIndex])
let pointToAdd: CGPoint
if currentFromPoint.x == currentToPoint.x {
pointToAdd = CGPoint.valueBetween(start: currentFromPoint, end: currentToPoint, offset: animationOffset)
previousFromPoint = currentFromPoint
previousToPoint = currentToPoint
fromIndex = validFromIndex + 1
toIndex = validToIndex + 1
} else if currentFromPoint.x < currentToPoint.x {
if previousToPoint.x < currentFromPoint.x {
let offset = Double((currentFromPoint.x - previousToPoint.x) / (currentToPoint.x - previousToPoint.x))
let intermidiateToPoint = CGPoint.valueBetween(start: previousToPoint, end: currentToPoint, offset: offset)
pointToAdd = CGPoint.valueBetween(start: currentFromPoint, end: intermidiateToPoint, offset: animationOffset)
} else {
pointToAdd = currentFromPoint
}
previousFromPoint = currentFromPoint
fromIndex = validFromIndex + 1
} else {
if previousFromPoint.x < currentToPoint.x {
let offset = Double((currentToPoint.x - previousFromPoint.x) / (currentFromPoint.x - previousFromPoint.x))
let intermidiateFromPoint = CGPoint.valueBetween(start: previousFromPoint, end: currentFromPoint, offset: offset)
pointToAdd = CGPoint.valueBetween(start: intermidiateFromPoint, end: currentToPoint, offset: animationOffset)
} else {
pointToAdd = currentToPoint
}
previousToPoint = currentToPoint
toIndex = validToIndex + 1
}
add(pointToDraw: pointToAdd)
if (pointToAdd.x > range.upperBound) {
break
}
}
while let validToIndex = toIndex, validToIndex < toPoints.count {
var pointToAdd = convertToPoint(toPoints[validToIndex])
pointToAdd.y = CGFloat.valueBetween(start: previousFromPoint.y,
end: pointToAdd.y,
offset: animationOffset)
add(pointToDraw: pointToAdd)
if (pointToAdd.x > range.upperBound) {
break
}
toIndex = validToIndex + 1
}
while let validFromIndex = fromIndex, validFromIndex < fromPoints.count {
var pointToAdd = convertFromPoint(fromPoints[validFromIndex])
pointToAdd.y = CGFloat.valueBetween(start: previousToPoint.y,
end: pointToAdd.y,
offset: 1 - animationOffset)
add(pointToDraw: pointToAdd)
if (pointToAdd.x > range.upperBound) {
break
}
fromIndex = validFromIndex + 1
}
var index = 0
var lines: [CGPoint] = []
var currentChartPoint = combinedPoints[index]
lines.append(currentChartPoint)
var chartPoints = [currentChartPoint]
var minIndex = 0
var maxIndex = 0
index += 1
while index < combinedPoints.count {
currentChartPoint = combinedPoints[index]
if currentChartPoint.x - chartPoints[0].x < lineWidth * optimizationLevel {
chartPoints.append(currentChartPoint)
if currentChartPoint.y > chartPoints[maxIndex].y {
maxIndex = chartPoints.count - 1
}
if currentChartPoint.y < chartPoints[minIndex].y {
minIndex = chartPoints.count - 1
}
index += 1
} else {
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
chartPoints[0] = currentChartPoint
index += 1
minIndex = 0
maxIndex = 0
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[maxIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[maxIndex]]
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[minIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[minIndex]]
}
}
if chartPoints.count == 2 {
if chartPoints[0].y < chartPoints[1].y {
minIndex = 0
maxIndex = 1
} else {
minIndex = 1
maxIndex = 0
}
} else {
minIndex = 0
maxIndex = 0
}
}
}
}
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
}
}
if (lines.count % 2) == 1 {
lines.removeLast()
}
context.setLineCap(.round)
context.strokeLineSegments(between: lines)
} else {
if var index = toLine.points.firstIndex(where: { $0.x >= range.lowerBound }) {
var lines: [CGPoint] = []
index = max(0, index - 1)
var currentPoint = toLine.points[index]
var currentChartPoint = transform(toChartCoordinate: currentPoint, chartFrame: chartFrame)
lines.append(currentChartPoint)
//context.move(to: currentChartPoint)
var chartPoints = [currentChartPoint]
var minIndex = 0
var maxIndex = 0
index += 1
while index < toLine.points.count {
currentPoint = toLine.points[index]
currentChartPoint = transform(toChartCoordinate: currentPoint, chartFrame: chartFrame)
if currentChartPoint.x - chartPoints[0].x < lineWidth * optimizationLevel {
chartPoints.append(currentChartPoint)
if currentChartPoint.y > chartPoints[maxIndex].y {
maxIndex = chartPoints.count - 1
}
if currentChartPoint.y < chartPoints[minIndex].y {
minIndex = chartPoints.count - 1
}
index += 1
} else {
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
chartPoints[0] = currentChartPoint
index += 1
minIndex = 0
maxIndex = 0
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[maxIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[maxIndex]]
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[minIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[minIndex]]
}
}
if chartPoints.count == 2 {
if chartPoints[0].y < chartPoints[1].y {
minIndex = 0
maxIndex = 1
} else {
minIndex = 1
maxIndex = 0
}
} else {
minIndex = 0
maxIndex = 0
}
}
}
if currentPoint.x > range.upperBound {
break
}
}
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
}
}
if (lines.count % 2) == 1 {
lines.removeLast()
}
context.setLineCap(.round)
context.strokeLineSegments(between: lines)
}
}
context.endTransparencyLayer()
context.setAlpha(1.0)
}
context.resetClip()
}
}
extension LinesChartRenderer.LineData {
static func initialComponents(chartsCollection: ChartsCollection) -> (linesData: [LinesChartRenderer.LineData],
totalHorizontalRange: ClosedRange<CGFloat>,
totalVerticalRange: ClosedRange<CGFloat>) {
let lines: [LinesChartRenderer.LineData] = chartsCollection.chartValues.map { chart in
let points = chart.values.enumerated().map({ (arg) -> CGPoint in
return CGPoint(x: chartsCollection.axisValues[arg.offset].timeIntervalSince1970,
y: arg.element)
})
return LinesChartRenderer.LineData(color: chart.color, points: points)
}
let horizontalRange = LinesChartRenderer.LineData.horizontalRange(lines: lines) ?? BaseConstants.defaultRange
let verticalRange = LinesChartRenderer.LineData.verticalRange(lines: lines) ?? BaseConstants.defaultRange
return (linesData: lines, totalHorizontalRange: horizontalRange, totalVerticalRange: verticalRange)
}
static func horizontalRange(lines: [LinesChartRenderer.LineData]) -> ClosedRange<CGFloat>? {
guard let firstPoint = lines.first?.points.first else { return nil }
var hMin: CGFloat = firstPoint.x
var hMax: CGFloat = firstPoint.x
for line in lines {
if let first = line.points.first,
let last = line.points.last {
hMin = min(hMin, first.x)
hMax = max(hMax, last.x)
}
}
return hMin...hMax
}
static func verticalRange(lines: [LinesChartRenderer.LineData], calculatingRange: ClosedRange<CGFloat>? = nil, addBounds: Bool = false) -> ClosedRange<CGFloat>? {
if let calculatingRange = calculatingRange {
guard let initalStart = lines.first?.points.first(where: { $0.x >= calculatingRange.lowerBound &&
$0.x <= calculatingRange.upperBound }) else { return nil }
var vMin: CGFloat = initalStart.y
var vMax: CGFloat = initalStart.y
for line in lines {
if var index = line.points.firstIndex(where: { $0.x > calculatingRange.lowerBound }) {
if addBounds {
index = max(0, index - 1)
}
while index < line.points.count {
let point = line.points[index]
if point.x < calculatingRange.upperBound {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
} else if addBounds {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
break
} else {
break
}
index += 1
}
}
}
if vMin == vMax {
return 0...vMax * 2.0
}
return vMin...vMax
} else {
guard let firstPoint = lines.first?.points.first else { return nil }
var vMin: CGFloat = firstPoint.y
var vMax: CGFloat = firstPoint.y
for line in lines {
for point in line.points {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
}
}
if vMin == vMax {
return 0...vMax * 2.0
}
return vMin...vMax
}
}
}
@@ -0,0 +1,137 @@
//
// PecentChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class PecentChartRenderer: BaseChartRenderer {
struct PercentageData {
static let blank = PecentChartRenderer.PercentageData(locations: [], components: [])
var locations: [CGFloat]
var components: [Component]
struct Component {
var color: GColor
var values: [CGFloat]
}
}
override func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
super.setup(verticalRange: 0...1, animated: animated, timeFunction: timeFunction)
}
private var componentsAnimators: [AnimationController<CGFloat>] = []
var percentageData: PercentageData = PercentageData(locations: [], components: []) {
willSet {
if percentageData.components.count != newValue.components.count {
componentsAnimators = newValue.components.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
}
}
didSet {
setNeedsDisplay()
}
}
func setComponentVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
componentsAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let alpha = chartAlphaAnimator.current
guard alpha > 0 else { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
let paths: [CGMutablePath] = percentageData.components.map { _ in CGMutablePath() }
var vertices: [CGFloat] = Array<CGFloat>(repeating: 0, count: percentageData.components.count)
if var locationIndex = percentageData.locations.firstIndex(where: { $0 > range.lowerBound }) {
locationIndex = max(0, locationIndex - 1)
var currentLocation = transform(toChartCoordinateHorizontal: percentageData.locations[locationIndex], chartFrame: chartFrame)
let startPoint = CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for path in paths {
path.move(to: startPoint)
}
paths.last?.addLine(to: CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
while locationIndex < percentageData.locations.count {
currentLocation = transform(toChartCoordinateHorizontal: percentageData.locations[locationIndex], chartFrame: chartFrame)
var summ: CGFloat = 0
for (index, component) in percentageData.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
let value = component.values[locationIndex] * visibilityPercent
if index == 0 {
vertices[index] = value
} else {
vertices[index] = value + vertices[index - 1]
}
summ += value
}
if summ > 0 {
for (index, value) in vertices.dropLast().enumerated() {
paths[index].addLine(to: CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: value / summ, chartFrame: chartFrame)))
}
}
if currentLocation > range.upperBound {
break
}
locationIndex += 1
}
paths.last?.addLine(to: CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
let endPoint = CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for (index, path) in paths.enumerated().reversed() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
path.addLine(to: endPoint)
path.closeSubpath()
context.saveGState()
context.beginPath()
context.addPath(path)
context.setFillColor(percentageData.components[index].color.cgColor)
context.fillPath()
context.restoreGState()
}
}
}
}
extension PecentChartRenderer.PercentageData {
static func horizontalRange(data: PecentChartRenderer.PercentageData) -> ClosedRange<CGFloat>? {
guard let firstPoint = data.locations.first,
let lastPoint = data.locations.last,
firstPoint <= lastPoint else {
return nil
}
return firstPoint...lastPoint
}
}
@@ -0,0 +1,206 @@
//
// PercentPieAnimationRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class PercentPieAnimationRenderer: BaseChartRenderer {
override func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
super.setup(verticalRange: 0...1, animated: animated, timeFunction: timeFunction)
}
private lazy var transitionAnimator = AnimationController<CGFloat>(current: 0, refreshClosure: refreshClosure)
private var animationComponentsPoints: [[CGPoint]] = []
var visiblePercentageData: PecentChartRenderer.PercentageData = .blank {
didSet {
animationComponentsPoints = []
}
}
var visiblePieComponents: [PieChartRenderer.PieComponent] = []
func animate(fromDataToPie: Bool, animated: Bool, completion: @escaping () -> Void) {
assert(visiblePercentageData.components.count == visiblePieComponents.count)
isEnabled = true
transitionAnimator.completionClosure = { [weak self] in
self?.isEnabled = false
completion()
}
transitionAnimator.animate(to: fromDataToPie ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
private func generateAnimationComponentPoints(bounds: CGRect, chartFrame: CGRect) {
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
let componentsCount = visiblePercentageData.components.count
guard componentsCount > 0 else { return }
animationComponentsPoints = visiblePercentageData.components.map { _ in [] }
var vertices: [CGFloat] = Array<CGFloat>(repeating: 0, count: visiblePercentageData.components.count)
if var locationIndex = visiblePercentageData.locations.firstIndex(where: { $0 > range.lowerBound }) {
locationIndex = max(0, locationIndex - 1)
var currentLocation = transform(toChartCoordinateHorizontal: visiblePercentageData.locations[locationIndex], chartFrame: chartFrame)
let startPoint = CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for index in 0..<componentsCount {
animationComponentsPoints[index].append(startPoint)
}
animationComponentsPoints[componentsCount - 1].append(CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
while locationIndex < visiblePercentageData.locations.count {
currentLocation = transform(toChartCoordinateHorizontal: visiblePercentageData.locations[locationIndex], chartFrame: chartFrame)
var summ: CGFloat = 0
for (index, component) in visiblePercentageData.components.enumerated() {
let value = component.values[locationIndex]
if index == 0 {
vertices[index] = value
} else {
vertices[index] = value + vertices[index - 1]
}
summ += value
}
for (index, value) in vertices.dropLast().enumerated() {
animationComponentsPoints[index].append(CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: value / summ, chartFrame: chartFrame)))
}
if visiblePercentageData.locations[locationIndex] > range.upperBound {
break
}
locationIndex += 1
}
animationComponentsPoints[componentsCount - 1].append(CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
let endPoint = CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for index in 0..<componentsCount {
animationComponentsPoints[index].append(endPoint)
}
}
}
private var initialPieAngle: CGFloat = .pi / 3
var backgroundColor: GColor = .white
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
self.optimizationLevel = 1
if animationComponentsPoints.isEmpty {
generateAnimationComponentPoints(bounds: bounds, chartFrame: chartFrame)
}
let numberOfComponents = animationComponentsPoints.count
guard numberOfComponents > 0 else { return }
let destinationRadius = max(chartFrame.width, chartFrame.height)
let animationFraction = transitionAnimator.current
let animationFractionD = Double(transitionAnimator.current)
let easeInAnimationFractionD = animationFractionD * animationFractionD * animationFractionD * animationFractionD
let center = CGPoint(x: chartFrame.midX, y: chartFrame.midY)
let totalPieSumm: CGFloat = visiblePieComponents.map { $0.value } .reduce(0, +)
let pathsToDraw: [CGMutablePath] = (0..<numberOfComponents).map { _ in CGMutablePath() }
var startAngle: CGFloat = initialPieAngle
for componentIndex in 0..<(numberOfComponents - 1) {
let componentPoints = animationComponentsPoints[componentIndex]
guard componentPoints.count > 4 else {
return
}
let percent = visiblePieComponents[componentIndex].value / totalPieSumm
let segmentSize = 2 * .pi * percent
let endAngle = startAngle + segmentSize
let centerAngle = (startAngle + endAngle) / 2
let lineCenterPoint = CGPoint.valueBetween(start: componentPoints[componentPoints.count / 2],
end: center,
offset: animationFractionD)
let startDestinationPoint = lineCenterPoint + CGPoint(x: destinationRadius, y: 0)
let endDestinationPoint = lineCenterPoint + CGPoint(x: -destinationRadius, y: 0)
let initialStartDestinationAngle: CGFloat = 0
let initialCenterDestinationAngle: CGFloat = .pi / 2
let initialEndDestinationAngle: CGFloat = .pi
var previousAddedPoint = (componentPoints[0] * 2 - center)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: centerAngle - initialCenterDestinationAngle, offset: animationFractionD))
pathsToDraw[componentIndex].move(to: previousAddedPoint)
func addPointToPath(_ point: CGPoint) {
if (point - previousAddedPoint).lengthSquared() > optimizationLevel {
pathsToDraw[componentIndex].addLine(to: point)
previousAddedPoint = point
}
}
for endPointIndex in 1..<(componentPoints.count / 2) {
addPointToPath(CGPoint.valueBetween(start: componentPoints[endPointIndex], end: endDestinationPoint, offset: easeInAnimationFractionD)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: endAngle - initialEndDestinationAngle, offset: animationFractionD)))
}
addPointToPath(lineCenterPoint)
for startPointIndex in (componentPoints.count / 2 + 1)..<(componentPoints.count - 1) {
addPointToPath(CGPoint.valueBetween(start: componentPoints[startPointIndex], end: startDestinationPoint, offset: easeInAnimationFractionD)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: startAngle - initialStartDestinationAngle, offset: animationFractionD)))
}
if let lastPoint = componentPoints.last {
addPointToPath((lastPoint * 2 - center)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: centerAngle - initialCenterDestinationAngle, offset: animationFractionD)))
}
startAngle = endAngle
}
if let lastPath = animationComponentsPoints.last {
pathsToDraw.last?.addLines(between: lastPath)
}
for (index, path) in pathsToDraw.enumerated().reversed() {
path.closeSubpath()
context.saveGState()
context.beginPath()
context.addPath(path)
context.setFillColor(visiblePieComponents[index].color.cgColor)
context.fillPath()
context.restoreGState()
}
let diagramRadius = (min(chartFrame.width, chartFrame.height) / 2) * 0.925
let targetFrame = CGRect(origin: CGPoint(x: center.x - diagramRadius,
y: center.y - diagramRadius),
size: CGSize(width: diagramRadius * 2,
height: diagramRadius * 2))
let minX = animationComponentsPoints.last?.first?.x ?? 0
let maxX = animationComponentsPoints.last?.last?.x ?? 0
let startFrame = CGRect(x: minX,
y: chartFrame.minY,
width: maxX - minX,
height: chartFrame.height)
let cornerRadius = diagramRadius * animationFraction
let fadeOutFrame = CGRect.valueBetween(start: startFrame, end: targetFrame, offset: animationFractionD)
let fadeOutPath = CGMutablePath()
fadeOutPath.addRect(bounds)
fadeOutPath.addPath(CGPath(roundedRect: fadeOutFrame, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil))
context.saveGState()
context.beginPath()
context.addPath(fadeOutPath)
context.setFillColor(backgroundColor.cgColor)
context.fillPath(using: .evenOdd)
context.restoreGState()
}
}
@@ -0,0 +1,36 @@
//
// PerformanceRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/10/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class PerformanceRenderer: ChartViewRenderer {
var containerViews: [ContainerViewReference] = []
private var previousTickTime: TimeInterval = CACurrentMediaTime()
func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
let currentTime = CACurrentMediaTime()
let delta = currentTime - previousTickTime
previousTickTime = currentTime
let normalDelta = 0.017
let redDelta = 0.05
if delta > normalDelta || delta < 0.75 {
let green = CGFloat( 1.0 - crop(0, (delta - normalDelta) / (redDelta - normalDelta), 1))
let color = GColor(red: 1.0, green: green, blue: 0, alpha: 1)
context.setFillColor(color.cgColor)
context.fill(CGRect(x: 0, y: 0, width: bounds.width, height: 3))
}
}
}
@@ -0,0 +1,199 @@
//
// PieChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class PieChartRenderer: BaseChartRenderer {
struct PieComponent: Hashable {
var color: GColor
var value: CGFloat
}
override func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
super.setup(verticalRange: 0...1, animated: animated, timeFunction: timeFunction)
}
var valuesFormatter: NumberFormatter = NumberFormatter()
var drawValues: Bool = true
private var componentsAnimators: [AnimationController<CGFloat>] = []
private lazy var transitionAnimator: AnimationController<CGFloat> = { AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }()
private var oldPercentageData: [PieComponent] = []
private var percentageData: [PieComponent] = []
private var setlectedSegmentsAnimators: [AnimationController<CGFloat>] = []
var drawPie: Bool = true
var initialAngle: CGFloat = .pi / 3
var hasSelectedSegments: Bool {
return selectedSegment != nil
}
private(set) var selectedSegment: Int?
func selectSegmentAt(at indexToSelect: Int?, animated: Bool) {
guard selectedSegment != indexToSelect else {
return
}
selectedSegment = indexToSelect
for (index, animator) in setlectedSegmentsAnimators.enumerated() {
let fraction: CGFloat = (index == indexToSelect) ? 1.0 : 0.0
if animated {
animator.animate(to: fraction, duration: .defaultDuration / 2)
} else {
animator.set(current: fraction)
}
}
}
func updatePercentageData(_ percentageData: [PieComponent], animated: Bool) {
if self.percentageData.count != percentageData.count {
componentsAnimators = percentageData.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
setlectedSegmentsAnimators = percentageData.map { _ in AnimationController<CGFloat>(current: 0, refreshClosure: self.refreshClosure) }
}
if animated {
self.oldPercentageData = self.currentTransitionAnimationData
self.percentageData = percentageData
transitionAnimator.completionClosure = { [weak self] in
self?.oldPercentageData = []
}
transitionAnimator.set(current: 0)
transitionAnimator.animate(to: 1, duration: .defaultDuration)
} else {
self.oldPercentageData = []
self.percentageData = percentageData
transitionAnimator.set(current: 0)
}
}
func setComponentVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
componentsAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
var lastRenderedBounds: CGRect = .zero
var lastRenderedChartFrame: CGRect = .zero
func selectedItemIndex(at point: CGPoint) -> Int? {
let touchPosition = lastRenderedChartFrame.origin + point * lastRenderedChartFrame.size
let center = CGPoint(x: lastRenderedChartFrame.midX, y: lastRenderedChartFrame.midY)
let radius = min(lastRenderedChartFrame.width, lastRenderedChartFrame.height) / 2
if center.distanceTo(touchPosition) > radius { return nil }
let angle = (center - touchPosition).angle + .pi
let currentData = currentlyVisibleData
let total: CGFloat = currentData.map({ $0.value }).reduce(0, +)
var startAngle: CGFloat = initialAngle
for (index, piece) in currentData.enumerated() {
let percent = piece.value / total
let segmentSize = 2 * .pi * percent
let endAngle = startAngle + segmentSize
if angle >= startAngle && angle <= endAngle ||
angle + .pi * 2 >= startAngle && angle + .pi * 2 <= endAngle {
return index
}
startAngle = endAngle
}
return nil
}
private var currentTransitionAnimationData: [PieComponent] {
if transitionAnimator.isAnimating {
let animationFraction = transitionAnimator.current
return percentageData.enumerated().map { arg in
return PieComponent(color: arg.element.color,
value: oldPercentageData[arg.offset].value * (1 - animationFraction) + arg.element.value * animationFraction)
}
} else {
return percentageData
}
}
var currentlyVisibleData: [PieComponent] {
return currentTransitionAnimationData.enumerated().map { arg in
return PieComponent(color: arg.element.color,
value: arg.element.value * componentsAnimators[arg.offset].current)
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
lastRenderedBounds = bounds
lastRenderedChartFrame = chartFrame
let chartAlpha = chartAlphaAnimator.current
if chartAlpha == 0 { return }
let center = CGPoint(x: chartFrame.midX, y: chartFrame.midY)
let radius = min(chartFrame.width, chartFrame.height) / 2
let currentData = currentlyVisibleData
let total: CGFloat = currentData.map({ $0.value }).reduce(0, +)
guard total > 0 else {
return
}
let animationSelectionOffset: CGFloat = radius / 15
let maximumFontSize: CGFloat = radius / 7
let minimumFontSize: CGFloat = 4
let centerOffsetStartAngle = CGFloat.pi / 4
let minimumValueToDraw: CGFloat = 0.015
let diagramRadius = radius - animationSelectionOffset
let numberOfVisibleItems = currentlyVisibleData.filter { $0.value > 0 }.count
var startAngle: CGFloat = initialAngle
for (index, piece) in currentData.enumerated() {
let percent = piece.value / total
guard percent > 0 else { continue }
let segmentSize = 2 * .pi * percent * chartAlpha
let endAngle = startAngle + segmentSize
let centerAngle = (startAngle + endAngle) / 2
let labelVector = CGPoint(x: cos(centerAngle),
y: sin(centerAngle))
let selectionAnimationFraction = (numberOfVisibleItems > 1 ? setlectedSegmentsAnimators[index].current : 0)
let updatedCenter = CGPoint(x: center.x + labelVector.x * selectionAnimationFraction * animationSelectionOffset,
y: center.y + labelVector.y * selectionAnimationFraction * animationSelectionOffset)
if drawPie {
context.saveGState()
context.setFillColor(piece.color.withAlphaComponent(piece.color.alphaValue * chartAlpha).cgColor)
context.move(to: updatedCenter)
context.addArc(center: updatedCenter,
radius: radius - animationSelectionOffset,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
context.fillPath()
context.restoreGState()
}
if drawValues && percent >= minimumValueToDraw {
context.saveGState()
let text = valuesFormatter.string(from: percent * 100)
let fraction = crop(0, segmentSize / centerOffsetStartAngle, 1)
let fontSize = (minimumFontSize + (maximumFontSize - minimumFontSize) * fraction).rounded(.up)
let labelPotisionOffset = diagramRadius / 2 + diagramRadius / 2 * (1 - fraction)
let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
let labelsEaseInColor = crop(0, chartAlpha * chartAlpha * 2 - 1, 1)
let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: GColor.white.withAlphaComponent(labelsEaseInColor),
.font: font]
let attributedString = NSAttributedString(string: text, attributes: attributes)
let textNode = LabelNode.layoutText(attributedString, bounds.size)
let labelPoint = CGPoint(x: labelVector.x * labelPotisionOffset + updatedCenter.x - textNode.0.size.width / 2,
y: labelVector.y * labelPotisionOffset + updatedCenter.y - textNode.0.size.height / 2)
textNode.1.draw(CGRect(origin: labelPoint, size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
context.restoreGState()
}
startAngle = endAngle
}
}
}
@@ -0,0 +1,50 @@
//
// VerticalLinesRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
#else
import UIKit
#endif
class VerticalLinesRenderer: BaseChartRenderer {
var values: [CGFloat] = [] {
didSet {
alphaAnimators = values.map { _ in AnimationController<CGFloat>(current: 1.0, refreshClosure: refreshClosure) }
setNeedsDisplay()
}
}
var offset: CGFloat = 0.0
private var alphaAnimators: [AnimationController<CGFloat>] = []
var linesColor: GColor = .black
var linesWidth: CGFloat = GView.oneDevicePixel
func setLineVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
if alphaAnimators.count > index {
alphaAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
context.setLineWidth(linesWidth)
for (index, value) in values.enumerated() {
let alpha = alphaAnimators[index].current
if alpha == 0 { continue }
context.setStrokeColor(linesColor.withAlphaComponent(linesColor.alphaValue * alpha).cgColor)
let pointX = transform(toChartCoordinateHorizontal: value, chartFrame: chartFrame) + offset
context.strokeLineSegments(between: [CGPoint(x: pointX, y: chartFrame.minY),
CGPoint(x: pointX, y: chartFrame.maxY)])
}
}
}
@@ -0,0 +1,180 @@
//
// VerticalScalesRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
#if os(macOS)
import Cocoa
public typealias UIColor = NSColor
#else
import UIKit
#endif
class VerticalScalesRenderer: BaseChartRenderer {
private var verticalLabelsAndLines: [LinesChartLabel] = []
private var animatedVerticalLabelsAndLines: [AnimatedLinesChartLabels] = []
private lazy var horizontalLinesAlphaAnimator: AnimationController<CGFloat> = {
return AnimationController(current: 1, refreshClosure: self.refreshClosure)
}()
var drawAxisX: Bool = true
var axisXColor: GColor = .black
var axisXWidth: CGFloat = GView.oneDevicePixel
var isRightAligned: Bool = false
var drawCurrency:((CGContext, UIColor, CGPoint)->Void)?
var horizontalLinesColor: GColor = .black {
didSet {
setNeedsDisplay()
}
}
var horizontalLinesWidth: CGFloat = GView.oneDevicePixel
var labelsAxisOffset: CGFloat = 6
var labelsColor: GColor = .black {
didSet {
setNeedsDisplay()
}
}
var labelsFont: NSFont = .systemFont(ofSize: 11)
func setHorizontalLinesVisible(_ visible: Bool, animated: Bool) {
let destinationValue: CGFloat = visible ? 1 : 0
guard self.horizontalLinesAlphaAnimator.end != destinationValue else { return }
if animated {
self.horizontalLinesAlphaAnimator.animate(to: destinationValue, duration: .defaultDuration)
} else {
self.horizontalLinesAlphaAnimator.set(current: destinationValue)
}
}
func setup(verticalLimitsLabels: [LinesChartLabel], animated: Bool) {
if animated {
var labelsToKeepVisible: [LinesChartLabel] = []
let labelsToHide: [LinesChartLabel]
var labelsToShow: [LinesChartLabel] = []
for label in verticalLimitsLabels {
if verticalLabelsAndLines.contains(label) {
labelsToKeepVisible.append(label)
} else {
labelsToShow.append(label)
}
}
labelsToHide = verticalLabelsAndLines.filter { !verticalLimitsLabels.contains($0) }
animatedVerticalLabelsAndLines.removeAll(where: { $0.isAppearing })
verticalLabelsAndLines = labelsToKeepVisible
let showAnimation = AnimatedLinesChartLabels(labels: labelsToShow, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
showAnimation.isAppearing = true
showAnimation.alphaAnimator.set(current: 0)
showAnimation.alphaAnimator.animate(to: 1, duration: .defaultDuration)
showAnimation.alphaAnimator.completionClosure = { [weak self, weak showAnimation] in
guard let self = self, let showAnimation = showAnimation else { return }
self.animatedVerticalLabelsAndLines.removeAll(where: { $0 === showAnimation })
self.verticalLabelsAndLines = verticalLimitsLabels
}
let hideAnimation = AnimatedLinesChartLabels(labels: labelsToHide, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
hideAnimation.isAppearing = false
hideAnimation.alphaAnimator.set(current: 1)
hideAnimation.alphaAnimator.animate(to: 0, duration: .defaultDuration)
hideAnimation.alphaAnimator.completionClosure = { [weak self, weak hideAnimation] in
guard let self = self, let hideAnimation = hideAnimation else { return }
self.animatedVerticalLabelsAndLines.removeAll(where: { $0 === hideAnimation })
}
animatedVerticalLabelsAndLines.append(showAnimation)
animatedVerticalLabelsAndLines.append(hideAnimation)
} else {
verticalLabelsAndLines = verticalLimitsLabels
animatedVerticalLabelsAndLines = []
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let generalAlpha = chartAlphaAnimator.current
if generalAlpha == 0 { return }
let labelColorAlpha = labelsColor.alphaValue
let spacing: CGFloat = 1.0
context.clip(to: CGRect(origin: CGPoint(x: 0.0, y: chartFrame.minY - spacing), size: CGSize(width: chartFrame.width + chartFrame.origin.x * 2.0, height: chartFrame.height + spacing * 2.0)))
func drawLines(_ labels: [LinesChartLabel], alpha: CGFloat) {
var lineSegments: [CGPoint] = []
let x0 = chartFrame.minX
let x1 = chartFrame.maxX
context.setStrokeColor(horizontalLinesColor.withAlphaComponent(horizontalLinesColor.alphaValue * alpha).cgColor)
for lineInfo in labels {
let y = transform(toChartCoordinateVertical: lineInfo.value, chartFrame: chartFrame).roundedUpToPixelGrid()
if y < chartFrame.maxY - 2.0 {
lineSegments.append(CGPoint(x: x0, y: y))
lineSegments.append(CGPoint(x: x1, y: y))
}
}
context.strokeLineSegments(between: lineSegments)
}
func drawVerticalLabels(_ labels: [LinesChartLabel], attributes: [NSAttributedString.Key: Any]) {
if isRightAligned {
for label in labels {
let y = transform(toChartCoordinateVertical: label.value, chartFrame: chartFrame) - labelsFont.pointSize - labelsAxisOffset
let attributedString = NSAttributedString(string: label.text, attributes: attributes)
let textNode = LabelNode.layoutText(attributedString, bounds.size)
textNode.1.draw(CGRect(origin: CGPoint(x:chartFrame.maxX - textNode.0.size.width, y: y), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
}
} else {
for label in labels {
let y = transform(toChartCoordinateVertical: label.value, chartFrame: chartFrame) - labelsFont.pointSize - labelsAxisOffset
let attributedString = NSAttributedString(string: label.text, attributes: attributes)
let textNode = LabelNode.layoutText(attributedString, bounds.size)
var xOffset = 0.0
if let drawCurrency {
xOffset += 11.0
drawCurrency(context, attributes[.foregroundColor] as? UIColor ?? .black, CGPoint(x: chartFrame.minX, y: y + 4.0))
}
textNode.1.draw(CGRect(origin: CGPoint(x: chartFrame.minX + xOffset, y: y), size: textNode.0.size), in: context, backingScaleFactor: deviceScale)
}
}
}
let horizontalLinesAlpha = horizontalLinesAlphaAnimator.current
if horizontalLinesAlpha > 0 {
context.setLineWidth(horizontalLinesWidth)
drawLines(verticalLabelsAndLines, alpha: generalAlpha)
for animatedLabesAndLines in animatedVerticalLabelsAndLines {
drawLines(animatedLabesAndLines.labels, alpha: animatedLabesAndLines.alphaAnimator.current * generalAlpha * horizontalLinesAlpha)
}
if drawAxisX {
context.setLineWidth(axisXWidth)
context.setStrokeColor(axisXColor.withAlphaComponent(axisXColor.alphaValue * horizontalLinesAlpha * generalAlpha).cgColor)
let lineSegments: [CGPoint] = [CGPoint(x: chartFrame.minX, y: chartFrame.maxY.roundedUpToPixelGrid()),
CGPoint(x: chartFrame.maxX, y: chartFrame.maxY.roundedUpToPixelGrid())]
context.strokeLineSegments(between: lineSegments)
}
}
drawVerticalLabels(verticalLabelsAndLines, attributes: [.foregroundColor: labelsColor.withAlphaComponent(labelColorAlpha * generalAlpha),
.font: labelsFont])
for animatedLabesAndLines in animatedVerticalLabelsAndLines {
drawVerticalLabels(animatedLabesAndLines.labels,
attributes: [.foregroundColor: labelsColor.withAlphaComponent(animatedLabesAndLines.alphaAnimator.current * labelColorAlpha * generalAlpha),
.font: labelsFont])
}
context.resetClip()
}
}