1 contributor
422 lines | 13.121kb
//
//  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
    }
}