1 contributor
//
// 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<Date>? = 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<Date>?
@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<Date>? = nil
) {
self.compactLayout = compactLayout
self.availableSize = availableSize
self.timeRange = timeRange
}
private var axisColumnWidth: CGFloat {
if compactLayout {
return 38
}
return isLargeDisplay ? 62 : 46
}
private var chartSectionSpacing: CGFloat {
compactLayout ? 6 : 8
}
private var xAxisHeight: CGFloat {
if compactLayout {
return 24
}
return isLargeDisplay ? 36 : 28
}
private var isPortraitLayout: Bool {
guard availableSize != .zero else { return verticalSizeClass != .compact }
return availableSize.height >= availableSize.width
}
private var 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<Date>? = 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<Date>? {
resolvedVisibleTimeRange(within: availableSelectionTimeRange())
}
private func filteredPoints(
_ measurement: Measurements.Measurement,
visibleTimeRange: ClosedRange<Date>? = 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<Date>? = nil
) -> [Measurements.Measurement.Point] {
filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
point.isSample
}
}
private func xBounds(
for samplePoints: [Measurements.Measurement.Point],
visibleTimeRange: ClosedRange<Date>? = nil
) -> ClosedRange<Date> {
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<Date>? {
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<Date>?
) -> ClosedRange<Date>? {
guard let availableTimeRange else { return nil }
guard let selectedVisibleTimeRange else { return availableTimeRange }
if isPinnedToPresent {
let pinnedRange: ClosedRange<Date>
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<Date>,
within bounds: ClosedRange<Date>
) -> ClosedRange<Date> {
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<Date>) -> ClosedRange<Date> {
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<Date>,
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<String> = ["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..<yLabels, id: \.self) { row in
let labelIndex = yLabels - row
Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
.font(yAxisFont)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(width: max(geometry.size.width - 10, 0))
.position(
x: geometry.size.width / 2,
y: topInset + yGuidePosition(
for: labelIndex,
context: context,
height: labelAreaHeight
)
)
}
Text(measurementUnit)
.font(unitFont)
.foregroundColor(tint)
.padding(.horizontal, isLargeDisplay ? 8 : 6)
.padding(.vertical, isLargeDisplay ? 5 : 4)
.background(
Capsule(style: .continuous)
.fill(tint.opacity(0.14))
)
.padding(.top, 8)
}
}
.frame(height: height)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(tint.opacity(0.12))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(tint.opacity(0.20), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.gesture(
DragGesture(minimumDistance: 0)
.onEnded { value in
handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
}
)
}
fileprivate func horizontalGuides(context: ChartContext) -> some View {
GeometryReader { geometry in
Path { path in
for labelIndex in 1...self.yLabels {
let y = yGuidePosition(
for: labelIndex,
context: context,
height: geometry.size.height
)
path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
}
}
.stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
}
}
fileprivate func verticalGuides(context: ChartContext) -> some View {
GeometryReader { geometry in
Path { path in
for labelIndex in 2..<self.xLabels {
let x = xGuidePosition(
for: labelIndex,
context: context,
width: geometry.size.width
)
path.move(to: CGPoint(x: x, y: 0) )
path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
}
}
.stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
}
}
fileprivate func discontinuityMarkers(
points: [Measurements.Measurement.Point],
context: ChartContext
) -> some View {
GeometryReader { geometry in
Path { path in
for point in points where point.isDiscontinuity {
let markerX = context.placeInRect(
point: CGPoint(
x: point.timestamp.timeIntervalSince1970,
y: context.origin.y
)
).x * geometry.size.width
path.move(to: CGPoint(x: markerX, y: 0))
path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
}
}
.stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
}
}
}
private struct TimeRangeSelectorView: View {
private enum DragTarget {
case lowerBound
case upperBound
case window
}
private struct DragState {
let target: DragTarget
let initialRange: ClosedRange<Date>
}
let points: [Measurements.Measurement.Point]
let context: ChartContext
let availableTimeRange: ClosedRange<Date>
let accentColor: Color
let compactLayout: Bool
let minimumSelectionSpan: TimeInterval
@Binding var selectedTimeRange: ClosedRange<Date>?
@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<Date> {
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<Date>,
target: DragTarget,
translationX: CGFloat,
totalWidth: CGFloat
) -> ClosedRange<Date> {
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<Date>,
target: DragTarget,
totalWidth: CGFloat
) -> ClosedRange<Date> {
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<Date> {
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<Date>
) -> ClosedRange<Date> {
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<Date>,
resultingRange: ClosedRange<Date>
) -> 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<Date>,
pinToPresent: Bool
) {
let normalizedRange = normalizedSelectionRange(candidateRange)
if selectionCoversFullRange(normalizedRange) && !pinToPresent {
selectedTimeRange = nil
} else {
selectedTimeRange = normalizedRange
}
isPinnedToPresent = pinToPresent
}
private func selectionTouchesPresent(
_ range: ClosedRange<Date>
) -> Bool {
let tolerance = max(0.5, minimumSelectionSpan * 0.25)
return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
}
private func selectionCoversFullRange(
_ range: ClosedRange<Date>
) -> 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<Date>,
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<Date>
) -> 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()
}
}
}
}