USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
3321 lines | 121.246kb
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
Bogdan Timofte authored a month ago
10
import UniformTypeIdentifiers
Bogdan Timofte authored 2 months ago
11

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
78
struct MeasurementChartExportAction {
79
    let title: String
80
    let shortTitle: String?
81
    let systemName: String
82
    let tone: MeasurementChartSelectorActionTone
83
    let fileName: (ClosedRange<Date>) -> String
84
    let content: (ClosedRange<Date>) -> String
85

            
86
    init(
87
        title: String,
88
        shortTitle: String? = nil,
89
        systemName: String,
90
        tone: MeasurementChartSelectorActionTone,
91
        fileName: @escaping (ClosedRange<Date>) -> String,
92
        content: @escaping (ClosedRange<Date>) -> String
93
    ) {
94
        self.title = title
95
        self.shortTitle = shortTitle
96
        self.systemName = systemName
97
        self.tone = tone
98
        self.fileName = fileName
99
        self.content = content
100
    }
101
}
102

            
Bogdan Timofte authored a month ago
103
struct MeasurementChartRangeSelectorConfiguration {
104
    let keepAction: MeasurementChartSelectionAction
105
    let removeAction: MeasurementChartSelectionAction?
106
    let resetAction: MeasurementChartResetAction
Bogdan Timofte authored a month ago
107
    let exportAction: MeasurementChartExportAction?
108

            
109
    init(
110
        keepAction: MeasurementChartSelectionAction,
111
        removeAction: MeasurementChartSelectionAction?,
112
        resetAction: MeasurementChartResetAction,
113
        exportAction: MeasurementChartExportAction? = nil
114
    ) {
115
        self.keepAction = keepAction
116
        self.removeAction = removeAction
117
        self.resetAction = resetAction
118
        self.exportAction = exportAction
119
    }
120
}
121

            
122
private struct MeasurementChartCSVDocument: FileDocument {
123
    static var readableContentTypes: [UTType] { [.commaSeparatedText] }
124

            
125
    var content: String
126

            
127
    init(content: String) {
128
        self.content = content
129
    }
130

            
131
    init(configuration: ReadConfiguration) throws {
132
        content = ""
133
    }
134

            
135
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
136
        FileWrapper(regularFileWithContents: Data(content.utf8))
137
    }
Bogdan Timofte authored a month ago
138
}
139

            
Bogdan Timofte authored 2 months ago
140
struct MeasurementChartView: View {
Bogdan Timofte authored 2 months ago
141
    private enum SmoothingLevel: CaseIterable, Hashable {
142
        case off
143
        case light
144
        case medium
145
        case strong
146

            
147
        var label: String {
148
            switch self {
149
            case .off: return "Off"
150
            case .light: return "Light"
151
            case .medium: return "Medium"
152
            case .strong: return "Strong"
153
            }
154
        }
155

            
156
        var shortLabel: String {
157
            switch self {
158
            case .off: return "Off"
159
            case .light: return "Low"
160
            case .medium: return "Med"
161
            case .strong: return "High"
162
            }
163
        }
164

            
165
        var movingAverageWindowSize: Int {
166
            switch self {
167
            case .off: return 1
168
            case .light: return 5
169
            case .medium: return 11
170
            case .strong: return 21
171
            }
172
        }
173
    }
174

            
Bogdan Timofte authored a month ago
175
    private enum SeriesKind: Hashable {
Bogdan Timofte authored 2 months ago
176
        case power
Bogdan Timofte authored 2 months ago
177
        case energy
Bogdan Timofte authored 2 months ago
178
        case voltage
179
        case current
Bogdan Timofte authored 2 months ago
180
        case temperature
Bogdan Timofte authored a month ago
181
        case batteryPercent
Bogdan Timofte authored 2 months ago
182

            
Bogdan Timofte authored a month ago
183
        var displayName: String {
184
            switch self {
185
            case .power: return "Power"
186
            case .energy: return "Energy"
187
            case .voltage: return "Voltage"
188
            case .current: return "Current"
189
            case .temperature: return "Temperature"
Bogdan Timofte authored a month ago
190
            case .batteryPercent: return "Battery"
Bogdan Timofte authored a month ago
191
            }
192
        }
193

            
Bogdan Timofte authored 2 months ago
194
        var unit: String {
195
            switch self {
196
            case .power: return "W"
Bogdan Timofte authored 2 months ago
197
            case .energy: return "Wh"
Bogdan Timofte authored 2 months ago
198
            case .voltage: return "V"
199
            case .current: return "A"
Bogdan Timofte authored 2 months ago
200
            case .temperature: return ""
Bogdan Timofte authored a month ago
201
            case .batteryPercent: return "%"
Bogdan Timofte authored 2 months ago
202
            }
203
        }
204

            
205
        var tint: Color {
206
            switch self {
207
            case .power: return .red
Bogdan Timofte authored 2 months ago
208
            case .energy: return .teal
Bogdan Timofte authored 2 months ago
209
            case .voltage: return .green
210
            case .current: return .blue
Bogdan Timofte authored 2 months ago
211
            case .temperature: return .orange
Bogdan Timofte authored a month ago
212
            case .batteryPercent: return .mint
Bogdan Timofte authored 2 months ago
213
            }
214
        }
215
    }
216

            
217
    private struct SeriesData {
218
        let kind: SeriesKind
219
        let points: [Measurements.Measurement.Point]
220
        let samplePoints: [Measurements.Measurement.Point]
221
        let context: ChartContext
222
        let autoLowerBound: Double
223
        let autoUpperBound: Double
224
        let maximumSampleValue: Double?
225
    }
226

            
Bogdan Timofte authored a month ago
227
    private enum LegendStatistic: CaseIterable, Hashable {
228
        case minimum
229
        case average
230
        case maximum
231
        case last
232
        case total
233

            
234
        var title: String {
235
            switch self {
236
            case .minimum: return "Min"
237
            case .average: return "Avg"
238
            case .maximum: return "Max"
239
            case .last: return "Last"
240
            case .total: return "Total"
241
            }
242
        }
243
    }
244

            
245
    private struct SeriesLegendValue: Identifiable {
246
        let statistic: LegendStatistic
247
        let text: String
248

            
249
        var id: LegendStatistic {
250
            statistic
251
        }
252
    }
253

            
Bogdan Timofte authored a month ago
254
    private struct SeriesLegendEntry: Identifiable {
255
        let id: SeriesKind
256
        let name: String
257
        let tint: Color
Bogdan Timofte authored a month ago
258
        let values: [SeriesLegendValue]
259

            
260
        func text(for statistic: LegendStatistic) -> String? {
261
            values.first { $0.statistic == statistic }?.text
262
        }
Bogdan Timofte authored a month ago
263
    }
264

            
Bogdan Timofte authored 2 months ago
265
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
266
    private let minimumVoltageSpan = 0.5
267
    private let minimumCurrentSpan = 0.5
268
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 2 months ago
269
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
270
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored a month ago
271
    private let minimumBatteryPercentSpan = 10.0
Bogdan Timofte authored 2 months ago
272
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 2 months ago
273
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
274

            
Bogdan Timofte authored a month ago
275
    let sizing: MeasurementChartSizing
Bogdan Timofte authored a month ago
276
    let showsRangeSelector: Bool
277
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored a month ago
278
    let extendsTimelineToPresent: Bool
Bogdan Timofte authored a month ago
279
    let showsTemperatureSeries: Bool
Bogdan Timofte authored a month ago
280
    let showsBatteryPercentSeries: Bool
281
    let batteryCheckpoints: [ChargeCheckpointSummary]
282
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
283
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored a month ago
284

            
Bogdan Timofte authored 2 months ago
285
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
286
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
287
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
288
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a month ago
289
    let timeRangeLowerBound: Date?
290
    let timeRangeUpperBound: Date?
Bogdan Timofte authored a month ago
291

            
292
    @State private var embeddedWidth: CGFloat = 760
293

            
294
    private var compactLayout: Bool {
295
        switch sizing {
296
        case .provided(_, let compact): return compact
297
        case .embedded: return embeddedWidth < 760
298
        }
299
    }
300

            
301
    private var availableSize: CGSize {
302
        switch sizing {
303
        case .provided(let size, _): return size
304
        case .embedded:
305
            let h = compactLayout ? 290 : 350
306
            return CGSize(width: embeddedWidth, height: CGFloat(h))
307
        }
308
    }
309

            
Bogdan Timofte authored 2 months ago
310
    @State var displayVoltage: Bool = false
311
    @State var displayCurrent: Bool = false
312
    @State var displayPower: Bool = true
Bogdan Timofte authored 2 months ago
313
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 2 months ago
314
    @State var displayTemperature: Bool = false
Bogdan Timofte authored a month ago
315
    @State private var displayBatteryPercent: Bool = false
Bogdan Timofte authored 2 months ago
316
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored 2 months ago
317
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 2 months ago
318
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
319
    @State private var isPinnedToPresent: Bool = false
Bogdan Timofte authored a month ago
320
    @State private var presentTrackingMode: PresentTrackingMode = .keepStartTimestamp
Bogdan Timofte authored 2 months ago
321
    @State private var pinOrigin: Bool = false
322
    @State private var useSharedOrigin: Bool = false
323
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
324
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored 2 months ago
325
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
326
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
327
    @State private var voltageAxisOrigin: Double = 0
328
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
329
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored a month ago
330
    @State private var batteryPercentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
331
    let xLabels: Int = 4
332
    let yLabels: Int = 4
333

            
Bogdan Timofte authored 2 months ago
334
    init(
Bogdan Timofte authored a month ago
335
        sizing: MeasurementChartSizing = .embedded,
Bogdan Timofte authored a month ago
336
        timeRange: ClosedRange<Date>? = nil,
Bogdan Timofte authored a month ago
337
        timeRangeLowerBound: Date? = nil,
338
        timeRangeUpperBound: Date? = nil,
Bogdan Timofte authored a month ago
339
        showsRangeSelector: Bool = true,
Bogdan Timofte authored a month ago
340
        rebasesEnergyToVisibleRangeStart: Bool = false,
341
        extendsTimelineToPresent: Bool = true,
Bogdan Timofte authored a month ago
342
        showsTemperatureSeries: Bool = true,
Bogdan Timofte authored a month ago
343
        showsBatteryPercentSeries: Bool = false,
344
        batteryCheckpoints: [ChargeCheckpointSummary] = [],
345
        batteryPercentPoints: [Measurements.Measurement.Point] = [],
Bogdan Timofte authored a month ago
346
        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
Bogdan Timofte authored 2 months ago
347
    ) {
Bogdan Timofte authored a month ago
348
        self.sizing = sizing
Bogdan Timofte authored 2 months ago
349
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
350
        self.timeRangeLowerBound = timeRangeLowerBound
351
        self.timeRangeUpperBound = timeRangeUpperBound
Bogdan Timofte authored a month ago
352
        self.showsRangeSelector = showsRangeSelector
353
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored a month ago
354
        self.extendsTimelineToPresent = extendsTimelineToPresent
Bogdan Timofte authored a month ago
355
        self.showsTemperatureSeries = showsTemperatureSeries
Bogdan Timofte authored a month ago
356
        self.showsBatteryPercentSeries = showsBatteryPercentSeries
357
        self.batteryCheckpoints = batteryCheckpoints
358
        self.batteryPercentPoints = batteryPercentPoints
Bogdan Timofte authored a month ago
359
        self.rangeSelectorConfiguration = rangeSelectorConfiguration
Bogdan Timofte authored a month ago
360
        _displayPower = State(initialValue: showsBatteryPercentSeries == false)
361
        _displayBatteryPercent = State(initialValue: showsBatteryPercentSeries)
Bogdan Timofte authored 2 months ago
362
    }
363

            
Bogdan Timofte authored a month ago
364
    private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
365
        let compact = width < 760
Bogdan Timofte authored a month ago
366
        let plotHeight: CGFloat = compact ? 240 : 300
367
        let toolbarHeight: CGFloat = width < 640
368
            ? (compact ? 92 : 104)
369
            : (compact ? 48 : 56)
370
        let legendHeight: CGFloat = compact ? 76 : 90
371
        let outerSpacing: CGFloat = 12
372
        let chartStackSpacing: CGFloat = compact ? 8 : 10
373
        let selectorHeight = showsRangeSelector
374
            ? TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
375
            : 0
376
        let selectorSpacing = showsRangeSelector ? chartStackSpacing : 0
377

            
378
        return toolbarHeight
379
            + outerSpacing
380
            + plotHeight
381
            + selectorSpacing
382
            + selectorHeight
383
            + chartStackSpacing
384
            + legendHeight
Bogdan Timofte authored a month ago
385
    }
386

            
Bogdan Timofte authored 2 months ago
387
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
388
        if compactLayout {
389
            return 38
390
        }
391
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
392
    }
393

            
394
    private var chartSectionSpacing: CGFloat {
395
        compactLayout ? 6 : 8
396
    }
397

            
398
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
399
        if compactLayout {
400
            return 24
401
        }
402
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
403
    }
404

            
Bogdan Timofte authored 2 months ago
405
    private var isPortraitLayout: Bool {
406
        guard availableSize != .zero else { return verticalSizeClass != .compact }
407
        return availableSize.height >= availableSize.width
408
    }
409

            
Bogdan Timofte authored 2 months ago
410
    private var isIPhone: Bool {
411
        #if os(iOS)
412
        return UIDevice.current.userInterfaceIdiom == .phone
413
        #else
414
        return false
415
        #endif
416
    }
417

            
Bogdan Timofte authored a month ago
418
    private var plotSectionHeight: CGFloat {
419
        if case .embedded = sizing {
420
            return compactLayout ? 240 : 300
Bogdan Timofte authored 2 months ago
421
        }
422

            
Bogdan Timofte authored 2 months ago
423
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
424
            return compactLayout ? 300 : 380
425
        }
426

            
427
        if isPortraitLayout {
428
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
429
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
430
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
431
        }
432

            
433
        if compactLayout {
434
            return min(max(availableSize.height * 0.36, 240), 300)
435
        }
436

            
437
        return min(max(availableSize.height * 0.5, 300), 440)
438
    }
