// // 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: 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 = [] 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? = 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 { visibleLabelRange ?? 2..(_ 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 } }