USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
1 contributor
1214 lines | 44.162kb
//
//  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 enum SeriesKind {
        case power
        case voltage
        case current

        var unit: String {
            switch self {
            case .power: return "W"
            case .voltage: return "V"
            case .current: return "A"
            }
        }

        var tint: Color {
            switch self {
            case .power: return .red
            case .voltage: return .green
            case .current: return .blue
            }
        }
    }

    private struct SeriesData {
        let kind: SeriesKind
        let points: [Measurements.Measurement.Point]
        let samplePoints: [Measurements.Measurement.Point]
        let context: ChartContext
        let autoLowerBound: Double
        let autoUpperBound: Double
        let maximumSampleValue: Double?
    }

    private let minimumTimeSpan: TimeInterval = 1
    private let minimumVoltageSpan = 0.5
    private let minimumCurrentSpan = 0.5
    private let minimumPowerSpan = 0.5
    private let defaultEmptyChartTimeSpan: TimeInterval = 60

    let compactLayout: Bool
    let availableSize: CGSize
    
    @EnvironmentObject private var measurements: Measurements
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass
    var timeRange: ClosedRange<Date>? = nil
    
    @State var displayVoltage: Bool = false
    @State var displayCurrent: Bool = false
    @State var displayPower: Bool = true
    @State private var showResetConfirmation: Bool = false
    @State private var chartNow: Date = Date()
    @State private var pinOrigin: Bool = false
    @State private var useSharedOrigin: Bool = false
    @State private var sharedAxisOrigin: Double = 0
    @State private var sharedAxisUpperBound: Double = 1
    @State private var powerAxisOrigin: Double = 0
    @State private var voltageAxisOrigin: Double = 0
    @State private var currentAxisOrigin: Double = 0
    let xLabels: Int = 4
    let yLabels: Int = 4

    init(
        compactLayout: Bool = false,
        availableSize: CGSize = .zero,
        timeRange: ClosedRange<Date>? = nil
    ) {
        self.compactLayout = compactLayout
        self.availableSize = availableSize
        self.timeRange = timeRange
    }

    private var axisColumnWidth: CGFloat {
        if compactLayout {
            return 38
        }
        return isLargeDisplay ? 62 : 46
    }

    private var chartSectionSpacing: CGFloat {
        compactLayout ? 6 : 8
    }

    private var xAxisHeight: CGFloat {
        if compactLayout {
            return 24
        }
        return isLargeDisplay ? 36 : 28
    }

    private var isPortraitLayout: Bool {
        guard availableSize != .zero else { return verticalSizeClass != .compact }
        return availableSize.height >= availableSize.width
    }

    private var plotSectionHeight: CGFloat {
        if availableSize == .zero {
            return compactLayout ? 300 : 380
        }

        if isPortraitLayout {
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
            return minimumPlotHeight + xAxisHeight
        }

        if compactLayout {
            return min(max(availableSize.height * 0.36, 240), 300)
        }

        return min(max(availableSize.height * 0.5, 300), 440)
    }

    private var stackedToolbarLayout: Bool {
        if availableSize.width > 0 {
            return availableSize.width < 640
        }

        return horizontalSizeClass == .compact && verticalSizeClass != .compact
    }

    private var showsLabeledOriginControls: Bool {
        !compactLayout && !stackedToolbarLayout
    }

    private var isLargeDisplay: Bool {
        #if os(iOS)
        if UIDevice.current.userInterfaceIdiom == .phone {
            return false
        }
        #endif

        if availableSize.width > 0 {
            return availableSize.width >= 900 || availableSize.height >= 700
        }
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
    }

    private var chartBaseFont: Font {
        isLargeDisplay ? .callout : .footnote
    }

    var body: some View {
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
        let currentSeries = series(for: measurements.current, kind: .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, compactLayout ? 180 : 220)

                        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)
                                    discontinuityMarkers(points: primarySeries.points, 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)
                            }
                            .overlay(alignment: .bottom) {
                                scaleControlsPill(
                                    voltageSeries: voltageSeries,
                                    currentSeries: currentSeries
                                )
                                .padding(.bottom, compactLayout ? 6 : 10)
                            }

                            xAxisLabelsView(context: primarySeries.context)
                            .frame(height: xAxisHeight)
                        }
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
                    }
                    .frame(height: plotSectionHeight)
                }
            } else {
                VStack(alignment: .leading, spacing: 12) {
                    chartToggleBar()
                    Text("Select at least one measurement series.")
                        .foregroundColor(.secondary)
                }
            }
        }
        .font(chartBaseFont)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
            guard timeRange == nil else { return }
            chartNow = now
        }
    }

    private func chartToggleBar() -> some View {
        let condensedLayout = compactLayout || verticalSizeClass == .compact
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)

        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
            seriesToggleRow(condensedLayout: condensedLayout)
        }
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
        .background(
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
                .fill(Color.primary.opacity(0.045))
        )
        .overlay(
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
        )

        return Group {
            if stackedToolbarLayout {
                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
                    controlsPanel
                    HStack {
                        Spacer(minLength: 0)
                        resetBufferButton(condensedLayout: condensedLayout)
                    }
                }
            } else {
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
                    controlsPanel
                    Spacer(minLength: 0)
                    resetBufferButton(condensedLayout: condensedLayout)
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    private var shouldFloatScaleControlsOverChart: Bool {
        #if os(iOS)
        if availableSize.width > 0, availableSize.height > 0 {
            return availableSize.width > availableSize.height
        }
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
        #else
        return false
        #endif
    }

    private func scaleControlsPill(
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) -> some View {
        let condensedLayout = compactLayout || verticalSizeClass == .compact

        return originControlsRow(
            voltageSeries: voltageSeries,
            currentSeries: currentSeries,
            condensedLayout: condensedLayout,
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
        )
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
        .background(
            Capsule(style: .continuous)
                .fill(shouldFloatScaleControlsOverChart ? Color.clear : Color.primary.opacity(0.08))
        )
        .overlay(
            Capsule(style: .continuous)
                .stroke(
                    shouldFloatScaleControlsOverChart ? Color.clear : Color.secondary.opacity(0.18),
                    lineWidth: 1
                )
        )
    }

    private func seriesToggleRow(condensedLayout: Bool) -> some View {
        HStack(spacing: condensedLayout ? 6 : 8) {
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
                displayVoltage.toggle()
                if displayVoltage {
                    displayPower = false
                }
            }

            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
                displayCurrent.toggle()
                if displayCurrent {
                    displayPower = false
                }
            }

            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
                displayPower.toggle()
                if displayPower {
                    displayCurrent = false
                    displayVoltage = false
                }
            }
        }
    }

    private func originControlsRow(
        voltageSeries: SeriesData,
        currentSeries: SeriesData,
        condensedLayout: Bool,
        showsLabel: Bool
    ) -> some View {
        HStack(spacing: condensedLayout ? 8 : 10) {
            symbolControlChip(
                systemImage: "equal.circle",
                enabled: supportsSharedOrigin,
                active: useSharedOrigin && supportsSharedOrigin,
                condensedLayout: condensedLayout,
                showsLabel: showsLabel,
                label: "Match Y Scale",
                accessibilityLabel: "Match Y scale"
            ) {
                toggleSharedOrigin(
                    voltageSeries: voltageSeries,
                    currentSeries: currentSeries
                )
            }

            symbolControlChip(
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
                enabled: true,
                active: pinOrigin,
                condensedLayout: condensedLayout,
                showsLabel: showsLabel,
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
            ) {
                togglePinnedOrigin(
                    voltageSeries: voltageSeries,
                    currentSeries: currentSeries
                )
            }

            symbolControlChip(
                systemImage: "0.circle",
                enabled: true,
                active: pinnedOriginIsZero,
                condensedLayout: condensedLayout,
                showsLabel: showsLabel,
                label: "Origin 0",
                accessibilityLabel: "Set origin to zero"
            ) {
                setVisibleOriginsToZero()
            }

        }
    }

    private func seriesToggleButton(
        title: String,
        isOn: Bool,
        condensedLayout: Bool,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            Text(title)
                .font(seriesToggleFont(condensedLayout: condensedLayout))
                .lineLimit(1)
                .minimumScaleFactor(0.82)
                .foregroundColor(isOn ? .white : .blue)
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
                .background(
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
                )
                .overlay(
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
                        .stroke(Color.blue, lineWidth: 1.5)
                )
        }
        .buttonStyle(.plain)
    }

    private func symbolControlChip(
        systemImage: String,
        enabled: Bool,
        active: Bool,
        condensedLayout: Bool,
        showsLabel: Bool,
        label: String,
        accessibilityLabel: String,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: {
            action()
        }) {
            Group {
                if showsLabel {
                    Label(label, systemImage: systemImage)
                        .font(controlChipFont(condensedLayout: condensedLayout))
                        .padding(.horizontal, condensedLayout ? 10 : 12)
                        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))
                } else {
                    Image(systemName: systemImage)
                        .font(.system(size: condensedLayout ? 15 : (isLargeDisplay ? 18 : 16), weight: .semibold))
                        .frame(
                            width: condensedLayout ? 34 : (isLargeDisplay ? 42 : 38),
                            height: condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)
                        )
                }
            }
                .background(
                    Capsule(style: .continuous)
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
                )
        }
        .buttonStyle(.plain)
        .foregroundColor(enabled ? .primary : .secondary)
        .opacity(enabled ? 1 : 0.55)
        .accessibilityLabel(accessibilityLabel)
    }

    private func resetBufferButton(condensedLayout: Bool) -> some View {
        Button(action: {
            showResetConfirmation = true
        }) {
            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
                .font(controlChipFont(condensedLayout: condensedLayout))
                .padding(.horizontal, condensedLayout ? 14 : 16)
                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
        }
        .buttonStyle(.plain)
        .foregroundColor(.white)
        .background(
            Capsule(style: .continuous)
                .fill(Color.red.opacity(0.8))
        )
        .fixedSize(horizontal: true, vertical: false)
        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
            Button("Reset series", role: .destructive) {
                measurements.resetSeries()
            }
            Button("Cancel", role: .cancel) {}
        }
    }

    private func seriesToggleFont(condensedLayout: Bool) -> Font {
        if isLargeDisplay {
            return .body.weight(.semibold)
        }
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
    }

    private func controlChipFont(condensedLayout: Bool) -> Font {
        if isLargeDisplay {
            return .callout.weight(.semibold)
        }
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
    }

    @ViewBuilder
    private func primaryAxisView(
        height: CGFloat,
        powerSeries: SeriesData,
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) -> some View {
        if displayPower {
            yAxisLabelsView(
                height: height,
                context: powerSeries.context,
                seriesKind: .power,
                measurementUnit: powerSeries.kind.unit,
                tint: powerSeries.kind.tint
            )
        } else if displayVoltage {
            yAxisLabelsView(
                height: height,
                context: voltageSeries.context,
                seriesKind: .voltage,
                measurementUnit: voltageSeries.kind.unit,
                tint: voltageSeries.kind.tint
            )
        } else if displayCurrent {
            yAxisLabelsView(
                height: height,
                context: currentSeries.context,
                seriesKind: .current,
                measurementUnit: currentSeries.kind.unit,
                tint: currentSeries.kind.tint
            )
        }
    }

    @ViewBuilder
    private func renderedChart(
        powerSeries: SeriesData,
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) -> 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: SeriesData,
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) -> some View {
        if displayVoltage && displayCurrent {
            yAxisLabelsView(
                height: height,
                context: currentSeries.context,
                seriesKind: .current,
                measurementUnit: currentSeries.kind.unit,
                tint: currentSeries.kind.tint
            )
        } else {
            primaryAxisView(
                height: height,
                powerSeries: powerSeries,
                voltageSeries: voltageSeries,
                currentSeries: currentSeries
            )
        }
    }

    private func displayedPrimarySeries(
        powerSeries: SeriesData,
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) -> SeriesData? {
        if displayPower {
            return powerSeries
        }
        if displayVoltage {
            return voltageSeries
        }
        if displayCurrent {
            return currentSeries
        }
        return nil
    }

    private func series(
        for measurement: Measurements.Measurement,
        kind: SeriesKind,
        minimumYSpan: Double
    ) -> SeriesData {
        let points = measurement.points.filter { point in
            guard let timeRange else { return true }
            return timeRange.contains(point.timestamp)
        }
        let samplePoints = points.filter { $0.isSample }
        let context = ChartContext()

        let autoBounds = automaticYBounds(
            for: samplePoints,
            minimumYSpan: minimumYSpan
        )
        let xBounds = xBounds(for: samplePoints)
        let lowerBound = resolvedLowerBound(
            for: kind,
            autoLowerBound: autoBounds.lowerBound
        )
        let upperBound = resolvedUpperBound(
            for: kind,
            lowerBound: lowerBound,
            autoUpperBound: autoBounds.upperBound,
            maximumSampleValue: samplePoints.map(\.value).max(),
            minimumYSpan: minimumYSpan
        )

        context.setBounds(
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
            yMin: CGFloat(lowerBound),
            yMax: CGFloat(upperBound)
        )

        return SeriesData(
            kind: kind,
            points: points,
            samplePoints: samplePoints,
            context: context,
            autoLowerBound: autoBounds.lowerBound,
            autoUpperBound: autoBounds.upperBound,
            maximumSampleValue: samplePoints.map(\.value).max()
        )
    }

    private var supportsSharedOrigin: Bool {
        displayVoltage && displayCurrent && !displayPower
    }

    private var minimumSharedScaleSpan: Double {
        max(minimumVoltageSpan, minimumCurrentSpan)
    }

    private var pinnedOriginIsZero: Bool {
        if useSharedOrigin && supportsSharedOrigin {
            return pinOrigin && sharedAxisOrigin == 0
        }

        if displayPower {
            return pinOrigin && powerAxisOrigin == 0
        }

        let visibleOrigins = [
            displayVoltage ? voltageAxisOrigin : nil,
            displayCurrent ? currentAxisOrigin : nil
        ]
        .compactMap { $0 }

        guard !visibleOrigins.isEmpty else { return false }
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
    }

    private func toggleSharedOrigin(
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) {
        guard supportsSharedOrigin else { return }

        if useSharedOrigin {
            useSharedOrigin = false
            return
        }

        captureCurrentOrigins(
            voltageSeries: voltageSeries,
            currentSeries: currentSeries
        )
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
        ensureSharedScaleSpan()
        useSharedOrigin = true
        pinOrigin = true
    }

    private func togglePinnedOrigin(
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) {
        if pinOrigin {
            pinOrigin = false
            return
        }

        captureCurrentOrigins(
            voltageSeries: voltageSeries,
            currentSeries: currentSeries
        )
        pinOrigin = true
    }

    private func setVisibleOriginsToZero() {
        if useSharedOrigin && supportsSharedOrigin {
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
            sharedAxisOrigin = 0
            sharedAxisUpperBound = currentSpan
            voltageAxisOrigin = 0
            currentAxisOrigin = 0
            ensureSharedScaleSpan()
        } else {
            if displayPower {
                powerAxisOrigin = 0
            }
            if displayVoltage {
                voltageAxisOrigin = 0
            }
            if displayCurrent {
                currentAxisOrigin = 0
            }
        }

        pinOrigin = true
    }

    private func captureCurrentOrigins(
        voltageSeries: SeriesData,
        currentSeries: SeriesData
    ) {
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
        voltageAxisOrigin = voltageSeries.autoLowerBound
        currentAxisOrigin = currentSeries.autoLowerBound
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
        ensureSharedScaleSpan()
    }

    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
        switch kind {
        case .power:
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
        case .voltage:
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
                return sharedAxisOrigin
            }
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
        case .current:
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
                return sharedAxisOrigin
            }
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
        }
    }

    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
        measurement.points.filter { point in
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
        }
    }

    private func xBounds(
        for samplePoints: [Measurements.Measurement.Point]
    ) -> ClosedRange<Date> {
        if let timeRange {
            return timeRange
        }

        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)

        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
            return lowerBound...upperBound
        }

        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
    }

    private func automaticYBounds(
        for samplePoints: [Measurements.Measurement.Point],
        minimumYSpan: Double
    ) -> (lowerBound: Double, upperBound: Double) {
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)

        guard
            let minimumSampleValue = samplePoints.map(\.value).min(),
            let maximumSampleValue = samplePoints.map(\.value).max()
        else {
            return (0, minimumYSpan)
        }

        var lowerBound = minimumSampleValue
        var upperBound = maximumSampleValue
        let currentSpan = upperBound - lowerBound

        if currentSpan < minimumYSpan {
            let expansion = (minimumYSpan - currentSpan) / 2
            lowerBound -= expansion
            upperBound += expansion
        }

        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
            let shift = -negativeAllowance - lowerBound
            lowerBound += shift
            upperBound += shift
        }

        let snappedLowerBound = snappedOriginValue(lowerBound)
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
        return (snappedLowerBound, resolvedUpperBound)
    }

    private func resolvedLowerBound(
        for kind: SeriesKind,
        autoLowerBound: Double
    ) -> Double {
        guard pinOrigin else { return autoLowerBound }

        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
            return sharedAxisOrigin
        }

        switch kind {
        case .power:
            return powerAxisOrigin
        case .voltage:
            return voltageAxisOrigin
        case .current:
            return currentAxisOrigin
        }
    }

    private func resolvedUpperBound(
        for kind: SeriesKind,
        lowerBound: Double,
        autoUpperBound: Double,
        maximumSampleValue: Double?,
        minimumYSpan: Double
    ) -> Double {
        guard pinOrigin else {
            return autoUpperBound
        }

        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
        }

        return max(
            maximumSampleValue ?? lowerBound,
            lowerBound + minimumYSpan,
            autoUpperBound
        )
    }

    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
        let baseline = displayedLowerBoundForSeries(kind)
        let proposedOrigin = snappedOriginValue(baseline + delta)

        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
            ensureSharedScaleSpan()
        } else {
            switch kind {
            case .power:
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
            case .voltage:
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
            case .current:
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
            }
        }

        pinOrigin = true
    }

    private func clearOriginOffset(for kind: SeriesKind) {
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
            sharedAxisOrigin = 0
            sharedAxisUpperBound = currentSpan
            ensureSharedScaleSpan()
            voltageAxisOrigin = 0
            currentAxisOrigin = 0
        } else {
            switch kind {
            case .power:
                powerAxisOrigin = 0
            case .voltage:
                voltageAxisOrigin = 0
            case .current:
                currentAxisOrigin = 0
            }
        }

        pinOrigin = true
    }

    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
        guard totalHeight > 1 else { return }

        let normalized = max(0, min(1, locationY / totalHeight))
        if normalized < (1.0 / 3.0) {
            applyOriginDelta(-1, kind: kind)
        } else if normalized < (2.0 / 3.0) {
            clearOriginOffset(for: kind)
        } else {
            applyOriginDelta(1, kind: kind)
        }
    }

    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
        switch kind {
        case .power:
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
        case .voltage:
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
        case .current:
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
        }
    }

    private func maximumVisibleSharedOrigin() -> Double {
        min(
            maximumVisibleOrigin(for: .voltage),
            maximumVisibleOrigin(for: .current)
        )
    }

    private func ensureSharedScaleSpan() {
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
    }

    private func snappedOriginValue(_ value: Double) -> Double {
        if value >= 0 {
            return value.rounded(.down)
        }

        return value.rounded(.up)
    }

    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((isLargeDisplay ? Font.callout : .caption).weight(.semibold))
                            .monospacedDigit()
                            .lineLimit(1)
                            .minimumScaleFactor(0.74)
                            .frame(width: labelWidth)
                            .position(
                                x: centerX,
                                y: geometry.size.height * 0.7
                            )
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }

            Color.clear
                .frame(width: axisColumnWidth)
        }
    }
    
    private func yAxisLabelsView(
        height: CGFloat,
        context: ChartContext,
        seriesKind: SeriesKind,
        measurementUnit: String,
        tint: Color
    ) -> some View {
        GeometryReader { geometry in
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)

            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((isLargeDisplay ? Font.callout : .footnote).weight(.semibold))
                        .monospacedDigit()
                        .lineLimit(1)
                        .minimumScaleFactor(0.8)
                        .frame(width: max(geometry.size.width - 10, 0))
                        .position(
                            x: geometry.size.width / 2,
                            y: topInset + yGuidePosition(
                                for: labelIndex,
                                context: context,
                                height: labelAreaHeight
                            )
                        )
                }

                Text(measurementUnit)
                    .font((isLargeDisplay ? Font.footnote : .caption2).weight(.bold))
                    .foregroundColor(tint)
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
                    .background(
                        Capsule(style: .continuous)
                            .fill(tint.opacity(0.14))
                    )
                    .padding(.top, 8)

            }
        }
        .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)
        )
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
        .gesture(
            DragGesture(minimumDistance: 0)
                .onEnded { value in
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
                }
        )
    }
    
    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]))
        }
    }

    fileprivate func discontinuityMarkers(
        points: [Measurements.Measurement.Point],
        context: ChartContext
    ) -> some View {
        GeometryReader { geometry in
            Path { path in
                for point in points where point.isDiscontinuity {
                    let markerX = context.placeInRect(
                        point: CGPoint(
                            x: point.timestamp.timeIntervalSince1970,
                            y: context.origin.y
                        )
                    ).x * geometry.size.width
                    path.move(to: CGPoint(x: markerX, y: 0))
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
                }
            }
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 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
            var firstSample: Measurements.Measurement.Point?
            var lastSample: Measurements.Measurement.Point?
            var needsMove = true

            for point in points {
                if point.isDiscontinuity {
                    needsMove = true
                    continue
                }

                let item = context.placeInRect(point: point.point())
                let renderedPoint = CGPoint(
                    x: item.x * geometry.size.width,
                    y: item.y * geometry.size.height
                )

                if firstSample == nil {
                    firstSample = point
                }
                lastSample = point

                if needsMove {
                    path.move(to: renderedPoint)
                    needsMove = false
                } else {
                    path.addLine(to: renderedPoint)
                }
            }

            if self.areaChart, let firstSample, let lastSample {
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.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()
            }
        }
    }
    
}