USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
3147 lines | 115.931kb
Bogdan Timofte authored 2 months ago
1
//
2
//  MeasurementChartView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 06/05/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10

            
Bogdan Timofte authored 2 months ago
11
private enum PresentTrackingMode: CaseIterable, Hashable {
12
    case keepDuration
13
    case keepStartTimestamp
14
}
15

            
Bogdan Timofte authored a month ago
16
enum MeasurementChartSizing {
17
    case provided(size: CGSize, compact: Bool)
18
    case embedded
19
}
20

            
Bogdan Timofte authored a month ago
21
enum MeasurementChartSelectorActionTone {
22
    case reversible
23
    case destructive
24
    case destructiveProminent
25
}
26

            
27
struct MeasurementChartSelectionAction {
28
    let title: String
Bogdan Timofte authored a month ago
29
    let shortTitle: String?
Bogdan Timofte authored a month ago
30
    let systemName: String
31
    let tone: MeasurementChartSelectorActionTone
32
    let handler: (ClosedRange<Date>) -> Void
Bogdan Timofte authored a month ago
33

            
34
    init(
35
        title: String,
36
        shortTitle: String? = nil,
37
        systemName: String,
38
        tone: MeasurementChartSelectorActionTone,
39
        handler: @escaping (ClosedRange<Date>) -> Void
40
    ) {
41
        self.title = title
42
        self.shortTitle = shortTitle
43
        self.systemName = systemName
44
        self.tone = tone
45
        self.handler = handler
46
    }
Bogdan Timofte authored a month ago
47
}
48

            
49
struct MeasurementChartResetAction {
50
    let title: String
Bogdan Timofte authored a month ago
51
    let shortTitle: String?
Bogdan Timofte authored a month ago
52
    let systemName: String
53
    let tone: MeasurementChartSelectorActionTone
54
    let confirmationTitle: String
55
    let confirmationButtonTitle: String
56
    let handler: () -> Void
Bogdan Timofte authored a month ago
57

            
58
    init(
59
        title: String,
60
        shortTitle: String? = nil,
61
        systemName: String,
62
        tone: MeasurementChartSelectorActionTone,
63
        confirmationTitle: String,
64
        confirmationButtonTitle: String,
65
        handler: @escaping () -> Void
66
    ) {
67
        self.title = title
68
        self.shortTitle = shortTitle
69
        self.systemName = systemName
70
        self.tone = tone
71
        self.confirmationTitle = confirmationTitle
72
        self.confirmationButtonTitle = confirmationButtonTitle
73
        self.handler = handler
74
    }
Bogdan Timofte authored a month ago
75
}
76

            
77
struct MeasurementChartRangeSelectorConfiguration {
78
    let keepAction: MeasurementChartSelectionAction
79
    let removeAction: MeasurementChartSelectionAction?
80
    let resetAction: MeasurementChartResetAction
81
}
82

            
Bogdan Timofte authored 2 months ago
83
struct MeasurementChartView: View {
Bogdan Timofte authored 2 months ago
84
    private enum SmoothingLevel: CaseIterable, Hashable {
85
        case off
86
        case light
87
        case medium
88
        case strong
89

            
90
        var label: String {
91
            switch self {
92
            case .off: return "Off"
93
            case .light: return "Light"
94
            case .medium: return "Medium"
95
            case .strong: return "Strong"
96
            }
97
        }
98

            
99
        var shortLabel: String {
100
            switch self {
101
            case .off: return "Off"
102
            case .light: return "Low"
103
            case .medium: return "Med"
104
            case .strong: return "High"
105
            }
106
        }
107

            
108
        var movingAverageWindowSize: Int {
109
            switch self {
110
            case .off: return 1
111
            case .light: return 5
112
            case .medium: return 11
113
            case .strong: return 21
114
            }
115
        }
116
    }
117

            
Bogdan Timofte authored a month ago
118
    private enum SeriesKind: Hashable {
Bogdan Timofte authored 2 months ago
119
        case power
Bogdan Timofte authored 2 months ago
120
        case energy
Bogdan Timofte authored 2 months ago
121
        case voltage
122
        case current
Bogdan Timofte authored 2 months ago
123
        case temperature
Bogdan Timofte authored a month ago
124
        case batteryPercent
Bogdan Timofte authored 2 months ago
125

            
Bogdan Timofte authored a month ago
126
        var displayName: String {
127
            switch self {
128
            case .power: return "Power"
129
            case .energy: return "Energy"
130
            case .voltage: return "Voltage"
131
            case .current: return "Current"
132
            case .temperature: return "Temperature"
Bogdan Timofte authored a month ago
133
            case .batteryPercent: return "Battery"
Bogdan Timofte authored a month ago
134
            }
135
        }
136

            
Bogdan Timofte authored 2 months ago
137
        var unit: String {
138
            switch self {
139
            case .power: return "W"
Bogdan Timofte authored 2 months ago
140
            case .energy: return "Wh"
Bogdan Timofte authored 2 months ago
141
            case .voltage: return "V"
142
            case .current: return "A"
Bogdan Timofte authored 2 months ago
143
            case .temperature: return ""
Bogdan Timofte authored a month ago
144
            case .batteryPercent: return "%"
Bogdan Timofte authored 2 months ago
145
            }
146
        }
147

            
148
        var tint: Color {
149
            switch self {
150
            case .power: return .red
Bogdan Timofte authored 2 months ago
151
            case .energy: return .teal
Bogdan Timofte authored 2 months ago
152
            case .voltage: return .green
153
            case .current: return .blue
Bogdan Timofte authored 2 months ago
154
            case .temperature: return .orange
Bogdan Timofte authored a month ago
155
            case .batteryPercent: return .mint
Bogdan Timofte authored 2 months ago
156
            }
157
        }
158
    }
159

            
160
    private struct SeriesData {
161
        let kind: SeriesKind
162
        let points: [Measurements.Measurement.Point]
163
        let samplePoints: [Measurements.Measurement.Point]
164
        let context: ChartContext
165
        let autoLowerBound: Double
166
        let autoUpperBound: Double
167
        let maximumSampleValue: Double?
168
    }
169

            
Bogdan Timofte authored a month ago
170
    private struct SeriesLegendEntry: Identifiable {
171
        let id: SeriesKind
172
        let name: String
173
        let tint: Color
174
        let minimumText: String
175
        let averageText: String
176
        let maximumText: String
177
        let lastText: String
178
    }
179

            
Bogdan Timofte authored 2 months ago
180
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
181
    private let minimumVoltageSpan = 0.5
182
    private let minimumCurrentSpan = 0.5
183
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 2 months ago
184
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
185
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored a month ago
186
    private let minimumBatteryPercentSpan = 10.0
Bogdan Timofte authored 2 months ago
187
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 2 months ago
188
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
189

            
Bogdan Timofte authored a month ago
190
    let sizing: MeasurementChartSizing
Bogdan Timofte authored a month ago
191
    let showsRangeSelector: Bool
192
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored a month ago
193
    let extendsTimelineToPresent: Bool
Bogdan Timofte authored a month ago
194
    let showsTemperatureSeries: Bool
Bogdan Timofte authored a month ago
195
    let showsBatteryPercentSeries: Bool
196
    let batteryCheckpoints: [ChargeCheckpointSummary]
197
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
198
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored a month ago
199

            
Bogdan Timofte authored 2 months ago
200
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
201
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
202
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
203
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a month ago
204
    let timeRangeLowerBound: Date?
205
    let timeRangeUpperBound: Date?
Bogdan Timofte authored a month ago
206

            
207
    @State private var embeddedWidth: CGFloat = 760
208

            
209
    private var compactLayout: Bool {
210
        switch sizing {
211
        case .provided(_, let compact): return compact
212
        case .embedded: return embeddedWidth < 760
213
        }
214
    }
215

            
216
    private var availableSize: CGSize {
217
        switch sizing {
218
        case .provided(let size, _): return size
219
        case .embedded:
220
            let h = compactLayout ? 290 : 350
221
            return CGSize(width: embeddedWidth, height: CGFloat(h))
222
        }
223
    }
224

            
Bogdan Timofte authored 2 months ago
225
    @State var displayVoltage: Bool = false
226
    @State var displayCurrent: Bool = false
227
    @State var displayPower: Bool = true
Bogdan Timofte authored 2 months ago
228
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 2 months ago
229
    @State var displayTemperature: Bool = false
Bogdan Timofte authored a month ago
230
    @State private var displayBatteryPercent: Bool = false
Bogdan Timofte authored 2 months ago
231
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored 2 months ago
232
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 2 months ago
233
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
234
    @State private var isPinnedToPresent: Bool = false
Bogdan Timofte authored a month ago
235
    @State private var presentTrackingMode: PresentTrackingMode = .keepStartTimestamp
Bogdan Timofte authored 2 months ago
236
    @State private var pinOrigin: Bool = false
237
    @State private var useSharedOrigin: Bool = false
238
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
239
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored 2 months ago
240
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
241
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
242
    @State private var voltageAxisOrigin: Double = 0
243
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
244
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored a month ago
245
    @State private var batteryPercentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
246
    let xLabels: Int = 4
247
    let yLabels: Int = 4
248

            
Bogdan Timofte authored 2 months ago
249
    init(
Bogdan Timofte authored a month ago
250
        sizing: MeasurementChartSizing = .embedded,
Bogdan Timofte authored a month ago
251
        timeRange: ClosedRange<Date>? = nil,
Bogdan Timofte authored a month ago
252
        timeRangeLowerBound: Date? = nil,
253
        timeRangeUpperBound: Date? = nil,
Bogdan Timofte authored a month ago
254
        showsRangeSelector: Bool = true,
Bogdan Timofte authored a month ago
255
        rebasesEnergyToVisibleRangeStart: Bool = false,
256
        extendsTimelineToPresent: Bool = true,
Bogdan Timofte authored a month ago
257
        showsTemperatureSeries: Bool = true,
Bogdan Timofte authored a month ago
258
        showsBatteryPercentSeries: Bool = false,
259
        batteryCheckpoints: [ChargeCheckpointSummary] = [],
260
        batteryPercentPoints: [Measurements.Measurement.Point] = [],
Bogdan Timofte authored a month ago
261
        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
Bogdan Timofte authored 2 months ago
262
    ) {
Bogdan Timofte authored a month ago
263
        self.sizing = sizing
Bogdan Timofte authored 2 months ago
264
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
265
        self.timeRangeLowerBound = timeRangeLowerBound
266
        self.timeRangeUpperBound = timeRangeUpperBound
Bogdan Timofte authored a month ago
267
        self.showsRangeSelector = showsRangeSelector
268
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored a month ago
269
        self.extendsTimelineToPresent = extendsTimelineToPresent
Bogdan Timofte authored a month ago
270
        self.showsTemperatureSeries = showsTemperatureSeries
Bogdan Timofte authored a month ago
271
        self.showsBatteryPercentSeries = showsBatteryPercentSeries
272
        self.batteryCheckpoints = batteryCheckpoints
273
        self.batteryPercentPoints = batteryPercentPoints
Bogdan Timofte authored a month ago
274
        self.rangeSelectorConfiguration = rangeSelectorConfiguration
Bogdan Timofte authored a month ago
275
        _displayPower = State(initialValue: showsBatteryPercentSeries == false)
276
        _displayBatteryPercent = State(initialValue: showsBatteryPercentSeries)
Bogdan Timofte authored 2 months ago
277
    }
278

            
Bogdan Timofte authored a month ago
279
    private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
280
        let compact = width < 760
Bogdan Timofte authored a month ago
281
        let plotHeight: CGFloat = compact ? 240 : 300
282
        let toolbarHeight: CGFloat = width < 640
283
            ? (compact ? 92 : 104)
284
            : (compact ? 48 : 56)
285
        let legendHeight: CGFloat = compact ? 76 : 90
286
        let outerSpacing: CGFloat = 12
287
        let chartStackSpacing: CGFloat = compact ? 8 : 10
288
        let selectorHeight = showsRangeSelector
289
            ? TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
290
            : 0
291
        let selectorSpacing = showsRangeSelector ? chartStackSpacing : 0
292

            
293
        return toolbarHeight
294
            + outerSpacing
295
            + plotHeight
296
            + selectorSpacing
297
            + selectorHeight
298
            + chartStackSpacing
299
            + legendHeight
Bogdan Timofte authored a month ago
300
    }
301

            
Bogdan Timofte authored 2 months ago
302
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
303
        if compactLayout {
304
            return 38
305
        }
306
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
307
    }
308

            
309
    private var chartSectionSpacing: CGFloat {
310
        compactLayout ? 6 : 8
311
    }
312

            
313
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
314
        if compactLayout {
315
            return 24
316
        }
317
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
318
    }
319

            
Bogdan Timofte authored 2 months ago
320
    private var isPortraitLayout: Bool {
321
        guard availableSize != .zero else { return verticalSizeClass != .compact }
322
        return availableSize.height >= availableSize.width
323
    }
324

            
Bogdan Timofte authored 2 months ago
325
    private var isIPhone: Bool {
326
        #if os(iOS)
327
        return UIDevice.current.userInterfaceIdiom == .phone
328
        #else
329
        return false
330
        #endif
331
    }
332

            
Bogdan Timofte authored a month ago
333
    private var plotSectionHeight: CGFloat {
334
        if case .embedded = sizing {
335
            return compactLayout ? 240 : 300
Bogdan Timofte authored 2 months ago
336
        }
337

            
Bogdan Timofte authored 2 months ago
338
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
339
            return compactLayout ? 300 : 380
340
        }
