// // MeasurementChartView.swift // USB Meter // // Created by Bogdan Timofte on 06/05/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import SwiftUI private enum PresentTrackingMode: CaseIterable, Hashable { case keepDuration case keepStartTimestamp } struct MeasurementChartView: View { private enum SeriesKind { case power case voltage case current case temperature var unit: String { switch self { case .power: return "W" case .voltage: return "V" case .current: return "A" case .temperature: return "" } } var tint: Color { switch self { case .power: return .red case .voltage: return .green case .current: return .blue case .temperature: return .orange } } } 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 minimumTemperatureSpan = 1.0 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 var displayTemperature: Bool = false @State private var showResetConfirmation: Bool = false @State private var chartNow: Date = Date() @State private var selectedVisibleTimeRange: ClosedRange? @State private var isPinnedToPresent: Bool = false @State private var presentTrackingMode: PresentTrackingMode = .keepDuration @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 @State private var temperatureAxisOrigin: 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 isIPhone: Bool { #if os(iOS) return UIDevice.current.userInterfaceIdiom == .phone #else return false #endif } private enum OriginControlsPlacement { case aboveXAxisLegend case overXAxisLegend case belowXAxisLegend } private var originControlsPlacement: OriginControlsPlacement { if isIPhone { return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend } return .belowXAxisLegend } 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 { if isIPhone && isPortraitLayout { return .caption } return isLargeDisplay ? .callout : .footnote } private var usesCompactLandscapeOriginControls: Bool { isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740 } var body: some View { let availableTimeRange = availableSelectionTimeRange() let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange) let powerSeries = series( for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan, visibleTimeRange: visibleTimeRange ) let voltageSeries = series( for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan, visibleTimeRange: visibleTimeRange ) let currentSeries = series( for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan, visibleTimeRange: visibleTimeRange ) let temperatureSeries = series( for: measurements.temperature, kind: .temperature, minimumYSpan: minimumTemperatureSpan, visibleTimeRange: visibleTimeRange ) let primarySeries = displayedPrimarySeries( powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) } 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, temperatureSeries: temperatureSeries ) } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .frame(maxWidth: .infinity) .frame(height: plotHeight) secondaryAxisView( height: plotHeight, powerSeries: powerSeries, voltageSeries: voltageSeries, currentSeries: currentSeries, temperatureSeries: temperatureSeries ) .frame(width: axisColumnWidth, height: plotHeight) } .overlay(alignment: .bottom) { if originControlsPlacement == .aboveXAxisLegend { scaleControlsPill( voltageSeries: voltageSeries, currentSeries: currentSeries ) .padding(.bottom, compactLayout ? 6 : 10) } } switch originControlsPlacement { case .aboveXAxisLegend: xAxisLabelsView(context: primarySeries.context) .frame(height: xAxisHeight) case .overXAxisLegend: xAxisLabelsView(context: primarySeries.context) .frame(height: xAxisHeight) .overlay(alignment: .center) { scaleControlsPill( voltageSeries: voltageSeries, currentSeries: currentSeries ) .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10)) } case .belowXAxisLegend: xAxisLabelsView(context: primarySeries.context) .frame(height: xAxisHeight) HStack { Spacer(minLength: 0) scaleControlsPill( voltageSeries: voltageSeries, currentSeries: currentSeries ) Spacer(minLength: 0) } } if let availableTimeRange, let selectorSeries, shouldShowRangeSelector( availableTimeRange: availableTimeRange, series: selectorSeries ) { TimeRangeSelectorView( points: selectorSeries.points, context: selectorSeries.context, availableTimeRange: availableTimeRange, accentColor: selectorSeries.kind.tint, compactLayout: compactLayout, minimumSelectionSpan: minimumTimeSpan, selectedTimeRange: $selectedVisibleTimeRange, isPinnedToPresent: $isPinnedToPresent, presentTrackingMode: $presentTrackingMode ) } } .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 let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10)) let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8)) return originControlsRow( voltageSeries: voltageSeries, currentSeries: currentSeries, condensedLayout: condensedLayout, showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls ) .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) .background( Capsule(style: .continuous) .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear) ) .overlay( Capsule(style: .continuous) .stroke( showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear, 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 if displayTemperature && displayCurrent { displayCurrent = false } } } seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) { displayCurrent.toggle() if displayCurrent { displayPower = false if displayTemperature && displayVoltage { displayVoltage = false } } } seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) { displayPower.toggle() if displayPower { displayCurrent = false displayVoltage = false } } seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) { displayTemperature.toggle() if displayTemperature && displayVoltage && displayCurrent { displayCurrent = false } } } } private func originControlsRow( voltageSeries: SeriesData, currentSeries: SeriesData, condensedLayout: Bool, showsLabel: Bool ) -> some View { HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) { if supportsSharedOrigin { symbolControlChip( systemImage: "equal.circle", enabled: true, active: useSharedOrigin, 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 ) } if !pinnedOriginIsZero { symbolControlChip( systemImage: "0.circle", enabled: true, active: false, 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, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12)) .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))) } else { Image(systemName: systemImage) .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold)) .frame( width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)), height: usesCompactLandscapeOriginControls ? 30 : (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, temperatureSeries: 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) } } if displayTemperature { Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange) .opacity(0.86) } } @ViewBuilder private func secondaryAxisView( height: CGFloat, powerSeries: SeriesData, voltageSeries: SeriesData, currentSeries: SeriesData, temperatureSeries: SeriesData ) -> some View { if displayTemperature { yAxisLabelsView( height: height, context: temperatureSeries.context, seriesKind: .temperature, measurementUnit: measurementUnit(for: .temperature), tint: temperatureSeries.kind.tint ) } else 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, visibleTimeRange: ClosedRange? = nil ) -> SeriesData { let points = filteredPoints( measurement, visibleTimeRange: visibleTimeRange ) let samplePoints = points.filter { $0.isSample } let context = ChartContext() let autoBounds = automaticYBounds( for: samplePoints, minimumYSpan: minimumYSpan ) let xBounds = xBounds( for: samplePoints, visibleTimeRange: visibleTimeRange ) 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 func overviewSeries(for kind: SeriesKind) -> SeriesData { series( for: measurement(for: kind), kind: kind, minimumYSpan: minimumYSpan(for: kind) ) } private func measurement(for kind: SeriesKind) -> Measurements.Measurement { switch kind { case .power: return measurements.power case .voltage: return measurements.voltage case .current: return measurements.current case .temperature: return measurements.temperature } } private func minimumYSpan(for kind: SeriesKind) -> Double { switch kind { case .power: return minimumPowerSpan case .voltage: return minimumVoltageSpan case .current: return minimumCurrentSpan case .temperature: return minimumTemperatureSpan } } 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 } if displayTemperature { temperatureAxisOrigin = 0 } } pinOrigin = true } private func captureCurrentOrigins( voltageSeries: SeriesData, currentSeries: SeriesData ) { powerAxisOrigin = displayedLowerBoundForSeries(.power) voltageAxisOrigin = voltageSeries.autoLowerBound currentAxisOrigin = currentSeries.autoLowerBound temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature) sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin) sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound) ensureSharedScaleSpan() } private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double { let visibleTimeRange = activeVisibleTimeRange switch kind { case .power: return pinOrigin ? powerAxisOrigin : automaticYBounds( for: filteredSamplePoints( measurements.power, visibleTimeRange: visibleTimeRange ), minimumYSpan: minimumPowerSpan ).lowerBound case .voltage: if pinOrigin && useSharedOrigin && supportsSharedOrigin { return sharedAxisOrigin } return pinOrigin ? voltageAxisOrigin : automaticYBounds( for: filteredSamplePoints( measurements.voltage, visibleTimeRange: visibleTimeRange ), minimumYSpan: minimumVoltageSpan ).lowerBound case .current: if pinOrigin && useSharedOrigin && supportsSharedOrigin { return sharedAxisOrigin } return pinOrigin ? currentAxisOrigin : automaticYBounds( for: filteredSamplePoints( measurements.current, visibleTimeRange: visibleTimeRange ), minimumYSpan: minimumCurrentSpan ).lowerBound case .temperature: return pinOrigin ? temperatureAxisOrigin : automaticYBounds( for: filteredSamplePoints( measurements.temperature, visibleTimeRange: visibleTimeRange ), minimumYSpan: minimumTemperatureSpan ).lowerBound } } private var activeVisibleTimeRange: ClosedRange? { resolvedVisibleTimeRange(within: availableSelectionTimeRange()) } private func filteredPoints( _ measurement: Measurements.Measurement, visibleTimeRange: ClosedRange? = nil ) -> [Measurements.Measurement.Point] { measurement.points.filter { point in guard timeRange?.contains(point.timestamp) ?? true else { return false } return visibleTimeRange?.contains(point.timestamp) ?? true } } private func filteredSamplePoints( _ measurement: Measurements.Measurement, visibleTimeRange: ClosedRange? = nil ) -> [Measurements.Measurement.Point] { filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in point.isSample } } private func xBounds( for samplePoints: [Measurements.Measurement.Point], visibleTimeRange: ClosedRange? = nil ) -> ClosedRange { if let visibleTimeRange { return normalizedTimeRange(visibleTimeRange) } if let timeRange { return normalizedTimeRange(timeRange) } let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow) let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan) return normalizedTimeRange(lowerBound...upperBound) } private func availableSelectionTimeRange() -> ClosedRange? { if let timeRange { return normalizedTimeRange(timeRange) } let samplePoints = timelineSamplePoints() guard let lowerBound = samplePoints.first?.timestamp else { return nil } let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow) return normalizedTimeRange(lowerBound...upperBound) } private func timelineSamplePoints() -> [Measurements.Measurement.Point] { let candidates = [ filteredSamplePoints(measurements.power), filteredSamplePoints(measurements.voltage), filteredSamplePoints(measurements.current), filteredSamplePoints(measurements.temperature) ] return candidates.first(where: { !$0.isEmpty }) ?? [] } private func resolvedVisibleTimeRange( within availableTimeRange: ClosedRange? ) -> ClosedRange? { guard let availableTimeRange else { return nil } guard let selectedVisibleTimeRange else { return availableTimeRange } if isPinnedToPresent { let pinnedRange: ClosedRange switch presentTrackingMode { case .keepDuration: let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound) pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound case .keepStartTimestamp: pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound } return clampedTimeRange(pinnedRange, within: availableTimeRange) } return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange) } private func clampedTimeRange( _ candidateRange: ClosedRange, within bounds: ClosedRange ) -> ClosedRange { let normalizedBounds = normalizedTimeRange(bounds) let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound) guard boundsSpan > 0 else { return normalizedBounds } let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan) let requestedSpan = min( max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan), boundsSpan ) if requestedSpan >= boundsSpan { return normalizedBounds } var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound) var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound) if upperBound.timeIntervalSince(lowerBound) < requestedSpan { if lowerBound == normalizedBounds.lowerBound { upperBound = lowerBound.addingTimeInterval(requestedSpan) } else { lowerBound = upperBound.addingTimeInterval(-requestedSpan) } } if upperBound > normalizedBounds.upperBound { let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound) upperBound = normalizedBounds.upperBound lowerBound = lowerBound.addingTimeInterval(-delta) } if lowerBound < normalizedBounds.lowerBound { let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound) lowerBound = normalizedBounds.lowerBound upperBound = upperBound.addingTimeInterval(delta) } return lowerBound...upperBound } private func normalizedTimeRange(_ range: ClosedRange) -> ClosedRange { let span = range.upperBound.timeIntervalSince(range.lowerBound) guard span < minimumTimeSpan else { return range } let expansion = (minimumTimeSpan - span) / 2 return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion) } private func shouldShowRangeSelector( availableTimeRange: ClosedRange, series: SeriesData ) -> Bool { series.samplePoints.count > 1 && availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan } 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 case .temperature: return temperatureAxisOrigin } } 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) } if kind == .temperature { return autoUpperBound } 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)) case .temperature: temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature)) } } 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 case .temperature: temperatureAxisOrigin = 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 { let visibleTimeRange = activeVisibleTimeRange switch kind { case .power: return snappedOriginValue( filteredSamplePoints( measurements.power, visibleTimeRange: visibleTimeRange ).map(\.value).min() ?? 0 ) case .voltage: return snappedOriginValue( filteredSamplePoints( measurements.voltage, visibleTimeRange: visibleTimeRange ).map(\.value).min() ?? 0 ) case .current: return snappedOriginValue( filteredSamplePoints( measurements.current, visibleTimeRange: visibleTimeRange ).map(\.value).min() ?? 0 ) case .temperature: return snappedOriginValue( filteredSamplePoints( measurements.temperature, visibleTimeRange: visibleTimeRange ).map(\.value).min() ?? 0 ) } } private func maximumVisibleSharedOrigin() -> Double { min( maximumVisibleOrigin(for: .voltage), maximumVisibleOrigin(for: .current) ) } private func measurementUnit(for kind: SeriesKind) -> String { switch kind { case .temperature: let locale = Locale.autoupdatingCurrent if #available(iOS 16.0, *) { switch locale.measurementSystem { case .us: return "°F" default: return "°C" } } let regionCode = locale.regionCode ?? "" let fahrenheitRegions: Set = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"] return fahrenheitRegions.contains(regionCode) ? "°F" : "°C" default: return kind.unit } } 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!) } let axisLabelFont: Font = { if isIPhone && isPortraitLayout { return .caption2.weight(.semibold) } return (isLargeDisplay ? Font.callout : .caption).weight(.semibold) }() 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(axisLabelFont) .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 { let yAxisFont: Font = { if isIPhone && isPortraitLayout { return .caption2.weight(.semibold) } return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold) }() let unitFont: Font = { if isIPhone && isPortraitLayout { return .caption2.weight(.bold) } return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold) }() return 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])) } } } private struct TimeRangeSelectorView: View { private enum DragTarget { case lowerBound case upperBound case window } private struct DragState { let target: DragTarget let initialRange: ClosedRange } let points: [Measurements.Measurement.Point] let context: ChartContext let availableTimeRange: ClosedRange let accentColor: Color let compactLayout: Bool let minimumSelectionSpan: TimeInterval @Binding var selectedTimeRange: ClosedRange? @Binding var isPinnedToPresent: Bool @Binding var presentTrackingMode: PresentTrackingMode @State private var dragState: DragState? private var totalSpan: TimeInterval { availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) } private var currentRange: ClosedRange { resolvedSelectionRange() } private var trackHeight: CGFloat { compactLayout ? 72 : 86 } private var cornerRadius: CGFloat { compactLayout ? 14 : 16 } private var summaryFont: Font { compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold) } private var boundaryFont: Font { compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold) } private var symbolButtonSize: CGFloat { compactLayout ? 28 : 32 } var body: some View { let coversFullRange = selectionCoversFullRange(currentRange) VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) { if !coversFullRange || isPinnedToPresent { HStack(spacing: 8) { alignmentButton( systemName: "arrow.left.to.line.compact", isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent, action: alignSelectionToLeadingEdge, accessibilityLabel: "Align selection to start" ) alignmentButton( systemName: "arrow.right.to.line.compact", isActive: isPinnedToPresent || selectionTouchesPresent(currentRange), action: alignSelectionToTrailingEdge, accessibilityLabel: "Align selection to present" ) Spacer(minLength: 0) if isPinnedToPresent { trackingModeToggleButton() } } } GeometryReader { geometry in let selectionFrame = selectionFrame(in: geometry.size) let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58) ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(Color.primary.opacity(0.05)) Chart( points: points, context: context, areaChart: true, strokeColor: accentColor, areaFillColor: accentColor.opacity(0.22) ) .opacity(0.94) .allowsHitTesting(false) Chart( points: points, context: context, strokeColor: accentColor.opacity(0.56) ) .opacity(0.82) .allowsHitTesting(false) if selectionFrame.minX > 0 { Rectangle() .fill(dimmingColor) .frame(width: selectionFrame.minX, height: geometry.size.height) .allowsHitTesting(false) } if selectionFrame.maxX < geometry.size.width { Rectangle() .fill(dimmingColor) .frame( width: max(geometry.size.width - selectionFrame.maxX, 0), height: geometry.size.height ) .offset(x: selectionFrame.maxX) .allowsHitTesting(false) } RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous) .fill(accentColor.opacity(0.18)) .frame(width: max(selectionFrame.width, 2), height: geometry.size.height) .offset(x: selectionFrame.minX) .allowsHitTesting(false) RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous) .stroke(accentColor.opacity(0.52), lineWidth: 1.2) .frame(width: max(selectionFrame.width, 2), height: geometry.size.height) .offset(x: selectionFrame.minX) .allowsHitTesting(false) handleView(height: max(geometry.size.height - 18, 16)) .offset(x: max(selectionFrame.minX + 6, 6), y: 9) .allowsHitTesting(false) handleView(height: max(geometry.size.height - 18, 16)) .offset(x: max(selectionFrame.maxX - 12, 6), y: 9) .allowsHitTesting(false) } .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.secondary.opacity(0.18), lineWidth: 1) ) .contentShape(Rectangle()) .gesture(selectionGesture(totalWidth: geometry.size.width)) } .frame(height: trackHeight) HStack { Text(boundaryLabel(for: availableTimeRange.lowerBound)) Spacer(minLength: 0) Text(boundaryLabel(for: availableTimeRange.upperBound)) } .font(boundaryFont) .foregroundColor(.secondary) .monospacedDigit() } } private func handleView(height: CGFloat) -> some View { Capsule(style: .continuous) .fill(Color.white.opacity(0.95)) .frame(width: 6, height: height) .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1) } private func alignmentButton( systemName: String, isActive: Bool, action: @escaping () -> Void, accessibilityLabel: String ) -> some View { Button(action: action) { Image(systemName: systemName) .font(.system(size: compactLayout ? 12 : 13, weight: .semibold)) .frame(width: symbolButtonSize, height: symbolButtonSize) } .buttonStyle(.plain) .foregroundColor(isActive ? .white : accentColor) .background( RoundedRectangle(cornerRadius: 9, style: .continuous) .fill(isActive ? accentColor : accentColor.opacity(0.14)) ) .overlay( RoundedRectangle(cornerRadius: 9, style: .continuous) .stroke(accentColor.opacity(0.28), lineWidth: 1) ) .accessibilityLabel(accessibilityLabel) } private func trackingModeToggleButton() -> some View { Button { presentTrackingMode = presentTrackingMode == .keepDuration ? .keepStartTimestamp : .keepDuration } label: { Image(systemName: trackingModeSymbolName) .font(.system(size: compactLayout ? 12 : 13, weight: .semibold)) .frame(width: symbolButtonSize, height: symbolButtonSize) } .buttonStyle(.plain) .foregroundColor(.white) .background( RoundedRectangle(cornerRadius: 9, style: .continuous) .fill(accentColor) ) .overlay( RoundedRectangle(cornerRadius: 9, style: .continuous) .stroke(accentColor.opacity(0.28), lineWidth: 1) ) .accessibilityLabel(trackingModeAccessibilityLabel) .accessibilityHint("Toggles how the interval follows the present") } private var trackingModeSymbolName: String { switch presentTrackingMode { case .keepDuration: return "arrow.left.and.right" case .keepStartTimestamp: return "arrow.left.to.line.compact" } } private var trackingModeAccessibilityLabel: String { switch presentTrackingMode { case .keepDuration: return "Follow present keeping span" case .keepStartTimestamp: return "Follow present keeping start" } } private func alignSelectionToLeadingEdge() { let alignedRange = normalizedSelectionRange( availableTimeRange.lowerBound...currentRange.upperBound ) applySelection(alignedRange, pinToPresent: false) } private func alignSelectionToTrailingEdge() { let alignedRange = normalizedSelectionRange( currentRange.lowerBound...availableTimeRange.upperBound ) applySelection(alignedRange, pinToPresent: true) } private func selectionGesture(totalWidth: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in updateSelectionDrag(value: value, totalWidth: totalWidth) } .onEnded { _ in dragState = nil } } private func updateSelectionDrag( value: DragGesture.Value, totalWidth: CGFloat ) { let startingRange = resolvedSelectionRange() if dragState == nil { dragState = DragState( target: dragTarget( for: value.startLocation.x, selectionFrame: selectionFrame(for: startingRange, width: totalWidth) ), initialRange: startingRange ) } guard let dragState else { return } let resultingRange = snappedToEdges( adjustedRange( from: dragState.initialRange, target: dragState.target, translationX: value.translation.width, totalWidth: totalWidth ), target: dragState.target, totalWidth: totalWidth ) applySelection( resultingRange, pinToPresent: shouldKeepPresentPin( during: dragState.target, initialRange: dragState.initialRange, resultingRange: resultingRange ), ) } private func dragTarget( for startX: CGFloat, selectionFrame: CGRect ) -> DragTarget { let handleZone: CGFloat = compactLayout ? 20 : 24 if abs(startX - selectionFrame.minX) <= handleZone { return .lowerBound } if abs(startX - selectionFrame.maxX) <= handleZone { return .upperBound } if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) { return .window } return startX < selectionFrame.minX ? .lowerBound : .upperBound } private func adjustedRange( from initialRange: ClosedRange, target: DragTarget, translationX: CGFloat, totalWidth: CGFloat ) -> ClosedRange { guard totalSpan > 0, totalWidth > 0 else { return availableTimeRange } let delta = TimeInterval(translationX / totalWidth) * totalSpan let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan) switch target { case .lowerBound: let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan) let newLowerBound = min( max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound), maximumLowerBound ) return normalizedSelectionRange(newLowerBound...initialRange.upperBound) case .upperBound: let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan) let newUpperBound = max( min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound), minimumUpperBound ) return normalizedSelectionRange(initialRange.lowerBound...newUpperBound) case .window: let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound) guard span < totalSpan else { return availableTimeRange } var lowerBound = initialRange.lowerBound.addingTimeInterval(delta) var upperBound = initialRange.upperBound.addingTimeInterval(delta) if lowerBound < availableTimeRange.lowerBound { upperBound = upperBound.addingTimeInterval( availableTimeRange.lowerBound.timeIntervalSince(lowerBound) ) lowerBound = availableTimeRange.lowerBound } if upperBound > availableTimeRange.upperBound { lowerBound = lowerBound.addingTimeInterval( -upperBound.timeIntervalSince(availableTimeRange.upperBound) ) upperBound = availableTimeRange.upperBound } return normalizedSelectionRange(lowerBound...upperBound) } } private func snappedToEdges( _ candidateRange: ClosedRange, target: DragTarget, totalWidth: CGFloat ) -> ClosedRange { guard totalSpan > 0 else { return availableTimeRange } let snapInterval = edgeSnapInterval(for: totalWidth) let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound) var lowerBound = candidateRange.lowerBound var upperBound = candidateRange.upperBound if target != .upperBound, lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval { lowerBound = availableTimeRange.lowerBound if target == .window { upperBound = lowerBound.addingTimeInterval(selectionSpan) } } if target != .lowerBound, availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval { upperBound = availableTimeRange.upperBound if target == .window { lowerBound = upperBound.addingTimeInterval(-selectionSpan) } } return normalizedSelectionRange(lowerBound...upperBound) } private func edgeSnapInterval( for totalWidth: CGFloat ) -> TimeInterval { guard totalWidth > 0 else { return minimumSelectionSpan } let snapWidth = min( max(compactLayout ? 18 : 22, totalWidth * 0.04), totalWidth * 0.18 ) let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan return min( max(intervalFromWidth, minimumSelectionSpan * 0.5), totalSpan / 4 ) } private func resolvedSelectionRange() -> ClosedRange { guard let selectedTimeRange else { return availableTimeRange } if isPinnedToPresent { switch presentTrackingMode { case .keepDuration: let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound) return normalizedSelectionRange( availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound ) case .keepStartTimestamp: return normalizedSelectionRange( selectedTimeRange.lowerBound...availableTimeRange.upperBound ) } } return normalizedSelectionRange(selectedTimeRange) } private func normalizedSelectionRange( _ candidateRange: ClosedRange ) -> ClosedRange { let availableSpan = totalSpan guard availableSpan > 0 else { return availableTimeRange } let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan) let requestedSpan = min( max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan), availableSpan ) if requestedSpan >= availableSpan { return availableTimeRange } var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound) var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound) if upperBound.timeIntervalSince(lowerBound) < requestedSpan { if lowerBound == availableTimeRange.lowerBound { upperBound = lowerBound.addingTimeInterval(requestedSpan) } else { lowerBound = upperBound.addingTimeInterval(-requestedSpan) } } if upperBound > availableTimeRange.upperBound { let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound) upperBound = availableTimeRange.upperBound lowerBound = lowerBound.addingTimeInterval(-delta) } if lowerBound < availableTimeRange.lowerBound { let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound) lowerBound = availableTimeRange.lowerBound upperBound = upperBound.addingTimeInterval(delta) } return lowerBound...upperBound } private func shouldKeepPresentPin( during target: DragTarget, initialRange: ClosedRange, resultingRange: ClosedRange ) -> Bool { let startedPinnedToPresent = isPinnedToPresent || selectionCoversFullRange(initialRange) guard startedPinnedToPresent else { return selectionTouchesPresent(resultingRange) } switch target { case .lowerBound: return true case .upperBound, .window: return selectionTouchesPresent(resultingRange) } } private func applySelection( _ candidateRange: ClosedRange, pinToPresent: Bool ) { let normalizedRange = normalizedSelectionRange(candidateRange) if selectionCoversFullRange(normalizedRange) && !pinToPresent { selectedTimeRange = nil } else { selectedTimeRange = normalizedRange } isPinnedToPresent = pinToPresent } private func selectionTouchesPresent( _ range: ClosedRange ) -> Bool { let tolerance = max(0.5, minimumSelectionSpan * 0.25) return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance } private func selectionCoversFullRange( _ range: ClosedRange ) -> Bool { let tolerance = max(0.5, minimumSelectionSpan * 0.25) return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance && abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance } private func selectionFrame(in size: CGSize) -> CGRect { selectionFrame(for: currentRange, width: size.width) } private func selectionFrame( for range: ClosedRange, width: CGFloat ) -> CGRect { guard width > 0, totalSpan > 0 else { return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight)) } let minimumX = xPosition(for: range.lowerBound, width: width) let maximumX = xPosition(for: range.upperBound, width: width) return CGRect( x: minimumX, y: 0, width: max(maximumX - minimumX, 2), height: trackHeight ) } private func xPosition(for date: Date, width: CGFloat) -> CGFloat { guard width > 0, totalSpan > 0 else { return 0 } let offset = date.timeIntervalSince(availableTimeRange.lowerBound) let normalizedOffset = min(max(offset / totalSpan, 0), 1) return CGFloat(normalizedOffset) * width } private func boundaryLabel(for date: Date) -> String { date.format(as: boundaryDateFormat) } private func selectionSummary( for range: ClosedRange ) -> String { "\(range.lowerBound.format(as: summaryDateFormat)) - \(range.upperBound.format(as: summaryDateFormat))" } private var boundaryDateFormat: String { switch totalSpan { case 0..<86400: return "HH:mm" case 86400..<604800: return "MMM d HH:mm" default: return "MMM d" } } private var summaryDateFormat: String { switch totalSpan { case 0..<3600: return "HH:mm:ss" case 3600..<172800: return "MMM d HH:mm" default: return "MMM d" } } } struct Chart : View { let points: [Measurements.Measurement.Point] let context: ChartContext var areaChart: Bool = false var strokeColor: Color = .black var areaFillColor: Color? = nil var body : some View { GeometryReader { geometry in if self.areaChart { let fillColor = areaFillColor ?? strokeColor.opacity(0.2) self.path( geometry: geometry ) .fill( LinearGradient( gradient: .init( colors: [ fillColor.opacity(0.72), fillColor.opacity(0.18) ] ), startPoint: .init(x: 0.5, y: 0.08), endPoint: .init(x: 0.5, y: 0.92) ) ) } 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() } } } }