439

            
440
    private var stackedToolbarLayout: Bool {
441
        if availableSize.width > 0 {
442
            return availableSize.width < 640
443
        }
444

            
445
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
446
    }
447

            
448
    private var showsLabeledOriginControls: Bool {
449
        !compactLayout && !stackedToolbarLayout
450
    }
451

            
Bogdan Timofte authored 2 months ago
452
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
453
        #if os(iOS)
454
        if UIDevice.current.userInterfaceIdiom == .phone {
455
            return false
456
        }
457
        #endif
458

            
Bogdan Timofte authored 2 months ago
459
        if availableSize.width > 0 {
460
            return availableSize.width >= 900 || availableSize.height >= 700
461
        }
462
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
463
    }
464

            
465
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
466
        if isIPhone && isPortraitLayout {
467
            return .caption
468
        }
469
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
470
    }
471

            
Bogdan Timofte authored 2 months ago
472
    private var usesCompactLandscapeOriginControls: Bool {
473
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
474
    }
475

            
Bogdan Timofte authored 2 months ago
476
    var body: some View {
Bogdan Timofte authored a month ago
477
        Group {
478
            switch sizing {
479
            case .provided:
480
                chartBody
Bogdan Timofte authored a month ago
481
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
482
            case .embedded:
483
                chartBody
484
                    .frame(maxWidth: .infinity, alignment: .topLeading)
485
                    .background(
486
                        GeometryReader { geometry in
487
                            Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
488
                        }
489
                    )
490
                    .onPreferenceChange(EmbeddedWidthKey.self) { width in
491
                        guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
492
                        embeddedWidth = width
Bogdan Timofte authored a month ago
493
                    }
Bogdan Timofte authored a month ago
494
            }
495
        }
Bogdan Timofte authored a month ago
496
        .onAppear {
497
            resetHiddenTemperatureDisplay()
498
            resetHiddenBatteryPercentDisplay()
499
        }
Bogdan Timofte authored a month ago
500
        .onChange(of: showsTemperatureSeries) { _ in
501
            resetHiddenTemperatureDisplay()
Bogdan Timofte authored a month ago
502
        }
Bogdan Timofte authored a month ago
503
        .onChange(of: showsBatteryPercentSeries) { _ in
504
            resetHiddenBatteryPercentDisplay()
505
        }
Bogdan Timofte authored a month ago
506
    }
507

            
Bogdan Timofte authored a month ago
508
    private func resetHiddenTemperatureDisplay() {
509
        guard !showsTemperatureSeries, displayTemperature else { return }
510
        displayTemperature = false
511
    }
512

            
Bogdan Timofte authored a month ago
513
    private func resetHiddenBatteryPercentDisplay() {
514
        guard !showsBatteryPercentSeries, displayBatteryPercent else { return }
515
        displayBatteryPercent = false
516
        if !displayPower && !displayEnergy && !displayVoltage && !displayCurrent {
517
            displayPower = true
518
        }
519
    }
520

            
Bogdan Timofte authored a month ago
521
    @ViewBuilder
522
    private var chartBody: some View {
Bogdan Timofte authored 2 months ago
523
        let availableTimeRange = availableSelectionTimeRange()
524
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
525
        let powerSeries = series(
526
            for: measurements.power,
527
            kind: .power,
528
            minimumYSpan: minimumPowerSpan,
529
            visibleTimeRange: visibleTimeRange
530
        )
Bogdan Timofte authored 2 months ago
531
        let energySeries = series(
532
            for: measurements.energy,
533
            kind: .energy,
534
            minimumYSpan: minimumEnergySpan,
535
            visibleTimeRange: visibleTimeRange
536
        )
Bogdan Timofte authored 2 months ago
537
        let voltageSeries = series(
538
            for: measurements.voltage,
539
            kind: .voltage,
540
            minimumYSpan: minimumVoltageSpan,
541
            visibleTimeRange: visibleTimeRange
542
        )
543
        let currentSeries = series(
544
            for: measurements.current,
545
            kind: .current,
546
            minimumYSpan: minimumCurrentSpan,
547
            visibleTimeRange: visibleTimeRange
548
        )
549
        let temperatureSeries = series(
550
            for: measurements.temperature,
551
            kind: .temperature,
552
            minimumYSpan: minimumTemperatureSpan,
553
            visibleTimeRange: visibleTimeRange
554
        )
Bogdan Timofte authored a month ago
555
        let batteryPercentSeries = series(
556
            for: batteryPercentPoints.isEmpty ? measurements.batteryPercent.points : batteryPercentPoints,
557
            kind: .batteryPercent,
558
            minimumYSpan: minimumBatteryPercentSpan,
559
            visibleTimeRange: visibleTimeRange
560
        )
Bogdan Timofte authored 2 months ago
561
        let primarySeries = displayedPrimarySeries(
562
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
563
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
564
            voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
565
            currentSeries: currentSeries,
566
            batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
567
        )
Bogdan Timofte authored 2 months ago
568
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
569

            
Bogdan Timofte authored 2 months ago
570
        Group {
Bogdan Timofte authored 2 months ago
571
            if let primarySeries {
572
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
573
                    chartTopToolbar(
574
                        voltageSeries: voltageSeries,
575
                        currentSeries: currentSeries
576
                    )
Bogdan Timofte authored 2 months ago
577

            
Bogdan Timofte authored a month ago
578
                    VStack(spacing: compactLayout ? 8 : 10) {
579
                        GeometryReader { geometry in
Bogdan Timofte authored a month ago
580
                            let minimumPlotHeight: CGFloat = compactLayout
581
                                ? (isPortraitLayout ? 180 : 120)
582
                                : 220
583
                            let plotHeight = max(geometry.size.height - xAxisHeight, minimumPlotHeight)
Bogdan Timofte authored a month ago
584

            
585
                            VStack(spacing: 6) {
586
                                HStack(spacing: chartSectionSpacing) {
587
                                    primaryAxisView(
588
                                        height: plotHeight,
589
                                        powerSeries: powerSeries,
590
                                        energySeries: energySeries,
591
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
592
                                        currentSeries: currentSeries,
593
                                        batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
594
                                    )
595
                                    .frame(width: axisColumnWidth, height: plotHeight)
596

            
597
                                    ZStack {
598
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
599
                                            .fill(Color.primary.opacity(0.05))
600

            
601
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
602
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
603

            
604
                                        horizontalGuides(context: primarySeries.context)
605
                                        verticalGuides(context: primarySeries.context)
606
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
607
                                        renderedChart(
608
                                            powerSeries: powerSeries,
609
                                            energySeries: energySeries,
610
                                            voltageSeries: voltageSeries,
611
                                            currentSeries: currentSeries,
Bogdan Timofte authored a month ago
612
                                            temperatureSeries: temperatureSeries,
613
                                            batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
614
                                        )
615
                                    }
616
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
617
                                    .frame(maxWidth: .infinity)
618
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
619

            
Bogdan Timofte authored a month ago
620
                                    secondaryAxisView(
621
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
622
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
623
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
624
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
625
                                        currentSeries: currentSeries,
Bogdan Timofte authored a month ago
626
                                        temperatureSeries: temperatureSeries,
627
                                        batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
628
                                    )
Bogdan Timofte authored a month ago
629
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
630
                                }
Bogdan Timofte authored 2 months ago
631

            
Bogdan Timofte authored a month ago
632
                                xAxisLabelsView(context: primarySeries.context)
633
                                    .frame(height: xAxisHeight)
Bogdan Timofte authored 2 months ago
634
                            }
Bogdan Timofte authored a month ago
635
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
636
                        }
637
                        .frame(height: plotSectionHeight)
638

            
Bogdan Timofte authored a month ago
639
                        chartLegend(
640
                            entries: chartLegendEntries(
641
                                powerSeries: powerSeries,
642
                                energySeries: energySeries,
643
                                voltageSeries: voltageSeries,
644
                                currentSeries: currentSeries,
Bogdan Timofte authored a month ago
645
                                temperatureSeries: temperatureSeries,
646
                                batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
647
                            )
648
                        )
649

            
Bogdan Timofte authored a month ago
650
                        if showsRangeSelector,
651
                           let availableTimeRange,
652
                           let selectorSeries,
653
                           shouldShowRangeSelector(
654
                            availableTimeRange: availableTimeRange,
655
                            series: selectorSeries
656
                           ) {
657
                            TimeRangeSelectorView(
658
                                points: selectorSeries.points,
659
                                context: selectorSeries.context,
Bogdan Timofte authored 2 months ago
660
                                availableTimeRange: availableTimeRange,
Bogdan Timofte authored a month ago
661
                                selectorTint: selectorTint,
662
                                compactLayout: compactLayout,
Bogdan Timofte authored a month ago
663
                                xAxisLabelCount: xLabels,
Bogdan Timofte authored a month ago
664
                                minimumSelectionSpan: minimumTimeSpan,
665
                                configuration: resolvedRangeSelectorConfiguration(),
666
                                selectedTimeRange: $selectedVisibleTimeRange,
667
                                isPinnedToPresent: $isPinnedToPresent,
668
                                presentTrackingMode: $presentTrackingMode
669
                            )
Bogdan Timofte authored 2 months ago
670
                        }
671
                    }
672
                }
Bogdan Timofte authored 2 months ago
673
            } else {
674
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
675
                    chartTopToolbar(
676
                        voltageSeries: voltageSeries,
677
                        currentSeries: currentSeries
678
                    )
Bogdan Timofte authored 2 months ago
679
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
680
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
681
                }
682
            }
Bogdan Timofte authored 2 months ago
683
        }
Bogdan Timofte authored 2 months ago
684
        .font(chartBaseFont)
Bogdan Timofte authored a month ago
685
        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
686
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
687
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
688
            chartNow = now
689
        }
Bogdan Timofte authored 2 months ago
690
    }
691

            
Bogdan Timofte authored a month ago
692
    private func chartTopToolbar(
693
        voltageSeries: SeriesData,
694
        currentSeries: SeriesData
695
    ) -> some View {
Bogdan Timofte authored 2 months ago
696
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
697
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
Bogdan Timofte authored 2 months ago
698

            
Bogdan Timofte authored a month ago
699
        let seriesPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
700
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
701
        }
702
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
703
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
704
        .background(
705
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
706
                .fill(Color.primary.opacity(0.045))
707
        )
708
        .overlay(
709
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
710
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
711
        )
Bogdan Timofte authored 2 months ago
712

            
Bogdan Timofte authored a month ago
713
        let controlPanel = chartControlsPanel(
714
            voltageSeries: voltageSeries,
715
            currentSeries: currentSeries,
716
            condensedLayout: condensedLayout
717
        )
718

            
Bogdan Timofte authored 2 months ago
719
        return Group {
Bogdan Timofte authored 2 months ago
720
            if stackedToolbarLayout {
Bogdan Timofte authored a month ago
721
                VStack(alignment: .leading, spacing: 8) {
722
                    seriesPanel
723
                    controlPanel
724
                }
Bogdan Timofte authored 2 months ago
725
            } else {
Bogdan Timofte authored a month ago
726
                HStack(alignment: .center, spacing: isLargeDisplay ? 14 : 12) {
727
                    seriesPanel
728
                    Spacer(minLength: 0)
729
                    controlPanel
Bogdan Timofte authored 2 months ago
730
                }
Bogdan Timofte authored 2 months ago
731
            }
732
        }
733
        .frame(maxWidth: .infinity, alignment: .leading)
734
    }
