1 contributor
//
// TimeSeriesChart.swift
// USB Meter
//
import CoreGraphics
import Foundation
import SwiftUI
enum TimeSeriesChartPointKind: Hashable {
case sample
case discontinuity
}
protocol TimeSeriesChartPointRepresentable {
var chartPointID: Int { get }
var chartTimestamp: Date { get }
var chartValue: Double { get }
var chartPointKind: TimeSeriesChartPointKind { get }
}
extension TimeSeriesChartPointRepresentable {
var isChartSample: Bool {
chartPointKind == .sample
}
var isChartDiscontinuity: Bool {
chartPointKind == .discontinuity
}
func chartCGPoint() -> CGPoint {
CGPoint(x: chartTimestamp.timeIntervalSince1970, y: chartValue)
}
}
struct TimeSeriesChartStyle {
var drawsArea: Bool
var strokeColor: Color
var areaFillColor: Color?
var lineWidth: CGFloat
static func line(
strokeColor: Color = .black,
lineWidth: CGFloat = 2
) -> TimeSeriesChartStyle {
TimeSeriesChartStyle(
drawsArea: false,
strokeColor: strokeColor,
areaFillColor: nil,
lineWidth: lineWidth
)
}
static func area(
strokeColor: Color = .black,
areaFillColor: Color? = nil,
lineWidth: CGFloat = 2
) -> TimeSeriesChartStyle {
TimeSeriesChartStyle(
drawsArea: true,
strokeColor: strokeColor,
areaFillColor: areaFillColor,
lineWidth: lineWidth
)
}
}
struct TimeSeriesChart<Point: TimeSeriesChartPointRepresentable>: View {
@Environment(\.displayScale) private var displayScale
let points: [Point]
let context: ChartContext
let style: TimeSeriesChartStyle
init(
points: [Point],
context: ChartContext,
style: TimeSeriesChartStyle
) {
self.points = points
self.context = context
self.style = style
}
init(
points: [Point],
context: ChartContext,
areaChart: Bool = false,
strokeColor: Color = .black,
areaFillColor: Color? = nil
) {
self.points = points
self.context = context
self.style = areaChart
? .area(strokeColor: strokeColor, areaFillColor: areaFillColor)
: .line(strokeColor: strokeColor)
}
var body: some View {
GeometryReader { geometry in
if style.drawsArea {
let fillColor = style.areaFillColor ?? style.strokeColor.opacity(0.2)
path(geometry: geometry)
.fill(
LinearGradient(
gradient: .init(
colors: [
fillColor.opacity(0.72),
fillColor.opacity(0.18)
]
),
startPoint: .init(x: 0.5, y: 0.08),
endPoint: .init(x: 0.5, y: 0.92)
)
)
} else {
path(geometry: geometry)
.stroke(
style.strokeColor,
style: StrokeStyle(
lineWidth: style.lineWidth,
lineCap: .round,
lineJoin: .round
)
)
}
}
}
private func path(geometry: GeometryProxy) -> Path {
let displayedPoints = scaledPoints(for: geometry.size.width)
let baselineY = context.placeInRect(
point: CGPoint(x: context.origin.x, y: context.origin.y)
).y * geometry.size.height
return Path { path in
var firstRenderedPoint: CGPoint?
var lastRenderedPoint: CGPoint?
var needsMove = true
for point in displayedPoints {
if point.isDiscontinuity {
closeAreaSegment(
in: &path,
firstPoint: firstRenderedPoint,
lastPoint: lastRenderedPoint,
baselineY: baselineY
)
firstRenderedPoint = nil
lastRenderedPoint = nil
needsMove = true
continue
}
let item = context.placeInRect(point: point.cgPoint)
let renderedPoint = CGPoint(
x: item.x * geometry.size.width,
y: item.y * geometry.size.height
)
if needsMove {
path.move(to: renderedPoint)
firstRenderedPoint = renderedPoint
needsMove = false
} else {
path.addLine(to: renderedPoint)
}
lastRenderedPoint = renderedPoint
}
closeAreaSegment(
in: &path,
firstPoint: firstRenderedPoint,
lastPoint: lastRenderedPoint,
baselineY: baselineY
)
}
}
private func closeAreaSegment(
in path: inout Path,
firstPoint: CGPoint?,
lastPoint: CGPoint?,
baselineY: CGFloat
) {
guard style.drawsArea, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
path.closeSubpath()
}
private func scaledPoints(for width: CGFloat) -> [TimeSeriesChartRenderPoint] {
let renderPoints = points.map(TimeSeriesChartRenderPoint.init)
let sampleCount = renderPoints.reduce(into: 0) { partialResult, point in
if point.isSample {
partialResult += 1
}
}
let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
let maximumSamplesToRender = max(displayColumns * (style.drawsArea ? 3 : 4), 240)
guard sampleCount > maximumSamplesToRender, context.isValid else {
return renderPoints
}
var scaledPoints: [TimeSeriesChartRenderPoint] = []
var currentSegment: [TimeSeriesChartRenderPoint] = []
for point in renderPoints {
if point.isDiscontinuity {
appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
currentSegment.removeAll(keepingCapacity: true)
if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
appendScaledPoint(point, to: &scaledPoints)
}
} else {
currentSegment.append(point)
}
}
appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
return scaledPoints.isEmpty ? renderPoints : scaledPoints
}
private func appendScaledSegment(
_ segment: [TimeSeriesChartRenderPoint],
to scaledPoints: inout [TimeSeriesChartRenderPoint],
displayColumns: Int
) {
guard !segment.isEmpty else { return }
if segment.count <= max(displayColumns * 2, 120) {
for point in segment {
appendScaledPoint(point, to: &scaledPoints)
}
return
}
var bucket: [TimeSeriesChartRenderPoint] = []
var currentColumn: Int?
for point in segment {
let column = displayColumn(for: point, totalColumns: displayColumns)
if let currentColumn, currentColumn != column {
appendBucket(bucket, to: &scaledPoints)
bucket.removeAll(keepingCapacity: true)
}
bucket.append(point)
currentColumn = column
}
appendBucket(bucket, to: &scaledPoints)
}
private func appendBucket(
_ bucket: [TimeSeriesChartRenderPoint],
to scaledPoints: inout [TimeSeriesChartRenderPoint]
) {
guard !bucket.isEmpty else { return }
if bucket.count <= 2 {
for point in bucket {
appendScaledPoint(point, to: &scaledPoints)
}
return
}
let firstPoint = bucket.first!
let lastPoint = bucket.last!
let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
.sorted { lhs, rhs in
if lhs.timestamp == rhs.timestamp {
return lhs.sourceID < rhs.sourceID
}
return lhs.timestamp < rhs.timestamp
}
var emittedPointIDs: Set<Int> = []
for point in orderedPoints where emittedPointIDs.insert(point.sourceID).inserted {
appendScaledPoint(point, to: &scaledPoints)
}
}
private func appendScaledPoint(
_ point: TimeSeriesChartRenderPoint,
to scaledPoints: inout [TimeSeriesChartRenderPoint]
) {
guard !(scaledPoints.last?.timestamp == point.timestamp &&
scaledPoints.last?.value == point.value &&
scaledPoints.last?.kind == point.kind) else {
return
}
scaledPoints.append(point)
}
private func displayColumn(
for point: TimeSeriesChartRenderPoint,
totalColumns: Int
) -> Int {
let totalColumns = max(totalColumns, 1)
let timeSpan = max(Double(context.size.width), 1)
let normalizedOffset = min(
max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
1
)
return min(
Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
totalColumns - 1
)
}
}
struct TimeSeriesChartHorizontalGuides: View {
let context: ChartContext
let labelCount: Int
var strokeColor: Color = Color.secondary.opacity(0.38)
var lineWidth: CGFloat = 0.85
var body: some View {
GeometryReader { geometry in
Path { path in
for labelIndex in 1...max(labelCount, 1) {
let y = context.yGuidePosition(
for: labelIndex,
of: labelCount,
height: geometry.size.height
)
path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
}
}
.stroke(strokeColor, lineWidth: lineWidth)
}
}
}
struct TimeSeriesChartVerticalGuides: View {
let context: ChartContext
let labelCount: Int
var visibleLabelRange: Range<Int>? = nil
var strokeColor: Color = Color.secondary.opacity(0.34)
var strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 0.8, dash: [4, 4])
var body: some View {
GeometryReader { geometry in
Path { path in
for labelIndex in resolvedLabelRange {
let x = context.xGuidePosition(
for: labelIndex,
of: labelCount,
width: geometry.size.width
)
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: geometry.size.height))
}
}
.stroke(strokeColor, style: strokeStyle)
}
}
private var resolvedLabelRange: Range<Int> {
visibleLabelRange ?? 2..<max(labelCount, 2)
}
}
private struct TimeSeriesChartRenderPoint: Hashable {
let sourceID: Int
let timestamp: Date
let value: Double
let kind: TimeSeriesChartPointKind
init<Point: TimeSeriesChartPointRepresentable>(_ point: Point) {
self.sourceID = point.chartPointID
self.timestamp = point.chartTimestamp
self.value = point.chartValue
self.kind = point.chartPointKind
}
var isSample: Bool {
kind == .sample
}
var isDiscontinuity: Bool {
kind == .discontinuity
}
var cgPoint: CGPoint {
CGPoint(x: timestamp.timeIntervalSince1970, y: value)
}
}
extension ChartContext {
func yGuidePosition(
for labelIndex: Int,
of labelCount: Int,
height: CGFloat
) -> CGFloat {
let value = yAxisLabel(for: labelIndex, of: max(labelCount, 2))
let anchorPoint = CGPoint(x: origin.x, y: CGFloat(value))
return placeInRect(point: anchorPoint).y * height
}
func xGuidePosition(
for labelIndex: Int,
of labelCount: Int,
width: CGFloat
) -> CGFloat {
let value = xAxisLabel(for: labelIndex, of: max(labelCount, 2))
let anchorPoint = CGPoint(x: CGFloat(value), y: origin.y)
return placeInRect(point: anchorPoint).x * width
}
}