Source/Charts/Renderers/XAxisRenderer.swift (367 lines of code) (raw):
//
// XAxisRenderer.swift
// Charts
//
// Copyright 2015 Daniel Cohen Gindi & Philipp Jahoda
// A port of MPAndroidChart for iOS
// Licensed under Apache License 2.0
//
// https://github.com/danielgindi/Charts
//
import Foundation
import CoreGraphics
@objc(ChartXAxisRenderer)
open class XAxisRenderer: NSObject, AxisRenderer
{
@objc public let viewPortHandler: ViewPortHandler
@objc public let axis: XAxis
@objc public let transformer: Transformer?
@objc public init(viewPortHandler: ViewPortHandler, axis: XAxis, transformer: Transformer?)
{
self.viewPortHandler = viewPortHandler
self.axis = axis
self.transformer = transformer
super.init()
}
open func computeAxis(min: Double, max: Double, inverted: Bool)
{
var min = min, max = max
if let transformer = self.transformer,
viewPortHandler.contentWidth > 10,
!viewPortHandler.isFullyZoomedOutX
{
// calculate the starting and entry point of the y-labels (depending on
// zoom / contentrect bounds)
let p1 = transformer.valueForTouchPoint(CGPoint(x: viewPortHandler.contentLeft, y: viewPortHandler.contentTop))
let p2 = transformer.valueForTouchPoint(CGPoint(x: viewPortHandler.contentRight, y: viewPortHandler.contentTop))
min = inverted ? Double(p2.x) : Double(p1.x)
max = inverted ? Double(p1.x) : Double(p2.x)
}
computeAxisValues(min: min, max: max)
}
open func computeAxisValues(min: Double, max: Double)
{
let yMin = min
let yMax = max
let labelCount = axis.labelCount
let range = abs(yMax - yMin)
guard
labelCount != 0,
range > 0,
range.isFinite
else
{
axis.entries = []
axis.centeredEntries = []
return
}
// Find out how much spacing (in y value space) between axis values
let rawInterval = range / Double(labelCount)
var interval = rawInterval.roundedToNextSignificant()
// If granularity is enabled, then do not allow the interval to go below specified granularity.
// This is used to avoid repeated values when rounding values for display.
if axis.granularityEnabled
{
interval = Swift.max(interval, axis.granularity)
}
// Normalize interval
let intervalMagnitude = pow(10.0, Double(Int(log10(interval)))).roundedToNextSignificant()
let intervalSigDigit = Int(interval / intervalMagnitude)
if intervalSigDigit > 5
{
// Use one order of magnitude higher, to avoid intervals like 0.9 or 90
interval = floor(10.0 * Double(intervalMagnitude))
}
var n = axis.centerAxisLabelsEnabled ? 1 : 0
// force label count
if axis.isForceLabelsEnabled
{
interval = range / Double(labelCount - 1)
// Ensure stops contains at least n elements.
axis.entries.removeAll(keepingCapacity: true)
axis.entries.reserveCapacity(labelCount)
let values = stride(from: yMin, to: Double(labelCount) * interval + yMin, by: interval)
axis.entries.append(contentsOf: values)
n = labelCount
}
else
{
// no forced count
var first = interval == 0.0 ? 0.0 : ceil(yMin / interval) * interval
if axis.centerAxisLabelsEnabled
{
first -= interval
}
let last = interval == 0.0 ? 0.0 : (floor(yMax / interval) * interval).nextUp
if interval != 0.0, last != first
{
stride(from: first, through: last, by: interval).forEach { _ in n += 1 }
}
// Ensure stops contains at least n elements.
axis.entries.removeAll(keepingCapacity: true)
axis.entries.reserveCapacity(labelCount)
let start = first, end = first + Double(n) * interval
// Fix for IEEE negative zero case (Where value == -0.0, and 0.0 == -0.0)
let values = stride(from: start, to: end, by: interval).map { $0 == 0.0 ? 0.0 : $0 }
axis.entries.append(contentsOf: values)
}
// set decimals
if interval < 1
{
axis.decimals = Int(ceil(-log10(interval)))
}
else
{
axis.decimals = 0
}
if axis.centerAxisLabelsEnabled
{
let offset: Double = interval / 2.0
axis.centeredEntries = axis.entries[..<n]
.map { $0 + offset }
}
computeSize()
}
@objc open func computeSize()
{
let longest = axis.getLongestLabel()
let labelSize = longest.size(withAttributes: [.font: axis.labelFont])
let labelWidth = labelSize.width
let labelHeight = labelSize.height
let labelRotatedSize = labelSize.rotatedBy(degrees: axis.labelRotationAngle)
axis.labelWidth = labelWidth
axis.labelHeight = labelHeight
axis.labelRotatedWidth = labelRotatedSize.width
axis.labelRotatedHeight = labelRotatedSize.height
}
open func renderAxisLabels(context: CGContext)
{
guard
axis.isEnabled,
axis.isDrawLabelsEnabled
else { return }
let yOffset = axis.yOffset
switch axis.labelPosition {
case .top:
drawLabels(context: context, pos: viewPortHandler.contentTop - yOffset, anchor: CGPoint(x: 0.5, y: 1.0))
case .topInside:
drawLabels(context: context, pos: viewPortHandler.contentTop + yOffset + axis.labelRotatedHeight, anchor: CGPoint(x: 0.5, y: 1.0))
case .bottom:
drawLabels(context: context, pos: viewPortHandler.contentBottom + yOffset, anchor: CGPoint(x: 0.5, y: 0.0))
case .bottomInside:
drawLabels(context: context, pos: viewPortHandler.contentBottom - yOffset - axis.labelRotatedHeight, anchor: CGPoint(x: 0.5, y: 0.0))
case .bothSided:
drawLabels(context: context, pos: viewPortHandler.contentTop - yOffset, anchor: CGPoint(x: 0.5, y: 1.0))
drawLabels(context: context, pos: viewPortHandler.contentBottom + yOffset, anchor: CGPoint(x: 0.5, y: 0.0))
}
}
private var axisLineSegmentsBuffer = [CGPoint](repeating: .zero, count: 2)
open func renderAxisLine(context: CGContext)
{
guard
axis.isEnabled,
axis.isDrawAxisLineEnabled
else { return }
context.saveGState()
defer { context.restoreGState() }
context.setStrokeColor(axis.axisLineColor.cgColor)
context.setLineWidth(axis.axisLineWidth)
if axis.axisLineDashLengths != nil
{
context.setLineDash(phase: axis.axisLineDashPhase, lengths: axis.axisLineDashLengths)
}
else
{
context.setLineDash(phase: 0.0, lengths: [])
}
if axis.labelPosition == .top
|| axis.labelPosition == .topInside
|| axis.labelPosition == .bothSided
{
axisLineSegmentsBuffer[0].x = viewPortHandler.contentLeft
axisLineSegmentsBuffer[0].y = viewPortHandler.contentTop
axisLineSegmentsBuffer[1].x = viewPortHandler.contentRight
axisLineSegmentsBuffer[1].y = viewPortHandler.contentTop
context.strokeLineSegments(between: axisLineSegmentsBuffer)
}
if axis.labelPosition == .bottom
|| axis.labelPosition == .bottomInside
|| axis.labelPosition == .bothSided
{
axisLineSegmentsBuffer[0].x = viewPortHandler.contentLeft
axisLineSegmentsBuffer[0].y = viewPortHandler.contentBottom
axisLineSegmentsBuffer[1].x = viewPortHandler.contentRight
axisLineSegmentsBuffer[1].y = viewPortHandler.contentBottom
context.strokeLineSegments(between: axisLineSegmentsBuffer)
}
}
/// draws the x-labels on the specified y-position
@objc open func drawLabels(context: CGContext, pos: CGFloat, anchor: CGPoint)
{
guard let transformer = self.transformer else { return }
let paraStyle = ParagraphStyle.default.mutableCopy() as! MutableParagraphStyle
paraStyle.alignment = .center
let labelAttrs: [NSAttributedString.Key : Any] = [.font: axis.labelFont,
.foregroundColor: axis.labelTextColor,
.paragraphStyle: paraStyle]
let labelRotationAngleRadians = axis.labelRotationAngle.DEG2RAD
let isCenteringEnabled = axis.isCenterAxisLabelsEnabled
let valueToPixelMatrix = transformer.valueToPixelMatrix
var position = CGPoint.zero
var labelMaxSize = CGSize.zero
if axis.isWordWrapEnabled
{
labelMaxSize.width = axis.wordWrapWidthPercent * valueToPixelMatrix.a
}
let entries = axis.entries
for i in entries.indices
{
let px = isCenteringEnabled ? CGFloat(axis.centeredEntries[i]) : CGFloat(entries[i])
position = CGPoint(x: px, y: 0)
.applying(valueToPixelMatrix)
guard viewPortHandler.isInBoundsX(position.x) else { continue }
let label = axis.valueFormatter?.stringForValue(axis.entries[i], axis: axis) ?? ""
let labelns = label as NSString
if axis.isAvoidFirstLastClippingEnabled
{
// avoid clipping of the last
if i == axis.entryCount - 1 && axis.entryCount > 1
{
let width = labelns.boundingRect(with: labelMaxSize, options: .usesLineFragmentOrigin, attributes: labelAttrs, context: nil).size.width
if width > viewPortHandler.offsetRight * 2.0,
position.x + width > viewPortHandler.chartWidth
{
position.x -= width / 2.0
}
}
else if i == 0
{ // avoid clipping of the first
let width = labelns.boundingRect(with: labelMaxSize, options: .usesLineFragmentOrigin, attributes: labelAttrs, context: nil).size.width
position.x += width / 2.0
}
}
drawLabel(context: context,
formattedLabel: label,
x: position.x,
y: pos,
attributes: labelAttrs,
constrainedTo: labelMaxSize,
anchor: anchor,
angleRadians: labelRotationAngleRadians)
}
}
@objc open func drawLabel(
context: CGContext,
formattedLabel: String,
x: CGFloat,
y: CGFloat,
attributes: [NSAttributedString.Key : Any],
constrainedTo size: CGSize,
anchor: CGPoint,
angleRadians: CGFloat)
{
context.drawMultilineText(formattedLabel,
at: CGPoint(x: x, y: y),
constrainedTo: size,
anchor: anchor,
angleRadians: angleRadians,
attributes: attributes)
}
open func renderGridLines(context: CGContext)
{
guard
let transformer = self.transformer,
axis.isEnabled,
axis.isDrawGridLinesEnabled
else { return }
context.saveGState()
defer { context.restoreGState() }
context.clip(to: self.gridClippingRect)
context.setShouldAntialias(axis.gridAntialiasEnabled)
context.setStrokeColor(axis.gridColor.cgColor)
context.setLineWidth(axis.gridLineWidth)
context.setLineCap(axis.gridLineCap)
if axis.gridLineDashLengths != nil
{
context.setLineDash(phase: axis.gridLineDashPhase, lengths: axis.gridLineDashLengths)
}
else
{
context.setLineDash(phase: 0.0, lengths: [])
}
let valueToPixelMatrix = transformer.valueToPixelMatrix
var position = CGPoint.zero
let entries = axis.entries
for entry in entries
{
position.x = CGFloat(entry)
position.y = CGFloat(entry)
position = position.applying(valueToPixelMatrix)
drawGridLine(context: context, x: position.x, y: position.y)
}
}
@objc open var gridClippingRect: CGRect
{
var contentRect = viewPortHandler.contentRect
let dx = self.axis.gridLineWidth
contentRect.origin.x -= dx / 2.0
contentRect.size.width += dx
return contentRect
}
@objc open func drawGridLine(context: CGContext, x: CGFloat, y: CGFloat)
{
guard x >= viewPortHandler.offsetLeft && x <= viewPortHandler.chartWidth else { return }
context.beginPath()
context.move(to: CGPoint(x: x, y: viewPortHandler.contentTop))
context.addLine(to: CGPoint(x: x, y: viewPortHandler.contentBottom))
context.strokePath()
}
open func renderLimitLines(context: CGContext)
{
guard
let transformer = self.transformer,
!axis.limitLines.isEmpty
else { return }
let trans = transformer.valueToPixelMatrix
var position = CGPoint.zero
for l in axis.limitLines where l.isEnabled
{
context.saveGState()
defer { context.restoreGState() }
var clippingRect = viewPortHandler.contentRect
clippingRect.origin.x -= l.lineWidth / 2.0
clippingRect.size.width += l.lineWidth
context.clip(to: clippingRect)
position.x = CGFloat(l.limit)
position.y = 0.0
position = position.applying(trans)
renderLimitLineLine(context: context, limitLine: l, position: position)
renderLimitLineLabel(context: context, limitLine: l, position: position, yOffset: 2.0 + l.yOffset)
}
}
@objc open func renderLimitLineLine(context: CGContext, limitLine: ChartLimitLine, position: CGPoint)
{
context.beginPath()
context.move(to: CGPoint(x: position.x, y: viewPortHandler.contentTop))
context.addLine(to: CGPoint(x: position.x, y: viewPortHandler.contentBottom))
context.setStrokeColor(limitLine.lineColor.cgColor)
context.setLineWidth(limitLine.lineWidth)
if limitLine.lineDashLengths != nil
{
context.setLineDash(phase: limitLine.lineDashPhase, lengths: limitLine.lineDashLengths!)
}
else
{
context.setLineDash(phase: 0.0, lengths: [])
}
context.strokePath()
}
@objc open func renderLimitLineLabel(context: CGContext, limitLine: ChartLimitLine, position: CGPoint, yOffset: CGFloat)
{
let label = limitLine.label
// if drawing the limit-value label is enabled
guard limitLine.drawLabelEnabled, !label.isEmpty else { return }
let labelLineHeight = limitLine.valueFont.lineHeight
let xOffset: CGFloat = limitLine.lineWidth + limitLine.xOffset
let align: TextAlignment
let point: CGPoint
switch limitLine.labelPosition
{
case .rightTop:
align = .left
point = CGPoint(x: position.x + xOffset,
y: viewPortHandler.contentTop + yOffset)
case .rightBottom:
align = .left
point = CGPoint(x: position.x + xOffset,
y: viewPortHandler.contentBottom - labelLineHeight - yOffset)
case .leftTop:
align = .right
point = CGPoint(x: position.x - xOffset,
y: viewPortHandler.contentTop + yOffset)
case .leftBottom:
align = .right
point = CGPoint(x: position.x - xOffset,
y: viewPortHandler.contentBottom - labelLineHeight - yOffset)
}
context.drawText(label,
at: point,
align: align,
attributes: [.font: limitLine.valueFont,
.foregroundColor: limitLine.valueTextColor])
}
}