1 contributor
276 lines | 12.611kb
//
//  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.1
    private let minimumCurrentSpan = 0.1
    private let minimumPowerSpan = 0.1
    
    @EnvironmentObject private var measurements: Measurements
    var timeRange: ClosedRange<Date>? = 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 {
            VStack {
                HStack {
                    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)
                }
                .padding(.bottom, 5)
                if let primarySeries {
                    VStack {
                        GeometryReader { geometry in
                            HStack {
                                Group { // MARK: Left Legend
                                    if self.displayPower {
                                        self.yAxisLabelsView(geometry: geometry, context: powerSeries.context, measurementUnit: "W")
                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .red, opacity: 0.5)
                                    } else if self.displayVoltage {
                                        self.yAxisLabelsView(geometry: geometry, context: voltageSeries.context, measurementUnit: "V")
                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .green, opacity: 0.5)
                                    }
                                    else if self.displayCurrent {
                                        self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
                                    }
                                }
                                ZStack { // MARK: Graph
                                    if self.displayPower {
                                        Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
                                            .opacity(0.5)
                                    } else {
                                        if self.displayVoltage{
                                            Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
                                                .opacity(0.5)
                                        }
                                        if self.displayCurrent{
                                            Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
                                                .opacity(0.5)
                                        }
                                    }
                                    
                                    // MARK: Grid
                                    self.horizontalGuides()
                                    self.verticalGuides()
                                }
                                .withRoundedRectangleBackground( cornerRadius: 0, foregroundColor: .primary, opacity: 0.06 )
                                Group { // MARK: Right Legend
                                    self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
                                        .foregroundColor(self.displayVoltage && self.displayCurrent ? .primary : .clear)
                                        .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
                                }
                            }
                        }
                        xAxisLabelsView(context: primarySeries.context)
                            .padding(.horizontal, 10)
                        
                    }
                }
                else {
                    Text("Nothing to show!")
                }
                
            }
            .padding(10)
            .font(.footnote)
            .frame(maxWidth: .greatestFiniteMagnitude)
            .withRoundedRectangleBackground( cornerRadius: 15, foregroundColor: .primary, opacity: 0.03 )
            .padding()
        }
    }

    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)
    }
    
    // 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"
        }
        return HStack {
            ForEach (1...xLabels, id: \.self) { i in
                Group {
                    Text( "\(Date(timeIntervalSince1970: context.xAxisLabel(for: i, of: self.yLabels)).format(as: timeFormat!))" )
                        .fontWeight(.semibold)
                    if i < self.xLabels {
                        Spacer()
                    }
                }
            }
        }
    }
    
    fileprivate func yAxisLabelsView(geometry: GeometryProxy, context: ChartContext, measurementUnit: String) -> some View {
        return ZStack {
            VStack {
                Text("\(context.yAxisLabel(for: 4, of: 4).format(fractionDigits: 2))")
                    .fontWeight(.semibold)
                    .padding(.top, geometry.size.height*Constants.chartUnderscan/2 )
                Spacer()
                ForEach (1..<yLabels-1, id: \.self) { i in
                    Group {
                        Text("\(context.yAxisLabel(for: self.yLabels-i, of: self.yLabels).format(fractionDigits: 2))")
                            .fontWeight(.semibold)
                        Spacer()
                    }
                }
                Text("\(context.yAxisLabel(for: 1, of: yLabels).format(fractionDigits: 2))")
                    .fontWeight(.semibold)
                    .padding(.bottom, geometry.size.height*Constants.chartUnderscan/2 )
            }
            VStack {
                Text(measurementUnit)
                    .fontWeight(.bold)
                    .padding(.top, 5)
                Spacer()
            }
        }
    }
    
    fileprivate func horizontalGuides() -> some View {
        GeometryReader { geometry in
            Path { path in
                let pading = geometry.size.height*Constants.chartUnderscan
                let height = geometry.size.height - pading
                let border = pading/2
                for i: CGFloat in stride(from: 0, through: CGFloat(self.yLabels-1), by: 1) {
                    path.addLine(from: CGPoint(x: 0, y: border + height*i/CGFloat(self.yLabels-1 )), to: CGPoint(x: geometry.size.width, y: border + height*i/CGFloat(self.yLabels-1)))
                }
            }.stroke(lineWidth: 0.25)
        }
    }
    
    fileprivate func verticalGuides() -> some View {
        GeometryReader { geometry in
            Path { path in
                
                for i: CGFloat in stride(from: 1, through: CGFloat(self.xLabels-1), by: 1) {
                    path.move(to: CGPoint(x: geometry.size.width*i/CGFloat(self.xLabels-1), y: 0) )
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
                }
            }.stroke(lineWidth: 0.25)
        }
    }
    
}

struct Chart : View {
    
    let points: [Measurements.Measurement.Point]
    let context: ChartContext
    var areaChart: Bool = false
    var strokeColor: Color = .black
    
    var body : some View {
        GeometryReader { geometry in
            if self.areaChart {
                self.path( geometry: geometry )
                    .fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9)))
            } else {
                self.path( geometry: geometry )
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
            }
        }
    }
    
    fileprivate func path(geometry: GeometryProxy) -> 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()
            }
        }
    }
    
}