735

            
Bogdan Timofte authored a month ago
736
    private func chartControlsPanel(
Bogdan Timofte authored 2 months ago
737
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
738
        currentSeries: SeriesData,
739
        condensedLayout: Bool
Bogdan Timofte authored 2 months ago
740
    ) -> some View {
Bogdan Timofte authored a month ago
741
        originControlsRow(
Bogdan Timofte authored 2 months ago
742
            voltageSeries: voltageSeries,
743
            currentSeries: currentSeries,
744
            condensedLayout: condensedLayout,
Bogdan Timofte authored a month ago
745
            showsLabel: showsLabeledOriginControls && !stackedToolbarLayout
Bogdan Timofte authored 2 months ago
746
        )
Bogdan Timofte authored a month ago
747
        .padding(.horizontal, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
748
        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
749
        .background(
Bogdan Timofte authored a month ago
750
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
751
                .fill(Color.primary.opacity(0.045))
Bogdan Timofte authored 2 months ago
752
        )
753
        .overlay(
Bogdan Timofte authored a month ago
754
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
755
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
Bogdan Timofte authored 2 months ago
756
        )
757
    }
758

            
Bogdan Timofte authored a month ago
759
    private func chartLegendEntries(
760
        powerSeries: SeriesData,
761
        energySeries: SeriesData,
762
        voltageSeries: SeriesData,
763
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
764
        temperatureSeries: SeriesData,
765
        batteryPercentSeries: SeriesData
Bogdan Timofte authored a month ago
766
    ) -> [SeriesLegendEntry] {
767
        var entries: [SeriesLegendEntry] = []
768

            
Bogdan Timofte authored a month ago
769
        if displayBatteryPercent {
770
            entries.append(contentsOf: legendEntry(for: batteryPercentSeries))
771
        } else if displayPower {
Bogdan Timofte authored a month ago
772
            entries.append(contentsOf: legendEntry(for: powerSeries))
773
        } else if displayEnergy {
774
            entries.append(contentsOf: legendEntry(for: energySeries))
775
        } else {
776
            if displayVoltage {
777
                entries.append(contentsOf: legendEntry(for: voltageSeries))
778
            }
779
            if displayCurrent {
780
                entries.append(contentsOf: legendEntry(for: currentSeries))
781
            }
782
        }
783

            
784
        if displayTemperature {
785
            entries.append(contentsOf: legendEntry(for: temperatureSeries))
786
        }
787

            
788
        return entries
789
    }
790

            
791
    private func legendEntry(for series: SeriesData) -> [SeriesLegendEntry] {
792
        let samples = series.samplePoints
793
        guard
794
            let minimumValue = samples.map(\.value).min(),
795
            let maximumValue = samples.map(\.value).max(),
796
            let lastValue = samples.last?.value
797
        else {
798
            return []
799
        }
800

            
801
        let averageValue = samples.reduce(0) { $0 + $1.value } / Double(samples.count)
Bogdan Timofte authored a month ago
802
        let values = legendValues(
803
            for: series.kind,
804
            minimumValue: minimumValue,
805
            averageValue: averageValue,
806
            maximumValue: maximumValue,
807
            lastValue: lastValue
808
        )
Bogdan Timofte authored a month ago
809

            
810
        return [
811
            SeriesLegendEntry(
812
                id: series.kind,
813
                name: series.kind.displayName,
814
                tint: series.kind.tint,
Bogdan Timofte authored a month ago
815
                values: values
Bogdan Timofte authored a month ago
816
            )
817
        ]
818
    }
819

            
Bogdan Timofte authored a month ago
820
    private func legendValues(
821
        for kind: SeriesKind,
822
        minimumValue: Double,
823
        averageValue: Double,
824
        maximumValue: Double,
825
        lastValue: Double
826
    ) -> [SeriesLegendValue] {
827
        switch kind {
828
        case .energy:
829
            return [
830
                SeriesLegendValue(
831
                    statistic: .total,
832
                    text: legendValueText(lastValue, for: kind)
833
                )
834
            ]
835
        case .batteryPercent:
836
            return [
837
                SeriesLegendValue(
838
                    statistic: .minimum,
839
                    text: legendValueText(minimumValue, for: kind)
840
                ),
841
                SeriesLegendValue(
842
                    statistic: .maximum,
843
                    text: legendValueText(maximumValue, for: kind)
844
                ),
845
                SeriesLegendValue(
846
                    statistic: .last,
847
                    text: legendValueText(lastValue, for: kind)
848
                )
849
            ]
850
        case .power, .voltage, .current, .temperature:
851
            return [
852
                SeriesLegendValue(
853
                    statistic: .minimum,
854
                    text: legendValueText(minimumValue, for: kind)
855
                ),
856
                SeriesLegendValue(
857
                    statistic: .average,
858
                    text: legendValueText(averageValue, for: kind)
859
                ),
860
                SeriesLegendValue(
861
                    statistic: .maximum,
862
                    text: legendValueText(maximumValue, for: kind)
863
                ),
864
                SeriesLegendValue(
865
                    statistic: .last,
866
                    text: legendValueText(lastValue, for: kind)
867
                )
868
            ]
869
        }
870
    }
871

            
Bogdan Timofte authored a month ago
872
    @ViewBuilder
873
    private func chartLegend(entries: [SeriesLegendEntry]) -> some View {
874
        if !entries.isEmpty {
875
            let nameWidth: CGFloat = compactLayout ? 88 : (isLargeDisplay ? 128 : 108)
876
            let valueWidth: CGFloat = compactLayout ? 78 : (isLargeDisplay ? 108 : 92)
Bogdan Timofte authored a month ago
877
            let statistics = legendStatistics(for: entries)
Bogdan Timofte authored a month ago
878

            
879
            ScrollView(.horizontal, showsIndicators: false) {
880
                VStack(alignment: .leading, spacing: compactLayout ? 5 : 7) {
881
                    HStack(spacing: compactLayout ? 8 : 10) {
882
                        legendHeaderText("Measurement", width: nameWidth, alignment: .leading)
Bogdan Timofte authored a month ago
883
                        ForEach(statistics, id: \.self) { statistic in
884
                            legendHeaderText(statistic.title, width: valueWidth)
885
                        }
Bogdan Timofte authored a month ago
886
                    }
887

            
888
                    ForEach(entries) { entry in
889
                        HStack(spacing: compactLayout ? 8 : 10) {
890
                            HStack(spacing: 6) {
891
                                Circle()
892
                                    .fill(entry.tint)
893
                                    .frame(width: compactLayout ? 7 : 8, height: compactLayout ? 7 : 8)
894

            
895
                                Text(entry.name)
896
                                    .lineLimit(1)
897
                                    .minimumScaleFactor(0.82)
898
                            }
899
                            .frame(width: nameWidth, alignment: .leading)
900

            
Bogdan Timofte authored a month ago
901
                            ForEach(statistics, id: \.self) { statistic in
902
                                legendValueText(entry.text(for: statistic) ?? "-", width: valueWidth)
903
                            }
Bogdan Timofte authored a month ago
904
                        }
905
                    }
906
                }
907
                .padding(.horizontal, compactLayout ? 10 : 12)
908
                .padding(.vertical, compactLayout ? 8 : 10)
909
            }
910
            .background(
911
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
912
                    .fill(Color.primary.opacity(0.045))
913
            )
914
            .overlay(
915
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
916
                    .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
917
            )
918
        }
919
    }
920

            
Bogdan Timofte authored a month ago
921
    private func legendStatistics(for entries: [SeriesLegendEntry]) -> [LegendStatistic] {
922
        LegendStatistic.allCases.filter { statistic in
923
            entries.contains { $0.text(for: statistic) != nil }
924
        }
925
    }
926

            
Bogdan Timofte authored a month ago
927
    private func legendHeaderText(
928
        _ text: String,
929
        width: CGFloat,
930
        alignment: Alignment = .trailing
931
    ) -> some View {
932
        Text(text)
933
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
934
            .foregroundColor(.secondary)
935
            .textCase(.uppercase)
936
            .lineLimit(1)
937
            .frame(width: width, alignment: alignment)
938
    }
939

            
940
    private func legendValueText(
941
        _ text: String,
942
        width: CGFloat
943
    ) -> some View {
944
        Text(text)
945
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
946
            .monospacedDigit()
947
            .lineLimit(1)
948
            .minimumScaleFactor(0.78)
949
            .frame(width: width, alignment: .trailing)
950
    }
951

            
952
    private func legendValueText(
953
        _ value: Double,
954
        for kind: SeriesKind
955
    ) -> String {
956
        let decimalDigits: Int
957
        switch kind {
958
        case .power:
959
            decimalDigits = 2
960
        case .energy, .voltage, .current:
961
            decimalDigits = 3
Bogdan Timofte authored a month ago
962
        case .temperature, .batteryPercent:
Bogdan Timofte authored a month ago
963
            decimalDigits = 1
964
        }
965

            
966
        let formattedValue = value.format(decimalDigits: decimalDigits)
967
        let unit = measurementUnit(for: kind)
968
        guard !unit.isEmpty else { return formattedValue }
969

            
970
        if kind == .temperature {
971
            return "\(formattedValue)\(unit)"
972
        }
973
        return "\(formattedValue) \(unit)"
974
    }
975

            
Bogdan Timofte authored 2 months ago
976
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
977
        HStack(spacing: condensedLayout ? 6 : 8) {
978
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
979
                displayVoltage.toggle()
980
                if displayVoltage {
Bogdan Timofte authored a month ago
981
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
982
                    displayPower = false
Bogdan Timofte authored 2 months ago
983
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
984
                    if displayTemperature && displayCurrent {
985
                        displayCurrent = false
986
                    }
Bogdan Timofte authored 2 months ago
987
                }
988
            }
989

            
990
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
991
                displayCurrent.toggle()
992
                if displayCurrent {
Bogdan Timofte authored a month ago
993
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
994
                    displayPower = false
Bogdan Timofte authored 2 months ago
995
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
996
                    if displayTemperature && displayVoltage {
997
                        displayVoltage = false
998
                    }
Bogdan Timofte authored 2 months ago
999
                }
Bogdan Timofte authored 2 months ago
1000
            }
1001

            
1002
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
1003
                displayPower.toggle()
1004
                if displayPower {
Bogdan Timofte authored a month ago
1005
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
1006
                    displayEnergy = false
1007
                    displayCurrent = false
1008
                    displayVoltage = false
1009
                }
1010
            }
1011

            
1012
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
1013
                displayEnergy.toggle()
1014
                if displayEnergy {
Bogdan Timofte authored a month ago
1015
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
1016
                    displayPower = false
Bogdan Timofte authored 2 months ago
1017
                    displayCurrent = false
1018
                    displayVoltage = false
1019
                }
1020
            }
Bogdan Timofte authored 2 months ago
1021

            
Bogdan Timofte authored a month ago
1022
            if showsBatteryPercentSeries {
1023
                seriesToggleButton(title: "Battery", isOn: displayBatteryPercent, condensedLayout: condensedLayout) {
1024
                    displayBatteryPercent.toggle()
1025
                    if displayBatteryPercent {
1026
                        displayPower = false
1027
                        displayEnergy = false
1028
                        displayCurrent = false
1029
                        displayVoltage = false
1030
                    }
1031
                }
1032
            }
1033

            
Bogdan Timofte authored a month ago
1034
            if showsTemperatureSeries {
1035
                seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
1036
                    displayTemperature.toggle()
1037
                    if displayTemperature && displayVoltage && displayCurrent {
1038
                        displayCurrent = false
1039
                    }
Bogdan Timofte authored 2 months ago
1040
                }
1041
            }
Bogdan Timofte authored 2 months ago
1042
        }
1043
    }
1044

            
1045
    private func originControlsRow(
1046
        voltageSeries: SeriesData,
1047
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1048
        condensedLayout: Bool,
1049
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
1050
    ) -> some View {
Bogdan Timofte authored 2 months ago
1051
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
1052
            if supportsSharedOrigin {
1053
                symbolControlChip(
1054
                    systemImage: "equal.circle",
1055
                    enabled: true,
1056
                    active: useSharedOrigin,
1057
                    condensedLayout: condensedLayout,
1058
                    showsLabel: showsLabel,
1059
                    label: "Match Y Scale",
1060
                    accessibilityLabel: "Match Y scale"
1061
                ) {
1062
                    toggleSharedOrigin(
1063
                        voltageSeries: voltageSeries,
1064
                        currentSeries: currentSeries
1065
                    )
1066
                }
Bogdan Timofte authored 2 months ago
1067
            }
1068

            
1069
            symbolControlChip(
1070
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
1071
                enabled: true,
1072
                active: pinOrigin,
1073
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
1074
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
1075
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
1076
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
1077
            ) {
1078
                togglePinnedOrigin(
1079
                    voltageSeries: voltageSeries,
1080
                    currentSeries: currentSeries
1081
                )
1082
            }
1083

            
Bogdan Timofte authored 2 months ago
1084
            if !pinnedOriginIsZero {
1085
                symbolControlChip(
1086
                    systemImage: "0.circle",
1087
                    enabled: true,
1088
                    active: false,
1089
                    condensedLayout: condensedLayout,
1090
                    showsLabel: showsLabel,
1091
                    label: "Origin 0",
1092
                    accessibilityLabel: "Set origin to zero"
1093
                ) {
1094
                    setVisibleOriginsToZero()
1095
                }
Bogdan Timofte authored 2 months ago
1096
            }
Bogdan Timofte authored 2 months ago
1097

            
Bogdan Timofte authored 2 months ago
1098
            smoothingControlChip(
1099
                condensedLayout: condensedLayout,
1100
                showsLabel: showsLabel
1101
            )
1102

            
Bogdan Timofte authored 2 months ago
1103
        }
1104
    }
1105

            
Bogdan Timofte authored 2 months ago
1106
    private func smoothingControlChip(
1107
        condensedLayout: Bool,
1108
        showsLabel: Bool
1109
    ) -> some View {
1110
        Menu {
1111
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
1112
                Button {
1113
                    smoothingLevel = level
1114
                } label: {
1115
                    if smoothingLevel == level {
1116
                        Label(level.label, systemImage: "checkmark")
1117
                    } else {
1118
                        Text(level.label)
Bogdan Timofte authored 2 months ago
1119
                    }
1120
                }
Bogdan Timofte authored 2 months ago
1121
            }
1122
        } label: {
1123
            Group {
1124
                if showsLabel {
1125
                    VStack(alignment: .leading, spacing: 2) {
1126
                        Label("Smoothing", systemImage: "waveform.path")
1127
                            .font(controlChipFont(condensedLayout: condensedLayout))
1128

            
1129
                        Text(
Bogdan Timofte authored 2 months ago
1130
                            smoothingLevel == .off
Bogdan Timofte authored 2 months ago
1131
                            ? "Off"
1132
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
1133
                        )
Bogdan Timofte authored 2 months ago
1134
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
1135
                        .foregroundColor(.secondary)
1136
                        .monospacedDigit()
1137
                    }
1138
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
1139
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
1140
                } else {
1141
                    VStack(spacing: 1) {
1142
                        Image(systemName: "waveform.path")
1143
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
1144

            
1145
                        Text(smoothingLevel.shortLabel)
1146
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
1147
                            .monospacedDigit()
1148
                    }
1149
                    .frame(
1150
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
1151
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
1152
                    )
1153
                }
Bogdan Timofte authored 2 months ago
1154
            }
Bogdan Timofte authored 2 months ago
1155
            .background(
1156
                Capsule(style: .continuous)
1157
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
1158
            )
1159
            .overlay(
1160
                Capsule(style: .continuous)
1161
                    .stroke(
1162
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
1163
                        lineWidth: 1
1164
                    )
1165
            )
Bogdan Timofte authored 2 months ago
1166
        }
Bogdan Timofte authored 2 months ago
1167
        .buttonStyle(.plain)
1168
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
1169
    }
1170

            
Bogdan Timofte authored 2 months ago
1171
    private func seriesToggleButton(
1172
        title: String,
1173
        isOn: Bool,
1174
        condensedLayout: Bool,
1175
        action: @escaping () -> Void
1176
    ) -> some View {
1177
        Button(action: action) {
1178
            Text(title)
Bogdan Timofte authored 2 months ago
1179
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1180
                .lineLimit(1)
1181
                .minimumScaleFactor(0.82)
1182
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
1183
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
1184
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
1185
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
1186
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
1187
                .background(
1188
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
1189
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
1190
                )
1191
                .overlay(
1192
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
1193
                        .stroke(Color.blue, lineWidth: 1.5)
1194
                )
1195
        }
1196
        .buttonStyle(.plain)
1197
    }