341

            
342
        if isPortraitLayout {
343
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
344
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
345
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
346
        }
347

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

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

            
355
    private var stackedToolbarLayout: Bool {
356
        if availableSize.width > 0 {
357
            return availableSize.width < 640
358
        }
359

            
360
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
361
    }
362

            
363
    private var showsLabeledOriginControls: Bool {
364
        !compactLayout && !stackedToolbarLayout
365
    }
366

            
Bogdan Timofte authored 2 months ago
367
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
368
        #if os(iOS)
369
        if UIDevice.current.userInterfaceIdiom == .phone {
370
            return false
371
        }
372
        #endif
373

            
Bogdan Timofte authored 2 months ago
374
        if availableSize.width > 0 {
375
            return availableSize.width >= 900 || availableSize.height >= 700
376
        }
377
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
378
    }
379

            
380
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
381
        if isIPhone && isPortraitLayout {
382
            return .caption
383
        }
384
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
385
    }
386

            
Bogdan Timofte authored 2 months ago
387
    private var usesCompactLandscapeOriginControls: Bool {
388
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
389
    }
390

            
Bogdan Timofte authored 2 months ago
391
    var body: some View {
Bogdan Timofte authored a month ago
392
        Group {
393
            switch sizing {
394
            case .provided:
395
                chartBody
Bogdan Timofte authored a month ago
396
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
397
            case .embedded:
398
                chartBody
399
                    .frame(maxWidth: .infinity, alignment: .topLeading)
400
                    .background(
401
                        GeometryReader { geometry in
402
                            Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
403
                        }
404
                    )
405
                    .onPreferenceChange(EmbeddedWidthKey.self) { width in
406
                        guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
407
                        embeddedWidth = width
Bogdan Timofte authored a month ago
408
                    }
Bogdan Timofte authored a month ago
409
            }
410
        }
Bogdan Timofte authored a month ago
411
        .onAppear {
412
            resetHiddenTemperatureDisplay()
413
            resetHiddenBatteryPercentDisplay()
414
        }
Bogdan Timofte authored a month ago
415
        .onChange(of: showsTemperatureSeries) { _ in
416
            resetHiddenTemperatureDisplay()
Bogdan Timofte authored a month ago
417
        }
Bogdan Timofte authored a month ago
418
        .onChange(of: showsBatteryPercentSeries) { _ in
419
            resetHiddenBatteryPercentDisplay()
420
        }
Bogdan Timofte authored a month ago
421
    }
422

            
Bogdan Timofte authored a month ago
423
    private func resetHiddenTemperatureDisplay() {
424
        guard !showsTemperatureSeries, displayTemperature else { return }
425
        displayTemperature = false
426
    }
427

            
Bogdan Timofte authored a month ago
428
    private func resetHiddenBatteryPercentDisplay() {
429
        guard !showsBatteryPercentSeries, displayBatteryPercent else { return }
430
        displayBatteryPercent = false
431
        if !displayPower && !displayEnergy && !displayVoltage && !displayCurrent {
432
            displayPower = true
433
        }
434
    }
435

            
Bogdan Timofte authored a month ago
436
    @ViewBuilder
437
    private var chartBody: some View {
Bogdan Timofte authored 2 months ago
438
        let availableTimeRange = availableSelectionTimeRange()
439
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
440
        let powerSeries = series(
441
            for: measurements.power,
442
            kind: .power,
443
            minimumYSpan: minimumPowerSpan,
444
            visibleTimeRange: visibleTimeRange
445
        )
Bogdan Timofte authored 2 months ago
446
        let energySeries = series(
447
            for: measurements.energy,
448
            kind: .energy,
449
            minimumYSpan: minimumEnergySpan,
450
            visibleTimeRange: visibleTimeRange
451
        )
Bogdan Timofte authored 2 months ago
452
        let voltageSeries = series(
453
            for: measurements.voltage,
454
            kind: .voltage,
455
            minimumYSpan: minimumVoltageSpan,
456
            visibleTimeRange: visibleTimeRange
457
        )
458
        let currentSeries = series(
459
            for: measurements.current,
460
            kind: .current,
461
            minimumYSpan: minimumCurrentSpan,
462
            visibleTimeRange: visibleTimeRange
463
        )
464
        let temperatureSeries = series(
465
            for: measurements.temperature,
466
            kind: .temperature,
467
            minimumYSpan: minimumTemperatureSpan,
468
            visibleTimeRange: visibleTimeRange
469
        )
Bogdan Timofte authored a month ago
470
        let batteryPercentSeries = series(
471
            for: batteryPercentPoints.isEmpty ? measurements.batteryPercent.points : batteryPercentPoints,
472
            kind: .batteryPercent,
473
            minimumYSpan: minimumBatteryPercentSpan,
474
            visibleTimeRange: visibleTimeRange
475
        )
Bogdan Timofte authored 2 months ago
476
        let primarySeries = displayedPrimarySeries(
477
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
478
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
479
            voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
480
            currentSeries: currentSeries,
481
            batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
482
        )
Bogdan Timofte authored 2 months ago
483
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
484

            
Bogdan Timofte authored 2 months ago
485
        Group {
Bogdan Timofte authored 2 months ago
486
            if let primarySeries {
487
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
488
                    chartTopToolbar(
489
                        voltageSeries: voltageSeries,
490
                        currentSeries: currentSeries
491
                    )
Bogdan Timofte authored 2 months ago
492

            
Bogdan Timofte authored a month ago
493
                    VStack(spacing: compactLayout ? 8 : 10) {
494
                        GeometryReader { geometry in
Bogdan Timofte authored a month ago
495
                            let minimumPlotHeight: CGFloat = compactLayout
496
                                ? (isPortraitLayout ? 180 : 120)
497
                                : 220
498
                            let plotHeight = max(geometry.size.height - xAxisHeight, minimumPlotHeight)
Bogdan Timofte authored a month ago
499

            
500
                            VStack(spacing: 6) {
501
                                HStack(spacing: chartSectionSpacing) {
502
                                    primaryAxisView(
503
                                        height: plotHeight,
504
                                        powerSeries: powerSeries,
505
                                        energySeries: energySeries,
506
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
507
                                        currentSeries: currentSeries,
508
                                        batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
509
                                    )
510
                                    .frame(width: axisColumnWidth, height: plotHeight)
511

            
512
                                    ZStack {
513
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
514
                                            .fill(Color.primary.opacity(0.05))
515

            
516
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
517
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
518

            
519
                                        horizontalGuides(context: primarySeries.context)
520
                                        verticalGuides(context: primarySeries.context)
521
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
522
                                        renderedChart(
523
                                            powerSeries: powerSeries,
524
                                            energySeries: energySeries,
525
                                            voltageSeries: voltageSeries,
526
                                            currentSeries: currentSeries,
Bogdan Timofte authored a month ago
527
                                            temperatureSeries: temperatureSeries,
528
                                            batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
529
                                        )
530
                                    }
531
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
532
                                    .frame(maxWidth: .infinity)
533
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
534

            
Bogdan Timofte authored a month ago
535
                                    secondaryAxisView(
536
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
537
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
538
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
539
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
540
                                        currentSeries: currentSeries,
Bogdan Timofte authored a month ago
541
                                        temperatureSeries: temperatureSeries,
542
                                        batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
543
                                    )
Bogdan Timofte authored a month ago
544
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
545
                                }
Bogdan Timofte authored 2 months ago
546

            
Bogdan Timofte authored a month ago
547
                                xAxisLabelsView(context: primarySeries.context)
548
                                    .frame(height: xAxisHeight)
Bogdan Timofte authored 2 months ago
549
                            }
Bogdan Timofte authored a month ago
550
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
551
                        }
552
                        .frame(height: plotSectionHeight)
553

            
Bogdan Timofte authored a month ago
554
                        chartLegend(
555
                            entries: chartLegendEntries(
556
                                powerSeries: powerSeries,
557
                                energySeries: energySeries,
558
                                voltageSeries: voltageSeries,
559
                                currentSeries: currentSeries,
Bogdan Timofte authored a month ago
560
                                temperatureSeries: temperatureSeries,
561
                                batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
562
                            )
563
                        )
564

            
Bogdan Timofte authored a month ago
565
                        if showsRangeSelector,
566
                           let availableTimeRange,
567
                           let selectorSeries,
568
                           shouldShowRangeSelector(
569
                            availableTimeRange: availableTimeRange,
570
                            series: selectorSeries
571
                           ) {
572
                            TimeRangeSelectorView(
573
                                points: selectorSeries.points,
574
                                context: selectorSeries.context,
Bogdan Timofte authored 2 months ago
575
                                availableTimeRange: availableTimeRange,
Bogdan Timofte authored a month ago
576
                                selectorTint: selectorTint,
577
                                compactLayout: compactLayout,
Bogdan Timofte authored a month ago
578
                                xAxisLabelCount: xLabels,
Bogdan Timofte authored a month ago
579
                                minimumSelectionSpan: minimumTimeSpan,
580
                                configuration: resolvedRangeSelectorConfiguration(),
581
                                selectedTimeRange: $selectedVisibleTimeRange,
582
                                isPinnedToPresent: $isPinnedToPresent,
583
                                presentTrackingMode: $presentTrackingMode
584
                            )
Bogdan Timofte authored 2 months ago
585
                        }
586
                    }
587
                }
Bogdan Timofte authored 2 months ago
588
            } else {
589
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
590
                    chartTopToolbar(
591
                        voltageSeries: voltageSeries,
592
                        currentSeries: currentSeries
593
                    )
Bogdan Timofte authored 2 months ago
594
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
595
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
596
                }
597
            }
Bogdan Timofte authored 2 months ago
598
        }
Bogdan Timofte authored 2 months ago
599
        .font(chartBaseFont)
Bogdan Timofte authored a month ago
600
        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
601
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
602
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
603
            chartNow = now
604
        }
Bogdan Timofte authored 2 months ago
605
    }
606

            
Bogdan Timofte authored a month ago
607
    private func chartTopToolbar(
608
        voltageSeries: SeriesData,
609
        currentSeries: SeriesData
610
    ) -> some View {
Bogdan Timofte authored 2 months ago
611
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
612
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
Bogdan Timofte authored 2 months ago
613

            
Bogdan Timofte authored a month ago
614
        let seriesPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
615
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
616
        }
617
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
618
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
619
        .background(
620
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
621
                .fill(Color.primary.opacity(0.045))
622
        )
623
        .overlay(
624
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
625
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
626
        )
Bogdan Timofte authored 2 months ago
627

            
Bogdan Timofte authored a month ago
628
        let controlPanel = chartControlsPanel(
629
            voltageSeries: voltageSeries,
630
            currentSeries: currentSeries,
631
            condensedLayout: condensedLayout
632
        )
633

            
Bogdan Timofte authored 2 months ago
634
        return Group {
Bogdan Timofte authored 2 months ago
635
            if stackedToolbarLayout {
Bogdan Timofte authored a month ago
636
                VStack(alignment: .leading, spacing: 8) {
637
                    seriesPanel
638
                    controlPanel
639
                }
Bogdan Timofte authored 2 months ago
640
            } else {
Bogdan Timofte authored a month ago
641
                HStack(alignment: .center, spacing: isLargeDisplay ? 14 : 12) {
642
                    seriesPanel
643
                    Spacer(minLength: 0)
644
                    controlPanel
Bogdan Timofte authored 2 months ago
645
                }
Bogdan Timofte authored 2 months ago
646
            }
647
        }
648
        .frame(maxWidth: .infinity, alignment: .leading)
649
    }
