Files
POCloud-iOS/Pods/SwiftChart/Source/Chart.swift
2018-05-29 16:54:16 -05:00

850 lines
26 KiB
Swift

//
// Chart.swift
//
// Created by Giampaolo Bellavite on 07/11/14.
// Copyright (c) 2014 Giampaolo Bellavite. All rights reserved.
//
import UIKit
public protocol ChartDelegate: class {
/**
Tells the delegate that the specified chart has been touched.
- parameter chart: The chart that has been touched.
- parameter indexes: Each element of this array contains the index of the data that has been touched, one for each
series. If the series hasn't been touched, its index will be nil.
- parameter x: The value on the x-axis that has been touched.
- parameter left: The distance from the left side of the chart.
*/
func didTouchChart(_ chart: Chart, indexes: [Int?], x: Double, left: CGFloat)
/**
Tells the delegate that the user finished touching the chart. The user will
"finish" touching the chart only swiping left/right outside the chart.
- parameter chart: The chart that has been touched.
*/
func didFinishTouchingChart(_ chart: Chart)
/**
Tells the delegate that the user ended touching the chart. The user
will "end" touching the chart whenever the touchesDidEnd method is
being called.
- parameter chart: The chart that has been touched.
*/
func didEndTouchingChart(_ chart: Chart)
}
/**
Represent the x- and the y-axis values for each point in a chart series.
*/
typealias ChartPoint = (x: Double, y: Double)
/**
Set the a x-label orientation.
*/
public enum ChartLabelOrientation {
case horizontal
case vertical
}
@IBDesignable
open class Chart: UIControl {
// MARK: Options
@IBInspectable
open var identifier: String?
/**
Series to display in the chart.
*/
open var series: [ChartSeries] = [] {
didSet {
setNeedsDisplay()
}
}
/**
The values to display as labels on the x-axis. You can format these values with the `xLabelFormatter` attribute.
As default, it will display the values of the series which has the most data.
*/
open var xLabels: [Double]?
/**
Formatter for the labels on the x-axis. `index` represents the `xLabels` index, `value` its value.
*/
open var xLabelsFormatter = { (labelIndex: Int, labelValue: Double) -> String in
String(Int(labelValue))
}
/**
Text alignment for the x-labels.
*/
open var xLabelsTextAlignment: NSTextAlignment = .left
/**
Orientation for the x-labels.
*/
open var xLabelsOrientation: ChartLabelOrientation = .horizontal
/**
Skip the last x-label. Setting this to false may make the label overflow the frame width.
*/
open var xLabelsSkipLast: Bool = true
/**
Values to display as labels of the y-axis. If not specified, will display the lowest, the middle and the highest
values.
*/
open var yLabels: [Double]?
/**
Formatter for the labels on the y-axis.
*/
open var yLabelsFormatter = { (labelIndex: Int, labelValue: Double) -> String in
String(Int(labelValue))
}
/**
Displays the y-axis labels on the right side of the chart.
*/
open var yLabelsOnRightSide: Bool = false
/**
Font used for the labels.
*/
open var labelFont: UIFont? = UIFont.systemFont(ofSize: 12)
/**
The color used for the labels.
*/
@IBInspectable
open var labelColor: UIColor = UIColor.black
/**
Color for the axes.
*/
@IBInspectable
open var axesColor: UIColor = UIColor.gray.withAlphaComponent(0.3)
/**
Color for the grid.
*/
@IBInspectable
open var gridColor: UIColor = UIColor.gray.withAlphaComponent(0.3)
/**
Enable the lines for the labels on the x-axis
*/
open var showXLabelsAndGrid: Bool = true
/**
Enable the lines for the labels on the y-axis
*/
open var showYLabelsAndGrid: Bool = true
/**
Height of the area at the bottom of the chart, containing the labels for the x-axis.
*/
open var bottomInset: CGFloat = 20
/**
Height of the area at the top of the chart, acting a padding to make place for the top y-axis label.
*/
open var topInset: CGFloat = 20
/**
Width of the chart's lines.
*/
@IBInspectable
open var lineWidth: CGFloat = 2
/**
Delegate for listening to Chart touch events.
*/
weak open var delegate: ChartDelegate?
/**
Custom minimum value for the x-axis.
*/
open var minX: Double?
/**
Custom minimum value for the y-axis.
*/
open var minY: Double?
/**
Custom maximum value for the x-axis.
*/
open var maxX: Double?
/**
Custom maximum value for the y-axis.
*/
open var maxY: Double?
/**
Color for the highlight line.
*/
open var highlightLineColor = UIColor.gray
/**
Width for the highlight line.
*/
open var highlightLineWidth: CGFloat = 0.5
/**
Hide the highlight line when touch event ends, e.g. when stop swiping over the chart
*/
open var hideHighlightLineOnTouchEnd = false
/**
Alpha component for the area color.
*/
open var areaAlphaComponent: CGFloat = 0.1
// MARK: Private variables
fileprivate var highlightShapeLayer: CAShapeLayer!
fileprivate var layerStore: [CAShapeLayer] = []
fileprivate var drawingHeight: CGFloat!
fileprivate var drawingWidth: CGFloat!
// Minimum and maximum values represented in the chart
fileprivate var min: ChartPoint!
fileprivate var max: ChartPoint!
// Represent a set of points corresponding to a segment line on the chart.
typealias ChartLineSegment = [ChartPoint]
// MARK: initializations
override public init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
convenience public init() {
self.init(frame: .zero)
commonInit()
}
private func commonInit() {
backgroundColor = UIColor.clear
contentMode = .redraw // redraw rects on bounds change
}
override open func draw(_ rect: CGRect) {
#if TARGET_INTERFACE_BUILDER
drawIBPlaceholder()
#else
drawChart()
#endif
}
/**
Adds a chart series.
*/
open func add(_ series: ChartSeries) {
self.series.append(series)
}
/**
Adds multiple chart series.
*/
open func add(_ series: [ChartSeries]) {
for s in series {
add(s)
}
}
/**
Remove the series at the specified index.
*/
open func removeSeriesAt(_ index: Int) {
series.remove(at: index)
}
/**
Remove all the series.
*/
open func removeAllSeries() {
series = []
}
/**
Return the value for the specified series at the given index.
*/
open func valueForSeries(_ seriesIndex: Int, atIndex dataIndex: Int?) -> Double? {
if dataIndex == nil { return nil }
let series = self.series[seriesIndex] as ChartSeries
return series.data[dataIndex!].y
}
fileprivate func drawIBPlaceholder() {
let placeholder = UIView(frame: self.frame)
placeholder.backgroundColor = UIColor(red: 0.93, green: 0.93, blue: 0.93, alpha: 1)
let label = UILabel()
label.text = "Chart"
label.font = UIFont.systemFont(ofSize: 28)
label.textColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.2)
label.sizeToFit()
label.frame.origin.x += frame.width/2 - (label.frame.width / 2)
label.frame.origin.y += frame.height/2 - (label.frame.height / 2)
placeholder.addSubview(label)
addSubview(placeholder)
}
fileprivate func drawChart() {
drawingHeight = bounds.height - bottomInset - topInset
drawingWidth = bounds.width
let minMax = getMinMax()
min = minMax.min
max = minMax.max
highlightShapeLayer = nil
// Remove things before drawing, e.g. when changing orientation
for view in self.subviews {
view.removeFromSuperview()
}
for layer in layerStore {
layer.removeFromSuperlayer()
}
layerStore.removeAll()
// Draw content
for (index, series) in self.series.enumerated() {
// Separate each line in multiple segments over and below the x axis
let segments = Chart.segmentLine(series.data as ChartLineSegment, zeroLevel: series.colors.zeroLevel)
segments.forEach({ segment in
let scaledXValues = scaleValuesOnXAxis( segment.map { $0.x } )
let scaledYValues = scaleValuesOnYAxis( segment.map { $0.y } )
if series.line {
drawLine(scaledXValues, yValues: scaledYValues, seriesIndex: index)
}
if series.area {
drawArea(scaledXValues, yValues: scaledYValues, seriesIndex: index)
}
})
}
drawAxes()
if showXLabelsAndGrid && (xLabels != nil || series.count > 0) {
drawLabelsAndGridOnXAxis()
}
if showYLabelsAndGrid && (yLabels != nil || series.count > 0) {
drawLabelsAndGridOnYAxis()
}
}
// MARK: - Scaling
fileprivate func getMinMax() -> (min: ChartPoint, max: ChartPoint) {
// Start with user-provided values
var min = (x: minX, y: minY)
var max = (x: maxX, y: maxY)
// Check in datasets
for series in self.series {
let xValues = series.data.map { $0.x }
let yValues = series.data.map { $0.y }
let newMinX = xValues.minOrZero()
let newMinY = yValues.minOrZero()
let newMaxX = xValues.maxOrZero()
let newMaxY = yValues.maxOrZero()
if min.x == nil || newMinX < min.x! { min.x = newMinX }
if min.y == nil || newMinY < min.y! { min.y = newMinY }
if max.x == nil || newMaxX > max.x! { max.x = newMaxX }
if max.y == nil || newMaxY > max.y! { max.y = newMaxY }
}
// Check in labels
if let xLabels = self.xLabels {
let newMinX = xLabels.minOrZero()
let newMaxX = xLabels.maxOrZero()
if min.x == nil || newMinX < min.x! { min.x = newMinX }
if max.x == nil || newMaxX > max.x! { max.x = newMaxX }
}
if let yLabels = self.yLabels {
let newMinY = yLabels.minOrZero()
let newMaxY = yLabels.maxOrZero()
if min.y == nil || newMinY < min.y! { min.y = newMinY }
if max.y == nil || newMaxY > max.y! { max.y = newMaxY }
}
if min.x == nil { min.x = 0 }
if min.y == nil { min.y = 0 }
if max.x == nil { max.x = 0 }
if max.y == nil { max.y = 0 }
return (min: (x: min.x!, y: min.y!), max: (x: max.x!, max.y!))
}
fileprivate func scaleValuesOnXAxis(_ values: [Double]) -> [Double] {
let width = Double(drawingWidth)
var factor: Double
if max.x - min.x == 0 {
factor = 0
} else {
factor = width / (max.x - min.x)
}
let scaled = values.map { factor * ($0 - self.min.x) }
return scaled
}
fileprivate func scaleValuesOnYAxis(_ values: [Double]) -> [Double] {
let height = Double(drawingHeight)
var factor: Double
if max.y - min.y == 0 {
factor = 0
} else {
factor = height / (max.y - min.y)
}
let scaled = values.map { Double(self.topInset) + height - factor * ($0 - self.min.y) }
return scaled
}
fileprivate func scaleValueOnYAxis(_ value: Double) -> Double {
let height = Double(drawingHeight)
var factor: Double
if max.y - min.y == 0 {
factor = 0
} else {
factor = height / (max.y - min.y)
}
let scaled = Double(self.topInset) + height - factor * (value - min.y)
return scaled
}
fileprivate func getZeroValueOnYAxis(zeroLevel: Double) -> Double {
if min.y > zeroLevel {
return scaleValueOnYAxis(min.y)
} else {
return scaleValueOnYAxis(zeroLevel)
}
}
// MARK: - Drawings
fileprivate func drawLine(_ xValues: [Double], yValues: [Double], seriesIndex: Int) {
// YValues are "reverted" from top to bottom, so 'above' means <= level
let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
let path = CGMutablePath()
path.move(to: CGPoint(x: CGFloat(xValues.first!), y: CGFloat(yValues.first!)))
for i in 1..<yValues.count {
let y = yValues[i]
path.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(y)))
}
let lineLayer = CAShapeLayer()
lineLayer.frame = self.bounds
lineLayer.path = path
if isAboveZeroLine {
lineLayer.strokeColor = series[seriesIndex].colors.above.cgColor
} else {
lineLayer.strokeColor = series[seriesIndex].colors.below.cgColor
}
lineLayer.fillColor = nil
lineLayer.lineWidth = lineWidth
lineLayer.lineJoin = kCALineJoinBevel
self.layer.addSublayer(lineLayer)
layerStore.append(lineLayer)
}
fileprivate func drawArea(_ xValues: [Double], yValues: [Double], seriesIndex: Int) {
// YValues are "reverted" from top to bottom, so 'above' means <= level
let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
let area = CGMutablePath()
let zero = CGFloat(getZeroValueOnYAxis(zeroLevel: series[seriesIndex].colors.zeroLevel))
area.move(to: CGPoint(x: CGFloat(xValues[0]), y: zero))
for i in 0..<xValues.count {
area.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(yValues[i])))
}
area.addLine(to: CGPoint(x: CGFloat(xValues.last!), y: zero))
let areaLayer = CAShapeLayer()
areaLayer.frame = self.bounds
areaLayer.path = area
areaLayer.strokeColor = nil
if isAboveZeroLine {
areaLayer.fillColor = series[seriesIndex].colors.above.withAlphaComponent(areaAlphaComponent).cgColor
} else {
areaLayer.fillColor = series[seriesIndex].colors.below.withAlphaComponent(areaAlphaComponent).cgColor
}
areaLayer.lineWidth = 0
self.layer.addSublayer(areaLayer)
layerStore.append(areaLayer)
}
fileprivate func drawAxes() {
let context = UIGraphicsGetCurrentContext()!
context.setStrokeColor(axesColor.cgColor)
context.setLineWidth(0.5)
// horizontal axis at the bottom
context.move(to: CGPoint(x: CGFloat(0), y: drawingHeight + topInset))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: drawingHeight + topInset))
context.strokePath()
// horizontal axis at the top
context.move(to: CGPoint(x: CGFloat(0), y: CGFloat(0)))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: CGFloat(0)))
context.strokePath()
// horizontal axis when y = 0
if min.y < 0 && max.y > 0 {
let y = CGFloat(getZeroValueOnYAxis(zeroLevel: 0))
context.move(to: CGPoint(x: CGFloat(0), y: y))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: y))
context.strokePath()
}
// vertical axis on the left
context.move(to: CGPoint(x: CGFloat(0), y: CGFloat(0)))
context.addLine(to: CGPoint(x: CGFloat(0), y: drawingHeight + topInset))
context.strokePath()
// vertical axis on the right
context.move(to: CGPoint(x: CGFloat(drawingWidth), y: CGFloat(0)))
context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: drawingHeight + topInset))
context.strokePath()
}
fileprivate func drawLabelsAndGridOnXAxis() {
let context = UIGraphicsGetCurrentContext()!
context.setStrokeColor(gridColor.cgColor)
context.setLineWidth(0.5)
var labels: [Double]
if xLabels == nil {
// Use labels from the first series
labels = series[0].data.map({ (point: ChartPoint) -> Double in
return point.x })
} else {
labels = xLabels!
}
let scaled = scaleValuesOnXAxis(labels)
let padding: CGFloat = 5
scaled.enumerated().forEach { (i, value) in
let x = CGFloat(value)
let isLastLabel = x == drawingWidth
// Add vertical grid for each label, except axes on the left and right
if x != 0 && x != drawingWidth {
context.move(to: CGPoint(x: x, y: CGFloat(0)))
context.addLine(to: CGPoint(x: x, y: bounds.height))
context.strokePath()
}
if xLabelsSkipLast && isLastLabel {
// Do not add label at the most right position
return
}
// Add label
let label = UILabel(frame: CGRect(x: x, y: drawingHeight, width: 0, height: 0))
label.font = labelFont
label.text = xLabelsFormatter(i, labels[i])
label.textColor = labelColor
// Set label size
label.sizeToFit()
// Center label vertically
label.frame.origin.y += topInset
if xLabelsOrientation == .horizontal {
// Add left padding
label.frame.origin.y -= (label.frame.height - bottomInset) / 2
label.frame.origin.x += padding
// Set label's text alignment
label.frame.size.width = (drawingWidth / CGFloat(labels.count)) - padding * 2
label.textAlignment = xLabelsTextAlignment
} else {
label.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi / 2))
// Adjust vertical position according to the label's height
label.frame.origin.y += label.frame.size.height / 2
// Adjust horizontal position as the series line
label.frame.origin.x = x
if xLabelsTextAlignment == .center {
// Align horizontally in series
label.frame.origin.x += ((drawingWidth / CGFloat(labels.count)) / 2) - (label.frame.size.width / 2)
} else {
// Give some space from the vertical line
label.frame.origin.x += padding
}
}
self.addSubview(label)
}
}
fileprivate func drawLabelsAndGridOnYAxis() {
let context = UIGraphicsGetCurrentContext()!
context.setStrokeColor(gridColor.cgColor)
context.setLineWidth(0.5)
var labels: [Double]
if yLabels == nil {
labels = [(min.y + max.y) / 2, max.y]
if yLabelsOnRightSide || min.y != 0 {
labels.insert(min.y, at: 0)
}
} else {
labels = yLabels!
}
let scaled = scaleValuesOnYAxis(labels)
let padding: CGFloat = 5
let zero = CGFloat(getZeroValueOnYAxis(zeroLevel: 0))
scaled.enumerated().forEach { (i, value) in
let y = CGFloat(value)
// Add horizontal grid for each label, but not over axes
if y != drawingHeight + topInset && y != zero {
context.move(to: CGPoint(x: CGFloat(0), y: y))
context.addLine(to: CGPoint(x: self.bounds.width, y: y))
if labels[i] != 0 {
// Horizontal grid for 0 is not dashed
context.setLineDash(phase: CGFloat(0), lengths: [CGFloat(5)])
} else {
context.setLineDash(phase: CGFloat(0), lengths: [])
}
context.strokePath()
}
let label = UILabel(frame: CGRect(x: padding, y: y, width: 0, height: 0))
label.font = labelFont
label.text = yLabelsFormatter(i, labels[i])
label.textColor = labelColor
label.sizeToFit()
if yLabelsOnRightSide {
label.frame.origin.x = drawingWidth
label.frame.origin.x -= label.frame.width + padding
}
// Labels should be placed above the horizontal grid
label.frame.origin.y -= label.frame.height
self.addSubview(label)
}
UIGraphicsEndImageContext()
}
// MARK: - Touch events
fileprivate func drawHighlightLineFromLeftPosition(_ left: CGFloat) {
if let shapeLayer = highlightShapeLayer {
// Use line already created
let path = CGMutablePath()
path.move(to: CGPoint(x: left, y: 0))
path.addLine(to: CGPoint(x: left, y: drawingHeight + topInset))
shapeLayer.path = path
} else {
// Create the line
let path = CGMutablePath()
path.move(to: CGPoint(x: left, y: CGFloat(0)))
path.addLine(to: CGPoint(x: left, y: drawingHeight + topInset))
let shapeLayer = CAShapeLayer()
shapeLayer.frame = self.bounds
shapeLayer.path = path
shapeLayer.strokeColor = highlightLineColor.cgColor
shapeLayer.fillColor = nil
shapeLayer.lineWidth = highlightLineWidth
highlightShapeLayer = shapeLayer
layer.addSublayer(shapeLayer)
layerStore.append(shapeLayer)
}
}
func handleTouchEvents(_ touches: Set<UITouch>, event: UIEvent!) {
let point = touches.first!
let left = point.location(in: self).x
let x = valueFromPointAtX(left)
if left < 0 || left > (drawingWidth as CGFloat) {
// Remove highlight line at the end of the touch event
if let shapeLayer = highlightShapeLayer {
shapeLayer.path = nil
}
delegate?.didFinishTouchingChart(self)
return
}
drawHighlightLineFromLeftPosition(left)
if delegate == nil {
return
}
var indexes: [Int?] = []
for series in self.series {
var index: Int? = nil
let xValues = series.data.map({ (point: ChartPoint) -> Double in
return point.x })
let closest = Chart.findClosestInValues(xValues, forValue: x)
if closest.lowestIndex != nil && closest.highestIndex != nil {
// Consider valid only values on the right
index = closest.lowestIndex
}
indexes.append(index)
}
delegate!.didTouchChart(self, indexes: indexes, x: x, left: left)
}
override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouchEvents(touches, event: event)
}
override open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouchEvents(touches, event: event)
if self.hideHighlightLineOnTouchEnd {
if let shapeLayer = highlightShapeLayer {
shapeLayer.path = nil
}
}
delegate?.didEndTouchingChart(self)
}
override open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouchEvents(touches, event: event)
}
// MARK: - Utilities
fileprivate func valueFromPointAtX(_ x: CGFloat) -> Double {
let value = ((max.x-min.x) / Double(drawingWidth)) * Double(x) + min.x
return value
}
fileprivate func valueFromPointAtY(_ y: CGFloat) -> Double {
let value = ((max.y - min.y) / Double(drawingHeight)) * Double(y) + min.y
return -value
}
fileprivate class func findClosestInValues(
_ values: [Double],
forValue value: Double
) -> (
lowestValue: Double?,
highestValue: Double?,
lowestIndex: Int?,
highestIndex: Int?
) {
var lowestValue: Double?, highestValue: Double?, lowestIndex: Int?, highestIndex: Int?
values.enumerated().forEach { (i, currentValue) in
if currentValue <= value && (lowestValue == nil || lowestValue! < currentValue) {
lowestValue = currentValue
lowestIndex = i
}
if currentValue >= value && (highestValue == nil || highestValue! > currentValue) {
highestValue = currentValue
highestIndex = i
}
}
return (
lowestValue: lowestValue,
highestValue: highestValue,
lowestIndex: lowestIndex,
highestIndex: highestIndex
)
}
/**
Segment a line in multiple lines when the line touches the x-axis, i.e. separating
positive from negative values.
*/
fileprivate class func segmentLine(_ line: ChartLineSegment, zeroLevel: Double) -> [ChartLineSegment] {
var segments: [ChartLineSegment] = []
var segment: ChartLineSegment = []
line.enumerated().forEach { (i, point) in
segment.append(point)
if i < line.count - 1 {
let nextPoint = line[i+1]
if point.y >= zeroLevel && nextPoint.y < zeroLevel || point.y < zeroLevel && nextPoint.y >= zeroLevel {
// The segment intersects zeroLevel, close the segment with the intersection point
let closingPoint = Chart.intersectionWithLevel(point, and: nextPoint, level: zeroLevel)
segment.append(closingPoint)
segments.append(segment)
// Start a new segment
segment = [closingPoint]
}
} else {
// End of the line
segments.append(segment)
}
}
return segments
}
/**
Return the intersection of a line between two points and 'y = level' line
*/
fileprivate class func intersectionWithLevel(_ p1: ChartPoint, and p2: ChartPoint, level: Double) -> ChartPoint {
let dy1 = level - p1.y
let dy2 = level - p2.y
return (x: (p2.x * dy1 - p1.x * dy2) / (dy1 - dy2), y: level)
}
}
extension Sequence where Element == Double {
func minOrZero() -> Double {
return self.min() ?? 0.0
}
func maxOrZero() -> Double {
return self.max() ?? 0.0
}
}