1198

            
1199
    private func symbolControlChip(
1200
        systemImage: String,
1201
        enabled: Bool,
1202
        active: Bool,
1203
        condensedLayout: Bool,
1204
        showsLabel: Bool,
1205
        label: String,
1206
        accessibilityLabel: String,
1207
        action: @escaping () -> Void
1208
    ) -> some View {
1209
        Button(action: {
1210
            action()
1211
        }) {
1212
            Group {
1213
                if showsLabel {
1214
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
1215
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1216
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
1217
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
1218
                } else {
1219
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
1220
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
1221
                        .frame(
Bogdan Timofte authored 2 months ago
1222
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
1223
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
1224
                        )
Bogdan Timofte authored 2 months ago
1225
                }
1226
            }
1227
                .background(
1228
                    Capsule(style: .continuous)
1229
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
1230
                )
1231
        }
1232
        .buttonStyle(.plain)
1233
        .foregroundColor(enabled ? .primary : .secondary)
1234
        .opacity(enabled ? 1 : 0.55)
1235
        .accessibilityLabel(accessibilityLabel)
1236
    }
1237

            
Bogdan Timofte authored 2 months ago
1238
    private func resetBuffer() {
1239
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
1240
    }
1241

            
Bogdan Timofte authored a month ago
1242
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
1243
        if let rangeSelectorConfiguration {
1244
            return rangeSelectorConfiguration
1245
        }
1246

            
1247
        return MeasurementChartRangeSelectorConfiguration(
1248
            keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1249
                title: "Keep Selection",
1250
                shortTitle: "Keep",
Bogdan Timofte authored a month ago
1251
                systemName: "scissors",
1252
                tone: .destructive,
1253
                handler: trimBufferToSelection
1254
            ),
1255
            removeAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1256
                title: "Remove Selection",
1257
                shortTitle: "Cut",
Bogdan Timofte authored a month ago
1258
                systemName: "minus.circle",
1259
                tone: .destructive,
1260
                handler: removeSelectionFromBuffer
1261
            ),
1262
            resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
1263
                title: "Reset Buffer",
1264
                shortTitle: "Reset",
Bogdan Timofte authored a month ago
1265
                systemName: "trash",
1266
                tone: .destructiveProminent,
1267
                confirmationTitle: "Reset captured measurements?",
1268
                confirmationButtonTitle: "Reset buffer",
1269
                handler: resetBuffer
1270
            )
1271
        )
1272
    }
1273

            
Bogdan Timofte authored 2 months ago
1274
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
1275
        if isLargeDisplay {
1276
            return .body.weight(.semibold)
1277
        }
1278
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
1279
    }
1280

            
1281
    private func controlChipFont(condensedLayout: Bool) -> Font {
1282
        if isLargeDisplay {
1283
            return .callout.weight(.semibold)
1284
        }
1285
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
1286
    }
1287

            
Bogdan Timofte authored 2 months ago
1288
    @ViewBuilder
1289
    private func primaryAxisView(
1290
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1291
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1292
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1293
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
1294
        currentSeries: SeriesData,
1295
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1296
    ) -> some View {
Bogdan Timofte authored a month ago
1297
        if displayBatteryPercent {
1298
            yAxisLabelsView(
1299
                height: height,
1300
                context: batteryPercentSeries.context,
1301
                seriesKind: .batteryPercent,
1302
                measurementUnit: batteryPercentSeries.kind.unit,
1303
                tint: batteryPercentSeries.kind.tint
1304
            )
1305
        } else if displayPower {
Bogdan Timofte authored 2 months ago
1306
            yAxisLabelsView(
1307
                height: height,
1308
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
1309
                seriesKind: .power,
1310
                measurementUnit: powerSeries.kind.unit,
1311
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
1312
            )
Bogdan Timofte authored 2 months ago
1313
        } else if displayEnergy {
1314
            yAxisLabelsView(
1315
                height: height,
1316
                context: energySeries.context,
1317
                seriesKind: .energy,
1318
                measurementUnit: energySeries.kind.unit,
1319
                tint: energySeries.kind.tint
1320
            )
Bogdan Timofte authored 2 months ago
1321
        } else if displayVoltage {
1322
            yAxisLabelsView(
1323
                height: height,
1324
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
1325
                seriesKind: .voltage,
1326
                measurementUnit: voltageSeries.kind.unit,
1327
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
1328
            )
1329
        } else if displayCurrent {
1330
            yAxisLabelsView(
1331
                height: height,
1332
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1333
                seriesKind: .current,
1334
                measurementUnit: currentSeries.kind.unit,
1335
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1336
            )
1337
        }
1338
    }
1339

            
1340
    @ViewBuilder
1341
    private func renderedChart(
Bogdan Timofte authored 2 months ago
1342
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1343
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1344
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1345
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
1346
        temperatureSeries: SeriesData,
1347
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1348
    ) -> some View {
Bogdan Timofte authored a month ago
1349
        if self.displayBatteryPercent {
1350
            TimeSeriesChart(points: batteryPercentSeries.points, context: batteryPercentSeries.context, strokeColor: batteryPercentSeries.kind.tint)
1351
                .opacity(0.82)
1352
            batteryCheckpointMarkers(context: batteryPercentSeries.context)
1353
        } else if self.displayPower {
Bogdan Timofte authored a month ago
1354
            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1355
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
1356
        } else if self.displayEnergy {
Bogdan Timofte authored a month ago
1357
            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
Bogdan Timofte authored 2 months ago
1358
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
1359
        } else {
1360
            if self.displayVoltage {
Bogdan Timofte authored a month ago
1361
                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1362
                    .opacity(0.78)
1363
            }
1364
            if self.displayCurrent {
Bogdan Timofte authored a month ago
1365
                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1366
                    .opacity(0.78)
1367
            }
1368
        }
Bogdan Timofte authored 2 months ago
1369

            
1370
        if displayTemperature {
Bogdan Timofte authored a month ago
1371
            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1372
                .opacity(0.86)
1373
        }
Bogdan Timofte authored 2 months ago
1374
    }
1375

            
Bogdan Timofte authored a month ago
1376
    private func batteryCheckpointMarkers(context: ChartContext) -> some View {
1377
        GeometryReader { geometry in
1378
            ForEach(visibleBatteryCheckpoints(context: context)) { checkpoint in
1379
                let normalizedPoint = context.placeInRect(
1380
                    point: CGPoint(
1381
                        x: checkpoint.timestamp.timeIntervalSince1970,
1382
                        y: checkpoint.batteryPercent
1383
                    )
1384
                )
1385
                let location = CGPoint(
1386
                    x: normalizedPoint.x * geometry.size.width,
1387
                    y: normalizedPoint.y * geometry.size.height
1388
                )
1389

            
1390
                Circle()
1391
                    .fill(Color(.systemBackground))
1392
                    .frame(width: 10, height: 10)
1393
                    .overlay(
1394
                        Circle()
1395
                            .stroke(Color.mint, lineWidth: 2)
1396
                    )
1397
                    .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
1398
                    .position(location)
1399
            }
1400
        }
1401
        .accessibilityHidden(true)
1402
    }
1403

            
1404
    private func visibleBatteryCheckpoints(context: ChartContext) -> [ChargeCheckpointSummary] {
1405
        guard context.isValid else { return [] }
1406

            
1407
        return batteryCheckpoints
1408
            .filter { checkpoint in
1409
                checkpoint.batteryPercent.isFinite &&
1410
                checkpoint.batteryPercent >= 0 &&
1411
                checkpoint.batteryPercent <= 100
1412
            }
1413
            .filter { checkpoint in
1414
                let normalizedPoint = context.placeInRect(
1415
                    point: CGPoint(
1416
                        x: checkpoint.timestamp.timeIntervalSince1970,
1417
                        y: checkpoint.batteryPercent
1418
                    )
1419
                )
1420
                return normalizedPoint.x >= 0 &&
1421
                    normalizedPoint.x <= 1 &&
1422
                    normalizedPoint.y >= 0 &&
1423
                    normalizedPoint.y <= 1
1424
            }
1425
    }
1426

            
Bogdan Timofte authored 2 months ago
1427
    @ViewBuilder
1428
    private func secondaryAxisView(
1429
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1430
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1431
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1432
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1433
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
1434
        temperatureSeries: SeriesData,
1435
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1436
    ) -> some View {
Bogdan Timofte authored 2 months ago
1437
        if displayTemperature {
1438
            yAxisLabelsView(
1439
                height: height,
1440
                context: temperatureSeries.context,
1441
                seriesKind: .temperature,
1442
                measurementUnit: measurementUnit(for: .temperature),
1443
                tint: temperatureSeries.kind.tint
1444
            )
1445
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
1446
            yAxisLabelsView(
1447
                height: height,
1448
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1449
                seriesKind: .current,
1450
                measurementUnit: currentSeries.kind.unit,
1451
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1452
            )
1453
        } else {
1454
            primaryAxisView(
1455
                height: height,
1456
                powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
1457
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
1458
                voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
1459
                currentSeries: currentSeries,
1460
                batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
1461
            )
Bogdan Timofte authored 2 months ago
1462
        }
1463
    }
Bogdan Timofte authored 2 months ago
1464

            
1465
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
1466
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1467
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1468
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
1469
        currentSeries: SeriesData,
1470
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1471
    ) -> SeriesData? {
Bogdan Timofte authored a month ago
1472
        if displayBatteryPercent {
1473
            return batteryPercentSeries
1474
        }
Bogdan Timofte authored 2 months ago
1475
        if displayPower {
Bogdan Timofte authored 2 months ago
1476
            return powerSeries
Bogdan Timofte authored 2 months ago
1477
        }
Bogdan Timofte authored 2 months ago
1478
        if displayEnergy {
1479
            return energySeries
1480
        }
Bogdan Timofte authored 2 months ago
1481
        if displayVoltage {
Bogdan Timofte authored 2 months ago
1482
            return voltageSeries
Bogdan Timofte authored 2 months ago
1483
        }
1484
        if displayCurrent {
Bogdan Timofte authored 2 months ago
1485
            return currentSeries
Bogdan Timofte authored 2 months ago
1486
        }
1487
        return nil
1488
    }
1489

            
1490
    private func series(
1491
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
1492
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
1493
        minimumYSpan: Double,
1494
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1495
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1496
        series(
1497
            for: filteredPoints(
1498
                measurement,
1499
                visibleTimeRange: visibleTimeRange
1500
            ),
1501
            kind: kind,
1502
            minimumYSpan: minimumYSpan,
Bogdan Timofte authored 2 months ago
1503
            visibleTimeRange: visibleTimeRange
1504
        )
Bogdan Timofte authored a month ago
1505
    }
1506

            
1507
    private func series(
1508
        for rawPoints: [Measurements.Measurement.Point],
1509
        kind: SeriesKind,
1510
        minimumYSpan: Double,
1511
        visibleTimeRange: ClosedRange<Date>? = nil
1512
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1513
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1514
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
1515
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1516
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
1517

            
Bogdan Timofte authored a month ago
1518
        let autoBounds = kind == .batteryPercent
1519
            ? (lowerBound: 0.0, upperBound: 100.0)
1520
            : automaticYBounds(
1521
                for: samplePoints,
1522
                minimumYSpan: minimumYSpan
1523
            )
Bogdan Timofte authored 2 months ago
1524
        let xBounds = xBounds(
1525
            for: samplePoints,
1526
            visibleTimeRange: visibleTimeRange
1527
        )
Bogdan Timofte authored 2 months ago
1528
        let lowerBound = resolvedLowerBound(
1529
            for: kind,
1530
            autoLowerBound: autoBounds.lowerBound
1531
        )
1532
        let upperBound = resolvedUpperBound(
1533
            for: kind,
1534
            lowerBound: lowerBound,
1535
            autoUpperBound: autoBounds.upperBound,
1536
            maximumSampleValue: samplePoints.map(\.value).max(),
1537
            minimumYSpan: minimumYSpan
1538
        )
1539

            
1540
        context.setBounds(
1541
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1542
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1543
            yMin: CGFloat(lowerBound),
1544
            yMax: CGFloat(upperBound)
1545
        )
1546

            
1547
        return SeriesData(
1548
            kind: kind,
1549
            points: points,
1550
            samplePoints: samplePoints,
1551
            context: context,
1552
            autoLowerBound: autoBounds.lowerBound,
1553
            autoUpperBound: autoBounds.upperBound,
1554
            maximumSampleValue: samplePoints.map(\.value).max()
1555
        )
1556
    }
1557

            
Bogdan Timofte authored a month ago
1558
    private func normalizedPoints(
1559
        _ points: [Measurements.Measurement.Point],
1560
        for kind: SeriesKind
1561
    ) -> [Measurements.Measurement.Point] {
1562
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1563
            return points
1564
        }
1565

            
1566
        guard let baseline = points.first(where: \.isSample)?.value else {
1567
            return points
1568
        }
1569

            
1570
        return points.enumerated().map { index, point in
1571
            Measurements.Measurement.Point(
1572
                id: point.id == index ? point.id : index,
1573
                timestamp: point.timestamp,
1574
                value: point.value - baseline,
1575
                kind: point.kind
1576
            )
1577
        }
1578
    }
1579

            
Bogdan Timofte authored 2 months ago
1580
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1581
        series(
1582
            for: measurement(for: kind),
1583
            kind: kind,
1584
            minimumYSpan: minimumYSpan(for: kind)
1585
        )
1586
    }
1587

            
Bogdan Timofte authored 2 months ago
1588
    private func smoothedPoints(
1589
        from points: [Measurements.Measurement.Point]
1590
    ) -> [Measurements.Measurement.Point] {
1591
        guard smoothingLevel != .off else { return points }
1592

            
1593
        var smoothedPoints: [Measurements.Measurement.Point] = []
1594
        var currentSegment: [Measurements.Measurement.Point] = []
1595

            
1596
        func flushCurrentSegment() {
1597
            guard !currentSegment.isEmpty else { return }
1598

            
1599
            for point in smoothedSegment(currentSegment) {
1600
                smoothedPoints.append(
1601
                    Measurements.Measurement.Point(
1602
                        id: smoothedPoints.count,
1603
                        timestamp: point.timestamp,
1604
                        value: point.value,
1605
                        kind: .sample
1606
                    )
1607
                )
1608
            }
1609

            
1610
            currentSegment.removeAll(keepingCapacity: true)
1611
        }
1612

            
1613
        for point in points {
1614
            if point.isDiscontinuity {
1615
                flushCurrentSegment()
1616

            
1617
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1618
                    smoothedPoints.append(
1619
                        Measurements.Measurement.Point(
1620
                            id: smoothedPoints.count,
1621
                            timestamp: point.timestamp,
1622
                            value: smoothedPoints.last?.value ?? point.value,
1623
                            kind: .discontinuity
1624
                        )
1625
                    )
1626
                }
1627
            } else {
1628
                currentSegment.append(point)
1629
            }
1630
        }