650

            
Bogdan Timofte authored a month ago
651
    private func chartControlsPanel(
Bogdan Timofte authored 2 months ago
652
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
653
        currentSeries: SeriesData,
654
        condensedLayout: Bool
Bogdan Timofte authored 2 months ago
655
    ) -> some View {
Bogdan Timofte authored a month ago
656
        originControlsRow(
Bogdan Timofte authored 2 months ago
657
            voltageSeries: voltageSeries,
658
            currentSeries: currentSeries,
659
            condensedLayout: condensedLayout,
Bogdan Timofte authored a month ago
660
            showsLabel: showsLabeledOriginControls && !stackedToolbarLayout
Bogdan Timofte authored 2 months ago
661
        )
Bogdan Timofte authored a month ago
662
        .padding(.horizontal, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
663
        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
664
        .background(
Bogdan Timofte authored a month ago
665
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
666
                .fill(Color.primary.opacity(0.045))
Bogdan Timofte authored 2 months ago
667
        )
668
        .overlay(
Bogdan Timofte authored a month ago
669
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
670
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
Bogdan Timofte authored 2 months ago
671
        )
672
    }
673

            
Bogdan Timofte authored a month ago
674
    private func chartLegendEntries(
675
        powerSeries: SeriesData,
676
        energySeries: SeriesData,
677
        voltageSeries: SeriesData,
678
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
679
        temperatureSeries: SeriesData,
680
        batteryPercentSeries: SeriesData
Bogdan Timofte authored a month ago
681
    ) -> [SeriesLegendEntry] {
682
        var entries: [SeriesLegendEntry] = []
683

            
Bogdan Timofte authored a month ago
684
        if displayBatteryPercent {
685
            entries.append(contentsOf: legendEntry(for: batteryPercentSeries))
686
        } else if displayPower {
Bogdan Timofte authored a month ago
687
            entries.append(contentsOf: legendEntry(for: powerSeries))
688
        } else if displayEnergy {
689
            entries.append(contentsOf: legendEntry(for: energySeries))
690
        } else {
691
            if displayVoltage {
692
                entries.append(contentsOf: legendEntry(for: voltageSeries))
693
            }
694
            if displayCurrent {
695
                entries.append(contentsOf: legendEntry(for: currentSeries))
696
            }
697
        }
698

            
699
        if displayTemperature {
700
            entries.append(contentsOf: legendEntry(for: temperatureSeries))
701
        }
702

            
703
        return entries
704
    }
705

            
706
    private func legendEntry(for series: SeriesData) -> [SeriesLegendEntry] {
707
        let samples = series.samplePoints
708
        guard
709
            let minimumValue = samples.map(\.value).min(),
710
            let maximumValue = samples.map(\.value).max(),
711
            let lastValue = samples.last?.value
712
        else {
713
            return []
714
        }
715

            
716
        let averageValue = samples.reduce(0) { $0 + $1.value } / Double(samples.count)
717

            
718
        return [
719
            SeriesLegendEntry(
720
                id: series.kind,
721
                name: series.kind.displayName,
722
                tint: series.kind.tint,
723
                minimumText: legendValueText(minimumValue, for: series.kind),
724
                averageText: legendValueText(averageValue, for: series.kind),
725
                maximumText: legendValueText(maximumValue, for: series.kind),
726
                lastText: legendValueText(lastValue, for: series.kind)
727
            )
728
        ]
729
    }
730

            
731
    @ViewBuilder
732
    private func chartLegend(entries: [SeriesLegendEntry]) -> some View {
733
        if !entries.isEmpty {
734
            let nameWidth: CGFloat = compactLayout ? 88 : (isLargeDisplay ? 128 : 108)
735
            let valueWidth: CGFloat = compactLayout ? 78 : (isLargeDisplay ? 108 : 92)
736

            
737
            ScrollView(.horizontal, showsIndicators: false) {
738
                VStack(alignment: .leading, spacing: compactLayout ? 5 : 7) {
739
                    HStack(spacing: compactLayout ? 8 : 10) {
740
                        legendHeaderText("Measurement", width: nameWidth, alignment: .leading)
741
                        legendHeaderText("Min", width: valueWidth)
742
                        legendHeaderText("Avg", width: valueWidth)
743
                        legendHeaderText("Max", width: valueWidth)
744
                        legendHeaderText("Last", width: valueWidth)
745
                    }
746

            
747
                    ForEach(entries) { entry in
748
                        HStack(spacing: compactLayout ? 8 : 10) {
749
                            HStack(spacing: 6) {
750
                                Circle()
751
                                    .fill(entry.tint)
752
                                    .frame(width: compactLayout ? 7 : 8, height: compactLayout ? 7 : 8)
753

            
754
                                Text(entry.name)
755
                                    .lineLimit(1)
756
                                    .minimumScaleFactor(0.82)
757
                            }
758
                            .frame(width: nameWidth, alignment: .leading)
759

            
760
                            legendValueText(entry.minimumText, width: valueWidth)
761
                            legendValueText(entry.averageText, width: valueWidth)
762
                            legendValueText(entry.maximumText, width: valueWidth)
763
                            legendValueText(entry.lastText, width: valueWidth)
764
                        }
765
                    }
766
                }
767
                .padding(.horizontal, compactLayout ? 10 : 12)
768
                .padding(.vertical, compactLayout ? 8 : 10)
769
            }
770
            .background(
771
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
772
                    .fill(Color.primary.opacity(0.045))
773
            )
774
            .overlay(
775
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
776
                    .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
777
            )
778
        }
779
    }
780

            
781
    private func legendHeaderText(
782
        _ text: String,
783
        width: CGFloat,
784
        alignment: Alignment = .trailing
785
    ) -> some View {
786
        Text(text)
787
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
788
            .foregroundColor(.secondary)
789
            .textCase(.uppercase)
790
            .lineLimit(1)
791
            .frame(width: width, alignment: alignment)
792
    }
793

            
794
    private func legendValueText(
795
        _ text: String,
796
        width: CGFloat
797
    ) -> some View {
798
        Text(text)
799
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
800
            .monospacedDigit()
801
            .lineLimit(1)
802
            .minimumScaleFactor(0.78)
803
            .frame(width: width, alignment: .trailing)
804
    }
805

            
806
    private func legendValueText(
807
        _ value: Double,
808
        for kind: SeriesKind
809
    ) -> String {
810
        let decimalDigits: Int
811
        switch kind {
812
        case .power:
813
            decimalDigits = 2
814
        case .energy, .voltage, .current:
815
            decimalDigits = 3
Bogdan Timofte authored a month ago
816
        case .temperature, .batteryPercent:
Bogdan Timofte authored a month ago
817
            decimalDigits = 1
818
        }
819

            
820
        let formattedValue = value.format(decimalDigits: decimalDigits)
821
        let unit = measurementUnit(for: kind)
822
        guard !unit.isEmpty else { return formattedValue }
823

            
824
        if kind == .temperature {
825
            return "\(formattedValue)\(unit)"
826
        }
827
        return "\(formattedValue) \(unit)"
828
    }
829

            
Bogdan Timofte authored 2 months ago
830
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
831
        HStack(spacing: condensedLayout ? 6 : 8) {
832
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
833
                displayVoltage.toggle()
834
                if displayVoltage {
Bogdan Timofte authored a month ago
835
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
836
                    displayPower = false
Bogdan Timofte authored 2 months ago
837
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
838
                    if displayTemperature && displayCurrent {
839
                        displayCurrent = false
840
                    }
Bogdan Timofte authored 2 months ago
841
                }
842
            }
843

            
844
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
845
                displayCurrent.toggle()
846
                if displayCurrent {
Bogdan Timofte authored a month ago
847
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
848
                    displayPower = false
Bogdan Timofte authored 2 months ago
849
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
850
                    if displayTemperature && displayVoltage {
851
                        displayVoltage = false
852
                    }
Bogdan Timofte authored 2 months ago
853
                }
Bogdan Timofte authored 2 months ago
854
            }
855

            
856
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
857
                displayPower.toggle()
858
                if displayPower {
Bogdan Timofte authored a month ago
859
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
860
                    displayEnergy = false
861
                    displayCurrent = false
862
                    displayVoltage = false
863
                }
864
            }
865

            
866
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
867
                displayEnergy.toggle()
868
                if displayEnergy {
Bogdan Timofte authored a month ago
869
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
870
                    displayPower = false
Bogdan Timofte authored 2 months ago
871
                    displayCurrent = false
872
                    displayVoltage = false
873
                }
874
            }
Bogdan Timofte authored 2 months ago
875

            
Bogdan Timofte authored a month ago
876
            if showsBatteryPercentSeries {
877
                seriesToggleButton(title: "Battery", isOn: displayBatteryPercent, condensedLayout: condensedLayout) {
878
                    displayBatteryPercent.toggle()
879
                    if displayBatteryPercent {
880
                        displayPower = false
881
                        displayEnergy = false
882
                        displayCurrent = false
883
                        displayVoltage = false
884
                    }
885
                }
886
            }
887

            
Bogdan Timofte authored a month ago
888
            if showsTemperatureSeries {
889
                seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
890
                    displayTemperature.toggle()
891
                    if displayTemperature && displayVoltage && displayCurrent {
892
                        displayCurrent = false
893
                    }
Bogdan Timofte authored 2 months ago
894
                }
895
            }
Bogdan Timofte authored 2 months ago
896
        }
897
    }
898

            
899
    private func originControlsRow(
900
        voltageSeries: SeriesData,
901
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
902
        condensedLayout: Bool,
903
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
904
    ) -> some View {
Bogdan Timofte authored 2 months ago
905
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
906
            if supportsSharedOrigin {
907
                symbolControlChip(
908
                    systemImage: "equal.circle",
909
                    enabled: true,
910
                    active: useSharedOrigin,
911
                    condensedLayout: condensedLayout,
912
                    showsLabel: showsLabel,
913
                    label: "Match Y Scale",
914
                    accessibilityLabel: "Match Y scale"
915
                ) {
916
                    toggleSharedOrigin(
917
                        voltageSeries: voltageSeries,
918
                        currentSeries: currentSeries
919
                    )
920
                }
Bogdan Timofte authored 2 months ago
921
            }
922

            
923
            symbolControlChip(
924
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
925
                enabled: true,
926
                active: pinOrigin,
927
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
928
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
929
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
930
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
931
            ) {
932
                togglePinnedOrigin(
933
                    voltageSeries: voltageSeries,
934
                    currentSeries: currentSeries
935
                )
936
            }
937

            
Bogdan Timofte authored 2 months ago
938
            if !pinnedOriginIsZero {
939
                symbolControlChip(
940
                    systemImage: "0.circle",
941
                    enabled: true,
942
                    active: false,
943
                    condensedLayout: condensedLayout,
944
                    showsLabel: showsLabel,
945
                    label: "Origin 0",
946
                    accessibilityLabel: "Set origin to zero"
947
                ) {
948
                    setVisibleOriginsToZero()
949
                }
Bogdan Timofte authored 2 months ago
950
            }
Bogdan Timofte authored 2 months ago
951

            
Bogdan Timofte authored 2 months ago
952
            smoothingControlChip(
953
                condensedLayout: condensedLayout,
954
                showsLabel: showsLabel
955
            )
956

            
Bogdan Timofte authored 2 months ago
957
        }
958
    }
959

            
Bogdan Timofte authored 2 months ago
960
    private func smoothingControlChip(
961
        condensedLayout: Bool,
962
        showsLabel: Bool
963
    ) -> some View {
964
        Menu {
965
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
966
                Button {
967
                    smoothingLevel = level
968
                } label: {
969
                    if smoothingLevel == level {
970
                        Label(level.label, systemImage: "checkmark")
971
                    } else {
972
                        Text(level.label)
Bogdan Timofte authored 2 months ago
973
                    }
974
                }
Bogdan Timofte authored 2 months ago
975
            }
976
        } label: {
977
            Group {
978
                if showsLabel {
979
                    VStack(alignment: .leading, spacing: 2) {
980
                        Label("Smoothing", systemImage: "waveform.path")
981
                            .font(controlChipFont(condensedLayout: condensedLayout))
982

            
983
                        Text(
Bogdan Timofte authored 2 months ago
984
                            smoothingLevel == .off
Bogdan Timofte authored 2 months ago
985
                            ? "Off"
986
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
987
                        )
Bogdan Timofte authored 2 months ago
988
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
989
                        .foregroundColor(.secondary)
990
                        .monospacedDigit()
991
                    }
992
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
993
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
994
                } else {
995
                    VStack(spacing: 1) {
996
                        Image(systemName: "waveform.path")
997
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
998

            
999
                        Text(smoothingLevel.shortLabel)
1000
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
1001
                            .monospacedDigit()
1002
                    }
1003
                    .frame(
1004
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
1005
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
1006
                    )
1007
                }
Bogdan Timofte authored 2 months ago
1008
            }
Bogdan Timofte authored 2 months ago
1009
            .background(
1010
                Capsule(style: .continuous)
1011
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
1012
            )
1013
            .overlay(
1014
                Capsule(style: .continuous)
1015
                    .stroke(
1016
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
1017
                        lineWidth: 1
1018
                    )
1019
            )
Bogdan Timofte authored 2 months ago
1020
        }
Bogdan Timofte authored 2 months ago
1021
        .buttonStyle(.plain)
1022
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
1023
    }
1024

            
Bogdan Timofte authored 2 months ago
1025
    private func seriesToggleButton(
1026
        title: String,
1027
        isOn: Bool,
1028
        condensedLayout: Bool,
1029
        action: @escaping () -> Void
1030
    ) -> some View {
1031
        Button(action: action) {
1032
            Text(title)
Bogdan Timofte authored 2 months ago
1033
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1034
                .lineLimit(1)
1035
                .minimumScaleFactor(0.82)
1036
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
1037
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
1038
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
1039
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
1040
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
1041
                .background(
1042
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
1043
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
1044
                )
1045
                .overlay(
1046
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
1047
                        .stroke(Color.blue, lineWidth: 1.5)
1048
                )
1049
        }
1050
        .buttonStyle(.plain)
1051
    }
1052

            
1053
    private func symbolControlChip(
1054
        systemImage: String,
1055
        enabled: Bool,
1056
        active: Bool,
1057
        condensedLayout: Bool,
1058
        showsLabel: Bool,
1059
        label: String,
1060
        accessibilityLabel: String,
1061
        action: @escaping () -> Void
1062
    ) -> some View {
1063
        Button(action: {
1064
            action()
1065
        }) {
1066
            Group {
1067
                if showsLabel {
1068
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
1069
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1070
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
1071
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
1072
                } else {
1073
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
1074
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
1075
                        .frame(
Bogdan Timofte authored 2 months ago
1076
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
1077
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
1078
                        )
Bogdan Timofte authored 2 months ago
1079
                }
1080
            }
1081
                .background(
1082
                    Capsule(style: .continuous)
1083
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
1084
                )
1085
        }
1086
        .buttonStyle(.plain)
1087
        .foregroundColor(enabled ? .primary : .secondary)
1088
        .opacity(enabled ? 1 : 0.55)
1089
        .accessibilityLabel(accessibilityLabel)
1090
    }
1091

            
Bogdan Timofte authored 2 months ago
1092
    private func resetBuffer() {
1093
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
1094
    }
1095

            
Bogdan Timofte authored a month ago
1096
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
1097
        if let rangeSelectorConfiguration {
1098
            return rangeSelectorConfiguration
1099
        }
