// // 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 } } internal func scaleValue(scale: Scale = .linear, value: Double, maxValue: Double, zeroValue: Double, maxHeight: CGFloat, limit: Double) -> 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/zeroValue) } if localMaxValue > 0 { localMaxValue = log(maxValue/zeroValue) } case .fixed: if value > limit { value = limit } localMaxValue = limit default: break } if value < 0 { value = 0 } if localMaxValue <= 0 { localMaxValue = 1 } if scale != .none { y = (maxHeight * value)/localMaxValue } return y } private func drawToolTip(_ frame: NSRect, _ point: CGPoint, _ size: CGSize, value: String, subtitle: String? = nil) { let style = NSMutableParagraphStyle() style.alignment = .left var position: CGPoint = point let textHeight: CGFloat = subtitle != nil ? 22 : 12 let valueOffset: CGFloat = subtitle != nil ? 11 : 1 if position.x + size.width > frame.size.width+frame.origin.x { position.x = point.x - size.width style.alignment = .right } if position.y + textHeight > size.height { position.y = point.y - textHeight - 20 } if position.y < 2 { position.y = 2 } let box = NSBezierPath(roundedRect: NSRect(x: position.x-3, y: position.y-2, width: size.width, height: textHeight+2), xRadius: 2, yRadius: 2) NSColor.gray.setStroke() box.stroke() (isDarkMode ? NSColor.black : NSColor.white).withAlphaComponent(0.8).setFill() box.fill() var attributes = [ NSAttributedString.Key.font: NSFont.systemFont(ofSize: 12, weight: .regular), NSAttributedString.Key.foregroundColor: isDarkMode ? NSColor.white : NSColor.textColor ] var rect = CGRect(x: position.x, y: position.y+valueOffset, width: size.width, height: 12) var str = NSAttributedString.init(string: value, attributes: attributes) str.draw(with: rect) if let subtitle { attributes[NSAttributedString.Key.font] = NSFont.systemFont(ofSize: 9, weight: .medium) attributes[NSAttributedString.Key.foregroundColor] = (isDarkMode ? NSColor.white : NSColor.textColor).withAlphaComponent(0.7) rect = CGRect(x: position.x, y: position.y, width: size.width-8, height: 9) str = NSAttributedString.init(string: subtitle, attributes: attributes) str.draw(with: rect) } } public class LineChartView: NSView { public var id: String = UUID().uuidString private let dateFormatter = DateFormatter() public var points: [DoubleValue?] public var shadowPoints: [DoubleValue?] = [] public var transparent: Bool = true public var flipY: Bool = false public var minMax: Bool = false public var color: NSColor public var suffix: String public var toolTipFunc: ((DoubleValue) -> String)? public var isTooltipEnabled: Bool = true private var scale: Scale private var fixedScale: Double private var zeroValue: Double private var cursor: NSPoint? = nil private var stop: Bool = false public init(frame: NSRect, num: Int, suffix: String = "%", color: NSColor = .controlAccentColor, scale: Scale = .none, fixedScale: Double = 1, zeroValue: Double = 0.01) { self.points = Array(repeating: nil, count: num) self.suffix = suffix self.color = color self.scale = scale self.fixedScale = fixedScale self.zeroValue = zeroValue super.init(frame: frame) self.dateFormatter.dateFormat = "dd/MM HH:mm:ss" 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) let points = self.stop ? self.shadowPoints : self.points guard let context = NSGraphicsContext.current?.cgContext, !points.isEmpty else { return } context.setShouldAntialias(true) let maxValue = points.compactMap { $0 }.max() ?? 0 let lineColor: NSColor = self.color var gradientColor: NSColor = self.color.withAlphaComponent(0.5) if !self.transparent { gradientColor = self.color.withAlphaComponent(0.8) } let offset: CGFloat = 1 / (NSScreen.main?.backingScaleFactor ?? 1) let height: CGFloat = self.frame.height - offset let xRatio: CGFloat = self.frame.width / CGFloat(points.count-1) let zero: CGFloat = self.flipY ? self.frame.height : 0 var lines: [[CGPoint]] = [] var line: [CGPoint] = [] var list: [(value: DoubleValue, point: CGPoint)] = [] for (i, v) in points.enumerated() { guard let v else { if !line.isEmpty { lines.append(line) line = [] } continue } var y = scaleValue(scale: self.scale, value: v.value, maxValue: maxValue, zeroValue: self.zeroValue, maxHeight: height, limit: self.fixedScale) if self.flipY { y = height - y } let point = CGPoint( x: (CGFloat(i) * xRatio) + dirtyRect.origin.x, y: y ) line.append(point) list.append((value: v, point: point)) } if lines.isEmpty && !line.isEmpty { lines.append(line) } var path = NSBezierPath() for linePoints in lines { if linePoints.count == 1 { path = NSBezierPath(ovalIn: CGRect(x: linePoints[0].x-offset, y: linePoints[0].y-offset, width: 1, height: 1)) lineColor.set() path.stroke() gradientColor.set() path.fill() continue } path = NSBezierPath() path.move(to: linePoints[0]) 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, 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 date = self.dateFormatter.string(from: nearest.value.ts) let roundedValue = (nearest.value.value * 100).rounded(toPlaces: 2) let strValue = roundedValue >= 1 ? "\(Int(roundedValue))\(self.suffix)" : "\(roundedValue)\(self.suffix)" let value = self.toolTipFunc != nil ? self.toolTipFunc!(nearest.value) : strValue drawToolTip(self.frame, CGPoint(x: nearest.point.x+4, y: nearest.point.y+4), CGSize(width: 78, height: height), value: value, subtitle: date) } } 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: DoubleValue) { self.points.remove(at: 0) self.points.append(value) if self.window?.isVisible ?? false { self.display() } } public func addValue(_ value: Double) { self.addValue(DoubleValue(value)) } 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.. 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() } } } internal class TachometerGraphView: NSView { private var filled: Bool private var segments: [circle_segment] internal 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 } } internal func setSegments(_ segments: [circle_segment]) { self.segments = segments if self.window?.isVisible ?? false { self.display() } } internal func setFrame(_ frame: NSRect) { var original = self.frame original = frame self.frame = original } } public class BarChartView: NSView { private var values: [ColorValue] = [] private var cursor: CGPoint? = nil public init(frame: NSRect = NSRect.zero, num: Int) { super.init(frame: frame) self.values = Array(repeating: ColorValue(0, color: .controlAccentColor), count: num) 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) { 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 list: [(value: Double, path: NSBezierPath)] = [] var x: CGFloat = 0 for i in 0.. 0.1 ? 32 : 24 drawToolTip(self.frame, CGPoint(x: p.x+4, y: p.y+4), CGSize(width: width, height: partitionSize.height), value: value) } } public func setValues(_ values: [ColorValue]) { self.values = values if self.window?.isVisible ?? false { self.display() } } public override func mouseEntered(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) self.display() } public override func mouseMoved(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) self.display() } public override func mouseDragged(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) self.display() } public override func mouseExited(with event: NSEvent) { self.cursor = nil self.display() } } public class GridChartView: NSView { private let okColor: NSColor = .systemGreen private let notOkColor: NSColor = .systemRed private let inactiveColor: NSColor = .underPageBackgroundColor.withAlphaComponent(0.4) private var values: [NSColor] = [] private let grid: (rows: Int, columns: Int) public init(frame: NSRect, grid: (rows: Int, columns: Int)) { self.grid = grid super.init(frame: frame) self.values = Array(repeating: self.inactiveColor, count: grid.rows * grid.columns) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func draw(_ dirtyRect: NSRect) { let spacing: CGFloat = 2 let size: CGSize = CGSize( width: (self.frame.width - ((CGFloat(self.grid.rows)-1) * spacing)) / CGFloat(self.grid.rows), height: (self.frame.height - ((CGFloat(self.grid.columns)-1) * spacing)) / CGFloat(self.grid.columns) ) var origin: CGPoint = CGPoint(x: 0, y: (size.height + spacing) * CGFloat(self.grid.columns - 1)) var i: Int = 0 for _ in 0..