1631

            
1632
        flushCurrentSegment()
1633
        return smoothedPoints
1634
    }
1635

            
1636
    private func smoothedSegment(
1637
        _ segment: [Measurements.Measurement.Point]
1638
    ) -> [Measurements.Measurement.Point] {
1639
        let windowSize = smoothingLevel.movingAverageWindowSize
1640
        guard windowSize > 1, segment.count > 2 else { return segment }
1641

            
1642
        let radius = windowSize / 2
1643
        var prefixSums: [Double] = [0]
1644
        prefixSums.reserveCapacity(segment.count + 1)
1645

            
1646
        for point in segment {
1647
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1648
        }
1649

            
1650
        return segment.enumerated().map { index, point in
1651
            let lowerBound = max(0, index - radius)
1652
            let upperBound = min(segment.count - 1, index + radius)
1653
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1654
            let average = sum / Double(upperBound - lowerBound + 1)
1655

            
1656
            return Measurements.Measurement.Point(
1657
                id: point.id,
1658
                timestamp: point.timestamp,
1659
                value: average,
1660
                kind: .sample
1661
            )
1662
        }
1663
    }
1664

            
Bogdan Timofte authored 2 months ago
1665
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1666
        switch kind {
1667
        case .power:
1668
            return measurements.power
Bogdan Timofte authored 2 months ago
1669
        case .energy:
1670
            return measurements.energy
Bogdan Timofte authored 2 months ago
1671
        case .voltage:
1672
            return measurements.voltage
1673
        case .current:
1674
            return measurements.current
1675
        case .temperature:
1676
            return measurements.temperature
Bogdan Timofte authored a month ago
1677
        case .batteryPercent:
1678
            return measurements.batteryPercent
Bogdan Timofte authored 2 months ago
1679
        }
1680
    }
1681

            
1682
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1683
        switch kind {
1684
        case .power:
1685
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1686
        case .energy:
1687
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1688
        case .voltage:
1689
            return minimumVoltageSpan
1690
        case .current:
1691
            return minimumCurrentSpan
1692
        case .temperature:
1693
            return minimumTemperatureSpan
Bogdan Timofte authored a month ago
1694
        case .batteryPercent:
1695
            return minimumBatteryPercentSpan
Bogdan Timofte authored 2 months ago
1696
        }
1697
    }
1698

            
Bogdan Timofte authored 2 months ago
1699
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored a month ago
1700
        displayVoltage && displayCurrent && !displayPower && !displayEnergy && !displayBatteryPercent
Bogdan Timofte authored 2 months ago
1701
    }
1702

            
Bogdan Timofte authored 2 months ago
1703
    private var minimumSharedScaleSpan: Double {
1704
        max(minimumVoltageSpan, minimumCurrentSpan)
1705
    }
1706

            
Bogdan Timofte authored 2 months ago
1707
    private var pinnedOriginIsZero: Bool {
1708
        if useSharedOrigin && supportsSharedOrigin {
1709
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1710
        }
Bogdan Timofte authored 2 months ago
1711

            
1712
        if displayPower {
1713
            return pinOrigin && powerAxisOrigin == 0
1714
        }
1715

            
Bogdan Timofte authored 2 months ago
1716
        if displayEnergy {
1717
            return pinOrigin && energyAxisOrigin == 0
1718
        }
1719

            
Bogdan Timofte authored a month ago
1720
        if displayBatteryPercent {
1721
            return pinOrigin && batteryPercentAxisOrigin == 0
1722
        }
1723

            
Bogdan Timofte authored 2 months ago
1724
        let visibleOrigins = [
1725
            displayVoltage ? voltageAxisOrigin : nil,
1726
            displayCurrent ? currentAxisOrigin : nil
1727
        ]
1728
        .compactMap { $0 }
1729

            
1730
        guard !visibleOrigins.isEmpty else { return false }
1731
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1732
    }
1733

            
1734
    private func toggleSharedOrigin(
1735
        voltageSeries: SeriesData,
1736
        currentSeries: SeriesData
1737
    ) {
1738
        guard supportsSharedOrigin else { return }
1739

            
1740
        if useSharedOrigin {
1741
            useSharedOrigin = false
1742
            return
1743
        }
1744

            
1745
        captureCurrentOrigins(
1746
            voltageSeries: voltageSeries,
1747
            currentSeries: currentSeries
1748
        )
1749
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1750
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1751
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1752
        useSharedOrigin = true
1753
        pinOrigin = true
1754
    }
1755

            
1756
    private func togglePinnedOrigin(
1757
        voltageSeries: SeriesData,
1758
        currentSeries: SeriesData
1759
    ) {
1760
        if pinOrigin {
1761
            pinOrigin = false
1762
            return
1763
        }
1764

            
1765
        captureCurrentOrigins(
1766
            voltageSeries: voltageSeries,
1767
            currentSeries: currentSeries
1768
        )
1769
        pinOrigin = true
1770
    }
1771

            
1772
    private func setVisibleOriginsToZero() {
1773
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1774
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1775
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1776
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1777
            voltageAxisOrigin = 0
1778
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1779
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1780
        } else {
1781
            if displayPower {
1782
                powerAxisOrigin = 0
1783
            }
Bogdan Timofte authored 2 months ago
1784
            if displayEnergy {
1785
                energyAxisOrigin = 0
1786
            }
Bogdan Timofte authored 2 months ago
1787
            if displayVoltage {
1788
                voltageAxisOrigin = 0
1789
            }
1790
            if displayCurrent {
1791
                currentAxisOrigin = 0
1792
            }
Bogdan Timofte authored 2 months ago
1793
            if displayTemperature {
1794
                temperatureAxisOrigin = 0
1795
            }
Bogdan Timofte authored a month ago
1796
            if displayBatteryPercent {
1797
                batteryPercentAxisOrigin = 0
1798
            }
Bogdan Timofte authored 2 months ago
1799
        }
1800

            
1801
        pinOrigin = true
1802
    }
1803

            
1804
    private func captureCurrentOrigins(
1805
        voltageSeries: SeriesData,
1806
        currentSeries: SeriesData
1807
    ) {
1808
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1809
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1810
        voltageAxisOrigin = voltageSeries.autoLowerBound
1811
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1812
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a month ago
1813
        batteryPercentAxisOrigin = displayedLowerBoundForSeries(.batteryPercent)
Bogdan Timofte authored 2 months ago
1814
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1815
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1816
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1817
    }
1818

            
1819
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1820
        let visibleTimeRange = activeVisibleTimeRange
1821

            
Bogdan Timofte authored 2 months ago
1822
        switch kind {
1823
        case .power:
Bogdan Timofte authored 2 months ago
1824
            return pinOrigin
1825
                ? powerAxisOrigin
1826
                : automaticYBounds(
1827
                    for: filteredSamplePoints(
1828
                        measurements.power,
1829
                        visibleTimeRange: visibleTimeRange
1830
                    ),
1831
                    minimumYSpan: minimumPowerSpan
1832
                ).lowerBound
Bogdan Timofte authored 2 months ago
1833
        case .energy:
1834
            return pinOrigin
1835
                ? energyAxisOrigin
1836
                : automaticYBounds(
1837
                    for: filteredSamplePoints(
1838
                        measurements.energy,
1839
                        visibleTimeRange: visibleTimeRange
1840
                    ),
1841
                    minimumYSpan: minimumEnergySpan
1842
                ).lowerBound
Bogdan Timofte authored 2 months ago
1843
        case .voltage:
1844
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1845
                return sharedAxisOrigin
1846
            }
Bogdan Timofte authored 2 months ago
1847
            return pinOrigin
1848
                ? voltageAxisOrigin
1849
                : automaticYBounds(
1850
                    for: filteredSamplePoints(
1851
                        measurements.voltage,
1852
                        visibleTimeRange: visibleTimeRange
1853
                    ),
1854
                    minimumYSpan: minimumVoltageSpan
1855
                ).lowerBound
Bogdan Timofte authored 2 months ago
1856
        case .current:
1857
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1858
                return sharedAxisOrigin
1859
            }
Bogdan Timofte authored 2 months ago
1860
            return pinOrigin
1861
                ? currentAxisOrigin
1862
                : automaticYBounds(
1863
                    for: filteredSamplePoints(
1864
                        measurements.current,
1865
                        visibleTimeRange: visibleTimeRange
1866
                    ),
1867
                    minimumYSpan: minimumCurrentSpan
1868
                ).lowerBound
Bogdan Timofte authored 2 months ago
1869
        case .temperature:
Bogdan Timofte authored 2 months ago
1870
            return pinOrigin
1871
                ? temperatureAxisOrigin
1872
                : automaticYBounds(
1873
                    for: filteredSamplePoints(
1874
                        measurements.temperature,
1875
                        visibleTimeRange: visibleTimeRange
1876
                    ),
1877
                    minimumYSpan: minimumTemperatureSpan
1878
                ).lowerBound
Bogdan Timofte authored a month ago
1879
        case .batteryPercent:
1880
            return pinOrigin ? batteryPercentAxisOrigin : 0
Bogdan Timofte authored 2 months ago
1881
        }
1882
    }
1883

            
Bogdan Timofte authored 2 months ago
1884
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1885
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1886
    }
1887

            
1888
    private func filteredPoints(
1889
        _ measurement: Measurements.Measurement,
1890
        visibleTimeRange: ClosedRange<Date>? = nil
1891
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1892
        let resolvedRange: ClosedRange<Date>?
1893

            
1894
        switch (timeRange, visibleTimeRange) {
1895
        case let (baseRange?, visibleRange?):
1896
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1897
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1898
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1899
        case let (baseRange?, nil):
1900
            resolvedRange = baseRange
1901
        case let (nil, visibleRange?):
1902
            resolvedRange = visibleRange
1903
        case (nil, nil):
1904
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1905
        }
Bogdan Timofte authored 2 months ago
1906

            
1907
        guard let resolvedRange else {
1908
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1909
        }
1910

            
1911
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1912
    }
1913

            
1914
    private func filteredSamplePoints(
1915
        _ measurement: Measurements.Measurement,
1916
        visibleTimeRange: ClosedRange<Date>? = nil
1917
    ) -> [Measurements.Measurement.Point] {
1918
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1919
            point.isSample
Bogdan Timofte authored 2 months ago
1920
        }
1921
    }
1922

            
1923
    private func xBounds(
Bogdan Timofte authored 2 months ago
1924
        for samplePoints: [Measurements.Measurement.Point],
1925
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1926
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1927
        if let visibleTimeRange {
1928
            return normalizedTimeRange(visibleTimeRange)
1929
        }
1930

            
Bogdan Timofte authored 2 months ago
1931
        if let timeRange {
Bogdan Timofte authored 2 months ago
1932
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1933
        }
1934

            
1935
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1936
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1937

            
Bogdan Timofte authored 2 months ago
1938
        return normalizedTimeRange(lowerBound...upperBound)
1939
    }
1940

            
1941
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1942
        if let timeRange {
1943
            return normalizedTimeRange(timeRange)
1944
        }
1945

            
1946
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1947
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1948
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1949
            return nil
1950
        }
1951

            
Bogdan Timofte authored a month ago
1952
        let latestSampleTimestamp = samplePoints.last?.timestamp
1953
        let resolvedUpperBound = timeRangeUpperBound ?? {
1954
            guard extendsTimelineToPresent else {
1955
                return latestSampleTimestamp ?? lowerBound
1956
            }
1957
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1958
        }()
1959
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1960
        return normalizedTimeRange(lowerBound...upperBound)
1961
    }
1962

            
1963
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1964
        let candidates = [
1965
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1966
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1967
            filteredSamplePoints(measurements.voltage),
1968
            filteredSamplePoints(measurements.current),
Bogdan Timofte authored a month ago
1969
            filteredSamplePoints(measurements.temperature),
1970
            batteryPercentPoints.isEmpty
1971
                ? filteredSamplePoints(measurements.batteryPercent)
1972
                : batteryPercentPoints.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1973
        ]
1974

            
1975
        return candidates.first(where: { !$0.isEmpty }) ?? []
1976
    }
1977

            
1978
    private func resolvedVisibleTimeRange(
1979
        within availableTimeRange: ClosedRange<Date>?
1980
    ) -> ClosedRange<Date>? {
1981
        guard let availableTimeRange else { return nil }
1982
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1983

            
1984
        if isPinnedToPresent {
1985
            let pinnedRange: ClosedRange<Date>
1986

            
1987
            switch presentTrackingMode {
1988
            case .keepDuration:
1989
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1990
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1991
            case .keepStartTimestamp:
1992
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1993
            }
1994

            
1995
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1996
        }
1997

            
1998
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1999
    }
2000

            
2001
    private func clampedTimeRange(
2002
        _ candidateRange: ClosedRange<Date>,
2003
        within bounds: ClosedRange<Date>
2004
    ) -> ClosedRange<Date> {
2005
        let normalizedBounds = normalizedTimeRange(bounds)
2006
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
2007

            
2008
        guard boundsSpan > 0 else {
2009
            return normalizedBounds
2010
        }
2011

            
2012
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
2013
        let requestedSpan = min(
2014
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2015
            boundsSpan
2016
        )
2017

            
2018
        if requestedSpan >= boundsSpan {
2019
            return normalizedBounds
2020
        }
2021

            
2022
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
2023
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
2024

            
2025
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2026
            if lowerBound == normalizedBounds.lowerBound {
2027
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2028
            } else {
2029
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2030
            }
2031
        }
2032

            
2033
        if upperBound > normalizedBounds.upperBound {
2034
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
2035
            upperBound = normalizedBounds.upperBound
2036
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
2037
        }