1100

            
1101
        return MeasurementChartRangeSelectorConfiguration(
1102
            keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1103
                title: "Keep Selection",
1104
                shortTitle: "Keep",
Bogdan Timofte authored a month ago
1105
                systemName: "scissors",
1106
                tone: .destructive,
1107
                handler: trimBufferToSelection
1108
            ),
1109
            removeAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1110
                title: "Remove Selection",
1111
                shortTitle: "Cut",
Bogdan Timofte authored a month ago
1112
                systemName: "minus.circle",
1113
                tone: .destructive,
1114
                handler: removeSelectionFromBuffer
1115
            ),
1116
            resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
1117
                title: "Reset Buffer",
1118
                shortTitle: "Reset",
Bogdan Timofte authored a month ago
1119
                systemName: "trash",
1120
                tone: .destructiveProminent,
1121
                confirmationTitle: "Reset captured measurements?",
1122
                confirmationButtonTitle: "Reset buffer",
1123
                handler: resetBuffer
1124
            )
1125
        )
1126
    }
1127

            
Bogdan Timofte authored 2 months ago
1128
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
1129
        if isLargeDisplay {
1130
            return .body.weight(.semibold)
1131
        }
1132
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
1133
    }
1134

            
1135
    private func controlChipFont(condensedLayout: Bool) -> Font {
1136
        if isLargeDisplay {
1137
            return .callout.weight(.semibold)
1138
        }
1139
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
1140
    }
1141

            
Bogdan Timofte authored 2 months ago
1142
    @ViewBuilder
1143
    private func primaryAxisView(
1144
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1145
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1146
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1147
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
1148
        currentSeries: SeriesData,
1149
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1150
    ) -> some View {
Bogdan Timofte authored a month ago
1151
        if displayBatteryPercent {
1152
            yAxisLabelsView(
1153
                height: height,
1154
                context: batteryPercentSeries.context,
1155
                seriesKind: .batteryPercent,
1156
                measurementUnit: batteryPercentSeries.kind.unit,
1157
                tint: batteryPercentSeries.kind.tint
1158
            )
1159
        } else if displayPower {
Bogdan Timofte authored 2 months ago
1160
            yAxisLabelsView(
1161
                height: height,
1162
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
1163
                seriesKind: .power,
1164
                measurementUnit: powerSeries.kind.unit,
1165
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
1166
            )
Bogdan Timofte authored 2 months ago
1167
        } else if displayEnergy {
1168
            yAxisLabelsView(
1169
                height: height,
1170
                context: energySeries.context,
1171
                seriesKind: .energy,
1172
                measurementUnit: energySeries.kind.unit,
1173
                tint: energySeries.kind.tint
1174
            )
Bogdan Timofte authored 2 months ago
1175
        } else if displayVoltage {
1176
            yAxisLabelsView(
1177
                height: height,
1178
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
1179
                seriesKind: .voltage,
1180
                measurementUnit: voltageSeries.kind.unit,
1181
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
1182
            )
1183
        } else if displayCurrent {
1184
            yAxisLabelsView(
1185
                height: height,
1186
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1187
                seriesKind: .current,
1188
                measurementUnit: currentSeries.kind.unit,
1189
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1190
            )
1191
        }
1192
    }
1193

            
1194
    @ViewBuilder
1195
    private func renderedChart(
Bogdan Timofte authored 2 months ago
1196
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1197
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1198
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1199
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
1200
        temperatureSeries: SeriesData,
1201
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1202
    ) -> some View {
Bogdan Timofte authored a month ago
1203
        if self.displayBatteryPercent {
1204
            TimeSeriesChart(points: batteryPercentSeries.points, context: batteryPercentSeries.context, strokeColor: batteryPercentSeries.kind.tint)
1205
                .opacity(0.82)
1206
            batteryCheckpointMarkers(context: batteryPercentSeries.context)
1207
        } else if self.displayPower {
Bogdan Timofte authored a month ago
1208
            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1209
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
1210
        } else if self.displayEnergy {
Bogdan Timofte authored a month ago
1211
            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
Bogdan Timofte authored 2 months ago
1212
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
1213
        } else {
1214
            if self.displayVoltage {
Bogdan Timofte authored a month ago
1215
                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1216
                    .opacity(0.78)
1217
            }
1218
            if self.displayCurrent {
Bogdan Timofte authored a month ago
1219
                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1220
                    .opacity(0.78)
1221
            }
1222
        }
Bogdan Timofte authored 2 months ago
1223

            
1224
        if displayTemperature {
Bogdan Timofte authored a month ago
1225
            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1226
                .opacity(0.86)
1227
        }
Bogdan Timofte authored 2 months ago
1228
    }
1229

            
Bogdan Timofte authored a month ago
1230
    private func batteryCheckpointMarkers(context: ChartContext) -> some View {
1231
        GeometryReader { geometry in
1232
            ForEach(visibleBatteryCheckpoints(context: context)) { checkpoint in
1233
                let normalizedPoint = context.placeInRect(
1234
                    point: CGPoint(
1235
                        x: checkpoint.timestamp.timeIntervalSince1970,
1236
                        y: checkpoint.batteryPercent
1237
                    )
1238
                )
1239
                let location = CGPoint(
1240
                    x: normalizedPoint.x * geometry.size.width,
1241
                    y: normalizedPoint.y * geometry.size.height
1242
                )
1243

            
1244
                Circle()
1245
                    .fill(Color(.systemBackground))
1246
                    .frame(width: 10, height: 10)
1247
                    .overlay(
1248
                        Circle()
1249
                            .stroke(Color.mint, lineWidth: 2)
1250
                    )
1251
                    .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
1252
                    .position(location)
1253
            }
1254
        }
1255
        .accessibilityHidden(true)
1256
    }
1257

            
1258
    private func visibleBatteryCheckpoints(context: ChartContext) -> [ChargeCheckpointSummary] {
1259
        guard context.isValid else { return [] }
1260

            
1261
        return batteryCheckpoints
1262
            .filter { checkpoint in
1263
                checkpoint.batteryPercent.isFinite &&
1264
                checkpoint.batteryPercent >= 0 &&
1265
                checkpoint.batteryPercent <= 100
1266
            }
1267
            .filter { checkpoint in
1268
                let normalizedPoint = context.placeInRect(
1269
                    point: CGPoint(
1270
                        x: checkpoint.timestamp.timeIntervalSince1970,
1271
                        y: checkpoint.batteryPercent
1272
                    )
1273
                )
1274
                return normalizedPoint.x >= 0 &&
1275
                    normalizedPoint.x <= 1 &&
1276
                    normalizedPoint.y >= 0 &&
1277
                    normalizedPoint.y <= 1
1278
            }
1279
    }
1280

            
Bogdan Timofte authored 2 months ago
1281
    @ViewBuilder
1282
    private func secondaryAxisView(
1283
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1284
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1285
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1286
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1287
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
1288
        temperatureSeries: SeriesData,
1289
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1290
    ) -> some View {
Bogdan Timofte authored 2 months ago
1291
        if displayTemperature {
1292
            yAxisLabelsView(
1293
                height: height,
1294
                context: temperatureSeries.context,
1295
                seriesKind: .temperature,
1296
                measurementUnit: measurementUnit(for: .temperature),
1297
                tint: temperatureSeries.kind.tint
1298
            )
1299
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
1300
            yAxisLabelsView(
1301
                height: height,
1302
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1303
                seriesKind: .current,
1304
                measurementUnit: currentSeries.kind.unit,
1305
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1306
            )
1307
        } else {
1308
            primaryAxisView(
1309
                height: height,
1310
                powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
1311
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
1312
                voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
1313
                currentSeries: currentSeries,
1314
                batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
1315
            )
Bogdan Timofte authored 2 months ago
1316
        }
1317
    }
Bogdan Timofte authored 2 months ago
1318

            
1319
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
1320
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1321
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1322
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
1323
        currentSeries: SeriesData,
1324
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1325
    ) -> SeriesData? {
Bogdan Timofte authored a month ago
1326
        if displayBatteryPercent {
1327
            return batteryPercentSeries
1328
        }
Bogdan Timofte authored 2 months ago
1329
        if displayPower {
Bogdan Timofte authored 2 months ago
1330
            return powerSeries
Bogdan Timofte authored 2 months ago
1331
        }
Bogdan Timofte authored 2 months ago
1332
        if displayEnergy {
1333
            return energySeries
1334
        }
Bogdan Timofte authored 2 months ago
1335
        if displayVoltage {
Bogdan Timofte authored 2 months ago
1336
            return voltageSeries
Bogdan Timofte authored 2 months ago
1337
        }
1338
        if displayCurrent {
Bogdan Timofte authored 2 months ago
1339
            return currentSeries
Bogdan Timofte authored 2 months ago
1340
        }
1341
        return nil
1342
    }
1343

            
1344
    private func series(
1345
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
1346
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
1347
        minimumYSpan: Double,
1348
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1349
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1350
        series(
1351
            for: filteredPoints(
1352
                measurement,
1353
                visibleTimeRange: visibleTimeRange
1354
            ),
1355
            kind: kind,
1356
            minimumYSpan: minimumYSpan,
Bogdan Timofte authored 2 months ago
1357
            visibleTimeRange: visibleTimeRange
1358
        )
Bogdan Timofte authored a month ago
1359
    }
1360

            
1361
    private func series(
1362
        for rawPoints: [Measurements.Measurement.Point],
1363
        kind: SeriesKind,
1364
        minimumYSpan: Double,
1365
        visibleTimeRange: ClosedRange<Date>? = nil
1366
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1367
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1368
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
1369
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1370
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
1371

            
Bogdan Timofte authored a month ago
1372
        let autoBounds = kind == .batteryPercent
1373
            ? (lowerBound: 0.0, upperBound: 100.0)
1374
            : automaticYBounds(
1375
                for: samplePoints,
1376
                minimumYSpan: minimumYSpan
1377
            )
Bogdan Timofte authored 2 months ago
1378
        let xBounds = xBounds(
1379
            for: samplePoints,
1380
            visibleTimeRange: visibleTimeRange
1381
        )
Bogdan Timofte authored 2 months ago
1382
        let lowerBound = resolvedLowerBound(
1383
            for: kind,
1384
            autoLowerBound: autoBounds.lowerBound
1385
        )
1386
        let upperBound = resolvedUpperBound(
1387
            for: kind,
1388
            lowerBound: lowerBound,
1389
            autoUpperBound: autoBounds.upperBound,
1390
            maximumSampleValue: samplePoints.map(\.value).max(),
1391
            minimumYSpan: minimumYSpan
1392
        )
1393

            
1394
        context.setBounds(
1395
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1396
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1397
            yMin: CGFloat(lowerBound),
1398
            yMax: CGFloat(upperBound)
1399
        )
1400

            
1401
        return SeriesData(
1402
            kind: kind,
1403
            points: points,
1404
            samplePoints: samplePoints,
1405
            context: context,
1406
            autoLowerBound: autoBounds.lowerBound,
1407
            autoUpperBound: autoBounds.upperBound,
1408
            maximumSampleValue: samplePoints.map(\.value).max()
1409
        )
1410
    }
1411

            
Bogdan Timofte authored a month ago
1412
    private func normalizedPoints(
1413
        _ points: [Measurements.Measurement.Point],
1414
        for kind: SeriesKind
1415
    ) -> [Measurements.Measurement.Point] {
1416
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1417
            return points
1418
        }
1419

            
1420
        guard let baseline = points.first(where: \.isSample)?.value else {
1421
            return points
1422
        }
1423

            
1424
        return points.enumerated().map { index, point in
1425
            Measurements.Measurement.Point(
1426
                id: point.id == index ? point.id : index,
1427
                timestamp: point.timestamp,
1428
                value: point.value - baseline,
1429
                kind: point.kind
1430
            )
1431
        }
1432
    }
1433

            
Bogdan Timofte authored 2 months ago
1434
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1435
        series(
1436
            for: measurement(for: kind),
1437
            kind: kind,
1438
            minimumYSpan: minimumYSpan(for: kind)
1439
        )
1440
    }
1441

            
Bogdan Timofte authored 2 months ago
1442
    private func smoothedPoints(
1443
        from points: [Measurements.Measurement.Point]
1444
    ) -> [Measurements.Measurement.Point] {
1445
        guard smoothingLevel != .off else { return points }
1446

            
1447
        var smoothedPoints: [Measurements.Measurement.Point] = []
1448
        var currentSegment: [Measurements.Measurement.Point] = []
1449

            
1450
        func flushCurrentSegment() {
1451
            guard !currentSegment.isEmpty else { return }
1452

            
1453
            for point in smoothedSegment(currentSegment) {
1454
                smoothedPoints.append(
1455
                    Measurements.Measurement.Point(
1456
                        id: smoothedPoints.count,
1457
                        timestamp: point.timestamp,
1458
                        value: point.value,
1459
                        kind: .sample
1460
                    )
1461
                )
1462
            }
1463

            
1464
            currentSegment.removeAll(keepingCapacity: true)
1465
        }
1466

            
1467
        for point in points {
1468
            if point.isDiscontinuity {
1469
                flushCurrentSegment()
1470

            
1471
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1472
                    smoothedPoints.append(
1473
                        Measurements.Measurement.Point(
1474
                            id: smoothedPoints.count,
1475
                            timestamp: point.timestamp,
1476
                            value: smoothedPoints.last?.value ?? point.value,
1477
                            kind: .discontinuity
1478
                        )
1479
                    )
1480
                }
1481
            } else {
1482
                currentSegment.append(point)
1483
            }
1484
        }
