793 lines
27 KiB
Swift
793 lines
27 KiB
Swift
//
|
|
// File.swift
|
|
//
|
|
//
|
|
// Created by Mike Renoir on 20.12.2022.
|
|
//
|
|
|
|
import Foundation
|
|
import MergeLists
|
|
import AppKit
|
|
import ColorPalette
|
|
|
|
|
|
private struct Particle {
|
|
var trackIndex: Int
|
|
var position: CGPoint
|
|
var scale: CGFloat
|
|
var alpha: CGFloat
|
|
var direction: CGPoint
|
|
var velocity: CGFloat
|
|
|
|
init(
|
|
trackIndex: Int,
|
|
position: CGPoint,
|
|
scale: CGFloat,
|
|
alpha: CGFloat,
|
|
direction: CGPoint,
|
|
velocity: CGFloat
|
|
) {
|
|
self.trackIndex = trackIndex
|
|
self.position = position
|
|
self.scale = scale
|
|
self.alpha = alpha
|
|
self.direction = direction
|
|
self.velocity = velocity
|
|
}
|
|
|
|
mutating func update(deltaTime: CGFloat) {
|
|
var position = self.position
|
|
position.x += self.direction.x * self.velocity * deltaTime
|
|
position.y += self.direction.y * self.velocity * deltaTime
|
|
self.position = position
|
|
}
|
|
}
|
|
|
|
private final class ParticleSet {
|
|
private(set) var particles: [Particle] = []
|
|
|
|
init() {
|
|
self.generateParticles(preAdvance: true)
|
|
}
|
|
|
|
private func generateParticles(preAdvance: Bool) {
|
|
let maxDirections = 24
|
|
|
|
if self.particles.count < maxDirections {
|
|
var allTrackIndices: [Int] = Array(repeating: 0, count: maxDirections)
|
|
for i in 0 ..< maxDirections {
|
|
allTrackIndices[i] = i
|
|
}
|
|
var takenIndexCount = 0
|
|
for particle in self.particles {
|
|
allTrackIndices[particle.trackIndex] = -1
|
|
takenIndexCount += 1
|
|
}
|
|
var availableTrackIndices: [Int] = []
|
|
availableTrackIndices.reserveCapacity(maxDirections - takenIndexCount)
|
|
for index in allTrackIndices {
|
|
if index != -1 {
|
|
availableTrackIndices.append(index)
|
|
}
|
|
}
|
|
|
|
if !availableTrackIndices.isEmpty {
|
|
availableTrackIndices.shuffle()
|
|
|
|
for takeIndex in availableTrackIndices {
|
|
let directionIndex = takeIndex
|
|
let angle = (CGFloat(directionIndex % maxDirections) / CGFloat(maxDirections)) * CGFloat.pi * 2.0
|
|
|
|
let direction = CGPoint(x: cos(angle), y: sin(angle))
|
|
let velocity = CGFloat.random(in: 20.0 ..< 40.0)
|
|
let alpha = CGFloat.random(in: 0.1 ..< 0.4)
|
|
let scale = CGFloat.random(in: 0.5 ... 1.0) * 0.22
|
|
|
|
var position = CGPoint(x: 100.0, y: 100.0)
|
|
var initialOffset: CGFloat = 0.4
|
|
if preAdvance {
|
|
initialOffset = CGFloat.random(in: initialOffset ... 1.0)
|
|
}
|
|
position.x += direction.x * initialOffset * 105.0
|
|
position.y += direction.y * initialOffset * 105.0
|
|
|
|
let particle = Particle(
|
|
trackIndex: directionIndex,
|
|
position: position,
|
|
scale: scale,
|
|
alpha: alpha,
|
|
direction: direction,
|
|
velocity: velocity
|
|
)
|
|
self.particles.append(particle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func update(deltaTime: CGFloat) {
|
|
let size = CGSize(width: 200.0, height: 200.0)
|
|
let radius2 = pow(size.width * 0.5 + 10.0, 2.0)
|
|
for i in (0 ..< self.particles.count).reversed() {
|
|
self.particles[i].update(deltaTime: deltaTime)
|
|
let position = self.particles[i].position
|
|
|
|
if pow(position.x - size.width * 0.5, 2.0) + pow(position.y - size.height * 0.5, 2.0) > radius2 {
|
|
self.particles.remove(at: i)
|
|
}
|
|
}
|
|
|
|
self.generateParticles(preAdvance: false)
|
|
}
|
|
}
|
|
|
|
|
|
public func optimizeArray(array: [Int], minPercent: Double) -> [Int] {
|
|
let total = array.reduce(0, +)
|
|
|
|
let minValue = Int(Double(total) * minPercent)
|
|
|
|
var array = array
|
|
var totalDiff = 0
|
|
|
|
for i in 0 ..< array.count {
|
|
if array[i] < minValue && array[i] > 0 {
|
|
let diff = minValue - array[i]
|
|
array[i] += diff
|
|
totalDiff += diff
|
|
}
|
|
}
|
|
|
|
let minus = totalDiff / array.count
|
|
for i in 0 ..< array.count {
|
|
if array[i] > 0 {
|
|
array[i] -= minus
|
|
}
|
|
}
|
|
|
|
return array
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private class TooltipView : View {
|
|
private let textView = TextView()
|
|
private let visual = VisualEffect()
|
|
required init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
|
|
addSubview(visual)
|
|
addSubview(textView)
|
|
|
|
|
|
textView.userInteractionEnabled = false
|
|
textView.isSelectable = false
|
|
|
|
layer?.cornerRadius = 10
|
|
|
|
}
|
|
|
|
func update(attr: NSAttributedString, animated: Bool) -> NSSize {
|
|
let layout = TextViewLayout(attr)
|
|
layout.measure(width: .greatestFiniteMagnitude)
|
|
textView.update(layout)
|
|
visual.bgColor = presentation.colors.background.withMultipliedAlpha(0.8)
|
|
return NSMakeSize(layout.layoutSize.width + 16, layout.layoutSize.height + 10)
|
|
}
|
|
|
|
func updateLayout(size: NSSize, transition: ContainedViewLayoutTransition) -> Void {
|
|
transition.updateFrame(view: textView, frame: textView.centerFrame())
|
|
transition.updateFrame(view: visual, frame: size.bounds.insetBy(dx: -10, dy: -5))
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
updateLayout(size: self.frame.size, transition: .immediate)
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
|
|
private final class SectionLayer: SimpleLayer {
|
|
private let maskLayer: SimpleShapeLayer
|
|
private let gradientLayer: SimpleGradientLayer
|
|
|
|
private var particleImage: CGImage?
|
|
private var particleLayers: [SimpleLayer] = []
|
|
|
|
private let item: PieChartView.Item
|
|
init(_ item: PieChartView.Item) {
|
|
self.item = item
|
|
self.maskLayer = SimpleShapeLayer()
|
|
self.maskLayer.fillColor = NSColor.white.cgColor
|
|
|
|
self.gradientLayer = SimpleGradientLayer()
|
|
self.gradientLayer.type = .radial
|
|
self.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
|
self.gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
|
|
|
|
super.init()
|
|
|
|
self.mask = self.maskLayer
|
|
self.addSublayer(self.gradientLayer)
|
|
|
|
self.particleImage = item.particle
|
|
}
|
|
|
|
|
|
required init(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
func isPointOnGraph(point: CGPoint) -> Bool {
|
|
if let path = self.maskLayer.path {
|
|
return path.contains(point)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func update(size: CGSize, path: CGPath) {
|
|
self.maskLayer.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.gradientLayer.frame = CGRect(origin: CGPoint(), size: size)
|
|
|
|
let normalColor = item.color.cgColor
|
|
let darkerColor = item.color.withMultipliedBrightnessBy(0.96).cgColor
|
|
let colors: [CGColor] = [
|
|
darkerColor,
|
|
normalColor,
|
|
normalColor,
|
|
normalColor,
|
|
darkerColor
|
|
]
|
|
self.gradientLayer.colors = colors
|
|
|
|
let locations: [CGFloat] = [
|
|
0.0,
|
|
0.3,
|
|
0.5,
|
|
0.7,
|
|
1.0
|
|
]
|
|
self.gradientLayer.locations = locations.map { location in
|
|
let location = location * 0.5 + 0.5
|
|
return location as NSNumber
|
|
}
|
|
|
|
let path = CGMutablePath()
|
|
self.maskLayer.path = path
|
|
|
|
}
|
|
|
|
func updateParticles(particleSet: ParticleSet) {
|
|
guard let particleImage = self.particleImage else {
|
|
return
|
|
}
|
|
for i in 0 ..< particleSet.particles.count {
|
|
let particle = particleSet.particles[i]
|
|
|
|
let particleLayer: SimpleLayer
|
|
if i < self.particleLayers.count {
|
|
particleLayer = self.particleLayers[i]
|
|
particleLayer.isHidden = false
|
|
} else {
|
|
particleLayer = SimpleLayer()
|
|
particleLayer.contents = particleImage
|
|
particleLayer.bounds = CGRect(origin: CGPoint(), size: particleImage.size)
|
|
self.particleLayers.append(particleLayer)
|
|
self.insertSublayer(particleLayer, above: self.gradientLayer)
|
|
}
|
|
|
|
particleLayer.position = particle.position
|
|
particleLayer.transform = CATransform3DMakeScale(particle.scale, particle.scale, 1.0)
|
|
particleLayer.opacity = Float(particle.alpha)
|
|
}
|
|
if particleSet.particles.count < self.particleLayers.count {
|
|
for i in particleSet.particles.count ..< self.particleLayers.count {
|
|
self.particleLayers[i].isHidden = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public class PieChartView : Control {
|
|
|
|
|
|
|
|
public struct Item : Comparable, Identifiable {
|
|
public var id: AnyHashable
|
|
public var index: Int
|
|
public var count: Int
|
|
public var color: NSColor
|
|
public var badge: NSAttributedString?
|
|
public var particle: CGImage?
|
|
public init(id: AnyHashable, index: Int, count: Int, color: NSColor, badge: NSAttributedString?, particle: CGImage?) {
|
|
self.id = id
|
|
self.index = index
|
|
self.count = count
|
|
self.color = color
|
|
self.badge = badge
|
|
self.particle = particle
|
|
}
|
|
public var stableId: AnyHashable {
|
|
return id
|
|
}
|
|
public static func <(lhs: Item, rhs: Item) -> Bool {
|
|
return lhs.index < rhs.index
|
|
}
|
|
}
|
|
|
|
public struct Presentation {
|
|
public var strokeColor: NSColor
|
|
public var strokeSize: CGFloat
|
|
public var bgColor: NSColor
|
|
public var totalTextColor: NSColor
|
|
public var itemTextColor: NSColor
|
|
public init(strokeColor: NSColor, strokeSize: CGFloat, bgColor: NSColor, totalTextColor: NSColor, itemTextColor: NSColor) {
|
|
self.strokeColor = strokeColor
|
|
self.strokeSize = strokeSize
|
|
self.bgColor = bgColor
|
|
self.totalTextColor = totalTextColor
|
|
self.itemTextColor = itemTextColor
|
|
}
|
|
}
|
|
|
|
public private(set) var items: [Item] = []
|
|
public var presentation: Presentation {
|
|
didSet {
|
|
needsDisplay = true
|
|
}
|
|
}
|
|
private let totalTextView = DynamicCounterTextView()
|
|
private var tooltipView: TooltipView?
|
|
|
|
|
|
public var toggleSelected:((Item)->Void)? = nil
|
|
|
|
|
|
private var displayAnimator: ConstantDisplayLinkAnimator?
|
|
|
|
private struct AnimationValues {
|
|
struct Value {
|
|
let value: Int
|
|
let timestamp: CGFloat
|
|
}
|
|
var values:[AnyHashable : Value] = [:]
|
|
var selection:[AnyHashable : CGFloat] = [:]
|
|
var radius:[AnyHashable : CGFloat] = [:]
|
|
var selected: AnyHashable? = nil
|
|
|
|
var isEmpty: Bool {
|
|
return radius.isEmpty && selection.isEmpty && values.isEmpty && selected == nil
|
|
}
|
|
}
|
|
|
|
private var animationValues: AnimationValues = .init() {
|
|
didSet {
|
|
DispatchQueue.main.async {
|
|
self.displayAnimator?.isPaused = self.animationValues.isEmpty
|
|
}
|
|
self.needsDisplay = true
|
|
}
|
|
}
|
|
private var sectionLayers: [AnyHashable: SectionLayer] = [:]
|
|
private let particleSet: ParticleSet = ParticleSet()
|
|
|
|
|
|
public init(frame frameRect: NSRect, presentation: Presentation) {
|
|
self.presentation = presentation
|
|
super.init(frame: frameRect)
|
|
addSubview(totalTextView)
|
|
|
|
set(handler: { [weak self] control in
|
|
self?.invokeToggle()
|
|
}, for: .Click)
|
|
}
|
|
|
|
private func invokeToggle() {
|
|
guard let window = self.window else {
|
|
return
|
|
}
|
|
let point = convert(window.mouseLocationOutsideOfEventStream, from: nil)
|
|
if let index = self.selectedItemIndex(at: point) {
|
|
self.toggleSelected?(self.items[index])
|
|
}
|
|
if let view = tooltipView {
|
|
performSubviewRemoval(view, animated: true)
|
|
self.tooltipView = nil
|
|
}
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
required public init(frame frameRect: NSRect) {
|
|
fatalError("init(frame:) has not been implemented")
|
|
}
|
|
|
|
|
|
public func update(items: [Item], dynamicText: String, animated: Bool) {
|
|
|
|
|
|
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: self.items, rightList: items)
|
|
|
|
|
|
let prevItems = self.items
|
|
|
|
for idx in deleteIndices.reversed() {
|
|
self.items.remove(at: idx)
|
|
}
|
|
var updated = self.animationValues.values
|
|
|
|
for indicesAndItem in indicesAndItems {
|
|
let item = indicesAndItem.1
|
|
self.items.insert(item, at: indicesAndItem.0)
|
|
if animated {
|
|
updated[item.stableId] = .init(value: 0, timestamp: 0)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
for updateIndex in updateIndices {
|
|
let item = updateIndex.1
|
|
let prev: Item = prevItems[updateIndex.2]
|
|
self.items[updateIndex.0] = item
|
|
if updated[item.stableId] == nil, animated {
|
|
updated[item.stableId] = .init(value: prev.count, timestamp: 0)
|
|
} else if let value = updated[item.stableId] {
|
|
updated[item.stableId] = .init(value: value.value, timestamp: 0)
|
|
}
|
|
}
|
|
|
|
if prevItems.isEmpty {
|
|
for item in self.items {
|
|
updated[item.stableId] = .init(value: items.reduce(0, { $0 + $1.count }) / items.count, timestamp: 0)
|
|
}
|
|
}
|
|
|
|
self.animationValues.values = updated
|
|
|
|
|
|
var dynamicFontSize: CGFloat = 15
|
|
var value: DynamicCounterTextView.Value = DynamicCounterTextView.make(for: dynamicText, count: dynamicText, font: .avatar(dynamicFontSize), textColor: presentation.totalTextColor, width: .greatestFiniteMagnitude)
|
|
|
|
let innerDiameter = min(bounds.width, bounds.height) / 2.5
|
|
|
|
while value.size.width > innerDiameter && dynamicFontSize > 1 {
|
|
dynamicFontSize -= 1
|
|
value = DynamicCounterTextView.make(for: dynamicText, count: dynamicText, font: .avatar(dynamicFontSize), textColor: presentation.totalTextColor, width: dynamicFontSize < 6 ? innerDiameter - 4 : .greatestFiniteMagnitude)
|
|
}
|
|
|
|
totalTextView.update(value, animated: animated)
|
|
totalTextView.change(size: value.size, animated: animated)
|
|
totalTextView.change(pos: focus(value.size).origin, animated: animated)
|
|
|
|
if displayAnimator == nil {
|
|
displayAnimator = ConstantDisplayLinkAnimator(update: { [weak self] in
|
|
self?.runAnimatorUpdater()
|
|
})
|
|
}
|
|
|
|
displayAnimator?.isPaused = self.animationValues.isEmpty
|
|
|
|
self.runAnimatorUpdater()
|
|
|
|
needsLayout = true
|
|
self.needsDisplay = true
|
|
}
|
|
|
|
private func runAnimatorUpdater() {
|
|
var updated = self.animationValues.values
|
|
for (key, value) in self.animationValues.values {
|
|
let item = self.items.first(where: { $0.id == key })
|
|
if let item = item {
|
|
let d = CGFloat(item.count - value.value)
|
|
let timestamp = value.timestamp + 0.016 / 3
|
|
let curve = listViewAnimationCurveEaseInOut(timestamp)
|
|
let result = d * curve
|
|
let current: Int
|
|
if result < 0 {
|
|
current = Int(floor(result))
|
|
} else {
|
|
current = Int(ceil(result))
|
|
}
|
|
let updatedValue = value.value + current
|
|
updated[key] = .init(value: updatedValue, timestamp: timestamp)
|
|
|
|
if abs(current) == 0 {
|
|
updated.removeValue(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
var selection = self.animationValues.selection
|
|
if let selected = self.animationValues.selected {
|
|
let current = selection[selected] ?? 0
|
|
selection[selected] = min(current + 0.016 * 8, 1)
|
|
}
|
|
for (key, value) in self.animationValues.selection {
|
|
if key != self.animationValues.selected {
|
|
let updatedValue = max(0, value - 0.016 * 8)
|
|
if updatedValue > 0 {
|
|
selection[key] = updatedValue
|
|
} else {
|
|
selection.removeValue(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
var radius = self.animationValues.radius
|
|
for item in items {
|
|
let current = radius[item.id] ?? 0
|
|
let value: CGFloat
|
|
if !selection.isEmpty && selection[item.id] == nil {
|
|
value = min(current + 0.016 * 8, 1)
|
|
} else {
|
|
value = max(current - 0.016 * 8, 0)
|
|
}
|
|
radius[item.id] = value
|
|
if value == 0 {
|
|
radius.removeValue(forKey: item.id)
|
|
}
|
|
}
|
|
|
|
|
|
self.animationValues = .init(values: updated, selection: selection, radius: radius, selected: self.animationValues.selected)
|
|
|
|
|
|
}
|
|
|
|
public override func layout() {
|
|
super.layout()
|
|
totalTextView.center()
|
|
}
|
|
|
|
func selectedItemIndex(at point: CGPoint) -> Int? {
|
|
|
|
|
|
|
|
let lastRenderedChartFrame = self.bounds
|
|
let center = CGPoint(x: lastRenderedChartFrame.midX, y: lastRenderedChartFrame.midY)
|
|
|
|
let radius = min(bounds.width, bounds.height) / 2 - presentation.strokeSize - 8
|
|
|
|
let innerRadius = min(bounds.width, bounds.height) / 2.5 / 2
|
|
|
|
if center.distance(p2: point) > radius || center.distance(p2: point) < innerRadius { return nil }
|
|
let angle = (center - point).angle + .pi
|
|
let total: CGFloat = items.map({ CGFloat($0.count) }).reduce(0, +)
|
|
var startAngle: CGFloat = initialAngle
|
|
for (index, piece) in items.enumerated() {
|
|
let percent = CGFloat(piece.count) / 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 let initialAngle: CGFloat = .pi / 3
|
|
|
|
public override func draw(_ layer: CALayer, in ctx: CGContext) {
|
|
|
|
guard let window = self.window else {
|
|
return
|
|
}
|
|
|
|
var startAngle: CGFloat = self.initialAngle
|
|
let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
|
|
|
|
|
|
let items = self.items.filter { $0.count > 0 || animationValues.values[$0.stableId] != nil }
|
|
|
|
var counts:[Int] = items.map { $0.count }
|
|
|
|
for (i, item) in items.enumerated() {
|
|
let count: CGFloat
|
|
if let current = animationValues.values[item.stableId] {
|
|
count = CGFloat(current.value)
|
|
} else {
|
|
count = CGFloat(item.count)
|
|
}
|
|
counts[i] = Int(count)
|
|
}
|
|
let total = counts.reduce(0, +)
|
|
|
|
let selected = selectedItemIndex(at: self.convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
|
|
|
var validIds: [AnyHashable] = []
|
|
|
|
|
|
for (i, item) in items.enumerated() {
|
|
|
|
var selectionAnimationFraction: CGFloat = self.animationValues.selection[item.id] ?? 0
|
|
if items.count == 1, selected == nil {
|
|
selectionAnimationFraction = 0
|
|
}
|
|
var radius = min(bounds.width, bounds.height) / 2 - presentation.strokeSize - 8
|
|
|
|
if let add = self.animationValues.radius[item.id], items.count > 1 {
|
|
radius -= 5 * add
|
|
}
|
|
|
|
|
|
let animationSelectionOffset: CGFloat = 5
|
|
|
|
let maximumFontSize: CGFloat = radius / 7
|
|
let minimumFontSize: CGFloat = 4
|
|
let centerOffsetStartAngle = CGFloat.pi / 4
|
|
let diagramRadius = radius - animationSelectionOffset
|
|
|
|
|
|
let count = CGFloat(counts[i])
|
|
let segmentSize = 2 * .pi * (count / CGFloat(total))
|
|
let endAngle = startAngle + segmentSize
|
|
let centerAngle = (startAngle + endAngle) / 2
|
|
let labelVector = CGPoint(x: cos(centerAngle),
|
|
y: sin(centerAngle))
|
|
|
|
let updatedCenter = CGPoint(x: center.x ,
|
|
y: center.y )
|
|
|
|
ctx.saveGState()
|
|
let path = CGMutablePath()
|
|
path.move(to: updatedCenter)
|
|
path.addArc(center: updatedCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
|
|
path.closeSubpath()
|
|
|
|
ctx.addPath(path)
|
|
ctx.setFillColor(item.color.cgColor)
|
|
ctx.fillPath()
|
|
|
|
ctx.addPath(path)
|
|
ctx.setLineWidth(presentation.strokeSize)
|
|
ctx.setLineCap(.round)
|
|
ctx.setStrokeColor(presentation.strokeColor.cgColor)
|
|
ctx.strokePath()
|
|
|
|
ctx.restoreGState()
|
|
|
|
|
|
ctx.saveGState()
|
|
|
|
let percent = (count / CGFloat(total))
|
|
|
|
if percent >= 0.04, percent != 1.0 {
|
|
let text = "\(Int(percent * 100))%"
|
|
let fraction = max(0, min(centerOffsetStartAngle, 1))
|
|
|
|
let fontSize = (minimumFontSize + (maximumFontSize - minimumFontSize) * fraction).rounded(.down)
|
|
let labelPotisionOffset = diagramRadius / 2 + diagramRadius / 2 * (1 - fraction) + 15
|
|
let font = NSFont.avatar(fontSize)
|
|
|
|
let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.white,
|
|
.font: font]
|
|
|
|
let attributedString = NSAttributedString(string: text, attributes: attributes)
|
|
let textNode = TextLabelNode.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: ctx, backingScaleFactor: System.backingScale)
|
|
|
|
}
|
|
|
|
ctx.restoreGState()
|
|
|
|
startAngle = endAngle
|
|
}
|
|
ctx.saveGState()
|
|
ctx.move(to: center)
|
|
ctx.setBlendMode(.clear)
|
|
let clearRadius = min(bounds.width, bounds.height) / 2.5
|
|
ctx.fillEllipse(in: focus(NSMakeSize(clearRadius, clearRadius)))
|
|
ctx.restoreGState()
|
|
|
|
}
|
|
|
|
private func middleTextPoint(_ point: NSPoint, item: Item) -> NSPoint {
|
|
|
|
let selectionAnimationFraction: CGFloat = 1.0
|
|
|
|
let radius = min(bounds.width, bounds.height) / 2 - presentation.strokeSize - 8
|
|
|
|
|
|
|
|
let centerOffsetStartAngle = CGFloat.pi / 4
|
|
let fraction = max(0, min(centerOffsetStartAngle, 1))
|
|
|
|
let animationSelectionOffset: CGFloat = 5
|
|
|
|
let diagramRadius = radius - animationSelectionOffset
|
|
|
|
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
|
let labelPotisionOffset = diagramRadius / 2 + diagramRadius / 2 * (1 - fraction) + 15
|
|
let angle = (center - point).angle + .pi
|
|
let total: CGFloat = items.map({ CGFloat($0.count) }).reduce(0, +)
|
|
|
|
var startAngle: CGFloat = initialAngle
|
|
for piece in items {
|
|
let percent = CGFloat(piece.count) / total
|
|
let segmentSize = 2 * .pi * percent
|
|
let endAngle = startAngle + segmentSize
|
|
if angle >= startAngle && angle <= endAngle ||
|
|
angle + .pi * 2 >= startAngle && angle + .pi * 2 <= endAngle {
|
|
|
|
let centerAngle = (startAngle + endAngle) / 2
|
|
let vector = CGPoint(x: cos(centerAngle),
|
|
y: sin(centerAngle))
|
|
|
|
let updatedCenter = CGPoint(x: center.x + vector.x * selectionAnimationFraction * animationSelectionOffset,
|
|
y: center.y + vector.y * selectionAnimationFraction * animationSelectionOffset)
|
|
|
|
|
|
return CGPoint(x: floor(vector.x * labelPotisionOffset + updatedCenter.x),
|
|
y: floor(vector.y * labelPotisionOffset + updatedCenter.y))
|
|
|
|
}
|
|
startAngle = endAngle
|
|
}
|
|
return .zero
|
|
}
|
|
|
|
private func updateTooltip(_ point: NSPoint) {
|
|
if let index = self.selectedItemIndex(at: point) {
|
|
let item = self.items[index]
|
|
if let badge = item.badge {
|
|
let current: TooltipView
|
|
if let view = self.tooltipView {
|
|
current = view
|
|
} else {
|
|
current = TooltipView(frame: .zero)
|
|
addSubview(current)
|
|
self.tooltipView = current
|
|
|
|
current.layer?.animateAlpha(from: 0, to: 1, duration: 0.2)
|
|
}
|
|
let size = current.update(attr: badge, animated: true)
|
|
let point = middleTextPoint(point, item: item)
|
|
current.frame = CGRect(origin: point - NSMakePoint(size.width / 2, size.height / 2), size: size)
|
|
} else if let view = self.tooltipView {
|
|
performSubviewRemoval(view, animated: true)
|
|
self.tooltipView = nil
|
|
}
|
|
} else if let view = self.tooltipView {
|
|
performSubviewRemoval(view, animated: true)
|
|
self.tooltipView = nil
|
|
}
|
|
}
|
|
|
|
private func updateGraph(_ point: NSPoint) {
|
|
if let index = self.selectedItemIndex(at: point) {
|
|
self.animationValues.selected = self.items[index].id
|
|
} else {
|
|
self.animationValues.selected = nil
|
|
}
|
|
self.updateTooltip(point)
|
|
}
|
|
|
|
public override func mouseMoved(with event: NSEvent) {
|
|
super.mouseMoved(with: event)
|
|
updateGraph(self.convert(event.locationInWindow, from: nil))
|
|
}
|
|
public override func mouseEntered(with event: NSEvent) {
|
|
super.mouseEntered(with: event)
|
|
updateGraph(self.convert(event.locationInWindow, from: nil))
|
|
}
|
|
public override func mouseExited(with event: NSEvent) {
|
|
super.mouseExited(with: event)
|
|
updateGraph(self.convert(event.locationInWindow, from: nil))
|
|
}
|
|
}
|