2038

            
Bogdan Timofte authored 2 months ago
2039
        if lowerBound < normalizedBounds.lowerBound {
2040
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
2041
            lowerBound = normalizedBounds.lowerBound
2042
            upperBound = upperBound.addingTimeInterval(delta)
2043
        }
2044

            
2045
        return lowerBound...upperBound
2046
    }
2047

            
2048
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
2049
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
2050
        guard span < minimumTimeSpan else { return range }
2051

            
2052
        let expansion = (minimumTimeSpan - span) / 2
2053
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
2054
    }
2055

            
2056
    private func shouldShowRangeSelector(
2057
        availableTimeRange: ClosedRange<Date>,
2058
        series: SeriesData
2059
    ) -> Bool {
2060
        series.samplePoints.count > 1 &&
2061
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
2062
    }
2063

            
2064
    private func automaticYBounds(
2065
        for samplePoints: [Measurements.Measurement.Point],
2066
        minimumYSpan: Double
2067
    ) -> (lowerBound: Double, upperBound: Double) {
2068
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
2069

            
2070
        guard
2071
            let minimumSampleValue = samplePoints.map(\.value).min(),
2072
            let maximumSampleValue = samplePoints.map(\.value).max()
2073
        else {
2074
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
2075
        }
Bogdan Timofte authored 2 months ago
2076

            
2077
        var lowerBound = minimumSampleValue
2078
        var upperBound = maximumSampleValue
2079
        let currentSpan = upperBound - lowerBound
2080

            
2081
        if currentSpan < minimumYSpan {
2082
            let expansion = (minimumYSpan - currentSpan) / 2
2083
            lowerBound -= expansion
2084
            upperBound += expansion
2085
        }
2086

            
2087
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
2088
            let shift = -negativeAllowance - lowerBound
2089
            lowerBound += shift
2090
            upperBound += shift
2091
        }
2092

            
2093
        let snappedLowerBound = snappedOriginValue(lowerBound)
2094
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
2095
        return (snappedLowerBound, resolvedUpperBound)
2096
    }
2097

            
2098
    private func resolvedLowerBound(
2099
        for kind: SeriesKind,
2100
        autoLowerBound: Double
2101
    ) -> Double {
2102
        guard pinOrigin else { return autoLowerBound }
2103

            
2104
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2105
            return sharedAxisOrigin
2106
        }
2107

            
2108
        switch kind {
2109
        case .power:
2110
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
2111
        case .energy:
2112
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
2113
        case .voltage:
2114
            return voltageAxisOrigin
2115
        case .current:
2116
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
2117
        case .temperature:
2118
            return temperatureAxisOrigin
Bogdan Timofte authored a month ago
2119
        case .batteryPercent:
2120
            return batteryPercentAxisOrigin
Bogdan Timofte authored 2 months ago
2121
        }
2122
    }
2123

            
2124
    private func resolvedUpperBound(
2125
        for kind: SeriesKind,
2126
        lowerBound: Double,
2127
        autoUpperBound: Double,
2128
        maximumSampleValue: Double?,
2129
        minimumYSpan: Double
2130
    ) -> Double {
2131
        guard pinOrigin else {
2132
            return autoUpperBound
2133
        }
2134

            
Bogdan Timofte authored 2 months ago
2135
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2136
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
2137
        }
2138

            
Bogdan Timofte authored a month ago
2139
        if kind == .temperature || kind == .batteryPercent {
Bogdan Timofte authored 2 months ago
2140
            return autoUpperBound
2141
        }
2142

            
Bogdan Timofte authored 2 months ago
2143
        return max(
2144
            maximumSampleValue ?? lowerBound,
2145
            lowerBound + minimumYSpan,
2146
            autoUpperBound
2147
        )
2148
    }
2149

            
Bogdan Timofte authored 2 months ago
2150
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
2151
        let baseline = displayedLowerBoundForSeries(kind)
2152
        let proposedOrigin = snappedOriginValue(baseline + delta)
2153

            
2154
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
2155
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
2156
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
2157
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
2158
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
2159
        } else {
2160
            switch kind {
2161
            case .power:
2162
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
2163
            case .energy:
2164
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
2165
            case .voltage:
2166
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
2167
            case .current:
2168
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
2169
            case .temperature:
2170
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a month ago
2171
            case .batteryPercent:
2172
                batteryPercentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .batteryPercent))
Bogdan Timofte authored 2 months ago
2173
            }
2174
        }
2175

            
2176
        pinOrigin = true
2177
    }
2178

            
Bogdan Timofte authored 2 months ago
2179
    private func clearOriginOffset(for kind: SeriesKind) {
2180
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2181
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
2182
            sharedAxisOrigin = 0
2183
            sharedAxisUpperBound = currentSpan
2184
            ensureSharedScaleSpan()
2185
            voltageAxisOrigin = 0
2186
            currentAxisOrigin = 0
2187
        } else {
2188
            switch kind {
2189
            case .power:
2190
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2191
            case .energy:
2192
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2193
            case .voltage:
2194
                voltageAxisOrigin = 0
2195
            case .current:
2196
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2197
            case .temperature:
2198
                temperatureAxisOrigin = 0
Bogdan Timofte authored a month ago
2199
            case .batteryPercent:
2200
                batteryPercentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2201
            }
2202
        }
2203

            
2204
        pinOrigin = true
2205
    }
2206

            
2207
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
2208
        guard totalHeight > 1 else { return }
2209

            
2210
        let normalized = max(0, min(1, locationY / totalHeight))
2211
        if normalized < (1.0 / 3.0) {
2212
            applyOriginDelta(-1, kind: kind)
2213
        } else if normalized < (2.0 / 3.0) {
2214
            clearOriginOffset(for: kind)
2215
        } else {
2216
            applyOriginDelta(1, kind: kind)
2217
        }
2218
    }
2219

            
Bogdan Timofte authored 2 months ago
2220
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
2221
        let visibleTimeRange = activeVisibleTimeRange
2222

            
Bogdan Timofte authored 2 months ago
2223
        switch kind {
2224
        case .power:
Bogdan Timofte authored 2 months ago
2225
            return snappedOriginValue(
2226
                filteredSamplePoints(
2227
                    measurements.power,
2228
                    visibleTimeRange: visibleTimeRange
2229
                ).map(\.value).min() ?? 0
2230
            )
Bogdan Timofte authored 2 months ago
2231
        case .energy:
2232
            return snappedOriginValue(
2233
                filteredSamplePoints(
2234
                    measurements.energy,
2235
                    visibleTimeRange: visibleTimeRange
2236
                ).map(\.value).min() ?? 0
2237
            )
Bogdan Timofte authored 2 months ago
2238
        case .voltage:
Bogdan Timofte authored 2 months ago
2239
            return snappedOriginValue(
2240
                filteredSamplePoints(
2241
                    measurements.voltage,
2242
                    visibleTimeRange: visibleTimeRange
2243
                ).map(\.value).min() ?? 0
2244
            )
Bogdan Timofte authored 2 months ago
2245
        case .current:
Bogdan Timofte authored 2 months ago
2246
            return snappedOriginValue(
2247
                filteredSamplePoints(
2248
                    measurements.current,
2249
                    visibleTimeRange: visibleTimeRange
2250
                ).map(\.value).min() ?? 0
2251
            )
Bogdan Timofte authored 2 months ago
2252
        case .temperature:
Bogdan Timofte authored 2 months ago
2253
            return snappedOriginValue(
2254
                filteredSamplePoints(
2255
                    measurements.temperature,
2256
                    visibleTimeRange: visibleTimeRange
Bogdan Timofte authored a month ago
2257
                ).map(\.value).min() ?? 0
2258
            )
2259
        case .batteryPercent:
2260
            return snappedOriginValue(
2261
                filteredSamplePoints(
2262
                    measurements.batteryPercent,
2263
                    visibleTimeRange: visibleTimeRange
Bogdan Timofte authored 2 months ago
2264
                ).map(\.value).min() ?? 0
2265
            )
Bogdan Timofte authored 2 months ago
2266
        }
2267
    }
2268

            
2269
    private func maximumVisibleSharedOrigin() -> Double {
2270
        min(
2271
            maximumVisibleOrigin(for: .voltage),
2272
            maximumVisibleOrigin(for: .current)
2273
        )
2274
    }
2275

            
Bogdan Timofte authored 2 months ago
2276
    private func measurementUnit(for kind: SeriesKind) -> String {
2277
        switch kind {
2278
        case .temperature:
2279
            let locale = Locale.autoupdatingCurrent
2280
            if #available(iOS 16.0, *) {
2281
                switch locale.measurementSystem {
2282
                case .us:
2283
                    return "°F"
2284
                default:
2285
                    return "°C"
2286
                }
2287
            }
2288

            
2289
            let regionCode = locale.regionCode ?? ""
2290
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
2291
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
2292
        default:
2293
            return kind.unit
2294
        }
2295
    }
2296

            
Bogdan Timofte authored 2 months ago
2297
    private func ensureSharedScaleSpan() {
2298
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
2299
    }
2300

            
Bogdan Timofte authored 2 months ago
2301
    private func snappedOriginValue(_ value: Double) -> Double {
2302
        if value >= 0 {
2303
            return value.rounded(.down)
2304
        }
2305

            
2306
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
2307
    }
Bogdan Timofte authored 2 months ago
2308

            
Bogdan Timofte authored 2 months ago
2309
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
2310
        measurements.keepOnly(in: range)
2311
        selectedVisibleTimeRange = nil
2312
        isPinnedToPresent = false
2313
    }
2314

            
2315
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
2316
        measurements.removeValues(in: range)
2317
        selectedVisibleTimeRange = nil
2318
        isPinnedToPresent = false
2319
    }
2320

            
Bogdan Timofte authored 2 months ago
2321
    private func yGuidePosition(
2322
        for labelIndex: Int,
2323
        context: ChartContext,
2324
        height: CGFloat
2325
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2326
        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
Bogdan Timofte authored 2 months ago
2327
    }
2328

            
2329
    private func xGuidePosition(
2330
        for labelIndex: Int,
2331
        context: ChartContext,
2332
        width: CGFloat
2333
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2334
        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
Bogdan Timofte authored 2 months ago
2335
    }
Bogdan Timofte authored 2 months ago
2336

            
2337
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
2338
    fileprivate func xAxisLabelsView(
2339
        context: ChartContext
2340
    ) -> some View {
Bogdan Timofte authored 2 months ago
2341
        var timeFormat: String?
2342
        switch context.size.width {
2343
        case 0..<3600: timeFormat = "HH:mm:ss"
2344
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
2345
        default: timeFormat = "E HH:mm"
2346
        }
2347
        let labels = (1...xLabels).map {
2348
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
2349
        }
Bogdan Timofte authored 2 months ago
2350
        let axisLabelFont: Font = {
2351
            if isIPhone && isPortraitLayout {
2352
                return .caption2.weight(.semibold)
2353
            }
2354
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
2355
        }()
Bogdan Timofte authored 2 months ago
2356

            
2357
        return HStack(spacing: chartSectionSpacing) {
2358
            Color.clear
2359
                .frame(width: axisColumnWidth)
2360

            
2361
            GeometryReader { geometry in
2362
                let labelWidth = max(
2363
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
2364
                    1
2365
                )
2366

            
2367
                ZStack(alignment: .topLeading) {
2368
                    Path { path in
2369
                        for labelIndex in 1...self.xLabels {
2370
                            let x = xGuidePosition(
2371
                                for: labelIndex,
2372
                                context: context,
2373
                                width: geometry.size.width
2374
                            )
2375
                            path.move(to: CGPoint(x: x, y: 0))
2376
                            path.addLine(to: CGPoint(x: x, y: 6))
2377
                        }
2378
                    }
2379
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
2380

            
2381
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
2382
                        let labelIndex = item.offset + 1
2383
                        let centerX = xGuidePosition(
2384
                            for: labelIndex,
2385
                            context: context,
2386
                            width: geometry.size.width
2387
                        )
2388

            
2389
                        Text(item.element)
Bogdan Timofte authored 2 months ago
2390
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
2391
                            .monospacedDigit()
2392
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
2393
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
2394
                            .frame(width: labelWidth)
2395
                            .position(
2396
                                x: centerX,
2397
                                y: geometry.size.height * 0.7
2398
                            )
Bogdan Timofte authored 2 months ago
2399
                    }
2400
                }
Bogdan Timofte authored 2 months ago
2401
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
2402
            }
Bogdan Timofte authored 2 months ago
2403

            
2404
            Color.clear
2405
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
2406
        }
2407
    }
2408

            
Bogdan Timofte authored 2 months ago
2409
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
2410
        height: CGFloat,
2411
        context: ChartContext,
Bogdan Timofte authored 2 months ago
2412
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
2413
        measurementUnit: String,
2414
        tint: Color
2415
    ) -> some View {
Bogdan Timofte authored 2 months ago
2416
        let yAxisFont: Font = {
2417
            if isIPhone && isPortraitLayout {
2418
                return .caption2.weight(.semibold)
2419
            }
2420
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
2421
        }()
2422

            
2423
        let unitFont: Font = {
2424
            if isIPhone && isPortraitLayout {
2425
                return .caption2.weight(.bold)
2426
            }
2427
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
2428
        }()
2429

            
2430
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
2431
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
2432
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
2433
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
2434

            
Bogdan Timofte authored 2 months ago
2435
            ZStack(alignment: .top) {
2436
                ForEach(0..<yLabels, id: \.self) { row in
2437
                    let labelIndex = yLabels - row
2438

            
2439
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
2440
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
2441
                        .monospacedDigit()
2442
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
2443
                        .minimumScaleFactor(0.8)
2444
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
2445
                        .position(
2446
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
2447
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
2448
                                for: labelIndex,
2449
                                context: context,
Bogdan Timofte authored 2 months ago
2450
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
2451
                            )
2452
                        )
Bogdan Timofte authored 2 months ago
2453
                }
Bogdan Timofte authored 2 months ago
2454

            
Bogdan Timofte authored 2 months ago
2455
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
2456
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
2457
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
2458
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
2459
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
2460
                    .background(
2461
                        Capsule(style: .continuous)
2462
                            .fill(tint.opacity(0.14))
2463
                    )
Bogdan Timofte authored 2 months ago
2464
                    .padding(.top, 8)
2465

            
Bogdan Timofte authored 2 months ago
2466
            }
