// // 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 SmoothingLevel: CaseIterable, Hashable { case off case light case medium case strong var label: String { switch self { case .off: return "Off" case .light: return "Light" case .medium: return "Medium" case .strong: return "Strong" } } var shortLabel: String { switch self { case .off: return "Off" case .light: return "Low" case .medium: return "Med" case .strong: return "High" } } var movingAverageWindowSize: Int { switch self { case .off: return 1 case .light: return 5 case .medium: return 11 case .strong: return 21 } } } private enum SeriesKind { case power case energy case voltage case current case temperature var unit: String { switch self { case .power: return "W" case .energy: return "Wh" case .voltage: return "V" case .current: return "A" case .temperature: return "" } } var tint: Color { switch self { case .power: return .red case .energy: return .teal 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 minimumEnergySpan = 0.1 private let minimumTemperatureSpan = 1.0 private let defaultEmptyChartTimeSpan: TimeInterval = 60 private let selectorTint: Color = .blue 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 displayEnergy: Bool = false @State var displayTemperature: Bool = false @State private var smoothingLevel: SmoothingLevel = .off @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 energyAxisOrigin: 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 energySeries = series( for: measurements.energy, kind: .energy, minimumYSpan: minimumEnergySpan, 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, energySeries: energySeries, 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, energySeries: energySeries, 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, energySeries: energySeries, voltageSeries: voltageSeries, currentSeries: currentSeries, temperatureSeries: temperatureSeries ) } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .frame(maxWidth: .infinity) .frame(height: plotHeight) secondaryAxisView( height: plotHeight, powerSeries: powerSeries, energySeries: energySeries, 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, selectorTint: selectorTint, compactLayout: compactLayout, minimumSelectionSpan: minimumTimeSpan, onKeepSelection: trimBufferToSelection, onRemoveSelection: removeSelectionFromBuffer, onResetBuffer: resetBuffer, 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 { controlsPanel } else { HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) { controlsPanel } } } .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 displayEnergy = false if displayTemperature && displayCurrent { displayCurrent = false } } } seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) { displayCurrent.toggle() if displayCurrent { displayPower = false displayEnergy = false if displayTemperature && displayVoltage { displayVoltage = false } } } seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) { displayPower.toggle() if displayPower { displayEnergy = false displayCurrent = false displayVoltage = false } } seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) { displayEnergy.toggle() if displayEnergy { displayPower = false 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() } } smoothingControlChip( condensedLayout: condensedLayout, showsLabel: showsLabel ) } } private func smoothingControlChip( condensedLayout: Bool, showsLabel: Bool ) -> some View { Menu { ForEach(SmoothingLevel.allCases, id: \.self) { level in Button { smoothingLevel = level } label: { if smoothingLevel == level { Label(level.label, systemImage: "checkmark") } else { Text(level.label) } } } } label: { Group { if showsLabel { VStack(alignment: .leading, spacing: 2) { Label("Smoothing", systemImage: "waveform.path") .font(controlChipFont(condensedLayout: condensedLayout)) Text( smoothingLevel == .off ? "Off" : "MA \(smoothingLevel.movingAverageWindowSize)" ) .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold)) .foregroundColor(.secondary) .monospacedDigit() } .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12)) .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))) } else { VStack(spacing: 1) { Image(systemName: "waveform.path") .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold)) Text(smoothingLevel.shortLabel) .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold)) .monospacedDigit() } .frame( width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)), height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42)) ) } } .background( Capsule(style: .continuous) .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14)) ) .overlay( Capsule(style: .continuous) .stroke( smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24), lineWidth: 1 ) ) } .buttonStyle(.plain) .foregroundColor(smoothingLevel == .off ? .primary : .blue) } 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 resetBuffer() { measurements.resetSeries() } 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, energySeries: 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 displayEnergy { yAxisLabelsView( height: height, context: energySeries.context, seriesKind: .energy, measurementUnit: energySeries.kind.unit, tint: energySeries.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, energySeries: 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.displayEnergy { Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal) .opacity(0.78) } 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, energySeries: 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, energySeries: energySeries, voltageSeries: voltageSeries, currentSeries: currentSeries ) } } private func displayedPrimarySeries( powerSeries: SeriesData, energySeries: SeriesData, voltageSeries: SeriesData, currentSeries: SeriesData ) -> SeriesData? { if displayPower { return powerSeries } if displayEnergy { return energySeries } 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 rawPoints = filteredPoints( measurement, visibleTimeRange: visibleTimeRange ) let points = smoothedPoints(from: rawPoints) 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 smoothedPoints( from points: [Measurements.Measurement.Point] ) -> [Measurements.Measurement.Point] { guard smoothingLevel != .off else { return points } var smoothedPoints: [Measurements.Measurement.Point] = [] var currentSegment: [Measurements.Measurement.Point] = [] func flushCurrentSegment() { guard !currentSegment.isEmpty else { return } for point in smoothedSegment(currentSegment) { smoothedPoints.append( Measurements.Measurement.Point( id: smoothedPoints.count, timestamp: point.timestamp, value: point.value, kind: .sample ) ) } currentSegment.removeAll(keepingCapacity: true) } for point in points { if point.isDiscontinuity { flushCurrentSegment() if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false { smoothedPoints.append( Measurements.Measurement.Point( id: smoothedPoints.count, timestamp: point.timestamp, value: smoothedPoints.last?.value ?? point.value, kind: .discontinuity ) ) } } else { currentSegment.append(point) } } flushCurrentSegment() return smoothedPoints } private func smoothedSegment( _ segment: [Measurements.Measurement.Point] ) -> [Measurements.Measurement.Point] { let windowSize = smoothingLevel.movingAverageWindowSize guard windowSize > 1, segment.count > 2 else { return segment } let radius = windowSize / 2 var prefixSums: [Double] = [0] prefixSums.reserveCapacity(segment.count + 1) for point in segment { prefixSums.append(prefixSums[prefixSums.count - 1] + point.value) } return segment.enumerated().map { index, point in let lowerBound = max(0, index - radius) let upperBound = min(segment.count - 1, index + radius) let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound] let average = sum / Double(upperBound - lowerBound + 1) return Measurements.Measurement.Point( id: point.id, timestamp: point.timestamp, value: average, kind: .sample ) } } private func measurement(for kind: SeriesKind) -> Measurements.Measurement { switch kind { case .power: return measurements.power case .energy: return measurements.energy 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 .energy: return minimumEnergySpan case .voltage: return minimumVoltageSpan case .current: return minimumCurrentSpan case .temperature: return minimumTemperatureSpan } } private var supportsSharedOrigin: Bool { displayVoltage && displayCurrent && !displayPower && !displayEnergy } private var minimumSharedScaleSpan: Double { max(minimumVoltageSpan, minimumCurrentSpan) } private var pinnedOriginIsZero: Bool { if useSharedOrigin && supportsSharedOrigin { return pinOrigin && sharedAxisOrigin == 0 } if displayPower { return pinOrigin && powerAxisOrigin == 0 } if displayEnergy { return pinOrigin && energyAxisOrigin == 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 displayEnergy { energyAxisOrigin = 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) energyAxisOrigin = displayedLowerBoundForSeries(.energy) 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 .energy: return pinOrigin ? energyAxisOrigin : automaticYBounds( for: filteredSamplePoints( measurements.energy, visibleTimeRange: visibleTimeRange ), minimumYSpan: minimumEnergySpan ).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] { let resolvedRange: ClosedRange? switch (timeRange, visibleTimeRange) { case let (baseRange?, visibleRange?): let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound) let upperBound = min(baseRange.upperBound, visibleRange.upperBound) resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil case let (baseRange?, nil): resolvedRange = baseRange case let (nil, visibleRange?): resolvedRange = visibleRange case (nil, nil): resolvedRange = nil } guard let resolvedRange else { return timeRange == nil && visibleTimeRange == nil ? measurement.points : [] } return measurement.points(in: resolvedRange) } 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.energy), 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 .energy: return energyAxisOrigin 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 .energy: energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy)) 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 .energy: energyAxisOrigin = 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 .energy: return snappedOriginValue( filteredSamplePoints( measurements.energy, 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 trimBufferToSelection(_ range: ClosedRange) { measurements.keepOnly(in: range) selectedVisibleTimeRange = nil isPinnedToPresent = false } private func removeSelectionFromBuffer(_ range: ClosedRange) { measurements.removeValues(in: range) selectedVisibleTimeRange = nil isPinnedToPresent = false } 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 enum ActionTone { case reversible case destructive case destructiveProminent } private struct DragState { let target: DragTarget let initialRange: ClosedRange } let points: [Measurements.Measurement.Point] let context: ChartContext let availableTimeRange: ClosedRange let selectorTint: Color let compactLayout: Bool let minimumSelectionSpan: TimeInterval let onKeepSelection: (ClosedRange) -> Void let onRemoveSelection: (ClosedRange) -> Void let onResetBuffer: () -> Void @Binding var selectedTimeRange: ClosedRange? @Binding var isPinnedToPresent: Bool @Binding var presentTrackingMode: PresentTrackingMode @State private var dragState: DragState? @State private var showResetConfirmation: Bool = false 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 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() } } } HStack(spacing: 8) { if !coversFullRange { actionButton( title: compactLayout ? "Keep" : "Keep Selection", systemName: "scissors", tone: .destructive, action: { onKeepSelection(currentRange) } ) actionButton( title: compactLayout ? "Cut" : "Remove Selection", systemName: "minus.circle", tone: .destructive, action: { onRemoveSelection(currentRange) } ) } Spacer(minLength: 0) actionButton( title: compactLayout ? "Reset" : "Reset Buffer", systemName: "trash", tone: .destructiveProminent, action: { showResetConfirmation = true } ) } .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) { Button("Reset buffer", role: .destructive) { onResetBuffer() } Button("Cancel", role: .cancel) {} } 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: selectorTint, areaFillColor: selectorTint.opacity(0.22) ) .opacity(0.94) .allowsHitTesting(false) Chart( points: points, context: context, strokeColor: selectorTint.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(selectorTint.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(selectorTint.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 : selectorTint) .background( RoundedRectangle(cornerRadius: 9, style: .continuous) .fill(isActive ? selectorTint : selectorTint.opacity(0.14)) ) .overlay( RoundedRectangle(cornerRadius: 9, style: .continuous) .stroke(selectorTint.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(selectorTint) ) .overlay( RoundedRectangle(cornerRadius: 9, style: .continuous) .stroke(selectorTint.opacity(0.28), lineWidth: 1) ) .accessibilityLabel(trackingModeAccessibilityLabel) .accessibilityHint("Toggles how the interval follows the present") } private func actionButton( title: String, systemName: String, tone: ActionTone, action: @escaping () -> Void ) -> some View { let foregroundColor: Color = { switch tone { case .reversible, .destructive: return toneColor(for: tone) case .destructiveProminent: return .white } }() return Button(action: action) { Label(title, systemImage: systemName) .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)) .padding(.horizontal, compactLayout ? 10 : 12) .padding(.vertical, compactLayout ? 7 : 8) } .buttonStyle(.plain) .foregroundColor(foregroundColor) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(actionButtonBackground(for: tone)) ) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(actionButtonBorder(for: tone), lineWidth: 1) ) } private func toneColor(for tone: ActionTone) -> Color { switch tone { case .reversible: return selectorTint case .destructive, .destructiveProminent: return .red } } private func actionButtonBackground(for tone: ActionTone) -> Color { switch tone { case .reversible: return selectorTint.opacity(0.12) case .destructive: return Color.red.opacity(0.12) case .destructiveProminent: return Color.red.opacity(0.82) } } private func actionButtonBorder(for tone: ActionTone) -> Color { switch tone { case .reversible: return selectorTint.opacity(0.22) case .destructive: return Color.red.opacity(0.22) case .destructiveProminent: return Color.red.opacity(0.72) } } 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 var boundaryDateFormat: String { switch totalSpan { case 0..<86400: return "HH:mm" case 86400..<604800: return "MMM d HH:mm" default: return "MMM d" } } } struct Chart : View { @Environment(\.displayScale) private var displayScale 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 { let displayedPoints = scaledPoints(for: geometry.size.width) let baselineY = context.placeInRect( point: CGPoint(x: context.origin.x, y: context.origin.y) ).y * geometry.size.height return Path { path in var firstRenderedPoint: CGPoint? var lastRenderedPoint: CGPoint? var needsMove = true for point in displayedPoints { if point.isDiscontinuity { closeAreaSegment( in: &path, firstPoint: firstRenderedPoint, lastPoint: lastRenderedPoint, baselineY: baselineY ) firstRenderedPoint = nil lastRenderedPoint = nil 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 needsMove { path.move(to: renderedPoint) firstRenderedPoint = renderedPoint needsMove = false } else { path.addLine(to: renderedPoint) } lastRenderedPoint = renderedPoint } closeAreaSegment( in: &path, firstPoint: firstRenderedPoint, lastPoint: lastRenderedPoint, baselineY: baselineY ) } } private func closeAreaSegment( in path: inout Path, firstPoint: CGPoint?, lastPoint: CGPoint?, baselineY: CGFloat ) { guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return } path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY)) path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY)) path.closeSubpath() } private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] { let sampleCount = points.reduce(into: 0) { partialResult, point in if point.isSample { partialResult += 1 } } let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1) let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240) guard sampleCount > maximumSamplesToRender, context.isValid else { return points } var scaledPoints: [Measurements.Measurement.Point] = [] var currentSegment: [Measurements.Measurement.Point] = [] for point in points { if point.isDiscontinuity { appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns) currentSegment.removeAll(keepingCapacity: true) if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false { appendScaledPoint(point, to: &scaledPoints) } } else { currentSegment.append(point) } } appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns) return scaledPoints.isEmpty ? points : scaledPoints } private func appendScaledSegment( _ segment: [Measurements.Measurement.Point], to scaledPoints: inout [Measurements.Measurement.Point], displayColumns: Int ) { guard !segment.isEmpty else { return } if segment.count <= max(displayColumns * 2, 120) { for point in segment { appendScaledPoint(point, to: &scaledPoints) } return } var bucket: [Measurements.Measurement.Point] = [] var currentColumn: Int? for point in segment { let column = displayColumn(for: point, totalColumns: displayColumns) if let currentColumn, currentColumn != column { appendBucket(bucket, to: &scaledPoints) bucket.removeAll(keepingCapacity: true) } bucket.append(point) currentColumn = column } appendBucket(bucket, to: &scaledPoints) } private func appendBucket( _ bucket: [Measurements.Measurement.Point], to scaledPoints: inout [Measurements.Measurement.Point] ) { guard !bucket.isEmpty else { return } if bucket.count <= 2 { for point in bucket { appendScaledPoint(point, to: &scaledPoints) } return } let firstPoint = bucket.first! let lastPoint = bucket.last! let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint] .sorted { lhs, rhs in if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } return lhs.timestamp < rhs.timestamp } var emittedPointIDs: Set = [] for point in orderedPoints where emittedPointIDs.insert(point.id).inserted { appendScaledPoint(point, to: &scaledPoints) } } private func appendScaledPoint( _ point: Measurements.Measurement.Point, to scaledPoints: inout [Measurements.Measurement.Point] ) { guard !(scaledPoints.last?.timestamp == point.timestamp && scaledPoints.last?.value == point.value && scaledPoints.last?.kind == point.kind) else { return } scaledPoints.append( Measurements.Measurement.Point( id: scaledPoints.count, timestamp: point.timestamp, value: point.value, kind: point.kind ) ) } private func displayColumn( for point: Measurements.Measurement.Point, totalColumns: Int ) -> Int { let totalColumns = max(totalColumns, 1) let timeSpan = max(Double(context.size.width), 1) let normalizedOffset = min( max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0), 1 ) return min( Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)), totalColumns - 1 ) } }