1485

            
1486
        flushCurrentSegment()
1487
        return smoothedPoints
1488
    }
1489

            
1490
    private func smoothedSegment(
1491
        _ segment: [Measurements.Measurement.Point]
1492
    ) -> [Measurements.Measurement.Point] {
1493
        let windowSize = smoothingLevel.movingAverageWindowSize
1494
        guard windowSize > 1, segment.count > 2 else { return segment }
1495

            
1496
        let radius = windowSize / 2
1497
        var prefixSums: [Double] = [0]
1498
        prefixSums.reserveCapacity(segment.count + 1)
1499

            
1500
        for point in segment {
1501
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1502
        }
1503

            
1504
        return segment.enumerated().map { index, point in
1505
            let lowerBound = max(0, index - radius)
1506
            let upperBound = min(segment.count - 1, index + radius)
1507
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1508
            let average = sum / Double(upperBound - lowerBound + 1)
1509

            
1510
            return Measurements.Measurement.Point(
1511
                id: point.id,
1512
                timestamp: point.timestamp,
1513
                value: average,
1514
                kind: .sample
1515
            )
1516
        }
1517
    }
1518

            
Bogdan Timofte authored 2 months ago
1519
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1520
        switch kind {
1521
        case .power:
1522
            return measurements.power
Bogdan Timofte authored 2 months ago
1523
        case .energy:
1524
            return measurements.energy
Bogdan Timofte authored 2 months ago
1525
        case .voltage:
1526
            return measurements.voltage
1527
        case .current:
1528
            return measurements.current
1529
        case .temperature:
1530
            return measurements.temperature
Bogdan Timofte authored a month ago
1531
        case .batteryPercent:
1532
            return measurements.batteryPercent
Bogdan Timofte authored 2 months ago
1533
        }
1534
    }
1535

            
1536
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1537
        switch kind {
1538
        case .power:
1539
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1540
        case .energy:
1541
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1542
        case .voltage:
1543
            return minimumVoltageSpan
1544
        case .current:
1545
            return minimumCurrentSpan
1546
        case .temperature:
1547
            return minimumTemperatureSpan
Bogdan Timofte authored a month ago
1548
        case .batteryPercent:
1549
            return minimumBatteryPercentSpan
Bogdan Timofte authored 2 months ago
1550
        }
1551
    }
1552

            
Bogdan Timofte authored 2 months ago
1553
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored a month ago
1554
        displayVoltage && displayCurrent && !displayPower && !displayEnergy && !displayBatteryPercent
Bogdan Timofte authored 2 months ago
1555
    }
1556

            
Bogdan Timofte authored 2 months ago
1557
    private var minimumSharedScaleSpan: Double {
1558
        max(minimumVoltageSpan, minimumCurrentSpan)
1559
    }
1560

            
Bogdan Timofte authored 2 months ago
1561
    private var pinnedOriginIsZero: Bool {
1562
        if useSharedOrigin && supportsSharedOrigin {
1563
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1564
        }
Bogdan Timofte authored 2 months ago
1565

            
1566
        if displayPower {
1567
            return pinOrigin && powerAxisOrigin == 0
1568
        }
1569

            
Bogdan Timofte authored 2 months ago
1570
        if displayEnergy {
1571
            return pinOrigin && energyAxisOrigin == 0
1572
        }
1573

            
Bogdan Timofte authored a month ago
1574
        if displayBatteryPercent {
1575
            return pinOrigin && batteryPercentAxisOrigin == 0
1576
        }
1577

            
Bogdan Timofte authored 2 months ago
1578
        let visibleOrigins = [
1579
            displayVoltage ? voltageAxisOrigin : nil,
1580
            displayCurrent ? currentAxisOrigin : nil
1581
        ]
1582
        .compactMap { $0 }
1583

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

            
1588
    private func toggleSharedOrigin(
1589
        voltageSeries: SeriesData,
1590
        currentSeries: SeriesData
1591
    ) {
1592
        guard supportsSharedOrigin else { return }
1593

            
1594
        if useSharedOrigin {
1595
            useSharedOrigin = false
1596
            return
1597
        }
1598

            
1599
        captureCurrentOrigins(
1600
            voltageSeries: voltageSeries,
1601
            currentSeries: currentSeries
1602
        )
1603
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1604
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1605
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1606
        useSharedOrigin = true
1607
        pinOrigin = true
1608
    }
1609

            
1610
    private func togglePinnedOrigin(
1611
        voltageSeries: SeriesData,
1612
        currentSeries: SeriesData
1613
    ) {
1614
        if pinOrigin {
1615
            pinOrigin = false
1616
            return
1617
        }
1618

            
1619
        captureCurrentOrigins(
1620
            voltageSeries: voltageSeries,
1621
            currentSeries: currentSeries
1622
        )
1623
        pinOrigin = true
1624
    }
1625

            
1626
    private func setVisibleOriginsToZero() {
1627
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1628
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1629
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1630
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1631
            voltageAxisOrigin = 0
1632
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1633
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1634
        } else {
1635
            if displayPower {
1636
                powerAxisOrigin = 0
1637
            }
Bogdan Timofte authored 2 months ago
1638
            if displayEnergy {
1639
                energyAxisOrigin = 0
1640
            }
Bogdan Timofte authored 2 months ago
1641
            if displayVoltage {
1642
                voltageAxisOrigin = 0
1643
            }
1644
            if displayCurrent {
1645
                currentAxisOrigin = 0
1646
            }
Bogdan Timofte authored 2 months ago
1647
            if displayTemperature {
1648
                temperatureAxisOrigin = 0
1649
            }
Bogdan Timofte authored a month ago
1650
            if displayBatteryPercent {
1651
                batteryPercentAxisOrigin = 0
1652
            }
Bogdan Timofte authored 2 months ago
1653
        }
1654

            
1655
        pinOrigin = true
1656
    }
1657

            
1658
    private func captureCurrentOrigins(
1659
        voltageSeries: SeriesData,
1660
        currentSeries: SeriesData
1661
    ) {
1662
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1663
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1664
        voltageAxisOrigin = voltageSeries.autoLowerBound
1665
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1666
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a month ago
1667
        batteryPercentAxisOrigin = displayedLowerBoundForSeries(.batteryPercent)
Bogdan Timofte authored 2 months ago
1668
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1669
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1670
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1671
    }
1672

            
1673
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1674
        let visibleTimeRange = activeVisibleTimeRange
1675

            
Bogdan Timofte authored 2 months ago
1676
        switch kind {
1677
        case .power:
Bogdan Timofte authored 2 months ago
1678
            return pinOrigin
1679
                ? powerAxisOrigin
1680
                : automaticYBounds(
1681
                    for: filteredSamplePoints(
1682
                        measurements.power,
1683
                        visibleTimeRange: visibleTimeRange
1684
                    ),
1685
                    minimumYSpan: minimumPowerSpan
1686
                ).lowerBound
Bogdan Timofte authored 2 months ago
1687
        case .energy:
1688
            return pinOrigin
1689
                ? energyAxisOrigin
1690
                : automaticYBounds(
1691
                    for: filteredSamplePoints(
1692
                        measurements.energy,
1693
                        visibleTimeRange: visibleTimeRange
1694
                    ),
1695
                    minimumYSpan: minimumEnergySpan
1696
                ).lowerBound
Bogdan Timofte authored 2 months ago
1697
        case .voltage:
1698
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1699
                return sharedAxisOrigin
1700
            }
Bogdan Timofte authored 2 months ago
1701
            return pinOrigin
1702
                ? voltageAxisOrigin
1703
                : automaticYBounds(
1704
                    for: filteredSamplePoints(
1705
                        measurements.voltage,
1706
                        visibleTimeRange: visibleTimeRange
1707
                    ),
1708
                    minimumYSpan: minimumVoltageSpan
1709
                ).lowerBound
Bogdan Timofte authored 2 months ago
1710
        case .current:
1711
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1712
                return sharedAxisOrigin
1713
            }
Bogdan Timofte authored 2 months ago
1714
            return pinOrigin
1715
                ? currentAxisOrigin
1716
                : automaticYBounds(
1717
                    for: filteredSamplePoints(
1718
                        measurements.current,
1719
                        visibleTimeRange: visibleTimeRange
1720
                    ),
1721
                    minimumYSpan: minimumCurrentSpan
1722
                ).lowerBound
Bogdan Timofte authored 2 months ago
1723
        case .temperature:
Bogdan Timofte authored 2 months ago
1724
            return pinOrigin
1725
                ? temperatureAxisOrigin
1726
                : automaticYBounds(
1727
                    for: filteredSamplePoints(
1728
                        measurements.temperature,
1729
                        visibleTimeRange: visibleTimeRange
1730
                    ),
1731
                    minimumYSpan: minimumTemperatureSpan
1732
                ).lowerBound
Bogdan Timofte authored a month ago
1733
        case .batteryPercent:
1734
            return pinOrigin ? batteryPercentAxisOrigin : 0
Bogdan Timofte authored 2 months ago
1735
        }
1736
    }
1737

            
Bogdan Timofte authored 2 months ago
1738
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1739
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1740
    }
1741

            
1742
    private func filteredPoints(
1743
        _ measurement: Measurements.Measurement,
1744
        visibleTimeRange: ClosedRange<Date>? = nil
1745
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1746
        let resolvedRange: ClosedRange<Date>?
1747

            
1748
        switch (timeRange, visibleTimeRange) {
1749
        case let (baseRange?, visibleRange?):
1750
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1751
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1752
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1753
        case let (baseRange?, nil):
1754
            resolvedRange = baseRange
1755
        case let (nil, visibleRange?):
1756
            resolvedRange = visibleRange
1757
        case (nil, nil):
1758
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1759
        }
Bogdan Timofte authored 2 months ago
1760

            
1761
        guard let resolvedRange else {
1762
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1763
        }
1764

            
1765
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1766
    }
1767

            
1768
    private func filteredSamplePoints(
1769
        _ measurement: Measurements.Measurement,
1770
        visibleTimeRange: ClosedRange<Date>? = nil
1771
    ) -> [Measurements.Measurement.Point] {
1772
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1773
            point.isSample
Bogdan Timofte authored 2 months ago
1774
        }
1775
    }
1776

            
1777
    private func xBounds(
Bogdan Timofte authored 2 months ago
1778
        for samplePoints: [Measurements.Measurement.Point],
1779
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1780
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1781
        if let visibleTimeRange {
1782
            return normalizedTimeRange(visibleTimeRange)
1783
        }
1784

            
Bogdan Timofte authored 2 months ago
1785
        if let timeRange {
Bogdan Timofte authored 2 months ago
1786
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1787
        }
1788

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

            
Bogdan Timofte authored 2 months ago
1792
        return normalizedTimeRange(lowerBound...upperBound)
1793
    }
1794

            
1795
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1796
        if let timeRange {
1797
            return normalizedTimeRange(timeRange)
1798
        }
1799

            
1800
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1801
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1802
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1803
            return nil
1804
        }
1805

            
Bogdan Timofte authored a month ago
1806
        let latestSampleTimestamp = samplePoints.last?.timestamp
1807
        let resolvedUpperBound = timeRangeUpperBound ?? {
1808
            guard extendsTimelineToPresent else {
1809
                return latestSampleTimestamp ?? lowerBound
1810
            }
1811
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1812
        }()
1813
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1814
        return normalizedTimeRange(lowerBound...upperBound)
1815
    }
1816

            
1817
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1818
        let candidates = [
1819
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1820
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1821
            filteredSamplePoints(measurements.voltage),
1822
            filteredSamplePoints(measurements.current),
Bogdan Timofte authored a month ago
1823
            filteredSamplePoints(measurements.temperature),
1824
            batteryPercentPoints.isEmpty
1825
                ? filteredSamplePoints(measurements.batteryPercent)
1826
                : batteryPercentPoints.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1827
        ]
1828

            
1829
        return candidates.first(where: { !$0.isEmpty }) ?? []
1830
    }
1831

            
1832
    private func resolvedVisibleTimeRange(
1833
        within availableTimeRange: ClosedRange<Date>?
1834
    ) -> ClosedRange<Date>? {
1835
        guard let availableTimeRange else { return nil }
1836
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1837

            
1838
        if isPinnedToPresent {
1839
            let pinnedRange: ClosedRange<Date>
1840

            
1841
            switch presentTrackingMode {
1842
            case .keepDuration:
1843
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1844
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1845
            case .keepStartTimestamp:
1846
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1847
            }
1848

            
1849
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1850
        }
1851

            
1852
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1853
    }
1854

            
1855
    private func clampedTimeRange(
1856
        _ candidateRange: ClosedRange<Date>,
1857
        within bounds: ClosedRange<Date>
1858
    ) -> ClosedRange<Date> {
1859
        let normalizedBounds = normalizedTimeRange(bounds)
1860
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1861

            
1862
        guard boundsSpan > 0 else {
1863
            return normalizedBounds
1864
        }
1865

            
1866
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1867
        let requestedSpan = min(
1868
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1869
            boundsSpan
1870
        )
1871

            
1872
        if requestedSpan >= boundsSpan {
1873
            return normalizedBounds
1874
        }
1875

            
1876
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1877
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1878

            
1879
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1880
            if lowerBound == normalizedBounds.lowerBound {
1881
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1882
            } else {
1883
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1884
            }
1885
        }
1886

            
1887
        if upperBound > normalizedBounds.upperBound {
1888
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1889
            upperBound = normalizedBounds.upperBound
1890
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1891
        }