2467
        }
Bogdan Timofte authored 2 months ago
2468
        .frame(height: height)
2469
        .background(
2470
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2471
                .fill(tint.opacity(0.12))
2472
        )
2473
        .overlay(
2474
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2475
                .stroke(tint.opacity(0.20), lineWidth: 1)
2476
        )
Bogdan Timofte authored 2 months ago
2477
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
2478
        .gesture(
Bogdan Timofte authored 2 months ago
2479
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
2480
                .onEnded { value in
Bogdan Timofte authored 2 months ago
2481
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
2482
                }
2483
        )
Bogdan Timofte authored 2 months ago
2484
    }
2485

            
Bogdan Timofte authored 2 months ago
2486
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2487
        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
Bogdan Timofte authored 2 months ago
2488
    }
2489

            
Bogdan Timofte authored 2 months ago
2490
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2491
        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
Bogdan Timofte authored 2 months ago
2492
    }
Bogdan Timofte authored 2 months ago
2493

            
2494
    fileprivate func discontinuityMarkers(
2495
        points: [Measurements.Measurement.Point],
2496
        context: ChartContext
2497
    ) -> some View {
2498
        GeometryReader { geometry in
2499
            Path { path in
2500
                for point in points where point.isDiscontinuity {
2501
                    let markerX = context.placeInRect(
2502
                        point: CGPoint(
2503
                            x: point.timestamp.timeIntervalSince1970,
2504
                            y: context.origin.y
2505
                        )
2506
                    ).x * geometry.size.width
2507
                    path.move(to: CGPoint(x: markerX, y: 0))
2508
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
2509
                }
2510
            }
2511
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
2512
        }
2513
    }
Bogdan Timofte authored 2 months ago
2514

            
2515
}
2516

            
Bogdan Timofte authored a month ago
2517
private struct EmbeddedWidthKey: PreferenceKey {
2518
    static let defaultValue: CGFloat = 760
2519
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2520
        let next = nextValue()
2521
        if next > 0 { value = next }
2522
    }
2523
}
2524

            
Bogdan Timofte authored 2 months ago
2525
private struct TimeRangeSelectorView: View {
2526
    private enum DragTarget {
2527
        case lowerBound
2528
        case upperBound
2529
        case window
2530
    }
2531

            
2532
    private struct DragState {
2533
        let target: DragTarget
2534
        let initialRange: ClosedRange<Date>
2535
    }
2536

            
2537
    let points: [Measurements.Measurement.Point]
2538
    let context: ChartContext
2539
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
2540
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2541
    let compactLayout: Bool
Bogdan Timofte authored a month ago
2542
    let xAxisLabelCount: Int
Bogdan Timofte authored 2 months ago
2543
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2544
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2545

            
2546
    @Binding var selectedTimeRange: ClosedRange<Date>?
2547
    @Binding var isPinnedToPresent: Bool
2548
    @Binding var presentTrackingMode: PresentTrackingMode
2549
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
2550
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored a month ago
2551
    @State private var isShowingCSVExporter: Bool = false
2552
    @State private var exportFileName: String = "charge-session"
2553
    @State private var exportDocument = MeasurementChartCSVDocument(content: "")
Bogdan Timofte authored 2 months ago
2554

            
2555
    private var totalSpan: TimeInterval {
2556
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2557
    }
2558

            
2559
    private var currentRange: ClosedRange<Date> {
2560
        resolvedSelectionRange()
2561
    }
2562

            
2563
    private var trackHeight: CGFloat {
Bogdan Timofte authored a month ago
2564
        Self.trackHeight(compactLayout: compactLayout)
2565
    }
2566

            
2567
    private static func trackHeight(compactLayout: Bool) -> CGFloat {
2568
        compactLayout ? 42 : 50
Bogdan Timofte authored 2 months ago
2569
    }
2570

            
Bogdan Timofte authored a month ago
2571
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2572
        let rowHeight: CGFloat = compactLayout ? 28 : 32
Bogdan Timofte authored a month ago
2573
        let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2574
        let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
Bogdan Timofte authored a month ago
2575
        let spacing: CGFloat = compactLayout ? 6 : 8
Bogdan Timofte authored a month ago
2576
        // Single row of controls instead of two
2577
        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
Bogdan Timofte authored a month ago
2578
    }
2579

            
Bogdan Timofte authored 2 months ago
2580
    private var cornerRadius: CGFloat {
2581
        compactLayout ? 14 : 16
2582
    }
2583

            
2584
    private var symbolButtonSize: CGFloat {
2585
        compactLayout ? 28 : 32
2586
    }
2587

            
2588
    var body: some View {
2589
        let coversFullRange = selectionCoversFullRange(currentRange)
2590

            
2591
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
Bogdan Timofte authored a month ago
2592
            HStack(spacing: 8) {
2593
                // Alignment controls
2594
                if !coversFullRange || isPinnedToPresent {
Bogdan Timofte authored 2 months ago
2595
                    alignmentButton(
2596
                        systemName: "arrow.left.to.line.compact",
2597
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2598
                        action: alignSelectionToLeadingEdge,
2599
                        accessibilityLabel: "Align selection to start"
2600
                    )
2601

            
2602
                    alignmentButton(
2603
                        systemName: "arrow.right.to.line.compact",
2604
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2605
                        action: alignSelectionToTrailingEdge,
2606
                        accessibilityLabel: "Align selection to present"
2607
                    )
2608

            
2609
                    if isPinnedToPresent {
2610
                        trackingModeToggleButton()
2611
                    }
2612
                }
2613

            
Bogdan Timofte authored a month ago
2614
                Spacer(minLength: 0)
2615

            
Bogdan Timofte authored a month ago
2616
                if let exportAction = configuration.exportAction {
2617
                    iconButton(
2618
                        systemName: exportAction.systemName,
2619
                        tone: exportAction.tone,
2620
                        action: {
2621
                            beginCSVExport(exportAction)
2622
                        }
2623
                    )
2624
                    .help(exportAction.title)
2625
                    .accessibilityLabel(exportAction.title)
2626
                }
2627

            
Bogdan Timofte authored a month ago
2628
                // Trim/Save actions
Bogdan Timofte authored 2 months ago
2629
                if !coversFullRange {
Bogdan Timofte authored a month ago
2630
                    iconButton(
Bogdan Timofte authored a month ago
2631
                        systemName: configuration.keepAction.systemName,
2632
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2633
                        action: {
Bogdan Timofte authored a month ago
2634
                            configuration.keepAction.handler(currentRange)
2635
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2636
                        }
2637
                    )
Bogdan Timofte authored a month ago
2638
                    .help(configuration.keepAction.title)
Bogdan Timofte authored 2 months ago
2639

            
Bogdan Timofte authored a month ago
2640
                    if let removeAction = configuration.removeAction {
Bogdan Timofte authored a month ago
2641
                        iconButton(
Bogdan Timofte authored a month ago
2642
                            systemName: removeAction.systemName,
2643
                            tone: removeAction.tone,
2644
                            action: {
2645
                                removeAction.handler(currentRange)
2646
                                resetSelectionState()
2647
                            }
2648
                        )
Bogdan Timofte authored a month ago
2649
                        .help(removeAction.title)
Bogdan Timofte authored a month ago
2650
                    }
Bogdan Timofte authored 2 months ago
2651

            
Bogdan Timofte authored a month ago
2652
                    // Reset action (only show when there's a trim to reset)
2653
                    iconButton(
2654
                        systemName: configuration.resetAction.systemName,
2655
                        tone: configuration.resetAction.tone,
2656
                        action: {
2657
                            showResetConfirmation = true
2658
                        }
2659
                    )
2660
                    .help(configuration.resetAction.title)
2661
                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2662
                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2663
                            configuration.resetAction.handler()
2664
                            resetSelectionState()
2665
                        }
2666
                        Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 months ago
2667
                    }
2668
                }
2669
            }
2670

            
Bogdan Timofte authored 2 months ago
2671
            GeometryReader { geometry in
2672
                let selectionFrame = selectionFrame(in: geometry.size)
2673
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2674

            
2675
                ZStack(alignment: .topLeading) {
2676
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2677
                        .fill(Color.primary.opacity(0.05))
2678

            
Bogdan Timofte authored a month ago
2679
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2680
                        points: points,
2681
                        context: context,
2682
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2683
                        strokeColor: selectorTint,
2684
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2685
                    )
2686
                    .opacity(0.94)
2687
                    .allowsHitTesting(false)
2688

            
Bogdan Timofte authored a month ago
2689
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2690
                        points: points,
2691
                        context: context,
Bogdan Timofte authored 2 months ago
2692
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2693
                    )
2694
                    .opacity(0.82)
2695
                    .allowsHitTesting(false)
2696

            
2697
                    if selectionFrame.minX > 0 {
2698
                        Rectangle()
2699
                            .fill(dimmingColor)
2700
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2701
                            .allowsHitTesting(false)
2702
                    }
2703

            
2704
                    if selectionFrame.maxX < geometry.size.width {
2705
                        Rectangle()
2706
                            .fill(dimmingColor)
2707
                            .frame(
2708
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2709
                                height: geometry.size.height
2710
                            )
2711
                            .offset(x: selectionFrame.maxX)
2712
                            .allowsHitTesting(false)
2713
                    }
2714

            
2715
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2716
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2717
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2718
                        .offset(x: selectionFrame.minX)
2719
                        .allowsHitTesting(false)
2720

            
2721
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2722
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2723
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2724
                        .offset(x: selectionFrame.minX)
2725
                        .allowsHitTesting(false)
2726

            
2727
                    handleView(height: max(geometry.size.height - 18, 16))
2728
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2729
                        .allowsHitTesting(false)
2730

            
2731
                    handleView(height: max(geometry.size.height - 18, 16))
2732
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2733
                        .allowsHitTesting(false)
2734
                }
2735
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2736
                .overlay(
2737
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
Bogdan Timofte authored a month ago
2738
                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2739
                )
2740
                .contentShape(Rectangle())
2741
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2742
            }
2743
            .frame(height: trackHeight)
2744

            
Bogdan Timofte authored a month ago
2745
            xAxisLabelsView
Bogdan Timofte authored 2 months ago
2746
        }
Bogdan Timofte authored a month ago
2747
        .fileExporter(
2748
            isPresented: $isShowingCSVExporter,
2749
            document: exportDocument,
2750
            contentType: .commaSeparatedText,
2751
            defaultFilename: exportFileName
2752
        ) { _ in }
Bogdan Timofte authored 2 months ago
2753
    }
2754

            
2755
    private func handleView(height: CGFloat) -> some View {
2756
        Capsule(style: .continuous)
2757
            .fill(Color.white.opacity(0.95))
2758
            .frame(width: 6, height: height)
2759
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2760
    }
2761

            
2762
    private func alignmentButton(
2763
        systemName: String,
2764
        isActive: Bool,
2765
        action: @escaping () -> Void,
2766
        accessibilityLabel: String
2767
    ) -> some View {
2768
        Button(action: action) {
2769
            Image(systemName: systemName)
2770
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2771
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2772
        }
2773
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2774
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2775
        .background(
2776
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2777
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2778
        )
2779
        .overlay(
2780
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2781
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2782
        )
2783
        .accessibilityLabel(accessibilityLabel)
2784
    }
2785

            
2786
    private func trackingModeToggleButton() -> some View {
2787
        Button {
2788
            presentTrackingMode = presentTrackingMode == .keepDuration
2789
                ? .keepStartTimestamp
2790
                : .keepDuration
2791
        } label: {
2792
            Image(systemName: trackingModeSymbolName)
2793
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2794
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2795
        }
2796
        .buttonStyle(.plain)
2797
        .foregroundColor(.white)
2798
        .background(
2799
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2800
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2801
        )
2802
        .overlay(
2803
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2804
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2805
        )
2806
        .accessibilityLabel(trackingModeAccessibilityLabel)
2807
        .accessibilityHint("Toggles how the interval follows the present")
2808
    }
2809

            
Bogdan Timofte authored a month ago
2810
    private func beginCSVExport(_ action: MeasurementChartExportAction) {
2811
        let exportRange = currentRange
2812
        exportFileName = action.fileName(exportRange)
2813
        exportDocument = MeasurementChartCSVDocument(content: action.content(exportRange))
2814
        isShowingCSVExporter = true
2815
    }
2816

            
Bogdan Timofte authored 2 months ago
2817
    private func actionButton(
2818
        title: String,
Bogdan Timofte authored a month ago
2819
        shortTitle: String? = nil,
Bogdan Timofte authored 2 months ago
2820
        systemName: String,
Bogdan Timofte authored a month ago
2821
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2822
        action: @escaping () -> Void
2823
    ) -> some View {
2824
        let foregroundColor: Color = {
2825
            switch tone {
2826
            case .reversible, .destructive:
2827
                return toneColor(for: tone)
2828
            case .destructiveProminent:
2829
                return .white
2830
            }
2831
        }()
Bogdan Timofte authored a month ago
2832
        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
Bogdan Timofte authored 2 months ago
2833

            
2834
        return Button(action: action) {
Bogdan Timofte authored a month ago
2835
            Label(displayTitle, systemImage: systemName)
Bogdan Timofte authored 2 months ago
2836
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2837
                .padding(.horizontal, compactLayout ? 10 : 12)
2838
                .padding(.vertical, compactLayout ? 7 : 8)
2839
        }
2840
        .buttonStyle(.plain)
2841
        .foregroundColor(foregroundColor)
2842
        .background(
2843
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2844
                .fill(actionButtonBackground(for: tone))
2845
        )
2846
        .overlay(
2847
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2848
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2849
        )
2850
    }
2851

            
Bogdan Timofte authored a month ago
2852
    private func iconButton(
2853
        systemName: String,
2854
        tone: MeasurementChartSelectorActionTone,
2855
        action: @escaping () -> Void
2856
    ) -> some View {
2857
        let foregroundColor: Color = {
2858
            switch tone {
2859
            case .reversible, .destructive:
2860
                return toneColor(for: tone)
2861
            case .destructiveProminent:
2862
                return .white
2863
            }
2864
        }()
2865

            
2866
        return Button(action: action) {
2867
            Image(systemName: systemName)
2868
                .font(.subheadline.weight(.semibold))
2869
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2870
        }
2871
        .buttonStyle(.plain)
2872
        .foregroundColor(foregroundColor)
2873
        .background(
2874
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2875
                .fill(actionButtonBackground(for: tone))
2876
        )
2877
        .overlay(
2878
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2879
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2880
        )
2881
    }
