// // Chart.swift // Kit // // Created by Serhiy Mytrovtsiy on 17/04/2020. // Using Swift 5.0. // Running on macOS 10.15. // // Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved. // import Cocoa public struct circle_segment { public let value: Double public var color: NSColor public init(value: Double, color: NSColor) { self.value = value self.color = color } } private func scaleValue(scale: Scale = .linear, value: Double, maxValue: Double, maxHeight: CGFloat) -> CGFloat { var value = value if scale == .none && value > 1 && maxValue != 0 { value /= maxValue } var localMaxValue = maxValue var y = value * maxHeight switch scale { case .square: if value > 0 { value = sqrt(value) } if localMaxValue > 0 { localMaxValue = sqrt(maxValue) } case .cube: if value > 0 { value = cbrt(value) } if localMaxValue > 0 { localMaxValue = cbrt(maxValue) } case .logarithmic: if value > 0 { value = log(value*100) } if localMaxValue > 0 { localMaxValue = log(maxValue*100) } default: break } if value < 0 { value = 0 } if localMaxValue <= 0 { localMaxValue = 1 } if scale != .none { y = (maxHeight * value)/localMaxValue } return y } public class LineChartView: NSView { public var id: String = UUID().uuidString public var points: [Double] public var shadowPoints: [Double] = [] public var transparent: Bool = true public var color: NSColor = .controlAccentColor public var suffix: String = "%" public var scale: Scale private var cursor: NSPoint? = nil private var stop: Bool = false public init(frame: NSRect, num: Int, scale: Scale = .none) { self.points = Array(repeating: 0, count: num) self.scale = scale super.init(frame: frame) self.addTrackingArea(NSTrackingArea( rect: CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height), options: [ NSTrackingArea.Options.activeAlways, NSTrackingArea.Options.mouseEnteredAndExited, NSTrackingArea.Options.mouseMoved ], owner: self, userInfo: nil )) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) var points = self.points if self.stop { points = self.shadowPoints } guard let maxValue = points.max() else { return } if points.isEmpty { return } let lineColor: NSColor = self.color var gradientColor: NSColor = self.color.withAlphaComponent(0.5) if !self.transparent { gradientColor = self.color.withAlphaComponent(0.8) } guard let context = NSGraphicsContext.current?.cgContext else { return } context.setShouldAntialias(true) let offset: CGFloat = 1 / (NSScreen.main?.backingScaleFactor ?? 1) let height: CGFloat = dirtyRect.height - self.frame.origin.y - offset let xRatio: CGFloat = dirtyRect.width / CGFloat(points.count-1) let list = points.enumerated().compactMap { (i: Int, v: Double) -> (value: Double, point: CGPoint) in return (v, CGPoint( x: (CGFloat(i) * xRatio) + dirtyRect.origin.x, y: scaleValue(scale: self.scale, value: v, maxValue: maxValue, maxHeight: height) + dirtyRect.origin.y + offset )) } let line = NSBezierPath() line.move(to: list[0].point) for i in 1..= p.x }), let under = list.last(where: { $0.point.x <= p.x }) { guard p.y <= height else { return } let diffOver = over.point.x - p.x let diffUnder = p.x - under.point.x let nearest = (diffOver < diffUnder) ? over : under let vLine = NSBezierPath() let hLine = NSBezierPath() vLine.setLineDash([4, 4], count: 2, phase: 0) hLine.setLineDash([6, 6], count: 2, phase: 0) vLine.move(to: CGPoint(x: p.x, y: 0)) vLine.line(to: CGPoint(x: p.x, y: height)) vLine.close() hLine.move(to: CGPoint(x: 0, y: p.y)) hLine.line(to: CGPoint(x: self.frame.size.width+self.frame.origin.x, y: p.y)) hLine.close() NSColor.tertiaryLabelColor.set() vLine.lineWidth = offset hLine.lineWidth = offset vLine.stroke() hLine.stroke() let dotSize: CGFloat = 4 let path = NSBezierPath(ovalIn: CGRect( x: nearest.point.x-(dotSize/2), y: nearest.point.y-(dotSize/2), width: dotSize, height: dotSize )) NSColor.red.set() path.stroke() let style = NSMutableParagraphStyle() style.alignment = .left var textPosition: CGPoint = CGPoint(x: nearest.point.x+4, y: nearest.point.y+4) if textPosition.x + 24 > self.frame.size.width+self.frame.origin.x { textPosition.x = nearest.point.x - 30 style.alignment = .right } if textPosition.y + 14 > height { textPosition.y = nearest.point.y - 14 } let stringAttributes = [ NSAttributedString.Key.font: NSFont.systemFont(ofSize: 10, weight: .regular), NSAttributedString.Key.foregroundColor: isDarkMode ? NSColor.white : NSColor.textColor, NSAttributedString.Key.paragraphStyle: style ] let rect = CGRect(x: textPosition.x, y: textPosition.y, width: 26, height: 10) let value = "\(Int(nearest.value.rounded(toPlaces: 2) * 100))\(self.suffix)" let str = NSAttributedString.init(string: value, attributes: stringAttributes) str.draw(with: rect) } } public override func updateTrackingAreas() { self.trackingAreas.forEach({ self.removeTrackingArea($0) }) self.addTrackingArea(NSTrackingArea( rect: CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height), options: [ NSTrackingArea.Options.activeAlways, NSTrackingArea.Options.mouseEnteredAndExited, NSTrackingArea.Options.mouseMoved ], owner: self, userInfo: nil )) super.updateTrackingAreas() } public func addValue(_ value: Double) { self.points.remove(at: 0) self.points.append(value) if self.window?.isVisible ?? false { self.display() } } public func reinit(_ num: Int = 60) { guard self.points.count != num else { return } if num < self.points.count { self.points = Array(self.points[self.points.count-num.. uploadMax { uploadMax = downloadMax } else { downloadMax = uploadMax } } let lineWidth = 1 / (NSScreen.main?.backingScaleFactor ?? 1) let zero: CGFloat = (self.frame.height/2) + self.frame.origin.y let xRatio: CGFloat = (self.frame.width + (lineWidth*3)) / CGFloat(points.count) let columnXPoint = { (point: Int) -> CGFloat in return (CGFloat(point) * xRatio) + (self.frame.origin.x - lineWidth) } let uploadYPoint = { (point: Int) -> CGFloat in return scaleValue(scale: self.scale, value: points[point].0, maxValue: uploadMax, maxHeight: self.frame.height/2) + (self.frame.height/2 + self.frame.origin.y) } let downloadYPoint = { (point: Int) -> CGFloat in return (self.frame.height/2 + self.frame.origin.y) - scaleValue(scale: self.scale, value: points[point].1, maxValue: downloadMax, maxHeight: self.frame.height/2) } let uploadlinePath = NSBezierPath() uploadlinePath.move(to: CGPoint(x: columnXPoint(0), y: uploadYPoint(0))) let downloadlinePath = NSBezierPath() downloadlinePath.move(to: CGPoint(x: columnXPoint(0), y: downloadYPoint(0))) for i in 1.. 1 ? value/100 : value if self.window?.isVisible ?? false { self.display() } } public func setText(_ value: String) { self.text = value if self.window?.isVisible ?? false { self.display() } } } public class TachometerGraphView: NSView { private var filled: Bool private var segments: [circle_segment] public init(frame: NSRect, segments: [circle_segment], filled: Bool = true) { self.filled = filled self.segments = segments super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func draw(_ rect: CGRect) { let arcWidth: CGFloat = self.filled ? min(self.frame.width, self.frame.height) / 2 : 7 var segments = self.segments let totalAmount = segments.reduce(0) { $0 + $1.value } if totalAmount < 1 { segments.append(circle_segment(value: Double(1-totalAmount), color: NSColor.lightGray.withAlphaComponent(0.5))) } let centerPoint = CGPoint(x: self.frame.width/2, y: self.frame.height/2) let radius = (min(self.frame.width, self.frame.height) - arcWidth) / 2 guard let context = NSGraphicsContext.current?.cgContext else { return } context.setShouldAntialias(true) context.setLineWidth(arcWidth) context.setLineCap(.butt) context.translateBy(x: self.frame.width, y: -4) context.scaleBy(x: -1, y: 1) let startAngle: CGFloat = 0 let endCircle: CGFloat = CGFloat.pi var previousAngle = startAngle for segment in segments { let currentAngle: CGFloat = previousAngle + (CGFloat(segment.value) * endCircle) context.setStrokeColor(segment.color.cgColor) context.addArc(center: centerPoint, radius: radius, startAngle: previousAngle, endAngle: currentAngle, clockwise: false) context.strokePath() previousAngle = currentAngle } } public func setSegments(_ segments: [circle_segment]) { self.segments = segments if self.window?.isVisible ?? false { self.display() } } public func setFrame(_ frame: NSRect) { var original = self.frame original = frame self.frame = original } } public class BarChartView: NSView { private var values: [ColorValue] = [] public init(frame: NSRect, num: Int) { super.init(frame: frame) self.values = Array(repeating: ColorValue(0, color: .controlAccentColor), count: num) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func draw(_ dirtyRect: NSRect) { let blocks: Int = 16 let spacing: CGFloat = 2 let count: CGFloat = CGFloat(self.values.count) let partitionSize: CGSize = CGSize(width: (self.frame.width - (count*spacing)) / count, height: self.frame.height) let blockSize = CGSize(width: partitionSize.width-(spacing*2), height: ((partitionSize.height - spacing - 1)/CGFloat(blocks))-1) var x: CGFloat = 0 for i in 0..