// // 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, 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] = [] private lazy var transitionAnimator: AnimationController = { AnimationController(current: 1, refreshClosure: self.refreshClosure) }() private var oldPercentageData: [PieComponent] = [] private var percentageData: [PieComponent] = [] private var setlectedSegmentsAnimators: [AnimationController] = [] 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(current: 1, refreshClosure: self.refreshClosure) } setlectedSegmentsAnimators = percentageData.map { _ in AnimationController(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 } } }