1892

            
Bogdan Timofte authored 2 months ago
1893
        if lowerBound < normalizedBounds.lowerBound {
1894
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1895
            lowerBound = normalizedBounds.lowerBound
1896
            upperBound = upperBound.addingTimeInterval(delta)
1897
        }
1898

            
1899
        return lowerBound...upperBound
1900
    }
1901

            
1902
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1903
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1904
        guard span < minimumTimeSpan else { return range }
1905

            
1906
        let expansion = (minimumTimeSpan - span) / 2
1907
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1908
    }
1909

            
1910
    private func shouldShowRangeSelector(
1911
        availableTimeRange: ClosedRange<Date>,
1912
        series: SeriesData
1913
    ) -> Bool {
1914
        series.samplePoints.count > 1 &&
1915
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1916
    }
1917

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

            
1924
        guard
1925
            let minimumSampleValue = samplePoints.map(\.value).min(),
1926
            let maximumSampleValue = samplePoints.map(\.value).max()
1927
        else {
1928
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1929
        }
Bogdan Timofte authored 2 months ago
1930

            
1931
        var lowerBound = minimumSampleValue
1932
        var upperBound = maximumSampleValue
1933
        let currentSpan = upperBound - lowerBound
1934

            
1935
        if currentSpan < minimumYSpan {
1936
            let expansion = (minimumYSpan - currentSpan) / 2
1937
            lowerBound -= expansion
1938
            upperBound += expansion
1939
        }
1940

            
1941
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1942
            let shift = -negativeAllowance - lowerBound
1943
            lowerBound += shift
1944
            upperBound += shift
1945
        }
1946

            
1947
        let snappedLowerBound = snappedOriginValue(lowerBound)
1948
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1949
        return (snappedLowerBound, resolvedUpperBound)
1950
    }
1951

            
1952
    private func resolvedLowerBound(
1953
        for kind: SeriesKind,
1954
        autoLowerBound: Double
1955
    ) -> Double {
1956
        guard pinOrigin else { return autoLowerBound }
1957

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

            
1962
        switch kind {
1963
        case .power:
1964
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
1965
        case .energy:
1966
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1967
        case .voltage:
1968
            return voltageAxisOrigin
1969
        case .current:
1970
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1971
        case .temperature:
1972
            return temperatureAxisOrigin
Bogdan Timofte authored a month ago
1973
        case .batteryPercent:
1974
            return batteryPercentAxisOrigin
Bogdan Timofte authored 2 months ago
1975
        }
1976
    }
1977

            
1978
    private func resolvedUpperBound(
1979
        for kind: SeriesKind,
1980
        lowerBound: Double,
1981
        autoUpperBound: Double,
1982
        maximumSampleValue: Double?,
1983
        minimumYSpan: Double
1984
    ) -> Double {
1985
        guard pinOrigin else {
1986
            return autoUpperBound
1987
        }
1988

            
Bogdan Timofte authored 2 months ago
1989
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1990
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1991
        }
1992

            
Bogdan Timofte authored a month ago
1993
        if kind == .temperature || kind == .batteryPercent {
Bogdan Timofte authored 2 months ago
1994
            return autoUpperBound
1995
        }
1996

            
Bogdan Timofte authored 2 months ago
1997
        return max(
1998
            maximumSampleValue ?? lowerBound,
1999
            lowerBound + minimumYSpan,
2000
            autoUpperBound
2001
        )
2002
    }
2003

            
Bogdan Timofte authored 2 months ago
2004
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
2005
        let baseline = displayedLowerBoundForSeries(kind)
2006
        let proposedOrigin = snappedOriginValue(baseline + delta)
2007

            
2008
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
2009
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
2010
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
2011
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
2012
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
2013
        } else {
2014
            switch kind {
2015
            case .power:
2016
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
2017
            case .energy:
2018
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
2019
            case .voltage:
2020
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
2021
            case .current:
2022
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
2023
            case .temperature:
2024
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a month ago
2025
            case .batteryPercent:
2026
                batteryPercentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .batteryPercent))
Bogdan Timofte authored 2 months ago
2027
            }
2028
        }
2029

            
2030
        pinOrigin = true
2031
    }
2032

            
Bogdan Timofte authored 2 months ago
2033
    private func clearOriginOffset(for kind: SeriesKind) {
2034
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2035
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
2036
            sharedAxisOrigin = 0
2037
            sharedAxisUpperBound = currentSpan
2038
            ensureSharedScaleSpan()
2039
            voltageAxisOrigin = 0
2040
            currentAxisOrigin = 0
2041
        } else {
2042
            switch kind {
2043
            case .power:
2044
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2045
            case .energy:
2046
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2047
            case .voltage:
2048
                voltageAxisOrigin = 0
2049
            case .current:
2050
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2051
            case .temperature:
2052
                temperatureAxisOrigin = 0
Bogdan Timofte authored a month ago
2053
            case .batteryPercent:
2054
                batteryPercentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2055
            }
2056
        }
2057

            
2058
        pinOrigin = true
2059
    }
2060

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

            
2064
        let normalized = max(0, min(1, locationY / totalHeight))
2065
        if normalized < (1.0 / 3.0) {
2066
            applyOriginDelta(-1, kind: kind)
2067
        } else if normalized < (2.0 / 3.0) {
2068
            clearOriginOffset(for: kind)
2069
        } else {
2070
            applyOriginDelta(1, kind: kind)
2071
        }
2072
    }
2073

            
Bogdan Timofte authored 2 months ago
2074
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
2075
        let visibleTimeRange = activeVisibleTimeRange
2076

            
Bogdan Timofte authored 2 months ago
2077
        switch kind {
2078
        case .power:
Bogdan Timofte authored 2 months ago
2079
            return snappedOriginValue(
2080
                filteredSamplePoints(
2081
                    measurements.power,
2082
                    visibleTimeRange: visibleTimeRange
2083
                ).map(\.value).min() ?? 0
2084
            )
Bogdan Timofte authored 2 months ago
2085
        case .energy:
2086
            return snappedOriginValue(
2087
                filteredSamplePoints(
2088
                    measurements.energy,
2089
                    visibleTimeRange: visibleTimeRange
2090
                ).map(\.value).min() ?? 0
2091
            )
Bogdan Timofte authored 2 months ago
2092
        case .voltage:
Bogdan Timofte authored 2 months ago
2093
            return snappedOriginValue(
2094
                filteredSamplePoints(
2095
                    measurements.voltage,
2096
                    visibleTimeRange: visibleTimeRange
2097
                ).map(\.value).min() ?? 0
2098
            )
Bogdan Timofte authored 2 months ago
2099
        case .current:
Bogdan Timofte authored 2 months ago
2100
            return snappedOriginValue(
2101
                filteredSamplePoints(
2102
                    measurements.current,
2103
                    visibleTimeRange: visibleTimeRange
2104
                ).map(\.value).min() ?? 0
2105
            )
Bogdan Timofte authored 2 months ago
2106
        case .temperature:
Bogdan Timofte authored 2 months ago
2107
            return snappedOriginValue(
2108
                filteredSamplePoints(
2109
                    measurements.temperature,
2110
                    visibleTimeRange: visibleTimeRange
Bogdan Timofte authored a month ago
2111
                ).map(\.value).min() ?? 0
2112
            )
2113
        case .batteryPercent:
2114
            return snappedOriginValue(
2115
                filteredSamplePoints(
2116
                    measurements.batteryPercent,
2117
                    visibleTimeRange: visibleTimeRange
Bogdan Timofte authored 2 months ago
2118
                ).map(\.value).min() ?? 0
2119
            )
Bogdan Timofte authored 2 months ago
2120
        }
2121
    }
2122

            
2123
    private func maximumVisibleSharedOrigin() -> Double {
2124
        min(
2125
            maximumVisibleOrigin(for: .voltage),
2126
            maximumVisibleOrigin(for: .current)
2127
        )
2128
    }
2129

            
Bogdan Timofte authored 2 months ago
2130
    private func measurementUnit(for kind: SeriesKind) -> String {
2131
        switch kind {
2132
        case .temperature:
2133
            let locale = Locale.autoupdatingCurrent
2134
            if #available(iOS 16.0, *) {
2135
                switch locale.measurementSystem {
2136
                case .us:
2137
                    return "°F"
2138
                default:
2139
                    return "°C"
2140
                }
2141
            }
2142

            
2143
            let regionCode = locale.regionCode ?? ""
2144
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
2145
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
2146
        default:
2147
            return kind.unit
2148
        }
2149
    }
2150

            
Bogdan Timofte authored 2 months ago
2151
    private func ensureSharedScaleSpan() {
2152
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
2153
    }
2154

            
Bogdan Timofte authored 2 months ago
2155
    private func snappedOriginValue(_ value: Double) -> Double {
2156
        if value >= 0 {
2157
            return value.rounded(.down)
2158
        }
2159

            
2160
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
2161
    }
Bogdan Timofte authored 2 months ago
2162

            
Bogdan Timofte authored 2 months ago
2163
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
2164
        measurements.keepOnly(in: range)
2165
        selectedVisibleTimeRange = nil
2166
        isPinnedToPresent = false
2167
    }
2168

            
2169
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
2170
        measurements.removeValues(in: range)
2171
        selectedVisibleTimeRange = nil
2172
        isPinnedToPresent = false
2173
    }
2174

            
Bogdan Timofte authored 2 months ago
2175
    private func yGuidePosition(
2176
        for labelIndex: Int,
2177
        context: ChartContext,
2178
        height: CGFloat
2179
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2180
        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
Bogdan Timofte authored 2 months ago
2181
    }
2182

            
2183
    private func xGuidePosition(
2184
        for labelIndex: Int,
2185
        context: ChartContext,
2186
        width: CGFloat
2187
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2188
        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
Bogdan Timofte authored 2 months ago
2189
    }
Bogdan Timofte authored 2 months ago
2190

            
2191
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
2192
    fileprivate func xAxisLabelsView(
2193
        context: ChartContext
2194
    ) -> some View {
Bogdan Timofte authored 2 months ago
2195
        var timeFormat: String?
2196
        switch context.size.width {
2197
        case 0..<3600: timeFormat = "HH:mm:ss"
2198
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
2199
        default: timeFormat = "E HH:mm"
2200
        }
2201
        let labels = (1...xLabels).map {
2202
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
2203
        }
Bogdan Timofte authored 2 months ago
2204
        let axisLabelFont: Font = {
2205
            if isIPhone && isPortraitLayout {
2206
                return .caption2.weight(.semibold)
2207
            }
2208
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
2209
        }()
Bogdan Timofte authored 2 months ago
2210

            
2211
        return HStack(spacing: chartSectionSpacing) {
2212
            Color.clear
2213
                .frame(width: axisColumnWidth)
2214

            
2215
            GeometryReader { geometry in
2216
                let labelWidth = max(
2217
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
2218
                    1
2219
                )
2220

            
2221
                ZStack(alignment: .topLeading) {
2222
                    Path { path in
2223
                        for labelIndex in 1...self.xLabels {
2224
                            let x = xGuidePosition(
2225
                                for: labelIndex,
2226
                                context: context,
2227
                                width: geometry.size.width
2228
                            )
2229
                            path.move(to: CGPoint(x: x, y: 0))
2230
                            path.addLine(to: CGPoint(x: x, y: 6))
2231
                        }
2232
                    }
2233
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
2234

            
2235
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
2236
                        let labelIndex = item.offset + 1
2237
                        let centerX = xGuidePosition(
2238
                            for: labelIndex,
2239
                            context: context,
2240
                            width: geometry.size.width
2241
                        )
2242

            
2243
                        Text(item.element)
Bogdan Timofte authored 2 months ago
2244
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
2245
                            .monospacedDigit()
2246
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
2247
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
2248
                            .frame(width: labelWidth)
2249
                            .position(
2250
                                x: centerX,
2251
                                y: geometry.size.height * 0.7
2252
                            )
Bogdan Timofte authored 2 months ago
2253
                    }
2254
                }
Bogdan Timofte authored 2 months ago
2255
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
2256
            }
Bogdan Timofte authored 2 months ago
2257

            
2258
            Color.clear
2259
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
2260
        }
2261
    }
2262

            
Bogdan Timofte authored 2 months ago
2263
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
2264
        height: CGFloat,
2265
        context: ChartContext,
Bogdan Timofte authored 2 months ago
2266
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
2267
        measurementUnit: String,
2268
        tint: Color
2269
    ) -> some View {
Bogdan Timofte authored 2 months ago
2270
        let yAxisFont: Font = {
2271
            if isIPhone && isPortraitLayout {
2272
                return .caption2.weight(.semibold)
2273
            }
2274
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
2275
        }()
2276

            
2277
        let unitFont: Font = {
2278
            if isIPhone && isPortraitLayout {
2279
                return .caption2.weight(.bold)
2280
            }
2281
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
2282
        }()
2283

            
2284
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
2285
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
2286
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
2287
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
2288

            
Bogdan Timofte authored 2 months ago
2289
            ZStack(alignment: .top) {
2290
                ForEach(0..<yLabels, id: \.self) { row in
2291
                    let labelIndex = yLabels - row
2292

            
2293
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
2294
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
2295
                        .monospacedDigit()
2296
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
2297
                        .minimumScaleFactor(0.8)
2298
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
2299
                        .position(
2300
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
2301
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
2302
                                for: labelIndex,
2303
                                context: context,
Bogdan Timofte authored 2 months ago
2304
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
2305
                            )
2306
                        )
Bogdan Timofte authored 2 months ago
2307
                }
Bogdan Timofte authored 2 months ago
2308

            
Bogdan Timofte authored 2 months ago
2309
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
2310
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
2311
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
2312
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
2313
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
2314
                    .background(
2315
                        Capsule(style: .continuous)
2316
                            .fill(tint.opacity(0.14))
2317
                    )
Bogdan Timofte authored 2 months ago
2318
                    .padding(.top, 8)
2319

            
Bogdan Timofte authored 2 months ago
2320
            }
