// // 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? = 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? = 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 { 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.. 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.. 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() } } } }