2882

            
Bogdan Timofte authored a month ago
2883
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2884
        switch tone {
2885
        case .reversible:
2886
            return selectorTint
2887
        case .destructive, .destructiveProminent:
2888
            return .red
2889
        }
2890
    }
2891

            
Bogdan Timofte authored a month ago
2892
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2893
        switch tone {
2894
        case .reversible:
2895
            return selectorTint.opacity(0.12)
2896
        case .destructive:
2897
            return Color.red.opacity(0.12)
2898
        case .destructiveProminent:
2899
            return Color.red.opacity(0.82)
2900
        }
2901
    }
2902

            
Bogdan Timofte authored a month ago
2903
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2904
        switch tone {
2905
        case .reversible:
2906
            return selectorTint.opacity(0.22)
2907
        case .destructive:
2908
            return Color.red.opacity(0.22)
2909
        case .destructiveProminent:
2910
            return Color.red.opacity(0.72)
2911
        }
2912
    }
2913

            
Bogdan Timofte authored 2 months ago
2914
    private var trackingModeSymbolName: String {
2915
        switch presentTrackingMode {
2916
        case .keepDuration:
2917
            return "arrow.left.and.right"
2918
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2919
            return "arrow.right"
Bogdan Timofte authored 2 months ago
2920
        }
2921
    }
2922

            
2923
    private var trackingModeAccessibilityLabel: String {
2924
        switch presentTrackingMode {
2925
        case .keepDuration:
Bogdan Timofte authored a month ago
2926
            return "Keep fixed duration"
Bogdan Timofte authored 2 months ago
2927
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2928
            return "Keep start fixed"
Bogdan Timofte authored 2 months ago
2929
        }
2930
    }
2931

            
2932
    private func alignSelectionToLeadingEdge() {
2933
        let alignedRange = normalizedSelectionRange(
2934
            availableTimeRange.lowerBound...currentRange.upperBound
2935
        )
2936
        applySelection(alignedRange, pinToPresent: false)
2937
    }
2938

            
2939
    private func alignSelectionToTrailingEdge() {
2940
        let alignedRange = normalizedSelectionRange(
2941
            currentRange.lowerBound...availableTimeRange.upperBound
2942
        )
2943
        applySelection(alignedRange, pinToPresent: true)
2944
    }
2945

            
2946
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2947
        DragGesture(minimumDistance: 0)
2948
            .onChanged { value in
2949
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2950
            }
2951
            .onEnded { _ in
2952
                dragState = nil
2953
            }
2954
    }
2955

            
2956
    private func updateSelectionDrag(
2957
        value: DragGesture.Value,
2958
        totalWidth: CGFloat
2959
    ) {
2960
        let startingRange = resolvedSelectionRange()
2961

            
2962
        if dragState == nil {
2963
            dragState = DragState(
2964
                target: dragTarget(
2965
                    for: value.startLocation.x,
2966
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2967
                ),
2968
                initialRange: startingRange
2969
            )
2970
        }
2971

            
2972
        guard let dragState else { return }
2973

            
2974
        let resultingRange = snappedToEdges(
2975
            adjustedRange(
2976
                from: dragState.initialRange,
2977
                target: dragState.target,
2978
                translationX: value.translation.width,
2979
                totalWidth: totalWidth
2980
            ),
2981
            target: dragState.target,
2982
            totalWidth: totalWidth
2983
        )
2984

            
2985
        applySelection(
2986
            resultingRange,
2987
            pinToPresent: shouldKeepPresentPin(
2988
                during: dragState.target,
2989
                initialRange: dragState.initialRange,
2990
                resultingRange: resultingRange
2991
            ),
2992
        )
2993
    }
2994

            
2995
    private func dragTarget(
2996
        for startX: CGFloat,
2997
        selectionFrame: CGRect
2998
    ) -> DragTarget {
2999
        let handleZone: CGFloat = compactLayout ? 20 : 24
3000

            
3001
        if abs(startX - selectionFrame.minX) <= handleZone {
3002
            return .lowerBound
3003
        }
3004

            
3005
        if abs(startX - selectionFrame.maxX) <= handleZone {
3006
            return .upperBound
3007
        }
3008

            
3009
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
3010
            return .window
3011
        }
3012

            
3013
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
3014
    }
3015

            
3016
    private func adjustedRange(
3017
        from initialRange: ClosedRange<Date>,
3018
        target: DragTarget,
3019
        translationX: CGFloat,
3020
        totalWidth: CGFloat
3021
    ) -> ClosedRange<Date> {
3022
        guard totalSpan > 0, totalWidth > 0 else {
3023
            return availableTimeRange
3024
        }
3025

            
3026
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
3027
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
3028

            
3029
        switch target {
3030
        case .lowerBound:
3031
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
3032
            let newLowerBound = min(
3033
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
3034
                maximumLowerBound
3035
            )
3036
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
3037

            
3038
        case .upperBound:
3039
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
3040
            let newUpperBound = max(
3041
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
3042
                minimumUpperBound
3043
            )
3044
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
3045

            
3046
        case .window:
3047
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
3048
            guard span < totalSpan else { return availableTimeRange }
3049

            
3050
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
3051
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
3052

            
3053
            if lowerBound < availableTimeRange.lowerBound {
3054
                upperBound = upperBound.addingTimeInterval(
3055
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
3056
                )
3057
                lowerBound = availableTimeRange.lowerBound
3058
            }
3059

            
3060
            if upperBound > availableTimeRange.upperBound {
3061
                lowerBound = lowerBound.addingTimeInterval(
3062
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
3063
                )
3064
                upperBound = availableTimeRange.upperBound
3065
            }
3066

            
3067
            return normalizedSelectionRange(lowerBound...upperBound)
3068
        }
3069
    }
3070

            
3071
    private func snappedToEdges(
3072
        _ candidateRange: ClosedRange<Date>,
3073
        target: DragTarget,
3074
        totalWidth: CGFloat
3075
    ) -> ClosedRange<Date> {
3076
        guard totalSpan > 0 else {
3077
            return availableTimeRange
3078
        }
3079

            
3080
        let snapInterval = edgeSnapInterval(for: totalWidth)
3081
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
3082
        var lowerBound = candidateRange.lowerBound
3083
        var upperBound = candidateRange.upperBound
3084

            
3085
        if target != .upperBound,
3086
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
3087
            lowerBound = availableTimeRange.lowerBound
3088
            if target == .window {
3089
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
3090
            }
3091
        }
3092

            
3093
        if target != .lowerBound,
3094
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
3095
            upperBound = availableTimeRange.upperBound
3096
            if target == .window {
3097
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
3098
            }
3099
        }
3100

            
3101
        return normalizedSelectionRange(lowerBound...upperBound)
3102
    }
3103

            
3104
    private func edgeSnapInterval(
3105
        for totalWidth: CGFloat
3106
    ) -> TimeInterval {
3107
        guard totalWidth > 0 else { return minimumSelectionSpan }
3108

            
3109
        let snapWidth = min(
3110
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
3111
            totalWidth * 0.18
3112
        )
3113
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
3114
        return min(
3115
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
3116
            totalSpan / 4
3117
        )
3118
    }
3119

            
3120
    private func resolvedSelectionRange() -> ClosedRange<Date> {
3121
        guard let selectedTimeRange else { return availableTimeRange }
3122

            
3123
        if isPinnedToPresent {
3124
            switch presentTrackingMode {
3125
            case .keepDuration:
3126
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
3127
                return normalizedSelectionRange(
3128
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
3129
                )
3130
            case .keepStartTimestamp:
3131
                return normalizedSelectionRange(
3132
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
3133
                )
3134
            }
3135
        }
3136

            
3137
        return normalizedSelectionRange(selectedTimeRange)
3138
    }
3139

            
3140
    private func normalizedSelectionRange(
3141
        _ candidateRange: ClosedRange<Date>
3142
    ) -> ClosedRange<Date> {
3143
        let availableSpan = totalSpan
3144
        guard availableSpan > 0 else { return availableTimeRange }
3145

            
3146
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
3147
        let requestedSpan = min(
3148
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
3149
            availableSpan
3150
        )
3151

            
3152
        if requestedSpan >= availableSpan {
3153
            return availableTimeRange
3154
        }
3155

            
3156
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
3157
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
3158

            
3159
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
3160
            if lowerBound == availableTimeRange.lowerBound {
3161
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
3162
            } else {
3163
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
3164
            }
3165
        }
3166

            
3167
        if upperBound > availableTimeRange.upperBound {
3168
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
3169
            upperBound = availableTimeRange.upperBound
3170
            lowerBound = lowerBound.addingTimeInterval(-delta)
3171
        }
3172

            
3173
        if lowerBound < availableTimeRange.lowerBound {
3174
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
3175
            lowerBound = availableTimeRange.lowerBound
3176
            upperBound = upperBound.addingTimeInterval(delta)
3177
        }
3178

            
3179
        return lowerBound...upperBound
3180
    }
3181

            
3182
    private func shouldKeepPresentPin(
3183
        during target: DragTarget,
3184
        initialRange: ClosedRange<Date>,
3185
        resultingRange: ClosedRange<Date>
3186
    ) -> Bool {
3187
        let startedPinnedToPresent =
3188
            isPinnedToPresent ||
3189
            selectionCoversFullRange(initialRange)
3190

            
3191
        guard startedPinnedToPresent else {
3192
            return selectionTouchesPresent(resultingRange)
3193
        }
3194

            
3195
        switch target {
3196
        case .lowerBound:
3197
            return true
3198
        case .upperBound, .window:
3199
            return selectionTouchesPresent(resultingRange)
3200
        }
3201
    }
3202

            
3203
    private func applySelection(
3204
        _ candidateRange: ClosedRange<Date>,
3205
        pinToPresent: Bool
3206
    ) {
3207
        let normalizedRange = normalizedSelectionRange(candidateRange)
3208

            
3209
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
3210
            selectedTimeRange = nil
3211
        } else {
3212
            selectedTimeRange = normalizedRange
3213
        }
3214

            
3215
        isPinnedToPresent = pinToPresent
3216
    }
3217

            
Bogdan Timofte authored a month ago
3218
    private func resetSelectionState() {
3219
        selectedTimeRange = nil
3220
        isPinnedToPresent = false
3221
    }
3222

            
Bogdan Timofte authored 2 months ago
3223
    private func selectionTouchesPresent(
3224
        _ range: ClosedRange<Date>
3225
    ) -> Bool {
3226
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
3227
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
3228
    }
3229

            
3230
    private func selectionCoversFullRange(
3231
        _ range: ClosedRange<Date>
3232
    ) -> Bool {
3233
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
3234
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
3235
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
3236
    }
3237

            
3238
    private func selectionFrame(in size: CGSize) -> CGRect {
3239
        selectionFrame(for: currentRange, width: size.width)
3240
    }
3241

            
3242
    private func selectionFrame(
3243
        for range: ClosedRange<Date>,
3244
        width: CGFloat
3245
    ) -> CGRect {
3246
        guard width > 0, totalSpan > 0 else {
3247
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
3248
        }
3249

            
3250
        let minimumX = xPosition(for: range.lowerBound, width: width)
3251
        let maximumX = xPosition(for: range.upperBound, width: width)
3252
        return CGRect(
3253
            x: minimumX,
3254
            y: 0,
3255
            width: max(maximumX - minimumX, 2),
3256
            height: trackHeight
3257
        )
3258
    }
3259

            
3260
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
3261
        guard width > 0, totalSpan > 0 else { return 0 }
3262

            
3263
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
3264
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
3265
        return CGFloat(normalizedOffset) * width
3266
    }
3267

            
Bogdan Timofte authored a month ago
3268
    private var xAxisLabelsView: some View {
3269
        let timeFormat: String = {
3270
            switch context.size.width {
3271
            case 0..<3600: return "HH:mm:ss"
3272
            case 3600...86400: return "HH:mm"
3273
            default: return "E HH:mm"
3274
            }
3275
        }()
Bogdan Timofte authored 2 months ago
3276

            
Bogdan Timofte authored a month ago
3277
        let labelCount = max(xAxisLabelCount, 2)
3278
        let labels = (1...labelCount).map {
3279
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat)
Bogdan Timofte authored 2 months ago
3280
        }
Bogdan Timofte authored a month ago
3281
        let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold)
3282

            
3283
        return GeometryReader { geometry in
3284
            let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1)
3285

            
3286
            ZStack(alignment: .topLeading) {
3287
                Path { path in
3288
                    for labelIndex in 1...labelCount {
3289
                        let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
3290
                        path.move(to: CGPoint(x: x, y: 0))
3291
                        path.addLine(to: CGPoint(x: x, y: 5))
3292
                    }
3293
                }
3294
                .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
3295

            
3296
                ForEach(Array(labels.enumerated()), id: \.offset) { item in
3297
                    let labelIndex = item.offset + 1
3298
                    let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
Bogdan Timofte authored a month ago
3299
                    let halfWidth = labelWidth / 2
3300
                    let clampedX = min(max(centerX, halfWidth), geometry.size.width - halfWidth)
Bogdan Timofte authored a month ago
3301

            
3302
                    Text(item.element)
3303
                        .font(axisLabelFont)
3304
                        .monospacedDigit()
3305
                        .lineLimit(1)
3306
                        .minimumScaleFactor(0.74)
3307
                        .frame(width: labelWidth)
3308
                        .position(
Bogdan Timofte authored a month ago
3309
                            x: clampedX,
Bogdan Timofte authored a month ago
3310
                            y: geometry.size.height * 0.66
3311
                        )
3312
                }
3313
            }
3314
        }
3315
        .frame(height: compactLayout ? 18 : 20)
3316
    }
3317

            
3318
    private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
3319
        context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width)
Bogdan Timofte authored 2 months ago
3320
    }
3321
}