2321
        }
Bogdan Timofte authored 2 months ago
2322
        .frame(height: height)
2323
        .background(
2324
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2325
                .fill(tint.opacity(0.12))
2326
        )
2327
        .overlay(
2328
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2329
                .stroke(tint.opacity(0.20), lineWidth: 1)
2330
        )
Bogdan Timofte authored 2 months ago
2331
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
2332
        .gesture(
Bogdan Timofte authored 2 months ago
2333
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
2334
                .onEnded { value in
Bogdan Timofte authored 2 months ago
2335
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
2336
                }
2337
        )
Bogdan Timofte authored 2 months ago
2338
    }
2339

            
Bogdan Timofte authored 2 months ago
2340
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2341
        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
Bogdan Timofte authored 2 months ago
2342
    }
2343

            
Bogdan Timofte authored 2 months ago
2344
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2345
        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
Bogdan Timofte authored 2 months ago
2346
    }
Bogdan Timofte authored 2 months ago
2347

            
2348
    fileprivate func discontinuityMarkers(
2349
        points: [Measurements.Measurement.Point],
2350
        context: ChartContext
2351
    ) -> some View {
2352
        GeometryReader { geometry in
2353
            Path { path in
2354
                for point in points where point.isDiscontinuity {
2355
                    let markerX = context.placeInRect(
2356
                        point: CGPoint(
2357
                            x: point.timestamp.timeIntervalSince1970,
2358
                            y: context.origin.y
2359
                        )
2360
                    ).x * geometry.size.width
2361
                    path.move(to: CGPoint(x: markerX, y: 0))
2362
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
2363
                }
2364
            }
2365
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
2366
        }
2367
    }
Bogdan Timofte authored 2 months ago
2368

            
2369
}
2370

            
Bogdan Timofte authored a month ago
2371
private struct EmbeddedWidthKey: PreferenceKey {
2372
    static let defaultValue: CGFloat = 760
2373
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2374
        let next = nextValue()
2375
        if next > 0 { value = next }
2376
    }
2377
}
2378

            
Bogdan Timofte authored 2 months ago
2379
private struct TimeRangeSelectorView: View {
2380
    private enum DragTarget {
2381
        case lowerBound
2382
        case upperBound
2383
        case window
2384
    }
2385

            
2386
    private struct DragState {
2387
        let target: DragTarget
2388
        let initialRange: ClosedRange<Date>
2389
    }
2390

            
2391
    let points: [Measurements.Measurement.Point]
2392
    let context: ChartContext
2393
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
2394
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2395
    let compactLayout: Bool
Bogdan Timofte authored a month ago
2396
    let xAxisLabelCount: Int
Bogdan Timofte authored 2 months ago
2397
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2398
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2399

            
2400
    @Binding var selectedTimeRange: ClosedRange<Date>?
2401
    @Binding var isPinnedToPresent: Bool
2402
    @Binding var presentTrackingMode: PresentTrackingMode
2403
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
2404
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
2405

            
2406
    private var totalSpan: TimeInterval {
2407
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2408
    }
2409

            
2410
    private var currentRange: ClosedRange<Date> {
2411
        resolvedSelectionRange()
2412
    }
2413

            
2414
    private var trackHeight: CGFloat {
Bogdan Timofte authored a month ago
2415
        Self.trackHeight(compactLayout: compactLayout)
2416
    }
2417

            
2418
    private static func trackHeight(compactLayout: Bool) -> CGFloat {
2419
        compactLayout ? 42 : 50
Bogdan Timofte authored 2 months ago
2420
    }
2421

            
Bogdan Timofte authored a month ago
2422
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2423
        let rowHeight: CGFloat = compactLayout ? 28 : 32
Bogdan Timofte authored a month ago
2424
        let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2425
        let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
Bogdan Timofte authored a month ago
2426
        let spacing: CGFloat = compactLayout ? 6 : 8
Bogdan Timofte authored a month ago
2427
        // Single row of controls instead of two
2428
        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
Bogdan Timofte authored a month ago
2429
    }
2430

            
Bogdan Timofte authored 2 months ago
2431
    private var cornerRadius: CGFloat {
2432
        compactLayout ? 14 : 16
2433
    }
2434

            
2435
    private var symbolButtonSize: CGFloat {
2436
        compactLayout ? 28 : 32
2437
    }
2438

            
2439
    var body: some View {
2440
        let coversFullRange = selectionCoversFullRange(currentRange)
2441

            
2442
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
Bogdan Timofte authored a month ago
2443
            HStack(spacing: 8) {
2444
                // Alignment controls
2445
                if !coversFullRange || isPinnedToPresent {
Bogdan Timofte authored 2 months ago
2446
                    alignmentButton(
2447
                        systemName: "arrow.left.to.line.compact",
2448
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2449
                        action: alignSelectionToLeadingEdge,
2450
                        accessibilityLabel: "Align selection to start"
2451
                    )
2452

            
2453
                    alignmentButton(
2454
                        systemName: "arrow.right.to.line.compact",
2455
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2456
                        action: alignSelectionToTrailingEdge,
2457
                        accessibilityLabel: "Align selection to present"
2458
                    )
2459

            
2460
                    if isPinnedToPresent {
2461
                        trackingModeToggleButton()
2462
                    }
2463
                }
2464

            
Bogdan Timofte authored a month ago
2465
                Spacer(minLength: 0)
2466

            
2467
                // Trim/Save actions
Bogdan Timofte authored 2 months ago
2468
                if !coversFullRange {
Bogdan Timofte authored a month ago
2469
                    iconButton(
Bogdan Timofte authored a month ago
2470
                        systemName: configuration.keepAction.systemName,
2471
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2472
                        action: {
Bogdan Timofte authored a month ago
2473
                            configuration.keepAction.handler(currentRange)
2474
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2475
                        }
2476
                    )
Bogdan Timofte authored a month ago
2477
                    .help(configuration.keepAction.title)
Bogdan Timofte authored 2 months ago
2478

            
Bogdan Timofte authored a month ago
2479
                    if let removeAction = configuration.removeAction {
Bogdan Timofte authored a month ago
2480
                        iconButton(
Bogdan Timofte authored a month ago
2481
                            systemName: removeAction.systemName,
2482
                            tone: removeAction.tone,
2483
                            action: {
2484
                                removeAction.handler(currentRange)
2485
                                resetSelectionState()
2486
                            }
2487
                        )
Bogdan Timofte authored a month ago
2488
                        .help(removeAction.title)
Bogdan Timofte authored a month ago
2489
                    }
Bogdan Timofte authored 2 months ago
2490

            
Bogdan Timofte authored a month ago
2491
                    // Reset action (only show when there's a trim to reset)
2492
                    iconButton(
2493
                        systemName: configuration.resetAction.systemName,
2494
                        tone: configuration.resetAction.tone,
2495
                        action: {
2496
                            showResetConfirmation = true
2497
                        }
2498
                    )
2499
                    .help(configuration.resetAction.title)
2500
                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2501
                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2502
                            configuration.resetAction.handler()
2503
                            resetSelectionState()
2504
                        }
2505
                        Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 months ago
2506
                    }
2507
                }
2508
            }
2509

            
Bogdan Timofte authored 2 months ago
2510
            GeometryReader { geometry in
2511
                let selectionFrame = selectionFrame(in: geometry.size)
2512
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2513

            
2514
                ZStack(alignment: .topLeading) {
2515
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2516
                        .fill(Color.primary.opacity(0.05))
2517

            
Bogdan Timofte authored a month ago
2518
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2519
                        points: points,
2520
                        context: context,
2521
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2522
                        strokeColor: selectorTint,
2523
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2524
                    )
2525
                    .opacity(0.94)
2526
                    .allowsHitTesting(false)
2527

            
Bogdan Timofte authored a month ago
2528
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2529
                        points: points,
2530
                        context: context,
Bogdan Timofte authored 2 months ago
2531
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2532
                    )
2533
                    .opacity(0.82)
2534
                    .allowsHitTesting(false)
2535

            
2536
                    if selectionFrame.minX > 0 {
2537
                        Rectangle()
2538
                            .fill(dimmingColor)
2539
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2540
                            .allowsHitTesting(false)
2541
                    }
2542

            
2543
                    if selectionFrame.maxX < geometry.size.width {
2544
                        Rectangle()
2545
                            .fill(dimmingColor)
2546
                            .frame(
2547
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2548
                                height: geometry.size.height
2549
                            )
2550
                            .offset(x: selectionFrame.maxX)
2551
                            .allowsHitTesting(false)
2552
                    }
2553

            
2554
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2555
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2556
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2557
                        .offset(x: selectionFrame.minX)
2558
                        .allowsHitTesting(false)
2559

            
2560
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2561
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2562
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2563
                        .offset(x: selectionFrame.minX)
2564
                        .allowsHitTesting(false)
2565

            
2566
                    handleView(height: max(geometry.size.height - 18, 16))
2567
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2568
                        .allowsHitTesting(false)
2569

            
2570
                    handleView(height: max(geometry.size.height - 18, 16))
2571
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2572
                        .allowsHitTesting(false)
2573
                }
2574
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2575
                .overlay(
2576
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
Bogdan Timofte authored a month ago
2577
                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2578
                )
2579
                .contentShape(Rectangle())
2580
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2581
            }
2582
            .frame(height: trackHeight)
2583

            
Bogdan Timofte authored a month ago
2584
            xAxisLabelsView
Bogdan Timofte authored 2 months ago
2585
        }
2586
    }
2587

            
2588
    private func handleView(height: CGFloat) -> some View {
2589
        Capsule(style: .continuous)
2590
            .fill(Color.white.opacity(0.95))
2591
            .frame(width: 6, height: height)
2592
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2593
    }
2594

            
2595
    private func alignmentButton(
2596
        systemName: String,
2597
        isActive: Bool,
2598
        action: @escaping () -> Void,
2599
        accessibilityLabel: String
2600
    ) -> some View {
2601
        Button(action: action) {
2602
            Image(systemName: systemName)
2603
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2604
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2605
        }
2606
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2607
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2608
        .background(
2609
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2610
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2611
        )
2612
        .overlay(
2613
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2614
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2615
        )
2616
        .accessibilityLabel(accessibilityLabel)
2617
    }
2618

            
2619
    private func trackingModeToggleButton() -> some View {
2620
        Button {
2621
            presentTrackingMode = presentTrackingMode == .keepDuration
2622
                ? .keepStartTimestamp
2623
                : .keepDuration
2624
        } label: {
2625
            Image(systemName: trackingModeSymbolName)
2626
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2627
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2628
        }
2629
        .buttonStyle(.plain)
2630
        .foregroundColor(.white)
2631
        .background(
2632
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2633
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2634
        )
2635
        .overlay(
2636
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2637
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2638
        )
2639
        .accessibilityLabel(trackingModeAccessibilityLabel)
2640
        .accessibilityHint("Toggles how the interval follows the present")
2641
    }
2642

            
Bogdan Timofte authored 2 months ago
2643
    private func actionButton(
2644
        title: String,
Bogdan Timofte authored a month ago
2645
        shortTitle: String? = nil,
Bogdan Timofte authored 2 months ago
2646
        systemName: String,
Bogdan Timofte authored a month ago
2647
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2648
        action: @escaping () -> Void
2649
    ) -> some View {
2650
        let foregroundColor: Color = {
2651
            switch tone {
2652
            case .reversible, .destructive:
2653
                return toneColor(for: tone)
2654
            case .destructiveProminent:
2655
                return .white
2656
            }
2657
        }()
Bogdan Timofte authored a month ago
2658
        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
Bogdan Timofte authored 2 months ago
2659

            
2660
        return Button(action: action) {
Bogdan Timofte authored a month ago
2661
            Label(displayTitle, systemImage: systemName)
Bogdan Timofte authored 2 months ago
2662
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2663
                .padding(.horizontal, compactLayout ? 10 : 12)
2664
                .padding(.vertical, compactLayout ? 7 : 8)
2665
        }
2666
        .buttonStyle(.plain)
2667
        .foregroundColor(foregroundColor)
2668
        .background(
2669
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2670
                .fill(actionButtonBackground(for: tone))
2671
        )
2672
        .overlay(
2673
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2674
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2675
        )
2676
    }
2677

            
Bogdan Timofte authored a month ago
2678
    private func iconButton(
2679
        systemName: String,
2680
        tone: MeasurementChartSelectorActionTone,
2681
        action: @escaping () -> Void
2682
    ) -> some View {
2683
        let foregroundColor: Color = {
2684
            switch tone {
2685
            case .reversible, .destructive:
2686
                return toneColor(for: tone)
2687
            case .destructiveProminent:
2688
                return .white
2689
            }
2690
        }()
2691

            
2692
        return Button(action: action) {
2693
            Image(systemName: systemName)
2694
                .font(.subheadline.weight(.semibold))
2695
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2696
        }
2697
        .buttonStyle(.plain)
2698
        .foregroundColor(foregroundColor)
2699
        .background(
2700
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2701
                .fill(actionButtonBackground(for: tone))
2702
        )
2703
        .overlay(
2704
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2705
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2706
        )
2707
    }
