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,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()
}
}