1 contributor
457 lines | 18.017kb
//
//  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<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 {
            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..<yLabels, id: \.self) { row in
                    let labelIndex = yLabels - row

                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
                        .font(.caption2.weight(.semibold))
                        .monospacedDigit()
                        .lineLimit(1)
                        .minimumScaleFactor(0.72)
                        .frame(width: max(geometry.size.width - 6, 0))
                        .position(
                            x: geometry.size.width / 2,
                            y: yGuidePosition(
                                for: labelIndex,
                                context: context,
                                height: geometry.size.height
                            )
                        )
                }

                Text(measurementUnit)
                    .font(.caption2.weight(.bold))
                    .foregroundColor(tint)
                    .padding(.horizontal, 6)
                    .padding(.vertical, 4)
                    .background(
                        Capsule(style: .continuous)
                            .fill(tint.opacity(0.14))
                    )
                    .padding(.top, 6)
            }
        }
        .frame(height: height)
        .background(
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .fill(tint.opacity(0.12))
        )
        .overlay(
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .stroke(tint.opacity(0.20), lineWidth: 1)
        )
    }
    
    fileprivate func horizontalGuides(context: ChartContext) -> 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..<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: path.currentPoint!.x, y: geometry.size.height) )
                }
            }
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
        }
    }
    
}

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()
            }
        }
    }
    
}