2708

            
Bogdan Timofte authored a month ago
2709
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2710
        switch tone {
2711
        case .reversible:
2712
            return selectorTint
2713
        case .destructive, .destructiveProminent:
2714
            return .red
2715
        }
2716
    }
2717

            
Bogdan Timofte authored a month ago
2718
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2719
        switch tone {
2720
        case .reversible:
2721
            return selectorTint.opacity(0.12)
2722
        case .destructive:
2723
            return Color.red.opacity(0.12)
2724
        case .destructiveProminent:
2725
            return Color.red.opacity(0.82)
2726
        }
2727
    }
2728

            
Bogdan Timofte authored a month ago
2729
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2730
        switch tone {
2731
        case .reversible:
2732
            return selectorTint.opacity(0.22)
2733
        case .destructive:
2734
            return Color.red.opacity(0.22)
2735
        case .destructiveProminent:
2736
            return Color.red.opacity(0.72)
2737
        }
2738
    }
2739

            
Bogdan Timofte authored 2 months ago
2740
    private var trackingModeSymbolName: String {
2741
        switch presentTrackingMode {
2742
        case .keepDuration:
2743
            return "arrow.left.and.right"
2744
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2745
            return "arrow.right"
Bogdan Timofte authored 2 months ago
2746
        }
2747
    }
2748

            
2749
    private var trackingModeAccessibilityLabel: String {
2750
        switch presentTrackingMode {
2751
        case .keepDuration:
Bogdan Timofte authored a month ago
2752
            return "Keep fixed duration"
Bogdan Timofte authored 2 months ago
2753
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2754
            return "Keep start fixed"
Bogdan Timofte authored 2 months ago
2755
        }
2756
    }
2757

            
2758
    private func alignSelectionToLeadingEdge() {
2759
        let alignedRange = normalizedSelectionRange(
2760
            availableTimeRange.lowerBound...currentRange.upperBound
2761
        )
2762
        applySelection(alignedRange, pinToPresent: false)
2763
    }
2764

            
2765
    private func alignSelectionToTrailingEdge() {
2766
        let alignedRange = normalizedSelectionRange(
2767
            currentRange.lowerBound...availableTimeRange.upperBound
2768
        )
2769
        applySelection(alignedRange, pinToPresent: true)
2770
    }
2771

            
2772
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2773
        DragGesture(minimumDistance: 0)
2774
            .onChanged { value in
2775
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2776
            }
2777
            .onEnded { _ in
2778
                dragState = nil
2779
            }
2780
    }
2781

            
2782
    private func updateSelectionDrag(
2783
        value: DragGesture.Value,
2784
        totalWidth: CGFloat
2785
    ) {
2786
        let startingRange = resolvedSelectionRange()
2787

            
2788
        if dragState == nil {
2789
            dragState = DragState(
2790
                target: dragTarget(
2791
                    for: value.startLocation.x,
2792
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2793
                ),
2794
                initialRange: startingRange
2795
            )
2796
        }
2797

            
2798
        guard let dragState else { return }
2799

            
2800
        let resultingRange = snappedToEdges(
2801
            adjustedRange(
2802
                from: dragState.initialRange,
2803
                target: dragState.target,
2804
                translationX: value.translation.width,
2805
                totalWidth: totalWidth
2806
            ),
2807
            target: dragState.target,
2808
            totalWidth: totalWidth
2809
        )
2810

            
2811
        applySelection(
2812
            resultingRange,
2813
            pinToPresent: shouldKeepPresentPin(
2814
                during: dragState.target,
2815
                initialRange: dragState.initialRange,
2816
                resultingRange: resultingRange
2817
            ),
2818
        )
2819
    }
2820

            
2821
    private func dragTarget(
2822
        for startX: CGFloat,
2823
        selectionFrame: CGRect
2824
    ) -> DragTarget {
2825
        let handleZone: CGFloat = compactLayout ? 20 : 24
2826

            
2827
        if abs(startX - selectionFrame.minX) <= handleZone {
2828
            return .lowerBound
2829
        }
2830

            
2831
        if abs(startX - selectionFrame.maxX) <= handleZone {
2832
            return .upperBound
2833
        }
2834

            
2835
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2836
            return .window
2837
        }
2838

            
2839
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2840
    }
2841

            
2842
    private func adjustedRange(
2843
        from initialRange: ClosedRange<Date>,
2844
        target: DragTarget,
2845
        translationX: CGFloat,
2846
        totalWidth: CGFloat
2847
    ) -> ClosedRange<Date> {
2848
        guard totalSpan > 0, totalWidth > 0 else {
2849
            return availableTimeRange
2850
        }
2851

            
2852
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2853
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2854

            
2855
        switch target {
2856
        case .lowerBound:
2857
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2858
            let newLowerBound = min(
2859
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2860
                maximumLowerBound
2861
            )
2862
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2863

            
2864
        case .upperBound:
2865
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2866
            let newUpperBound = max(
2867
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2868
                minimumUpperBound
2869
            )
2870
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2871

            
2872
        case .window:
2873
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2874
            guard span < totalSpan else { return availableTimeRange }
2875

            
2876
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2877
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2878

            
2879
            if lowerBound < availableTimeRange.lowerBound {
2880
                upperBound = upperBound.addingTimeInterval(
2881
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2882
                )
2883
                lowerBound = availableTimeRange.lowerBound
2884
            }
2885

            
2886
            if upperBound > availableTimeRange.upperBound {
2887
                lowerBound = lowerBound.addingTimeInterval(
2888
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2889
                )
2890
                upperBound = availableTimeRange.upperBound
2891
            }
2892

            
2893
            return normalizedSelectionRange(lowerBound...upperBound)
2894
        }
2895
    }
2896

            
2897
    private func snappedToEdges(
2898
        _ candidateRange: ClosedRange<Date>,
2899
        target: DragTarget,
2900
        totalWidth: CGFloat
2901
    ) -> ClosedRange<Date> {
2902
        guard totalSpan > 0 else {
2903
            return availableTimeRange
2904
        }
2905

            
2906
        let snapInterval = edgeSnapInterval(for: totalWidth)
2907
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2908
        var lowerBound = candidateRange.lowerBound
2909
        var upperBound = candidateRange.upperBound
2910

            
2911
        if target != .upperBound,
2912
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2913
            lowerBound = availableTimeRange.lowerBound
2914
            if target == .window {
2915
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2916
            }
2917
        }
2918

            
2919
        if target != .lowerBound,
2920
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2921
            upperBound = availableTimeRange.upperBound
2922
            if target == .window {
2923
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2924
            }
2925
        }
2926

            
2927
        return normalizedSelectionRange(lowerBound...upperBound)
2928
    }
2929

            
2930
    private func edgeSnapInterval(
2931
        for totalWidth: CGFloat
2932
    ) -> TimeInterval {
2933
        guard totalWidth > 0 else { return minimumSelectionSpan }
2934

            
2935
        let snapWidth = min(
2936
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2937
            totalWidth * 0.18
2938
        )
2939
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2940
        return min(
2941
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2942
            totalSpan / 4
2943
        )
2944
    }
2945

            
2946
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2947
        guard let selectedTimeRange else { return availableTimeRange }
2948

            
2949
        if isPinnedToPresent {
2950
            switch presentTrackingMode {
2951
            case .keepDuration:
2952
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2953
                return normalizedSelectionRange(
2954
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2955
                )
2956
            case .keepStartTimestamp:
2957
                return normalizedSelectionRange(
2958
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2959
                )
2960
            }
2961
        }
2962

            
2963
        return normalizedSelectionRange(selectedTimeRange)
2964
    }
2965

            
2966
    private func normalizedSelectionRange(
2967
        _ candidateRange: ClosedRange<Date>
2968
    ) -> ClosedRange<Date> {
2969
        let availableSpan = totalSpan
2970
        guard availableSpan > 0 else { return availableTimeRange }
2971

            
2972
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2973
        let requestedSpan = min(
2974
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2975
            availableSpan
2976
        )
2977

            
2978
        if requestedSpan >= availableSpan {
2979
            return availableTimeRange
2980
        }
2981

            
2982
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2983
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2984

            
2985
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2986
            if lowerBound == availableTimeRange.lowerBound {
2987
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2988
            } else {
2989
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2990
            }
2991
        }
2992

            
2993
        if upperBound > availableTimeRange.upperBound {
2994
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2995
            upperBound = availableTimeRange.upperBound
2996
            lowerBound = lowerBound.addingTimeInterval(-delta)
2997
        }
2998

            
2999
        if lowerBound < availableTimeRange.lowerBound {
3000
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
3001
            lowerBound = availableTimeRange.lowerBound
3002
            upperBound = upperBound.addingTimeInterval(delta)
3003
        }
3004

            
3005
        return lowerBound...upperBound
3006
    }
3007

            
3008
    private func shouldKeepPresentPin(
3009
        during target: DragTarget,
3010
        initialRange: ClosedRange<Date>,
3011
        resultingRange: ClosedRange<Date>
3012
    ) -> Bool {
3013
        let startedPinnedToPresent =
3014
            isPinnedToPresent ||
3015
            selectionCoversFullRange(initialRange)
3016

            
3017
        guard startedPinnedToPresent else {
3018
            return selectionTouchesPresent(resultingRange)
3019
        }
3020

            
3021
        switch target {
3022
        case .lowerBound:
3023
            return true
3024
        case .upperBound, .window:
3025
            return selectionTouchesPresent(resultingRange)
3026
        }
3027
    }
3028

            
3029
    private func applySelection(
3030
        _ candidateRange: ClosedRange<Date>,
3031
        pinToPresent: Bool
3032
    ) {
3033
        let normalizedRange = normalizedSelectionRange(candidateRange)
3034

            
3035
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
3036
            selectedTimeRange = nil
3037
        } else {
3038
            selectedTimeRange = normalizedRange
3039
        }
3040

            
3041
        isPinnedToPresent = pinToPresent
3042
    }
3043

            
Bogdan Timofte authored a month ago
3044
    private func resetSelectionState() {
3045
        selectedTimeRange = nil
3046
        isPinnedToPresent = false
3047
    }
3048

            
Bogdan Timofte authored 2 months ago
3049
    private func selectionTouchesPresent(
3050
        _ range: ClosedRange<Date>
3051
    ) -> Bool {
3052
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
3053
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
3054
    }
3055

            
3056
    private func selectionCoversFullRange(
3057
        _ range: ClosedRange<Date>
3058
    ) -> Bool {
3059
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
3060
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
3061
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
3062
    }
3063

            
3064
    private func selectionFrame(in size: CGSize) -> CGRect {
3065
        selectionFrame(for: currentRange, width: size.width)
3066
    }
3067

            
3068
    private func selectionFrame(
3069
        for range: ClosedRange<Date>,
3070
        width: CGFloat
3071
    ) -> CGRect {
3072
        guard width > 0, totalSpan > 0 else {
3073
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
3074
        }
3075

            
3076
        let minimumX = xPosition(for: range.lowerBound, width: width)
3077
        let maximumX = xPosition(for: range.upperBound, width: width)
3078
        return CGRect(
3079
            x: minimumX,
3080
            y: 0,
3081
            width: max(maximumX - minimumX, 2),
3082
            height: trackHeight
3083
        )
3084
    }
3085

            
3086
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
3087
        guard width > 0, totalSpan > 0 else { return 0 }
3088

            
3089
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
3090
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
3091
        return CGFloat(normalizedOffset) * width
3092
    }
3093

            
Bogdan Timofte authored a month ago
3094
    private var xAxisLabelsView: some View {
3095
        let timeFormat: String = {
3096
            switch context.size.width {
3097
            case 0..<3600: return "HH:mm:ss"
3098
            case 3600...86400: return "HH:mm"
3099
            default: return "E HH:mm"
3100
            }
3101
        }()
Bogdan Timofte authored 2 months ago
3102

            
Bogdan Timofte authored a month ago
3103
        let labelCount = max(xAxisLabelCount, 2)
3104
        let labels = (1...labelCount).map {
3105
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat)
Bogdan Timofte authored 2 months ago
3106
        }
Bogdan Timofte authored a month ago
3107
        let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold)
3108

            
3109
        return GeometryReader { geometry in
3110
            let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1)
3111

            
3112
            ZStack(alignment: .topLeading) {
3113
                Path { path in
3114
                    for labelIndex in 1...labelCount {
3115
                        let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
3116
                        path.move(to: CGPoint(x: x, y: 0))
3117
                        path.addLine(to: CGPoint(x: x, y: 5))
3118
                    }
3119
                }
3120
                .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
3121

            
3122
                ForEach(Array(labels.enumerated()), id: \.offset) { item in
3123
                    let labelIndex = item.offset + 1
3124
                    let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
Bogdan Timofte authored a month ago
3125
                    let halfWidth = labelWidth / 2
3126
                    let clampedX = min(max(centerX, halfWidth), geometry.size.width - halfWidth)
Bogdan Timofte authored a month ago
3127

            
3128
                    Text(item.element)
3129
                        .font(axisLabelFont)
3130
                        .monospacedDigit()
3131
                        .lineLimit(1)
3132
                        .minimumScaleFactor(0.74)
3133
                        .frame(width: labelWidth)
3134
                        .position(
Bogdan Timofte authored a month ago
3135
                            x: clampedX,
Bogdan Timofte authored a month ago
3136
                            y: geometry.size.height * 0.66
3137
                        )
3138
                }
3139
            }
3140
        }
3141
        .frame(height: compactLayout ? 18 : 20)
3142
    }
3143

            
3144
    private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
3145
        context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width)
Bogdan Timofte authored 2 months ago
3146
    }
3147
}