// // MeasurementChartView.swift // USB Meter // // Created by Bogdan Timofte on 06/05/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import SwiftUI struct MeasurementChartView: View { private let minimumTimeSpan: TimeInterval = 1 private let minimumVoltageSpan = 0.5 private let minimumCurrentSpan = 0.5 private let minimumPowerSpan = 0.5 private let axisColumnWidth: CGFloat = 46 private let chartSectionSpacing: CGFloat = 8 private let xAxisHeight: CGFloat = 28 @EnvironmentObject private var measurements: Measurements var timeRange: ClosedRange? = nil @State var displayVoltage: Bool = false @State var displayCurrent: Bool = false @State var displayPower: Bool = true let xLabels: Int = 4 let yLabels: Int = 4 var body: some View { let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan) let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan) let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan) let primarySeries = displayedPrimarySeries( powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) Group { if let primarySeries { VStack(alignment: .leading, spacing: 12) { chartToggleBar GeometryReader { geometry in let plotHeight = max(geometry.size.height - xAxisHeight, 140) VStack(spacing: 6) { HStack(spacing: chartSectionSpacing) { primaryAxisView( height: plotHeight, powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) .frame(width: axisColumnWidth, height: plotHeight) ZStack { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color.primary.opacity(0.05)) RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(Color.secondary.opacity(0.16), lineWidth: 1) horizontalGuides(context: primarySeries.context) verticalGuides(context: primarySeries.context) renderedChart( powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .frame(maxWidth: .infinity) .frame(height: plotHeight) secondaryAxisView( height: plotHeight, powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) .frame(width: axisColumnWidth, height: plotHeight) } xAxisLabelsView(context: primarySeries.context) .frame(height: xAxisHeight) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } } else { VStack(alignment: .leading, spacing: 12) { chartToggleBar Text("Nothing to show!") .foregroundColor(.secondary) } } } .font(.footnote) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var chartToggleBar: some View { HStack(spacing: 8) { Button(action: { self.displayVoltage.toggle() if self.displayVoltage { self.displayPower = false } }) { Text("Voltage") } .asEnableFeatureButton(state: displayVoltage) Button(action: { self.displayCurrent.toggle() if self.displayCurrent { self.displayPower = false } }) { Text("Current") } .asEnableFeatureButton(state: displayCurrent) Button(action: { self.displayPower.toggle() if self.displayPower { self.displayCurrent = false self.displayVoltage = false } }) { Text("Power") } .asEnableFeatureButton(state: displayPower) } .frame(maxWidth: .infinity, alignment: .center) } @ViewBuilder private func primaryAxisView( height: CGFloat, powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) ) -> some View { if displayPower { yAxisLabelsView( height: height, context: powerSeries.context, measurementUnit: "W", tint: .red ) } else if displayVoltage { yAxisLabelsView( height: height, context: voltageSeries.context, measurementUnit: "V", tint: .green ) } else if displayCurrent { yAxisLabelsView( height: height, context: currentSeries.context, measurementUnit: "A", tint: .blue ) } } @ViewBuilder private func renderedChart( powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) ) -> some View { if self.displayPower { Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red) .opacity(0.72) } else { if self.displayVoltage { Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green) .opacity(0.78) } if self.displayCurrent { Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue) .opacity(0.78) } } } @ViewBuilder private func secondaryAxisView( height: CGFloat, powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) ) -> some View { if displayVoltage && displayCurrent { yAxisLabelsView( height: height, context: currentSeries.context, measurementUnit: "A", tint: .blue ) } else { primaryAxisView( height: height, powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) } } private func displayedPrimarySeries( powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? { if displayPower { return powerSeries.points.isEmpty ? nil : powerSeries } if displayVoltage { return voltageSeries.points.isEmpty ? nil : voltageSeries } if displayCurrent { return currentSeries.points.isEmpty ? nil : currentSeries } return nil } private func series( for measurement: Measurements.Measurement, minimumYSpan: Double ) -> (points: [Measurements.Measurement.Point], context: ChartContext) { let points = measurement.points.filter { point in guard let timeRange else { return true } return timeRange.contains(point.timestamp) } let context = ChartContext() for point in points { context.include(point: point.point()) } if !points.isEmpty { context.ensureMinimumSize( width: CGFloat(minimumTimeSpan), height: CGFloat(minimumYSpan) ) } return (points, context) } private func yGuidePosition( for labelIndex: Int, context: ChartContext, height: CGFloat ) -> CGFloat { let value = context.yAxisLabel(for: labelIndex, of: yLabels) let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value)) return context.placeInRect(point: anchorPoint).y * height } private func xGuidePosition( for labelIndex: Int, context: ChartContext, width: CGFloat ) -> CGFloat { let value = context.xAxisLabel(for: labelIndex, of: xLabels) let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y) return context.placeInRect(point: anchorPoint).x * width } // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat fileprivate func xAxisLabelsView( context: ChartContext ) -> some View { var timeFormat: String? switch context.size.width { case 0..<3600: timeFormat = "HH:mm:ss" case 3600...86400: timeFormat = "HH:mm" default: timeFormat = "E HH:mm" } let labels = (1...xLabels).map { Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!) } return HStack(spacing: chartSectionSpacing) { Color.clear .frame(width: axisColumnWidth) GeometryReader { geometry in let labelWidth = max( geometry.size.width / CGFloat(max(xLabels - 1, 1)), 1 ) ZStack(alignment: .topLeading) { Path { path in for labelIndex in 1...self.xLabels { let x = xGuidePosition( for: labelIndex, context: context, width: geometry.size.width ) path.move(to: CGPoint(x: x, y: 0)) path.addLine(to: CGPoint(x: x, y: 6)) } } .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75) ForEach(Array(labels.enumerated()), id: \.offset) { item in let labelIndex = item.offset + 1 let centerX = xGuidePosition( for: labelIndex, context: context, width: geometry.size.width ) Text(item.element) .font(.caption.weight(.semibold)) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.68) .frame(width: labelWidth) .position( x: centerX, y: geometry.size.height * 0.7 ) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } Color.clear .frame(width: axisColumnWidth) } } fileprivate func yAxisLabelsView( height: CGFloat, context: ChartContext, measurementUnit: String, tint: Color ) -> some View { GeometryReader { geometry in ZStack(alignment: .top) { ForEach(0.. some View { GeometryReader { geometry in Path { path in for labelIndex in 1...self.yLabels { let y = yGuidePosition( for: labelIndex, context: context, height: geometry.size.height ) path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y)) } } .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85) } } fileprivate func verticalGuides(context: ChartContext) -> some View { GeometryReader { geometry in Path { path in for labelIndex in 2.. Path { return Path { path in guard let first = points.first else { return } let firstPoint = context.placeInRect(point: first.point()) path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) ) for item in points.map({ context.placeInRect(point: $0.point()) }) { path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) ) } if self.areaChart { let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y )) let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y )) path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) ) path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) ) // MARK: Nu e nevoie. Fill inchide automat calea // path.closeSubpath() } } } }