USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
3232 lines | 118.795kb
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 struct SeriesLegendEntry: Identifiable {
228
        let id: SeriesKind
229
        let name: String
230
        let tint: Color
231
        let minimumText: String
232
        let averageText: String
233
        let maximumText: String
234
        let lastText: String
235
    }
236

            
Bogdan Timofte authored 2 months ago
237
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
238
    private let minimumVoltageSpan = 0.5
239
    private let minimumCurrentSpan = 0.5
240
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 2 months ago
241
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
242
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored a month ago
243
    private let minimumBatteryPercentSpan = 10.0
Bogdan Timofte authored 2 months ago
244
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 2 months ago
245
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
246

            
Bogdan Timofte authored a month ago
247
    let sizing: MeasurementChartSizing
Bogdan Timofte authored a month ago
248
    let showsRangeSelector: Bool
249
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored a month ago
250
    let extendsTimelineToPresent: Bool
Bogdan Timofte authored a month ago
251
    let showsTemperatureSeries: Bool
Bogdan Timofte authored a month ago
252
    let showsBatteryPercentSeries: Bool
253
    let batteryCheckpoints: [ChargeCheckpointSummary]
254
    let batteryPercentPoints: [Measurements.Measurement.Point]
Bogdan Timofte authored a month ago
255
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored a month ago
256

            
Bogdan Timofte authored 2 months ago
257
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
258
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
259
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
260
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a month ago
261
    let timeRangeLowerBound: Date?
262
    let timeRangeUpperBound: Date?
Bogdan Timofte authored a month ago
263

            
264
    @State private var embeddedWidth: CGFloat = 760
265

            
266
    private var compactLayout: Bool {
267
        switch sizing {
268
        case .provided(_, let compact): return compact
269
        case .embedded: return embeddedWidth < 760
270
        }
271
    }
272

            
273
    private var availableSize: CGSize {
274
        switch sizing {
275
        case .provided(let size, _): return size
276
        case .embedded:
277
            let h = compactLayout ? 290 : 350
278
            return CGSize(width: embeddedWidth, height: CGFloat(h))
279
        }
280
    }
281

            
Bogdan Timofte authored 2 months ago
282
    @State var displayVoltage: Bool = false
283
    @State var displayCurrent: Bool = false
284
    @State var displayPower: Bool = true
Bogdan Timofte authored 2 months ago
285
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 2 months ago
286
    @State var displayTemperature: Bool = false
Bogdan Timofte authored a month ago
287
    @State private var displayBatteryPercent: Bool = false
Bogdan Timofte authored 2 months ago
288
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored 2 months ago
289
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 2 months ago
290
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
291
    @State private var isPinnedToPresent: Bool = false
Bogdan Timofte authored a month ago
292
    @State private var presentTrackingMode: PresentTrackingMode = .keepStartTimestamp
Bogdan Timofte authored 2 months ago
293
    @State private var pinOrigin: Bool = false
294
    @State private var useSharedOrigin: Bool = false
295
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
296
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored 2 months ago
297
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
298
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
299
    @State private var voltageAxisOrigin: Double = 0
300
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
301
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored a month ago
302
    @State private var batteryPercentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
303
    let xLabels: Int = 4
304
    let yLabels: Int = 4
305

            
Bogdan Timofte authored 2 months ago
306
    init(
Bogdan Timofte authored a month ago
307
        sizing: MeasurementChartSizing = .embedded,
Bogdan Timofte authored a month ago
308
        timeRange: ClosedRange<Date>? = nil,
Bogdan Timofte authored a month ago
309
        timeRangeLowerBound: Date? = nil,
310
        timeRangeUpperBound: Date? = nil,
Bogdan Timofte authored a month ago
311
        showsRangeSelector: Bool = true,
Bogdan Timofte authored a month ago
312
        rebasesEnergyToVisibleRangeStart: Bool = false,
313
        extendsTimelineToPresent: Bool = true,
Bogdan Timofte authored a month ago
314
        showsTemperatureSeries: Bool = true,
Bogdan Timofte authored a month ago
315
        showsBatteryPercentSeries: Bool = false,
316
        batteryCheckpoints: [ChargeCheckpointSummary] = [],
317
        batteryPercentPoints: [Measurements.Measurement.Point] = [],
Bogdan Timofte authored a month ago
318
        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
Bogdan Timofte authored 2 months ago
319
    ) {
Bogdan Timofte authored a month ago
320
        self.sizing = sizing
Bogdan Timofte authored 2 months ago
321
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
322
        self.timeRangeLowerBound = timeRangeLowerBound
323
        self.timeRangeUpperBound = timeRangeUpperBound
Bogdan Timofte authored a month ago
324
        self.showsRangeSelector = showsRangeSelector
325
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored a month ago
326
        self.extendsTimelineToPresent = extendsTimelineToPresent
Bogdan Timofte authored a month ago
327
        self.showsTemperatureSeries = showsTemperatureSeries
Bogdan Timofte authored a month ago
328
        self.showsBatteryPercentSeries = showsBatteryPercentSeries
329
        self.batteryCheckpoints = batteryCheckpoints
330
        self.batteryPercentPoints = batteryPercentPoints
Bogdan Timofte authored a month ago
331
        self.rangeSelectorConfiguration = rangeSelectorConfiguration
Bogdan Timofte authored a month ago
332
        _displayPower = State(initialValue: showsBatteryPercentSeries == false)
333
        _displayBatteryPercent = State(initialValue: showsBatteryPercentSeries)
Bogdan Timofte authored 2 months ago
334
    }
335

            
Bogdan Timofte authored a month ago
336
    private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
337
        let compact = width < 760
Bogdan Timofte authored a month ago
338
        let plotHeight: CGFloat = compact ? 240 : 300
339
        let toolbarHeight: CGFloat = width < 640
340
            ? (compact ? 92 : 104)
341
            : (compact ? 48 : 56)
342
        let legendHeight: CGFloat = compact ? 76 : 90
343
        let outerSpacing: CGFloat = 12
344
        let chartStackSpacing: CGFloat = compact ? 8 : 10
345
        let selectorHeight = showsRangeSelector
346
            ? TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
347
            : 0
348
        let selectorSpacing = showsRangeSelector ? chartStackSpacing : 0
349

            
350
        return toolbarHeight
351
            + outerSpacing
352
            + plotHeight
353
            + selectorSpacing
354
            + selectorHeight
355
            + chartStackSpacing
356
            + legendHeight
Bogdan Timofte authored a month ago
357
    }
358

            
Bogdan Timofte authored 2 months ago
359
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
360
        if compactLayout {
361
            return 38
362
        }
363
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
364
    }
365

            
366
    private var chartSectionSpacing: CGFloat {
367
        compactLayout ? 6 : 8
368
    }
369

            
370
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
371
        if compactLayout {
372
            return 24
373
        }
374
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
375
    }
376

            
Bogdan Timofte authored 2 months ago
377
    private var isPortraitLayout: Bool {
378
        guard availableSize != .zero else { return verticalSizeClass != .compact }
379
        return availableSize.height >= availableSize.width
380
    }
381

            
Bogdan Timofte authored 2 months ago
382
    private var isIPhone: Bool {
383
        #if os(iOS)
384
        return UIDevice.current.userInterfaceIdiom == .phone
385
        #else
386
        return false
387
        #endif
388
    }
389

            
Bogdan Timofte authored a month ago
390
    private var plotSectionHeight: CGFloat {
391
        if case .embedded = sizing {
392
            return compactLayout ? 240 : 300
Bogdan Timofte authored 2 months ago
393
        }
394

            
Bogdan Timofte authored 2 months ago
395
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
396
            return compactLayout ? 300 : 380
397
        }
398

            
399
        if isPortraitLayout {
400
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
401
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
402
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
403
        }
404

            
405
        if compactLayout {
406
            return min(max(availableSize.height * 0.36, 240), 300)
407
        }
408

            
409
        return min(max(availableSize.height * 0.5, 300), 440)
410
    }
411

            
412
    private var stackedToolbarLayout: Bool {
413
        if availableSize.width > 0 {
414
            return availableSize.width < 640
415
        }
416

            
417
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
418
    }
419

            
420
    private var showsLabeledOriginControls: Bool {
421
        !compactLayout && !stackedToolbarLayout
422
    }
423

            
Bogdan Timofte authored 2 months ago
424
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
425
        #if os(iOS)
426
        if UIDevice.current.userInterfaceIdiom == .phone {
427
            return false
428
        }
429
        #endif
430

            
Bogdan Timofte authored 2 months ago
431
        if availableSize.width > 0 {
432
            return availableSize.width >= 900 || availableSize.height >= 700
433
        }
434
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
435
    }
436

            
437
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
438
        if isIPhone && isPortraitLayout {
439
            return .caption
440
        }
441
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
442
    }
443

            
Bogdan Timofte authored 2 months ago
444
    private var usesCompactLandscapeOriginControls: Bool {
445
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
446
    }
447

            
Bogdan Timofte authored 2 months ago
448
    var body: some View {
Bogdan Timofte authored a month ago
449
        Group {
450
            switch sizing {
451
            case .provided:
452
                chartBody
Bogdan Timofte authored a month ago
453
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
454
            case .embedded:
455
                chartBody
456
                    .frame(maxWidth: .infinity, alignment: .topLeading)
457
                    .background(
458
                        GeometryReader { geometry in
459
                            Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
460
                        }
461
                    )
462
                    .onPreferenceChange(EmbeddedWidthKey.self) { width in
463
                        guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
464
                        embeddedWidth = width
Bogdan Timofte authored a month ago
465
                    }
Bogdan Timofte authored a month ago
466
            }
467
        }
Bogdan Timofte authored a month ago
468
        .onAppear {
469
            resetHiddenTemperatureDisplay()
470
            resetHiddenBatteryPercentDisplay()
471
        }
Bogdan Timofte authored a month ago
472
        .onChange(of: showsTemperatureSeries) { _ in
473
            resetHiddenTemperatureDisplay()
Bogdan Timofte authored a month ago
474
        }
Bogdan Timofte authored a month ago
475
        .onChange(of: showsBatteryPercentSeries) { _ in
476
            resetHiddenBatteryPercentDisplay()
477
        }
Bogdan Timofte authored a month ago
478
    }
479

            
Bogdan Timofte authored a month ago
480
    private func resetHiddenTemperatureDisplay() {
481
        guard !showsTemperatureSeries, displayTemperature else { return }
482
        displayTemperature = false
483
    }
484

            
Bogdan Timofte authored a month ago
485
    private func resetHiddenBatteryPercentDisplay() {
486
        guard !showsBatteryPercentSeries, displayBatteryPercent else { return }
487
        displayBatteryPercent = false
488
        if !displayPower && !displayEnergy && !displayVoltage && !displayCurrent {
489
            displayPower = true
490
        }
491
    }
492

            
Bogdan Timofte authored a month ago
493
    @ViewBuilder
494
    private var chartBody: some View {
Bogdan Timofte authored 2 months ago
495
        let availableTimeRange = availableSelectionTimeRange()
496
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
497
        let powerSeries = series(
498
            for: measurements.power,
499
            kind: .power,
500
            minimumYSpan: minimumPowerSpan,
501
            visibleTimeRange: visibleTimeRange
502
        )
Bogdan Timofte authored 2 months ago
503
        let energySeries = series(
504
            for: measurements.energy,
505
            kind: .energy,
506
            minimumYSpan: minimumEnergySpan,
507
            visibleTimeRange: visibleTimeRange
508
        )
Bogdan Timofte authored 2 months ago
509
        let voltageSeries = series(
510
            for: measurements.voltage,
511
            kind: .voltage,
512
            minimumYSpan: minimumVoltageSpan,
513
            visibleTimeRange: visibleTimeRange
514
        )
515
        let currentSeries = series(
516
            for: measurements.current,
517
            kind: .current,
518
            minimumYSpan: minimumCurrentSpan,
519
            visibleTimeRange: visibleTimeRange
520
        )
521
        let temperatureSeries = series(
522
            for: measurements.temperature,
523
            kind: .temperature,
524
            minimumYSpan: minimumTemperatureSpan,
525
            visibleTimeRange: visibleTimeRange
526
        )
Bogdan Timofte authored a month ago
527
        let batteryPercentSeries = series(
528
            for: batteryPercentPoints.isEmpty ? measurements.batteryPercent.points : batteryPercentPoints,
529
            kind: .batteryPercent,
530
            minimumYSpan: minimumBatteryPercentSpan,
531
            visibleTimeRange: visibleTimeRange
532
        )
Bogdan Timofte authored 2 months ago
533
        let primarySeries = displayedPrimarySeries(
534
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
535
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
536
            voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
537
            currentSeries: currentSeries,
538
            batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
539
        )
Bogdan Timofte authored 2 months ago
540
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
541

            
Bogdan Timofte authored 2 months ago
542
        Group {
Bogdan Timofte authored 2 months ago
543
            if let primarySeries {
544
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
545
                    chartTopToolbar(
546
                        voltageSeries: voltageSeries,
547
                        currentSeries: currentSeries
548
                    )
Bogdan Timofte authored 2 months ago
549

            
Bogdan Timofte authored a month ago
550
                    VStack(spacing: compactLayout ? 8 : 10) {
551
                        GeometryReader { geometry in
Bogdan Timofte authored a month ago
552
                            let minimumPlotHeight: CGFloat = compactLayout
553
                                ? (isPortraitLayout ? 180 : 120)
554
                                : 220
555
                            let plotHeight = max(geometry.size.height - xAxisHeight, minimumPlotHeight)
Bogdan Timofte authored a month ago
556

            
557
                            VStack(spacing: 6) {
558
                                HStack(spacing: chartSectionSpacing) {
559
                                    primaryAxisView(
560
                                        height: plotHeight,
561
                                        powerSeries: powerSeries,
562
                                        energySeries: energySeries,
563
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
564
                                        currentSeries: currentSeries,
565
                                        batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
566
                                    )
567
                                    .frame(width: axisColumnWidth, height: plotHeight)
568

            
569
                                    ZStack {
570
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
571
                                            .fill(Color.primary.opacity(0.05))
572

            
573
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
574
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
575

            
576
                                        horizontalGuides(context: primarySeries.context)
577
                                        verticalGuides(context: primarySeries.context)
578
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
579
                                        renderedChart(
580
                                            powerSeries: powerSeries,
581
                                            energySeries: energySeries,
582
                                            voltageSeries: voltageSeries,
583
                                            currentSeries: currentSeries,
Bogdan Timofte authored a month ago
584
                                            temperatureSeries: temperatureSeries,
585
                                            batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
586
                                        )
587
                                    }
588
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
589
                                    .frame(maxWidth: .infinity)
590
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
591

            
Bogdan Timofte authored a month ago
592
                                    secondaryAxisView(
593
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
594
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
595
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
596
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
597
                                        currentSeries: currentSeries,
Bogdan Timofte authored a month ago
598
                                        temperatureSeries: temperatureSeries,
599
                                        batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
600
                                    )
Bogdan Timofte authored a month ago
601
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
602
                                }
Bogdan Timofte authored 2 months ago
603

            
Bogdan Timofte authored a month ago
604
                                xAxisLabelsView(context: primarySeries.context)
605
                                    .frame(height: xAxisHeight)
Bogdan Timofte authored 2 months ago
606
                            }
Bogdan Timofte authored a month ago
607
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
608
                        }
609
                        .frame(height: plotSectionHeight)
610

            
Bogdan Timofte authored a month ago
611
                        chartLegend(
612
                            entries: chartLegendEntries(
613
                                powerSeries: powerSeries,
614
                                energySeries: energySeries,
615
                                voltageSeries: voltageSeries,
616
                                currentSeries: currentSeries,
Bogdan Timofte authored a month ago
617
                                temperatureSeries: temperatureSeries,
618
                                batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored a month ago
619
                            )
620
                        )
621

            
Bogdan Timofte authored a month ago
622
                        if showsRangeSelector,
623
                           let availableTimeRange,
624
                           let selectorSeries,
625
                           shouldShowRangeSelector(
626
                            availableTimeRange: availableTimeRange,
627
                            series: selectorSeries
628
                           ) {
629
                            TimeRangeSelectorView(
630
                                points: selectorSeries.points,
631
                                context: selectorSeries.context,
Bogdan Timofte authored 2 months ago
632
                                availableTimeRange: availableTimeRange,
Bogdan Timofte authored a month ago
633
                                selectorTint: selectorTint,
634
                                compactLayout: compactLayout,
Bogdan Timofte authored a month ago
635
                                xAxisLabelCount: xLabels,
Bogdan Timofte authored a month ago
636
                                minimumSelectionSpan: minimumTimeSpan,
637
                                configuration: resolvedRangeSelectorConfiguration(),
638
                                selectedTimeRange: $selectedVisibleTimeRange,
639
                                isPinnedToPresent: $isPinnedToPresent,
640
                                presentTrackingMode: $presentTrackingMode
641
                            )
Bogdan Timofte authored 2 months ago
642
                        }
643
                    }
644
                }
Bogdan Timofte authored 2 months ago
645
            } else {
646
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
647
                    chartTopToolbar(
648
                        voltageSeries: voltageSeries,
649
                        currentSeries: currentSeries
650
                    )
Bogdan Timofte authored 2 months ago
651
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
652
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
653
                }
654
            }
Bogdan Timofte authored 2 months ago
655
        }
Bogdan Timofte authored 2 months ago
656
        .font(chartBaseFont)
Bogdan Timofte authored a month ago
657
        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
658
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
659
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
660
            chartNow = now
661
        }
Bogdan Timofte authored 2 months ago
662
    }
663

            
Bogdan Timofte authored a month ago
664
    private func chartTopToolbar(
665
        voltageSeries: SeriesData,
666
        currentSeries: SeriesData
667
    ) -> some View {
Bogdan Timofte authored 2 months ago
668
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
669
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
Bogdan Timofte authored 2 months ago
670

            
Bogdan Timofte authored a month ago
671
        let seriesPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
672
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
673
        }
674
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
675
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
676
        .background(
677
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
678
                .fill(Color.primary.opacity(0.045))
679
        )
680
        .overlay(
681
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
682
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
683
        )
Bogdan Timofte authored 2 months ago
684

            
Bogdan Timofte authored a month ago
685
        let controlPanel = chartControlsPanel(
686
            voltageSeries: voltageSeries,
687
            currentSeries: currentSeries,
688
            condensedLayout: condensedLayout
689
        )
690

            
Bogdan Timofte authored 2 months ago
691
        return Group {
Bogdan Timofte authored 2 months ago
692
            if stackedToolbarLayout {
Bogdan Timofte authored a month ago
693
                VStack(alignment: .leading, spacing: 8) {
694
                    seriesPanel
695
                    controlPanel
696
                }
Bogdan Timofte authored 2 months ago
697
            } else {
Bogdan Timofte authored a month ago
698
                HStack(alignment: .center, spacing: isLargeDisplay ? 14 : 12) {
699
                    seriesPanel
700
                    Spacer(minLength: 0)
701
                    controlPanel
Bogdan Timofte authored 2 months ago
702
                }
Bogdan Timofte authored 2 months ago
703
            }
704
        }
705
        .frame(maxWidth: .infinity, alignment: .leading)
706
    }
707

            
Bogdan Timofte authored a month ago
708
    private func chartControlsPanel(
Bogdan Timofte authored 2 months ago
709
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
710
        currentSeries: SeriesData,
711
        condensedLayout: Bool
Bogdan Timofte authored 2 months ago
712
    ) -> some View {
Bogdan Timofte authored a month ago
713
        originControlsRow(
Bogdan Timofte authored 2 months ago
714
            voltageSeries: voltageSeries,
715
            currentSeries: currentSeries,
716
            condensedLayout: condensedLayout,
Bogdan Timofte authored a month ago
717
            showsLabel: showsLabeledOriginControls && !stackedToolbarLayout
Bogdan Timofte authored 2 months ago
718
        )
Bogdan Timofte authored a month ago
719
        .padding(.horizontal, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
720
        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
721
        .background(
Bogdan Timofte authored a month ago
722
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
723
                .fill(Color.primary.opacity(0.045))
Bogdan Timofte authored 2 months ago
724
        )
725
        .overlay(
Bogdan Timofte authored a month ago
726
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
727
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
Bogdan Timofte authored 2 months ago
728
        )
729
    }
730

            
Bogdan Timofte authored a month ago
731
    private func chartLegendEntries(
732
        powerSeries: SeriesData,
733
        energySeries: SeriesData,
734
        voltageSeries: SeriesData,
735
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
736
        temperatureSeries: SeriesData,
737
        batteryPercentSeries: SeriesData
Bogdan Timofte authored a month ago
738
    ) -> [SeriesLegendEntry] {
739
        var entries: [SeriesLegendEntry] = []
740

            
Bogdan Timofte authored a month ago
741
        if displayBatteryPercent {
742
            entries.append(contentsOf: legendEntry(for: batteryPercentSeries))
743
        } else if displayPower {
Bogdan Timofte authored a month ago
744
            entries.append(contentsOf: legendEntry(for: powerSeries))
745
        } else if displayEnergy {
746
            entries.append(contentsOf: legendEntry(for: energySeries))
747
        } else {
748
            if displayVoltage {
749
                entries.append(contentsOf: legendEntry(for: voltageSeries))
750
            }
751
            if displayCurrent {
752
                entries.append(contentsOf: legendEntry(for: currentSeries))
753
            }
754
        }
755

            
756
        if displayTemperature {
757
            entries.append(contentsOf: legendEntry(for: temperatureSeries))
758
        }
759

            
760
        return entries
761
    }
762

            
763
    private func legendEntry(for series: SeriesData) -> [SeriesLegendEntry] {
764
        let samples = series.samplePoints
765
        guard
766
            let minimumValue = samples.map(\.value).min(),
767
            let maximumValue = samples.map(\.value).max(),
768
            let lastValue = samples.last?.value
769
        else {
770
            return []
771
        }
772

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

            
775
        return [
776
            SeriesLegendEntry(
777
                id: series.kind,
778
                name: series.kind.displayName,
779
                tint: series.kind.tint,
780
                minimumText: legendValueText(minimumValue, for: series.kind),
781
                averageText: legendValueText(averageValue, for: series.kind),
782
                maximumText: legendValueText(maximumValue, for: series.kind),
783
                lastText: legendValueText(lastValue, for: series.kind)
784
            )
785
        ]
786
    }
787

            
788
    @ViewBuilder
789
    private func chartLegend(entries: [SeriesLegendEntry]) -> some View {
790
        if !entries.isEmpty {
791
            let nameWidth: CGFloat = compactLayout ? 88 : (isLargeDisplay ? 128 : 108)
792
            let valueWidth: CGFloat = compactLayout ? 78 : (isLargeDisplay ? 108 : 92)
793

            
794
            ScrollView(.horizontal, showsIndicators: false) {
795
                VStack(alignment: .leading, spacing: compactLayout ? 5 : 7) {
796
                    HStack(spacing: compactLayout ? 8 : 10) {
797
                        legendHeaderText("Measurement", width: nameWidth, alignment: .leading)
798
                        legendHeaderText("Min", width: valueWidth)
799
                        legendHeaderText("Avg", width: valueWidth)
800
                        legendHeaderText("Max", width: valueWidth)
801
                        legendHeaderText("Last", width: valueWidth)
802
                    }
803

            
804
                    ForEach(entries) { entry in
805
                        HStack(spacing: compactLayout ? 8 : 10) {
806
                            HStack(spacing: 6) {
807
                                Circle()
808
                                    .fill(entry.tint)
809
                                    .frame(width: compactLayout ? 7 : 8, height: compactLayout ? 7 : 8)
810

            
811
                                Text(entry.name)
812
                                    .lineLimit(1)
813
                                    .minimumScaleFactor(0.82)
814
                            }
815
                            .frame(width: nameWidth, alignment: .leading)
816

            
817
                            legendValueText(entry.minimumText, width: valueWidth)
818
                            legendValueText(entry.averageText, width: valueWidth)
819
                            legendValueText(entry.maximumText, width: valueWidth)
820
                            legendValueText(entry.lastText, width: valueWidth)
821
                        }
822
                    }
823
                }
824
                .padding(.horizontal, compactLayout ? 10 : 12)
825
                .padding(.vertical, compactLayout ? 8 : 10)
826
            }
827
            .background(
828
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
829
                    .fill(Color.primary.opacity(0.045))
830
            )
831
            .overlay(
832
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
833
                    .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
834
            )
835
        }
836
    }
837

            
838
    private func legendHeaderText(
839
        _ text: String,
840
        width: CGFloat,
841
        alignment: Alignment = .trailing
842
    ) -> some View {
843
        Text(text)
844
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
845
            .foregroundColor(.secondary)
846
            .textCase(.uppercase)
847
            .lineLimit(1)
848
            .frame(width: width, alignment: alignment)
849
    }
850

            
851
    private func legendValueText(
852
        _ text: String,
853
        width: CGFloat
854
    ) -> some View {
855
        Text(text)
856
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
857
            .monospacedDigit()
858
            .lineLimit(1)
859
            .minimumScaleFactor(0.78)
860
            .frame(width: width, alignment: .trailing)
861
    }
862

            
863
    private func legendValueText(
864
        _ value: Double,
865
        for kind: SeriesKind
866
    ) -> String {
867
        let decimalDigits: Int
868
        switch kind {
869
        case .power:
870
            decimalDigits = 2
871
        case .energy, .voltage, .current:
872
            decimalDigits = 3
Bogdan Timofte authored a month ago
873
        case .temperature, .batteryPercent:
Bogdan Timofte authored a month ago
874
            decimalDigits = 1
875
        }
876

            
877
        let formattedValue = value.format(decimalDigits: decimalDigits)
878
        let unit = measurementUnit(for: kind)
879
        guard !unit.isEmpty else { return formattedValue }
880

            
881
        if kind == .temperature {
882
            return "\(formattedValue)\(unit)"
883
        }
884
        return "\(formattedValue) \(unit)"
885
    }
886

            
Bogdan Timofte authored 2 months ago
887
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
888
        HStack(spacing: condensedLayout ? 6 : 8) {
889
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
890
                displayVoltage.toggle()
891
                if displayVoltage {
Bogdan Timofte authored a month ago
892
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
893
                    displayPower = false
Bogdan Timofte authored 2 months ago
894
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
895
                    if displayTemperature && displayCurrent {
896
                        displayCurrent = false
897
                    }
Bogdan Timofte authored 2 months ago
898
                }
899
            }
900

            
901
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
902
                displayCurrent.toggle()
903
                if displayCurrent {
Bogdan Timofte authored a month ago
904
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
905
                    displayPower = false
Bogdan Timofte authored 2 months ago
906
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
907
                    if displayTemperature && displayVoltage {
908
                        displayVoltage = false
909
                    }
Bogdan Timofte authored 2 months ago
910
                }
Bogdan Timofte authored 2 months ago
911
            }
912

            
913
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
914
                displayPower.toggle()
915
                if displayPower {
Bogdan Timofte authored a month ago
916
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
917
                    displayEnergy = false
918
                    displayCurrent = false
919
                    displayVoltage = false
920
                }
921
            }
922

            
923
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
924
                displayEnergy.toggle()
925
                if displayEnergy {
Bogdan Timofte authored a month ago
926
                    displayBatteryPercent = false
Bogdan Timofte authored 2 months ago
927
                    displayPower = false
Bogdan Timofte authored 2 months ago
928
                    displayCurrent = false
929
                    displayVoltage = false
930
                }
931
            }
Bogdan Timofte authored 2 months ago
932

            
Bogdan Timofte authored a month ago
933
            if showsBatteryPercentSeries {
934
                seriesToggleButton(title: "Battery", isOn: displayBatteryPercent, condensedLayout: condensedLayout) {
935
                    displayBatteryPercent.toggle()
936
                    if displayBatteryPercent {
937
                        displayPower = false
938
                        displayEnergy = false
939
                        displayCurrent = false
940
                        displayVoltage = false
941
                    }
942
                }
943
            }
944

            
Bogdan Timofte authored a month ago
945
            if showsTemperatureSeries {
946
                seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
947
                    displayTemperature.toggle()
948
                    if displayTemperature && displayVoltage && displayCurrent {
949
                        displayCurrent = false
950
                    }
Bogdan Timofte authored 2 months ago
951
                }
952
            }
Bogdan Timofte authored 2 months ago
953
        }
954
    }
955

            
956
    private func originControlsRow(
957
        voltageSeries: SeriesData,
958
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
959
        condensedLayout: Bool,
960
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
961
    ) -> some View {
Bogdan Timofte authored 2 months ago
962
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
963
            if supportsSharedOrigin {
964
                symbolControlChip(
965
                    systemImage: "equal.circle",
966
                    enabled: true,
967
                    active: useSharedOrigin,
968
                    condensedLayout: condensedLayout,
969
                    showsLabel: showsLabel,
970
                    label: "Match Y Scale",
971
                    accessibilityLabel: "Match Y scale"
972
                ) {
973
                    toggleSharedOrigin(
974
                        voltageSeries: voltageSeries,
975
                        currentSeries: currentSeries
976
                    )
977
                }
Bogdan Timofte authored 2 months ago
978
            }
979

            
980
            symbolControlChip(
981
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
982
                enabled: true,
983
                active: pinOrigin,
984
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
985
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
986
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
987
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
988
            ) {
989
                togglePinnedOrigin(
990
                    voltageSeries: voltageSeries,
991
                    currentSeries: currentSeries
992
                )
993
            }
994

            
Bogdan Timofte authored 2 months ago
995
            if !pinnedOriginIsZero {
996
                symbolControlChip(
997
                    systemImage: "0.circle",
998
                    enabled: true,
999
                    active: false,
1000
                    condensedLayout: condensedLayout,
1001
                    showsLabel: showsLabel,
1002
                    label: "Origin 0",
1003
                    accessibilityLabel: "Set origin to zero"
1004
                ) {
1005
                    setVisibleOriginsToZero()
1006
                }
Bogdan Timofte authored 2 months ago
1007
            }
Bogdan Timofte authored 2 months ago
1008

            
Bogdan Timofte authored 2 months ago
1009
            smoothingControlChip(
1010
                condensedLayout: condensedLayout,
1011
                showsLabel: showsLabel
1012
            )
1013

            
Bogdan Timofte authored 2 months ago
1014
        }
1015
    }
1016

            
Bogdan Timofte authored 2 months ago
1017
    private func smoothingControlChip(
1018
        condensedLayout: Bool,
1019
        showsLabel: Bool
1020
    ) -> some View {
1021
        Menu {
1022
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
1023
                Button {
1024
                    smoothingLevel = level
1025
                } label: {
1026
                    if smoothingLevel == level {
1027
                        Label(level.label, systemImage: "checkmark")
1028
                    } else {
1029
                        Text(level.label)
Bogdan Timofte authored 2 months ago
1030
                    }
1031
                }
Bogdan Timofte authored 2 months ago
1032
            }
1033
        } label: {
1034
            Group {
1035
                if showsLabel {
1036
                    VStack(alignment: .leading, spacing: 2) {
1037
                        Label("Smoothing", systemImage: "waveform.path")
1038
                            .font(controlChipFont(condensedLayout: condensedLayout))
1039

            
1040
                        Text(
Bogdan Timofte authored 2 months ago
1041
                            smoothingLevel == .off
Bogdan Timofte authored 2 months ago
1042
                            ? "Off"
1043
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
1044
                        )
Bogdan Timofte authored 2 months ago
1045
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
1046
                        .foregroundColor(.secondary)
1047
                        .monospacedDigit()
1048
                    }
1049
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
1050
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
1051
                } else {
1052
                    VStack(spacing: 1) {
1053
                        Image(systemName: "waveform.path")
1054
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
1055

            
1056
                        Text(smoothingLevel.shortLabel)
1057
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
1058
                            .monospacedDigit()
1059
                    }
1060
                    .frame(
1061
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
1062
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
1063
                    )
1064
                }
Bogdan Timofte authored 2 months ago
1065
            }
Bogdan Timofte authored 2 months ago
1066
            .background(
1067
                Capsule(style: .continuous)
1068
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
1069
            )
1070
            .overlay(
1071
                Capsule(style: .continuous)
1072
                    .stroke(
1073
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
1074
                        lineWidth: 1
1075
                    )
1076
            )
Bogdan Timofte authored 2 months ago
1077
        }
Bogdan Timofte authored 2 months ago
1078
        .buttonStyle(.plain)
1079
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
1080
    }
1081

            
Bogdan Timofte authored 2 months ago
1082
    private func seriesToggleButton(
1083
        title: String,
1084
        isOn: Bool,
1085
        condensedLayout: Bool,
1086
        action: @escaping () -> Void
1087
    ) -> some View {
1088
        Button(action: action) {
1089
            Text(title)
Bogdan Timofte authored 2 months ago
1090
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1091
                .lineLimit(1)
1092
                .minimumScaleFactor(0.82)
1093
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
1094
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
1095
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
1096
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
1097
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
1098
                .background(
1099
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
1100
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
1101
                )
1102
                .overlay(
1103
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
1104
                        .stroke(Color.blue, lineWidth: 1.5)
1105
                )
1106
        }
1107
        .buttonStyle(.plain)
1108
    }
1109

            
1110
    private func symbolControlChip(
1111
        systemImage: String,
1112
        enabled: Bool,
1113
        active: Bool,
1114
        condensedLayout: Bool,
1115
        showsLabel: Bool,
1116
        label: String,
1117
        accessibilityLabel: String,
1118
        action: @escaping () -> Void
1119
    ) -> some View {
1120
        Button(action: {
1121
            action()
1122
        }) {
1123
            Group {
1124
                if showsLabel {
1125
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
1126
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1127
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
1128
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
1129
                } else {
1130
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
1131
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
1132
                        .frame(
Bogdan Timofte authored 2 months ago
1133
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
1134
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
1135
                        )
Bogdan Timofte authored 2 months ago
1136
                }
1137
            }
1138
                .background(
1139
                    Capsule(style: .continuous)
1140
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
1141
                )
1142
        }
1143
        .buttonStyle(.plain)
1144
        .foregroundColor(enabled ? .primary : .secondary)
1145
        .opacity(enabled ? 1 : 0.55)
1146
        .accessibilityLabel(accessibilityLabel)
1147
    }
1148

            
Bogdan Timofte authored 2 months ago
1149
    private func resetBuffer() {
1150
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
1151
    }
1152

            
Bogdan Timofte authored a month ago
1153
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
1154
        if let rangeSelectorConfiguration {
1155
            return rangeSelectorConfiguration
1156
        }
1157

            
1158
        return MeasurementChartRangeSelectorConfiguration(
1159
            keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1160
                title: "Keep Selection",
1161
                shortTitle: "Keep",
Bogdan Timofte authored a month ago
1162
                systemName: "scissors",
1163
                tone: .destructive,
1164
                handler: trimBufferToSelection
1165
            ),
1166
            removeAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1167
                title: "Remove Selection",
1168
                shortTitle: "Cut",
Bogdan Timofte authored a month ago
1169
                systemName: "minus.circle",
1170
                tone: .destructive,
1171
                handler: removeSelectionFromBuffer
1172
            ),
1173
            resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
1174
                title: "Reset Buffer",
1175
                shortTitle: "Reset",
Bogdan Timofte authored a month ago
1176
                systemName: "trash",
1177
                tone: .destructiveProminent,
1178
                confirmationTitle: "Reset captured measurements?",
1179
                confirmationButtonTitle: "Reset buffer",
1180
                handler: resetBuffer
1181
            )
1182
        )
1183
    }
1184

            
Bogdan Timofte authored 2 months ago
1185
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
1186
        if isLargeDisplay {
1187
            return .body.weight(.semibold)
1188
        }
1189
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
1190
    }
1191

            
1192
    private func controlChipFont(condensedLayout: Bool) -> Font {
1193
        if isLargeDisplay {
1194
            return .callout.weight(.semibold)
1195
        }
1196
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
1197
    }
1198

            
Bogdan Timofte authored 2 months ago
1199
    @ViewBuilder
1200
    private func primaryAxisView(
1201
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1202
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1203
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1204
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
1205
        currentSeries: SeriesData,
1206
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1207
    ) -> some View {
Bogdan Timofte authored a month ago
1208
        if displayBatteryPercent {
1209
            yAxisLabelsView(
1210
                height: height,
1211
                context: batteryPercentSeries.context,
1212
                seriesKind: .batteryPercent,
1213
                measurementUnit: batteryPercentSeries.kind.unit,
1214
                tint: batteryPercentSeries.kind.tint
1215
            )
1216
        } else if displayPower {
Bogdan Timofte authored 2 months ago
1217
            yAxisLabelsView(
1218
                height: height,
1219
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
1220
                seriesKind: .power,
1221
                measurementUnit: powerSeries.kind.unit,
1222
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
1223
            )
Bogdan Timofte authored 2 months ago
1224
        } else if displayEnergy {
1225
            yAxisLabelsView(
1226
                height: height,
1227
                context: energySeries.context,
1228
                seriesKind: .energy,
1229
                measurementUnit: energySeries.kind.unit,
1230
                tint: energySeries.kind.tint
1231
            )
Bogdan Timofte authored 2 months ago
1232
        } else if displayVoltage {
1233
            yAxisLabelsView(
1234
                height: height,
1235
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
1236
                seriesKind: .voltage,
1237
                measurementUnit: voltageSeries.kind.unit,
1238
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
1239
            )
1240
        } else if displayCurrent {
1241
            yAxisLabelsView(
1242
                height: height,
1243
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1244
                seriesKind: .current,
1245
                measurementUnit: currentSeries.kind.unit,
1246
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1247
            )
1248
        }
1249
    }
1250

            
1251
    @ViewBuilder
1252
    private func renderedChart(
Bogdan Timofte authored 2 months ago
1253
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1254
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1255
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1256
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
1257
        temperatureSeries: SeriesData,
1258
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1259
    ) -> some View {
Bogdan Timofte authored a month ago
1260
        if self.displayBatteryPercent {
1261
            TimeSeriesChart(points: batteryPercentSeries.points, context: batteryPercentSeries.context, strokeColor: batteryPercentSeries.kind.tint)
1262
                .opacity(0.82)
1263
            batteryCheckpointMarkers(context: batteryPercentSeries.context)
1264
        } else if self.displayPower {
Bogdan Timofte authored a month ago
1265
            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1266
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
1267
        } else if self.displayEnergy {
Bogdan Timofte authored a month ago
1268
            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
Bogdan Timofte authored 2 months ago
1269
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
1270
        } else {
1271
            if self.displayVoltage {
Bogdan Timofte authored a month ago
1272
                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1273
                    .opacity(0.78)
1274
            }
1275
            if self.displayCurrent {
Bogdan Timofte authored a month ago
1276
                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1277
                    .opacity(0.78)
1278
            }
1279
        }
Bogdan Timofte authored 2 months ago
1280

            
1281
        if displayTemperature {
Bogdan Timofte authored a month ago
1282
            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1283
                .opacity(0.86)
1284
        }
Bogdan Timofte authored 2 months ago
1285
    }
1286

            
Bogdan Timofte authored a month ago
1287
    private func batteryCheckpointMarkers(context: ChartContext) -> some View {
1288
        GeometryReader { geometry in
1289
            ForEach(visibleBatteryCheckpoints(context: context)) { checkpoint in
1290
                let normalizedPoint = context.placeInRect(
1291
                    point: CGPoint(
1292
                        x: checkpoint.timestamp.timeIntervalSince1970,
1293
                        y: checkpoint.batteryPercent
1294
                    )
1295
                )
1296
                let location = CGPoint(
1297
                    x: normalizedPoint.x * geometry.size.width,
1298
                    y: normalizedPoint.y * geometry.size.height
1299
                )
1300

            
1301
                Circle()
1302
                    .fill(Color(.systemBackground))
1303
                    .frame(width: 10, height: 10)
1304
                    .overlay(
1305
                        Circle()
1306
                            .stroke(Color.mint, lineWidth: 2)
1307
                    )
1308
                    .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
1309
                    .position(location)
1310
            }
1311
        }
1312
        .accessibilityHidden(true)
1313
    }
1314

            
1315
    private func visibleBatteryCheckpoints(context: ChartContext) -> [ChargeCheckpointSummary] {
1316
        guard context.isValid else { return [] }
1317

            
1318
        return batteryCheckpoints
1319
            .filter { checkpoint in
1320
                checkpoint.batteryPercent.isFinite &&
1321
                checkpoint.batteryPercent >= 0 &&
1322
                checkpoint.batteryPercent <= 100
1323
            }
1324
            .filter { checkpoint in
1325
                let normalizedPoint = context.placeInRect(
1326
                    point: CGPoint(
1327
                        x: checkpoint.timestamp.timeIntervalSince1970,
1328
                        y: checkpoint.batteryPercent
1329
                    )
1330
                )
1331
                return normalizedPoint.x >= 0 &&
1332
                    normalizedPoint.x <= 1 &&
1333
                    normalizedPoint.y >= 0 &&
1334
                    normalizedPoint.y <= 1
1335
            }
1336
    }
1337

            
Bogdan Timofte authored 2 months ago
1338
    @ViewBuilder
1339
    private func secondaryAxisView(
1340
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1341
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1342
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1343
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1344
        currentSeries: SeriesData,
Bogdan Timofte authored a month ago
1345
        temperatureSeries: SeriesData,
1346
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1347
    ) -> some View {
Bogdan Timofte authored 2 months ago
1348
        if displayTemperature {
1349
            yAxisLabelsView(
1350
                height: height,
1351
                context: temperatureSeries.context,
1352
                seriesKind: .temperature,
1353
                measurementUnit: measurementUnit(for: .temperature),
1354
                tint: temperatureSeries.kind.tint
1355
            )
1356
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
1357
            yAxisLabelsView(
1358
                height: height,
1359
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1360
                seriesKind: .current,
1361
                measurementUnit: currentSeries.kind.unit,
1362
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1363
            )
1364
        } else {
1365
            primaryAxisView(
1366
                height: height,
1367
                powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
1368
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
1369
                voltageSeries: voltageSeries,
Bogdan Timofte authored a month ago
1370
                currentSeries: currentSeries,
1371
                batteryPercentSeries: batteryPercentSeries
Bogdan Timofte authored 2 months ago
1372
            )
Bogdan Timofte authored 2 months ago
1373
        }
1374
    }
Bogdan Timofte authored 2 months ago
1375

            
1376
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
1377
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1378
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1379
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
1380
        currentSeries: SeriesData,
1381
        batteryPercentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1382
    ) -> SeriesData? {
Bogdan Timofte authored a month ago
1383
        if displayBatteryPercent {
1384
            return batteryPercentSeries
1385
        }
Bogdan Timofte authored 2 months ago
1386
        if displayPower {
Bogdan Timofte authored 2 months ago
1387
            return powerSeries
Bogdan Timofte authored 2 months ago
1388
        }
Bogdan Timofte authored 2 months ago
1389
        if displayEnergy {
1390
            return energySeries
1391
        }
Bogdan Timofte authored 2 months ago
1392
        if displayVoltage {
Bogdan Timofte authored 2 months ago
1393
            return voltageSeries
Bogdan Timofte authored 2 months ago
1394
        }
1395
        if displayCurrent {
Bogdan Timofte authored 2 months ago
1396
            return currentSeries
Bogdan Timofte authored 2 months ago
1397
        }
1398
        return nil
1399
    }
1400

            
1401
    private func series(
1402
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
1403
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
1404
        minimumYSpan: Double,
1405
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1406
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1407
        series(
1408
            for: filteredPoints(
1409
                measurement,
1410
                visibleTimeRange: visibleTimeRange
1411
            ),
1412
            kind: kind,
1413
            minimumYSpan: minimumYSpan,
Bogdan Timofte authored 2 months ago
1414
            visibleTimeRange: visibleTimeRange
1415
        )
Bogdan Timofte authored a month ago
1416
    }
1417

            
1418
    private func series(
1419
        for rawPoints: [Measurements.Measurement.Point],
1420
        kind: SeriesKind,
1421
        minimumYSpan: Double,
1422
        visibleTimeRange: ClosedRange<Date>? = nil
1423
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1424
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1425
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
1426
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1427
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
1428

            
Bogdan Timofte authored a month ago
1429
        let autoBounds = kind == .batteryPercent
1430
            ? (lowerBound: 0.0, upperBound: 100.0)
1431
            : automaticYBounds(
1432
                for: samplePoints,
1433
                minimumYSpan: minimumYSpan
1434
            )
Bogdan Timofte authored 2 months ago
1435
        let xBounds = xBounds(
1436
            for: samplePoints,
1437
            visibleTimeRange: visibleTimeRange
1438
        )
Bogdan Timofte authored 2 months ago
1439
        let lowerBound = resolvedLowerBound(
1440
            for: kind,
1441
            autoLowerBound: autoBounds.lowerBound
1442
        )
1443
        let upperBound = resolvedUpperBound(
1444
            for: kind,
1445
            lowerBound: lowerBound,
1446
            autoUpperBound: autoBounds.upperBound,
1447
            maximumSampleValue: samplePoints.map(\.value).max(),
1448
            minimumYSpan: minimumYSpan
1449
        )
1450

            
1451
        context.setBounds(
1452
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1453
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1454
            yMin: CGFloat(lowerBound),
1455
            yMax: CGFloat(upperBound)
1456
        )
1457

            
1458
        return SeriesData(
1459
            kind: kind,
1460
            points: points,
1461
            samplePoints: samplePoints,
1462
            context: context,
1463
            autoLowerBound: autoBounds.lowerBound,
1464
            autoUpperBound: autoBounds.upperBound,
1465
            maximumSampleValue: samplePoints.map(\.value).max()
1466
        )
1467
    }
1468

            
Bogdan Timofte authored a month ago
1469
    private func normalizedPoints(
1470
        _ points: [Measurements.Measurement.Point],
1471
        for kind: SeriesKind
1472
    ) -> [Measurements.Measurement.Point] {
1473
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1474
            return points
1475
        }
1476

            
1477
        guard let baseline = points.first(where: \.isSample)?.value else {
1478
            return points
1479
        }
1480

            
1481
        return points.enumerated().map { index, point in
1482
            Measurements.Measurement.Point(
1483
                id: point.id == index ? point.id : index,
1484
                timestamp: point.timestamp,
1485
                value: point.value - baseline,
1486
                kind: point.kind
1487
            )
1488
        }
1489
    }
1490

            
Bogdan Timofte authored 2 months ago
1491
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1492
        series(
1493
            for: measurement(for: kind),
1494
            kind: kind,
1495
            minimumYSpan: minimumYSpan(for: kind)
1496
        )
1497
    }
1498

            
Bogdan Timofte authored 2 months ago
1499
    private func smoothedPoints(
1500
        from points: [Measurements.Measurement.Point]
1501
    ) -> [Measurements.Measurement.Point] {
1502
        guard smoothingLevel != .off else { return points }
1503

            
1504
        var smoothedPoints: [Measurements.Measurement.Point] = []
1505
        var currentSegment: [Measurements.Measurement.Point] = []
1506

            
1507
        func flushCurrentSegment() {
1508
            guard !currentSegment.isEmpty else { return }
1509

            
1510
            for point in smoothedSegment(currentSegment) {
1511
                smoothedPoints.append(
1512
                    Measurements.Measurement.Point(
1513
                        id: smoothedPoints.count,
1514
                        timestamp: point.timestamp,
1515
                        value: point.value,
1516
                        kind: .sample
1517
                    )
1518
                )
1519
            }
1520

            
1521
            currentSegment.removeAll(keepingCapacity: true)
1522
        }
1523

            
1524
        for point in points {
1525
            if point.isDiscontinuity {
1526
                flushCurrentSegment()
1527

            
1528
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1529
                    smoothedPoints.append(
1530
                        Measurements.Measurement.Point(
1531
                            id: smoothedPoints.count,
1532
                            timestamp: point.timestamp,
1533
                            value: smoothedPoints.last?.value ?? point.value,
1534
                            kind: .discontinuity
1535
                        )
1536
                    )
1537
                }
1538
            } else {
1539
                currentSegment.append(point)
1540
            }
1541
        }
1542

            
1543
        flushCurrentSegment()
1544
        return smoothedPoints
1545
    }
1546

            
1547
    private func smoothedSegment(
1548
        _ segment: [Measurements.Measurement.Point]
1549
    ) -> [Measurements.Measurement.Point] {
1550
        let windowSize = smoothingLevel.movingAverageWindowSize
1551
        guard windowSize > 1, segment.count > 2 else { return segment }
1552

            
1553
        let radius = windowSize / 2
1554
        var prefixSums: [Double] = [0]
1555
        prefixSums.reserveCapacity(segment.count + 1)
1556

            
1557
        for point in segment {
1558
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1559
        }
1560

            
1561
        return segment.enumerated().map { index, point in
1562
            let lowerBound = max(0, index - radius)
1563
            let upperBound = min(segment.count - 1, index + radius)
1564
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1565
            let average = sum / Double(upperBound - lowerBound + 1)
1566

            
1567
            return Measurements.Measurement.Point(
1568
                id: point.id,
1569
                timestamp: point.timestamp,
1570
                value: average,
1571
                kind: .sample
1572
            )
1573
        }
1574
    }
1575

            
Bogdan Timofte authored 2 months ago
1576
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1577
        switch kind {
1578
        case .power:
1579
            return measurements.power
Bogdan Timofte authored 2 months ago
1580
        case .energy:
1581
            return measurements.energy
Bogdan Timofte authored 2 months ago
1582
        case .voltage:
1583
            return measurements.voltage
1584
        case .current:
1585
            return measurements.current
1586
        case .temperature:
1587
            return measurements.temperature
Bogdan Timofte authored a month ago
1588
        case .batteryPercent:
1589
            return measurements.batteryPercent
Bogdan Timofte authored 2 months ago
1590
        }
1591
    }
1592

            
1593
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1594
        switch kind {
1595
        case .power:
1596
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1597
        case .energy:
1598
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1599
        case .voltage:
1600
            return minimumVoltageSpan
1601
        case .current:
1602
            return minimumCurrentSpan
1603
        case .temperature:
1604
            return minimumTemperatureSpan
Bogdan Timofte authored a month ago
1605
        case .batteryPercent:
1606
            return minimumBatteryPercentSpan
Bogdan Timofte authored 2 months ago
1607
        }
1608
    }
1609

            
Bogdan Timofte authored 2 months ago
1610
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored a month ago
1611
        displayVoltage && displayCurrent && !displayPower && !displayEnergy && !displayBatteryPercent
Bogdan Timofte authored 2 months ago
1612
    }
1613

            
Bogdan Timofte authored 2 months ago
1614
    private var minimumSharedScaleSpan: Double {
1615
        max(minimumVoltageSpan, minimumCurrentSpan)
1616
    }
1617

            
Bogdan Timofte authored 2 months ago
1618
    private var pinnedOriginIsZero: Bool {
1619
        if useSharedOrigin && supportsSharedOrigin {
1620
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1621
        }
Bogdan Timofte authored 2 months ago
1622

            
1623
        if displayPower {
1624
            return pinOrigin && powerAxisOrigin == 0
1625
        }
1626

            
Bogdan Timofte authored 2 months ago
1627
        if displayEnergy {
1628
            return pinOrigin && energyAxisOrigin == 0
1629
        }
1630

            
Bogdan Timofte authored a month ago
1631
        if displayBatteryPercent {
1632
            return pinOrigin && batteryPercentAxisOrigin == 0
1633
        }
1634

            
Bogdan Timofte authored 2 months ago
1635
        let visibleOrigins = [
1636
            displayVoltage ? voltageAxisOrigin : nil,
1637
            displayCurrent ? currentAxisOrigin : nil
1638
        ]
1639
        .compactMap { $0 }
1640

            
1641
        guard !visibleOrigins.isEmpty else { return false }
1642
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1643
    }
1644

            
1645
    private func toggleSharedOrigin(
1646
        voltageSeries: SeriesData,
1647
        currentSeries: SeriesData
1648
    ) {
1649
        guard supportsSharedOrigin else { return }
1650

            
1651
        if useSharedOrigin {
1652
            useSharedOrigin = false
1653
            return
1654
        }
1655

            
1656
        captureCurrentOrigins(
1657
            voltageSeries: voltageSeries,
1658
            currentSeries: currentSeries
1659
        )
1660
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1661
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1662
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1663
        useSharedOrigin = true
1664
        pinOrigin = true
1665
    }
1666

            
1667
    private func togglePinnedOrigin(
1668
        voltageSeries: SeriesData,
1669
        currentSeries: SeriesData
1670
    ) {
1671
        if pinOrigin {
1672
            pinOrigin = false
1673
            return
1674
        }
1675

            
1676
        captureCurrentOrigins(
1677
            voltageSeries: voltageSeries,
1678
            currentSeries: currentSeries
1679
        )
1680
        pinOrigin = true
1681
    }
1682

            
1683
    private func setVisibleOriginsToZero() {
1684
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1685
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1686
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1687
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1688
            voltageAxisOrigin = 0
1689
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1690
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1691
        } else {
1692
            if displayPower {
1693
                powerAxisOrigin = 0
1694
            }
Bogdan Timofte authored 2 months ago
1695
            if displayEnergy {
1696
                energyAxisOrigin = 0
1697
            }
Bogdan Timofte authored 2 months ago
1698
            if displayVoltage {
1699
                voltageAxisOrigin = 0
1700
            }
1701
            if displayCurrent {
1702
                currentAxisOrigin = 0
1703
            }
Bogdan Timofte authored 2 months ago
1704
            if displayTemperature {
1705
                temperatureAxisOrigin = 0
1706
            }
Bogdan Timofte authored a month ago
1707
            if displayBatteryPercent {
1708
                batteryPercentAxisOrigin = 0
1709
            }
Bogdan Timofte authored 2 months ago
1710
        }
1711

            
1712
        pinOrigin = true
1713
    }
1714

            
1715
    private func captureCurrentOrigins(
1716
        voltageSeries: SeriesData,
1717
        currentSeries: SeriesData
1718
    ) {
1719
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1720
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1721
        voltageAxisOrigin = voltageSeries.autoLowerBound
1722
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1723
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a month ago
1724
        batteryPercentAxisOrigin = displayedLowerBoundForSeries(.batteryPercent)
Bogdan Timofte authored 2 months ago
1725
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1726
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1727
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1728
    }
1729

            
1730
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1731
        let visibleTimeRange = activeVisibleTimeRange
1732

            
Bogdan Timofte authored 2 months ago
1733
        switch kind {
1734
        case .power:
Bogdan Timofte authored 2 months ago
1735
            return pinOrigin
1736
                ? powerAxisOrigin
1737
                : automaticYBounds(
1738
                    for: filteredSamplePoints(
1739
                        measurements.power,
1740
                        visibleTimeRange: visibleTimeRange
1741
                    ),
1742
                    minimumYSpan: minimumPowerSpan
1743
                ).lowerBound
Bogdan Timofte authored 2 months ago
1744
        case .energy:
1745
            return pinOrigin
1746
                ? energyAxisOrigin
1747
                : automaticYBounds(
1748
                    for: filteredSamplePoints(
1749
                        measurements.energy,
1750
                        visibleTimeRange: visibleTimeRange
1751
                    ),
1752
                    minimumYSpan: minimumEnergySpan
1753
                ).lowerBound
Bogdan Timofte authored 2 months ago
1754
        case .voltage:
1755
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1756
                return sharedAxisOrigin
1757
            }
Bogdan Timofte authored 2 months ago
1758
            return pinOrigin
1759
                ? voltageAxisOrigin
1760
                : automaticYBounds(
1761
                    for: filteredSamplePoints(
1762
                        measurements.voltage,
1763
                        visibleTimeRange: visibleTimeRange
1764
                    ),
1765
                    minimumYSpan: minimumVoltageSpan
1766
                ).lowerBound
Bogdan Timofte authored 2 months ago
1767
        case .current:
1768
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1769
                return sharedAxisOrigin
1770
            }
Bogdan Timofte authored 2 months ago
1771
            return pinOrigin
1772
                ? currentAxisOrigin
1773
                : automaticYBounds(
1774
                    for: filteredSamplePoints(
1775
                        measurements.current,
1776
                        visibleTimeRange: visibleTimeRange
1777
                    ),
1778
                    minimumYSpan: minimumCurrentSpan
1779
                ).lowerBound
Bogdan Timofte authored 2 months ago
1780
        case .temperature:
Bogdan Timofte authored 2 months ago
1781
            return pinOrigin
1782
                ? temperatureAxisOrigin
1783
                : automaticYBounds(
1784
                    for: filteredSamplePoints(
1785
                        measurements.temperature,
1786
                        visibleTimeRange: visibleTimeRange
1787
                    ),
1788
                    minimumYSpan: minimumTemperatureSpan
1789
                ).lowerBound
Bogdan Timofte authored a month ago
1790
        case .batteryPercent:
1791
            return pinOrigin ? batteryPercentAxisOrigin : 0
Bogdan Timofte authored 2 months ago
1792
        }
1793
    }
1794

            
Bogdan Timofte authored 2 months ago
1795
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1796
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1797
    }
1798

            
1799
    private func filteredPoints(
1800
        _ measurement: Measurements.Measurement,
1801
        visibleTimeRange: ClosedRange<Date>? = nil
1802
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1803
        let resolvedRange: ClosedRange<Date>?
1804

            
1805
        switch (timeRange, visibleTimeRange) {
1806
        case let (baseRange?, visibleRange?):
1807
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1808
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1809
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1810
        case let (baseRange?, nil):
1811
            resolvedRange = baseRange
1812
        case let (nil, visibleRange?):
1813
            resolvedRange = visibleRange
1814
        case (nil, nil):
1815
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1816
        }
Bogdan Timofte authored 2 months ago
1817

            
1818
        guard let resolvedRange else {
1819
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1820
        }
1821

            
1822
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1823
    }
1824

            
1825
    private func filteredSamplePoints(
1826
        _ measurement: Measurements.Measurement,
1827
        visibleTimeRange: ClosedRange<Date>? = nil
1828
    ) -> [Measurements.Measurement.Point] {
1829
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1830
            point.isSample
Bogdan Timofte authored 2 months ago
1831
        }
1832
    }
1833

            
1834
    private func xBounds(
Bogdan Timofte authored 2 months ago
1835
        for samplePoints: [Measurements.Measurement.Point],
1836
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1837
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1838
        if let visibleTimeRange {
1839
            return normalizedTimeRange(visibleTimeRange)
1840
        }
1841

            
Bogdan Timofte authored 2 months ago
1842
        if let timeRange {
Bogdan Timofte authored 2 months ago
1843
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1844
        }
1845

            
1846
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1847
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1848

            
Bogdan Timofte authored 2 months ago
1849
        return normalizedTimeRange(lowerBound...upperBound)
1850
    }
1851

            
1852
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1853
        if let timeRange {
1854
            return normalizedTimeRange(timeRange)
1855
        }
1856

            
1857
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1858
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1859
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1860
            return nil
1861
        }
1862

            
Bogdan Timofte authored a month ago
1863
        let latestSampleTimestamp = samplePoints.last?.timestamp
1864
        let resolvedUpperBound = timeRangeUpperBound ?? {
1865
            guard extendsTimelineToPresent else {
1866
                return latestSampleTimestamp ?? lowerBound
1867
            }
1868
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1869
        }()
1870
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1871
        return normalizedTimeRange(lowerBound...upperBound)
1872
    }
1873

            
1874
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1875
        let candidates = [
1876
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1877
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1878
            filteredSamplePoints(measurements.voltage),
1879
            filteredSamplePoints(measurements.current),
Bogdan Timofte authored a month ago
1880
            filteredSamplePoints(measurements.temperature),
1881
            batteryPercentPoints.isEmpty
1882
                ? filteredSamplePoints(measurements.batteryPercent)
1883
                : batteryPercentPoints.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1884
        ]
1885

            
1886
        return candidates.first(where: { !$0.isEmpty }) ?? []
1887
    }
1888

            
1889
    private func resolvedVisibleTimeRange(
1890
        within availableTimeRange: ClosedRange<Date>?
1891
    ) -> ClosedRange<Date>? {
1892
        guard let availableTimeRange else { return nil }
1893
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1894

            
1895
        if isPinnedToPresent {
1896
            let pinnedRange: ClosedRange<Date>
1897

            
1898
            switch presentTrackingMode {
1899
            case .keepDuration:
1900
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1901
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1902
            case .keepStartTimestamp:
1903
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1904
            }
1905

            
1906
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1907
        }
1908

            
1909
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1910
    }
1911

            
1912
    private func clampedTimeRange(
1913
        _ candidateRange: ClosedRange<Date>,
1914
        within bounds: ClosedRange<Date>
1915
    ) -> ClosedRange<Date> {
1916
        let normalizedBounds = normalizedTimeRange(bounds)
1917
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1918

            
1919
        guard boundsSpan > 0 else {
1920
            return normalizedBounds
1921
        }
1922

            
1923
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1924
        let requestedSpan = min(
1925
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1926
            boundsSpan
1927
        )
1928

            
1929
        if requestedSpan >= boundsSpan {
1930
            return normalizedBounds
1931
        }
1932

            
1933
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1934
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1935

            
1936
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1937
            if lowerBound == normalizedBounds.lowerBound {
1938
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1939
            } else {
1940
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1941
            }
1942
        }
1943

            
1944
        if upperBound > normalizedBounds.upperBound {
1945
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1946
            upperBound = normalizedBounds.upperBound
1947
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1948
        }
1949

            
Bogdan Timofte authored 2 months ago
1950
        if lowerBound < normalizedBounds.lowerBound {
1951
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1952
            lowerBound = normalizedBounds.lowerBound
1953
            upperBound = upperBound.addingTimeInterval(delta)
1954
        }
1955

            
1956
        return lowerBound...upperBound
1957
    }
1958

            
1959
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1960
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1961
        guard span < minimumTimeSpan else { return range }
1962

            
1963
        let expansion = (minimumTimeSpan - span) / 2
1964
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1965
    }
1966

            
1967
    private func shouldShowRangeSelector(
1968
        availableTimeRange: ClosedRange<Date>,
1969
        series: SeriesData
1970
    ) -> Bool {
1971
        series.samplePoints.count > 1 &&
1972
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1973
    }
1974

            
1975
    private func automaticYBounds(
1976
        for samplePoints: [Measurements.Measurement.Point],
1977
        minimumYSpan: Double
1978
    ) -> (lowerBound: Double, upperBound: Double) {
1979
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1980

            
1981
        guard
1982
            let minimumSampleValue = samplePoints.map(\.value).min(),
1983
            let maximumSampleValue = samplePoints.map(\.value).max()
1984
        else {
1985
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1986
        }
Bogdan Timofte authored 2 months ago
1987

            
1988
        var lowerBound = minimumSampleValue
1989
        var upperBound = maximumSampleValue
1990
        let currentSpan = upperBound - lowerBound
1991

            
1992
        if currentSpan < minimumYSpan {
1993
            let expansion = (minimumYSpan - currentSpan) / 2
1994
            lowerBound -= expansion
1995
            upperBound += expansion
1996
        }
1997

            
1998
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1999
            let shift = -negativeAllowance - lowerBound
2000
            lowerBound += shift
2001
            upperBound += shift
2002
        }
2003

            
2004
        let snappedLowerBound = snappedOriginValue(lowerBound)
2005
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
2006
        return (snappedLowerBound, resolvedUpperBound)
2007
    }
2008

            
2009
    private func resolvedLowerBound(
2010
        for kind: SeriesKind,
2011
        autoLowerBound: Double
2012
    ) -> Double {
2013
        guard pinOrigin else { return autoLowerBound }
2014

            
2015
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2016
            return sharedAxisOrigin
2017
        }
2018

            
2019
        switch kind {
2020
        case .power:
2021
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
2022
        case .energy:
2023
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
2024
        case .voltage:
2025
            return voltageAxisOrigin
2026
        case .current:
2027
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
2028
        case .temperature:
2029
            return temperatureAxisOrigin
Bogdan Timofte authored a month ago
2030
        case .batteryPercent:
2031
            return batteryPercentAxisOrigin
Bogdan Timofte authored 2 months ago
2032
        }
2033
    }
2034

            
2035
    private func resolvedUpperBound(
2036
        for kind: SeriesKind,
2037
        lowerBound: Double,
2038
        autoUpperBound: Double,
2039
        maximumSampleValue: Double?,
2040
        minimumYSpan: Double
2041
    ) -> Double {
2042
        guard pinOrigin else {
2043
            return autoUpperBound
2044
        }
2045

            
Bogdan Timofte authored 2 months ago
2046
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2047
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
2048
        }
2049

            
Bogdan Timofte authored a month ago
2050
        if kind == .temperature || kind == .batteryPercent {
Bogdan Timofte authored 2 months ago
2051
            return autoUpperBound
2052
        }
2053

            
Bogdan Timofte authored 2 months ago
2054
        return max(
2055
            maximumSampleValue ?? lowerBound,
2056
            lowerBound + minimumYSpan,
2057
            autoUpperBound
2058
        )
2059
    }
2060

            
Bogdan Timofte authored 2 months ago
2061
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
2062
        let baseline = displayedLowerBoundForSeries(kind)
2063
        let proposedOrigin = snappedOriginValue(baseline + delta)
2064

            
2065
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
2066
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
2067
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
2068
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
2069
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
2070
        } else {
2071
            switch kind {
2072
            case .power:
2073
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
2074
            case .energy:
2075
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
2076
            case .voltage:
2077
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
2078
            case .current:
2079
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
2080
            case .temperature:
2081
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a month ago
2082
            case .batteryPercent:
2083
                batteryPercentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .batteryPercent))
Bogdan Timofte authored 2 months ago
2084
            }
2085
        }
2086

            
2087
        pinOrigin = true
2088
    }
2089

            
Bogdan Timofte authored 2 months ago
2090
    private func clearOriginOffset(for kind: SeriesKind) {
2091
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
2092
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
2093
            sharedAxisOrigin = 0
2094
            sharedAxisUpperBound = currentSpan
2095
            ensureSharedScaleSpan()
2096
            voltageAxisOrigin = 0
2097
            currentAxisOrigin = 0
2098
        } else {
2099
            switch kind {
2100
            case .power:
2101
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2102
            case .energy:
2103
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2104
            case .voltage:
2105
                voltageAxisOrigin = 0
2106
            case .current:
2107
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2108
            case .temperature:
2109
                temperatureAxisOrigin = 0
Bogdan Timofte authored a month ago
2110
            case .batteryPercent:
2111
                batteryPercentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
2112
            }
2113
        }
2114

            
2115
        pinOrigin = true
2116
    }
2117

            
2118
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
2119
        guard totalHeight > 1 else { return }
2120

            
2121
        let normalized = max(0, min(1, locationY / totalHeight))
2122
        if normalized < (1.0 / 3.0) {
2123
            applyOriginDelta(-1, kind: kind)
2124
        } else if normalized < (2.0 / 3.0) {
2125
            clearOriginOffset(for: kind)
2126
        } else {
2127
            applyOriginDelta(1, kind: kind)
2128
        }
2129
    }
2130

            
Bogdan Timofte authored 2 months ago
2131
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
2132
        let visibleTimeRange = activeVisibleTimeRange
2133

            
Bogdan Timofte authored 2 months ago
2134
        switch kind {
2135
        case .power:
Bogdan Timofte authored 2 months ago
2136
            return snappedOriginValue(
2137
                filteredSamplePoints(
2138
                    measurements.power,
2139
                    visibleTimeRange: visibleTimeRange
2140
                ).map(\.value).min() ?? 0
2141
            )
Bogdan Timofte authored 2 months ago
2142
        case .energy:
2143
            return snappedOriginValue(
2144
                filteredSamplePoints(
2145
                    measurements.energy,
2146
                    visibleTimeRange: visibleTimeRange
2147
                ).map(\.value).min() ?? 0
2148
            )
Bogdan Timofte authored 2 months ago
2149
        case .voltage:
Bogdan Timofte authored 2 months ago
2150
            return snappedOriginValue(
2151
                filteredSamplePoints(
2152
                    measurements.voltage,
2153
                    visibleTimeRange: visibleTimeRange
2154
                ).map(\.value).min() ?? 0
2155
            )
Bogdan Timofte authored 2 months ago
2156
        case .current:
Bogdan Timofte authored 2 months ago
2157
            return snappedOriginValue(
2158
                filteredSamplePoints(
2159
                    measurements.current,
2160
                    visibleTimeRange: visibleTimeRange
2161
                ).map(\.value).min() ?? 0
2162
            )
Bogdan Timofte authored 2 months ago
2163
        case .temperature:
Bogdan Timofte authored 2 months ago
2164
            return snappedOriginValue(
2165
                filteredSamplePoints(
2166
                    measurements.temperature,
2167
                    visibleTimeRange: visibleTimeRange
Bogdan Timofte authored a month ago
2168
                ).map(\.value).min() ?? 0
2169
            )
2170
        case .batteryPercent:
2171
            return snappedOriginValue(
2172
                filteredSamplePoints(
2173
                    measurements.batteryPercent,
2174
                    visibleTimeRange: visibleTimeRange
Bogdan Timofte authored 2 months ago
2175
                ).map(\.value).min() ?? 0
2176
            )
Bogdan Timofte authored 2 months ago
2177
        }
2178
    }
2179

            
2180
    private func maximumVisibleSharedOrigin() -> Double {
2181
        min(
2182
            maximumVisibleOrigin(for: .voltage),
2183
            maximumVisibleOrigin(for: .current)
2184
        )
2185
    }
2186

            
Bogdan Timofte authored 2 months ago
2187
    private func measurementUnit(for kind: SeriesKind) -> String {
2188
        switch kind {
2189
        case .temperature:
2190
            let locale = Locale.autoupdatingCurrent
2191
            if #available(iOS 16.0, *) {
2192
                switch locale.measurementSystem {
2193
                case .us:
2194
                    return "°F"
2195
                default:
2196
                    return "°C"
2197
                }
2198
            }
2199

            
2200
            let regionCode = locale.regionCode ?? ""
2201
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
2202
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
2203
        default:
2204
            return kind.unit
2205
        }
2206
    }
2207

            
Bogdan Timofte authored 2 months ago
2208
    private func ensureSharedScaleSpan() {
2209
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
2210
    }
2211

            
Bogdan Timofte authored 2 months ago
2212
    private func snappedOriginValue(_ value: Double) -> Double {
2213
        if value >= 0 {
2214
            return value.rounded(.down)
2215
        }
2216

            
2217
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
2218
    }
Bogdan Timofte authored 2 months ago
2219

            
Bogdan Timofte authored 2 months ago
2220
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
2221
        measurements.keepOnly(in: range)
2222
        selectedVisibleTimeRange = nil
2223
        isPinnedToPresent = false
2224
    }
2225

            
2226
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
2227
        measurements.removeValues(in: range)
2228
        selectedVisibleTimeRange = nil
2229
        isPinnedToPresent = false
2230
    }
2231

            
Bogdan Timofte authored 2 months ago
2232
    private func yGuidePosition(
2233
        for labelIndex: Int,
2234
        context: ChartContext,
2235
        height: CGFloat
2236
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2237
        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
Bogdan Timofte authored 2 months ago
2238
    }
2239

            
2240
    private func xGuidePosition(
2241
        for labelIndex: Int,
2242
        context: ChartContext,
2243
        width: CGFloat
2244
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2245
        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
Bogdan Timofte authored 2 months ago
2246
    }
Bogdan Timofte authored 2 months ago
2247

            
2248
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
2249
    fileprivate func xAxisLabelsView(
2250
        context: ChartContext
2251
    ) -> some View {
Bogdan Timofte authored 2 months ago
2252
        var timeFormat: String?
2253
        switch context.size.width {
2254
        case 0..<3600: timeFormat = "HH:mm:ss"
2255
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
2256
        default: timeFormat = "E HH:mm"
2257
        }
2258
        let labels = (1...xLabels).map {
2259
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
2260
        }
Bogdan Timofte authored 2 months ago
2261
        let axisLabelFont: Font = {
2262
            if isIPhone && isPortraitLayout {
2263
                return .caption2.weight(.semibold)
2264
            }
2265
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
2266
        }()
Bogdan Timofte authored 2 months ago
2267

            
2268
        return HStack(spacing: chartSectionSpacing) {
2269
            Color.clear
2270
                .frame(width: axisColumnWidth)
2271

            
2272
            GeometryReader { geometry in
2273
                let labelWidth = max(
2274
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
2275
                    1
2276
                )
2277

            
2278
                ZStack(alignment: .topLeading) {
2279
                    Path { path in
2280
                        for labelIndex in 1...self.xLabels {
2281
                            let x = xGuidePosition(
2282
                                for: labelIndex,
2283
                                context: context,
2284
                                width: geometry.size.width
2285
                            )
2286
                            path.move(to: CGPoint(x: x, y: 0))
2287
                            path.addLine(to: CGPoint(x: x, y: 6))
2288
                        }
2289
                    }
2290
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
2291

            
2292
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
2293
                        let labelIndex = item.offset + 1
2294
                        let centerX = xGuidePosition(
2295
                            for: labelIndex,
2296
                            context: context,
2297
                            width: geometry.size.width
2298
                        )
2299

            
2300
                        Text(item.element)
Bogdan Timofte authored 2 months ago
2301
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
2302
                            .monospacedDigit()
2303
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
2304
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
2305
                            .frame(width: labelWidth)
2306
                            .position(
2307
                                x: centerX,
2308
                                y: geometry.size.height * 0.7
2309
                            )
Bogdan Timofte authored 2 months ago
2310
                    }
2311
                }
Bogdan Timofte authored 2 months ago
2312
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
2313
            }
Bogdan Timofte authored 2 months ago
2314

            
2315
            Color.clear
2316
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
2317
        }
2318
    }
2319

            
Bogdan Timofte authored 2 months ago
2320
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
2321
        height: CGFloat,
2322
        context: ChartContext,
Bogdan Timofte authored 2 months ago
2323
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
2324
        measurementUnit: String,
2325
        tint: Color
2326
    ) -> some View {
Bogdan Timofte authored 2 months ago
2327
        let yAxisFont: Font = {
2328
            if isIPhone && isPortraitLayout {
2329
                return .caption2.weight(.semibold)
2330
            }
2331
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
2332
        }()
2333

            
2334
        let unitFont: Font = {
2335
            if isIPhone && isPortraitLayout {
2336
                return .caption2.weight(.bold)
2337
            }
2338
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
2339
        }()
2340

            
2341
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
2342
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
2343
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
2344
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
2345

            
Bogdan Timofte authored 2 months ago
2346
            ZStack(alignment: .top) {
2347
                ForEach(0..<yLabels, id: \.self) { row in
2348
                    let labelIndex = yLabels - row
2349

            
2350
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
2351
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
2352
                        .monospacedDigit()
2353
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
2354
                        .minimumScaleFactor(0.8)
2355
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
2356
                        .position(
2357
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
2358
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
2359
                                for: labelIndex,
2360
                                context: context,
Bogdan Timofte authored 2 months ago
2361
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
2362
                            )
2363
                        )
Bogdan Timofte authored 2 months ago
2364
                }
Bogdan Timofte authored 2 months ago
2365

            
Bogdan Timofte authored 2 months ago
2366
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
2367
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
2368
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
2369
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
2370
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
2371
                    .background(
2372
                        Capsule(style: .continuous)
2373
                            .fill(tint.opacity(0.14))
2374
                    )
Bogdan Timofte authored 2 months ago
2375
                    .padding(.top, 8)
2376

            
Bogdan Timofte authored 2 months ago
2377
            }
2378
        }
Bogdan Timofte authored 2 months ago
2379
        .frame(height: height)
2380
        .background(
2381
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2382
                .fill(tint.opacity(0.12))
2383
        )
2384
        .overlay(
2385
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2386
                .stroke(tint.opacity(0.20), lineWidth: 1)
2387
        )
Bogdan Timofte authored 2 months ago
2388
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
2389
        .gesture(
Bogdan Timofte authored 2 months ago
2390
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
2391
                .onEnded { value in
Bogdan Timofte authored 2 months ago
2392
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
2393
                }
2394
        )
Bogdan Timofte authored 2 months ago
2395
    }
2396

            
Bogdan Timofte authored 2 months ago
2397
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2398
        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
Bogdan Timofte authored 2 months ago
2399
    }
2400

            
Bogdan Timofte authored 2 months ago
2401
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2402
        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
Bogdan Timofte authored 2 months ago
2403
    }
Bogdan Timofte authored 2 months ago
2404

            
2405
    fileprivate func discontinuityMarkers(
2406
        points: [Measurements.Measurement.Point],
2407
        context: ChartContext
2408
    ) -> some View {
2409
        GeometryReader { geometry in
2410
            Path { path in
2411
                for point in points where point.isDiscontinuity {
2412
                    let markerX = context.placeInRect(
2413
                        point: CGPoint(
2414
                            x: point.timestamp.timeIntervalSince1970,
2415
                            y: context.origin.y
2416
                        )
2417
                    ).x * geometry.size.width
2418
                    path.move(to: CGPoint(x: markerX, y: 0))
2419
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
2420
                }
2421
            }
2422
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
2423
        }
2424
    }
Bogdan Timofte authored 2 months ago
2425

            
2426
}
2427

            
Bogdan Timofte authored a month ago
2428
private struct EmbeddedWidthKey: PreferenceKey {
2429
    static let defaultValue: CGFloat = 760
2430
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2431
        let next = nextValue()
2432
        if next > 0 { value = next }
2433
    }
2434
}
2435

            
Bogdan Timofte authored 2 months ago
2436
private struct TimeRangeSelectorView: View {
2437
    private enum DragTarget {
2438
        case lowerBound
2439
        case upperBound
2440
        case window
2441
    }
2442

            
2443
    private struct DragState {
2444
        let target: DragTarget
2445
        let initialRange: ClosedRange<Date>
2446
    }
2447

            
2448
    let points: [Measurements.Measurement.Point]
2449
    let context: ChartContext
2450
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
2451
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2452
    let compactLayout: Bool
Bogdan Timofte authored a month ago
2453
    let xAxisLabelCount: Int
Bogdan Timofte authored 2 months ago
2454
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2455
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2456

            
2457
    @Binding var selectedTimeRange: ClosedRange<Date>?
2458
    @Binding var isPinnedToPresent: Bool
2459
    @Binding var presentTrackingMode: PresentTrackingMode
2460
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
2461
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored a month ago
2462
    @State private var isShowingCSVExporter: Bool = false
2463
    @State private var exportFileName: String = "charge-session"
2464
    @State private var exportDocument = MeasurementChartCSVDocument(content: "")
Bogdan Timofte authored 2 months ago
2465

            
2466
    private var totalSpan: TimeInterval {
2467
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2468
    }
2469

            
2470
    private var currentRange: ClosedRange<Date> {
2471
        resolvedSelectionRange()
2472
    }
2473

            
2474
    private var trackHeight: CGFloat {
Bogdan Timofte authored a month ago
2475
        Self.trackHeight(compactLayout: compactLayout)
2476
    }
2477

            
2478
    private static func trackHeight(compactLayout: Bool) -> CGFloat {
2479
        compactLayout ? 42 : 50
Bogdan Timofte authored 2 months ago
2480
    }
2481

            
Bogdan Timofte authored a month ago
2482
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2483
        let rowHeight: CGFloat = compactLayout ? 28 : 32
Bogdan Timofte authored a month ago
2484
        let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2485
        let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
Bogdan Timofte authored a month ago
2486
        let spacing: CGFloat = compactLayout ? 6 : 8
Bogdan Timofte authored a month ago
2487
        // Single row of controls instead of two
2488
        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
Bogdan Timofte authored a month ago
2489
    }
2490

            
Bogdan Timofte authored 2 months ago
2491
    private var cornerRadius: CGFloat {
2492
        compactLayout ? 14 : 16
2493
    }
2494

            
2495
    private var symbolButtonSize: CGFloat {
2496
        compactLayout ? 28 : 32
2497
    }
2498

            
2499
    var body: some View {
2500
        let coversFullRange = selectionCoversFullRange(currentRange)
2501

            
2502
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
Bogdan Timofte authored a month ago
2503
            HStack(spacing: 8) {
2504
                // Alignment controls
2505
                if !coversFullRange || isPinnedToPresent {
Bogdan Timofte authored 2 months ago
2506
                    alignmentButton(
2507
                        systemName: "arrow.left.to.line.compact",
2508
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2509
                        action: alignSelectionToLeadingEdge,
2510
                        accessibilityLabel: "Align selection to start"
2511
                    )
2512

            
2513
                    alignmentButton(
2514
                        systemName: "arrow.right.to.line.compact",
2515
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2516
                        action: alignSelectionToTrailingEdge,
2517
                        accessibilityLabel: "Align selection to present"
2518
                    )
2519

            
2520
                    if isPinnedToPresent {
2521
                        trackingModeToggleButton()
2522
                    }
2523
                }
2524

            
Bogdan Timofte authored a month ago
2525
                Spacer(minLength: 0)
2526

            
Bogdan Timofte authored a month ago
2527
                if let exportAction = configuration.exportAction {
2528
                    iconButton(
2529
                        systemName: exportAction.systemName,
2530
                        tone: exportAction.tone,
2531
                        action: {
2532
                            beginCSVExport(exportAction)
2533
                        }
2534
                    )
2535
                    .help(exportAction.title)
2536
                    .accessibilityLabel(exportAction.title)
2537
                }
2538

            
Bogdan Timofte authored a month ago
2539
                // Trim/Save actions
Bogdan Timofte authored 2 months ago
2540
                if !coversFullRange {
Bogdan Timofte authored a month ago
2541
                    iconButton(
Bogdan Timofte authored a month ago
2542
                        systemName: configuration.keepAction.systemName,
2543
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2544
                        action: {
Bogdan Timofte authored a month ago
2545
                            configuration.keepAction.handler(currentRange)
2546
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2547
                        }
2548
                    )
Bogdan Timofte authored a month ago
2549
                    .help(configuration.keepAction.title)
Bogdan Timofte authored 2 months ago
2550

            
Bogdan Timofte authored a month ago
2551
                    if let removeAction = configuration.removeAction {
Bogdan Timofte authored a month ago
2552
                        iconButton(
Bogdan Timofte authored a month ago
2553
                            systemName: removeAction.systemName,
2554
                            tone: removeAction.tone,
2555
                            action: {
2556
                                removeAction.handler(currentRange)
2557
                                resetSelectionState()
2558
                            }
2559
                        )
Bogdan Timofte authored a month ago
2560
                        .help(removeAction.title)
Bogdan Timofte authored a month ago
2561
                    }
Bogdan Timofte authored 2 months ago
2562

            
Bogdan Timofte authored a month ago
2563
                    // Reset action (only show when there's a trim to reset)
2564
                    iconButton(
2565
                        systemName: configuration.resetAction.systemName,
2566
                        tone: configuration.resetAction.tone,
2567
                        action: {
2568
                            showResetConfirmation = true
2569
                        }
2570
                    )
2571
                    .help(configuration.resetAction.title)
2572
                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2573
                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2574
                            configuration.resetAction.handler()
2575
                            resetSelectionState()
2576
                        }
2577
                        Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 months ago
2578
                    }
2579
                }
2580
            }
2581

            
Bogdan Timofte authored 2 months ago
2582
            GeometryReader { geometry in
2583
                let selectionFrame = selectionFrame(in: geometry.size)
2584
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2585

            
2586
                ZStack(alignment: .topLeading) {
2587
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2588
                        .fill(Color.primary.opacity(0.05))
2589

            
Bogdan Timofte authored a month ago
2590
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2591
                        points: points,
2592
                        context: context,
2593
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2594
                        strokeColor: selectorTint,
2595
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2596
                    )
2597
                    .opacity(0.94)
2598
                    .allowsHitTesting(false)
2599

            
Bogdan Timofte authored a month ago
2600
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2601
                        points: points,
2602
                        context: context,
Bogdan Timofte authored 2 months ago
2603
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2604
                    )
2605
                    .opacity(0.82)
2606
                    .allowsHitTesting(false)
2607

            
2608
                    if selectionFrame.minX > 0 {
2609
                        Rectangle()
2610
                            .fill(dimmingColor)
2611
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2612
                            .allowsHitTesting(false)
2613
                    }
2614

            
2615
                    if selectionFrame.maxX < geometry.size.width {
2616
                        Rectangle()
2617
                            .fill(dimmingColor)
2618
                            .frame(
2619
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2620
                                height: geometry.size.height
2621
                            )
2622
                            .offset(x: selectionFrame.maxX)
2623
                            .allowsHitTesting(false)
2624
                    }
2625

            
2626
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2627
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2628
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2629
                        .offset(x: selectionFrame.minX)
2630
                        .allowsHitTesting(false)
2631

            
2632
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2633
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2634
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2635
                        .offset(x: selectionFrame.minX)
2636
                        .allowsHitTesting(false)
2637

            
2638
                    handleView(height: max(geometry.size.height - 18, 16))
2639
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2640
                        .allowsHitTesting(false)
2641

            
2642
                    handleView(height: max(geometry.size.height - 18, 16))
2643
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2644
                        .allowsHitTesting(false)
2645
                }
2646
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2647
                .overlay(
2648
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
Bogdan Timofte authored a month ago
2649
                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2650
                )
2651
                .contentShape(Rectangle())
2652
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2653
            }
2654
            .frame(height: trackHeight)
2655

            
Bogdan Timofte authored a month ago
2656
            xAxisLabelsView
Bogdan Timofte authored 2 months ago
2657
        }
Bogdan Timofte authored a month ago
2658
        .fileExporter(
2659
            isPresented: $isShowingCSVExporter,
2660
            document: exportDocument,
2661
            contentType: .commaSeparatedText,
2662
            defaultFilename: exportFileName
2663
        ) { _ in }
Bogdan Timofte authored 2 months ago
2664
    }
2665

            
2666
    private func handleView(height: CGFloat) -> some View {
2667
        Capsule(style: .continuous)
2668
            .fill(Color.white.opacity(0.95))
2669
            .frame(width: 6, height: height)
2670
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2671
    }
2672

            
2673
    private func alignmentButton(
2674
        systemName: String,
2675
        isActive: Bool,
2676
        action: @escaping () -> Void,
2677
        accessibilityLabel: String
2678
    ) -> some View {
2679
        Button(action: action) {
2680
            Image(systemName: systemName)
2681
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2682
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2683
        }
2684
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2685
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2686
        .background(
2687
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2688
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2689
        )
2690
        .overlay(
2691
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2692
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2693
        )
2694
        .accessibilityLabel(accessibilityLabel)
2695
    }
2696

            
2697
    private func trackingModeToggleButton() -> some View {
2698
        Button {
2699
            presentTrackingMode = presentTrackingMode == .keepDuration
2700
                ? .keepStartTimestamp
2701
                : .keepDuration
2702
        } label: {
2703
            Image(systemName: trackingModeSymbolName)
2704
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2705
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2706
        }
2707
        .buttonStyle(.plain)
2708
        .foregroundColor(.white)
2709
        .background(
2710
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2711
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2712
        )
2713
        .overlay(
2714
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2715
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2716
        )
2717
        .accessibilityLabel(trackingModeAccessibilityLabel)
2718
        .accessibilityHint("Toggles how the interval follows the present")
2719
    }
2720

            
Bogdan Timofte authored a month ago
2721
    private func beginCSVExport(_ action: MeasurementChartExportAction) {
2722
        let exportRange = currentRange
2723
        exportFileName = action.fileName(exportRange)
2724
        exportDocument = MeasurementChartCSVDocument(content: action.content(exportRange))
2725
        isShowingCSVExporter = true
2726
    }
2727

            
Bogdan Timofte authored 2 months ago
2728
    private func actionButton(
2729
        title: String,
Bogdan Timofte authored a month ago
2730
        shortTitle: String? = nil,
Bogdan Timofte authored 2 months ago
2731
        systemName: String,
Bogdan Timofte authored a month ago
2732
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2733
        action: @escaping () -> Void
2734
    ) -> some View {
2735
        let foregroundColor: Color = {
2736
            switch tone {
2737
            case .reversible, .destructive:
2738
                return toneColor(for: tone)
2739
            case .destructiveProminent:
2740
                return .white
2741
            }
2742
        }()
Bogdan Timofte authored a month ago
2743
        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
Bogdan Timofte authored 2 months ago
2744

            
2745
        return Button(action: action) {
Bogdan Timofte authored a month ago
2746
            Label(displayTitle, systemImage: systemName)
Bogdan Timofte authored 2 months ago
2747
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2748
                .padding(.horizontal, compactLayout ? 10 : 12)
2749
                .padding(.vertical, compactLayout ? 7 : 8)
2750
        }
2751
        .buttonStyle(.plain)
2752
        .foregroundColor(foregroundColor)
2753
        .background(
2754
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2755
                .fill(actionButtonBackground(for: tone))
2756
        )
2757
        .overlay(
2758
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2759
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2760
        )
2761
    }
2762

            
Bogdan Timofte authored a month ago
2763
    private func iconButton(
2764
        systemName: String,
2765
        tone: MeasurementChartSelectorActionTone,
2766
        action: @escaping () -> Void
2767
    ) -> some View {
2768
        let foregroundColor: Color = {
2769
            switch tone {
2770
            case .reversible, .destructive:
2771
                return toneColor(for: tone)
2772
            case .destructiveProminent:
2773
                return .white
2774
            }
2775
        }()
2776

            
2777
        return Button(action: action) {
2778
            Image(systemName: systemName)
2779
                .font(.subheadline.weight(.semibold))
2780
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2781
        }
2782
        .buttonStyle(.plain)
2783
        .foregroundColor(foregroundColor)
2784
        .background(
2785
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2786
                .fill(actionButtonBackground(for: tone))
2787
        )
2788
        .overlay(
2789
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2790
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2791
        )
2792
    }
2793

            
Bogdan Timofte authored a month ago
2794
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2795
        switch tone {
2796
        case .reversible:
2797
            return selectorTint
2798
        case .destructive, .destructiveProminent:
2799
            return .red
2800
        }
2801
    }
2802

            
Bogdan Timofte authored a month ago
2803
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2804
        switch tone {
2805
        case .reversible:
2806
            return selectorTint.opacity(0.12)
2807
        case .destructive:
2808
            return Color.red.opacity(0.12)
2809
        case .destructiveProminent:
2810
            return Color.red.opacity(0.82)
2811
        }
2812
    }
2813

            
Bogdan Timofte authored a month ago
2814
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2815
        switch tone {
2816
        case .reversible:
2817
            return selectorTint.opacity(0.22)
2818
        case .destructive:
2819
            return Color.red.opacity(0.22)
2820
        case .destructiveProminent:
2821
            return Color.red.opacity(0.72)
2822
        }
2823
    }
2824

            
Bogdan Timofte authored 2 months ago
2825
    private var trackingModeSymbolName: String {
2826
        switch presentTrackingMode {
2827
        case .keepDuration:
2828
            return "arrow.left.and.right"
2829
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2830
            return "arrow.right"
Bogdan Timofte authored 2 months ago
2831
        }
2832
    }
2833

            
2834
    private var trackingModeAccessibilityLabel: String {
2835
        switch presentTrackingMode {
2836
        case .keepDuration:
Bogdan Timofte authored a month ago
2837
            return "Keep fixed duration"
Bogdan Timofte authored 2 months ago
2838
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2839
            return "Keep start fixed"
Bogdan Timofte authored 2 months ago
2840
        }
2841
    }
2842

            
2843
    private func alignSelectionToLeadingEdge() {
2844
        let alignedRange = normalizedSelectionRange(
2845
            availableTimeRange.lowerBound...currentRange.upperBound
2846
        )
2847
        applySelection(alignedRange, pinToPresent: false)
2848
    }
2849

            
2850
    private func alignSelectionToTrailingEdge() {
2851
        let alignedRange = normalizedSelectionRange(
2852
            currentRange.lowerBound...availableTimeRange.upperBound
2853
        )
2854
        applySelection(alignedRange, pinToPresent: true)
2855
    }
2856

            
2857
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2858
        DragGesture(minimumDistance: 0)
2859
            .onChanged { value in
2860
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2861
            }
2862
            .onEnded { _ in
2863
                dragState = nil
2864
            }
2865
    }
2866

            
2867
    private func updateSelectionDrag(
2868
        value: DragGesture.Value,
2869
        totalWidth: CGFloat
2870
    ) {
2871
        let startingRange = resolvedSelectionRange()
2872

            
2873
        if dragState == nil {
2874
            dragState = DragState(
2875
                target: dragTarget(
2876
                    for: value.startLocation.x,
2877
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2878
                ),
2879
                initialRange: startingRange
2880
            )
2881
        }
2882

            
2883
        guard let dragState else { return }
2884

            
2885
        let resultingRange = snappedToEdges(
2886
            adjustedRange(
2887
                from: dragState.initialRange,
2888
                target: dragState.target,
2889
                translationX: value.translation.width,
2890
                totalWidth: totalWidth
2891
            ),
2892
            target: dragState.target,
2893
            totalWidth: totalWidth
2894
        )
2895

            
2896
        applySelection(
2897
            resultingRange,
2898
            pinToPresent: shouldKeepPresentPin(
2899
                during: dragState.target,
2900
                initialRange: dragState.initialRange,
2901
                resultingRange: resultingRange
2902
            ),
2903
        )
2904
    }
2905

            
2906
    private func dragTarget(
2907
        for startX: CGFloat,
2908
        selectionFrame: CGRect
2909
    ) -> DragTarget {
2910
        let handleZone: CGFloat = compactLayout ? 20 : 24
2911

            
2912
        if abs(startX - selectionFrame.minX) <= handleZone {
2913
            return .lowerBound
2914
        }
2915

            
2916
        if abs(startX - selectionFrame.maxX) <= handleZone {
2917
            return .upperBound
2918
        }
2919

            
2920
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2921
            return .window
2922
        }
2923

            
2924
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2925
    }
2926

            
2927
    private func adjustedRange(
2928
        from initialRange: ClosedRange<Date>,
2929
        target: DragTarget,
2930
        translationX: CGFloat,
2931
        totalWidth: CGFloat
2932
    ) -> ClosedRange<Date> {
2933
        guard totalSpan > 0, totalWidth > 0 else {
2934
            return availableTimeRange
2935
        }
2936

            
2937
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2938
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2939

            
2940
        switch target {
2941
        case .lowerBound:
2942
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2943
            let newLowerBound = min(
2944
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2945
                maximumLowerBound
2946
            )
2947
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2948

            
2949
        case .upperBound:
2950
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2951
            let newUpperBound = max(
2952
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2953
                minimumUpperBound
2954
            )
2955
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2956

            
2957
        case .window:
2958
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2959
            guard span < totalSpan else { return availableTimeRange }
2960

            
2961
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2962
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2963

            
2964
            if lowerBound < availableTimeRange.lowerBound {
2965
                upperBound = upperBound.addingTimeInterval(
2966
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2967
                )
2968
                lowerBound = availableTimeRange.lowerBound
2969
            }
2970

            
2971
            if upperBound > availableTimeRange.upperBound {
2972
                lowerBound = lowerBound.addingTimeInterval(
2973
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2974
                )
2975
                upperBound = availableTimeRange.upperBound
2976
            }
2977

            
2978
            return normalizedSelectionRange(lowerBound...upperBound)
2979
        }
2980
    }
2981

            
2982
    private func snappedToEdges(
2983
        _ candidateRange: ClosedRange<Date>,
2984
        target: DragTarget,
2985
        totalWidth: CGFloat
2986
    ) -> ClosedRange<Date> {
2987
        guard totalSpan > 0 else {
2988
            return availableTimeRange
2989
        }
2990

            
2991
        let snapInterval = edgeSnapInterval(for: totalWidth)
2992
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2993
        var lowerBound = candidateRange.lowerBound
2994
        var upperBound = candidateRange.upperBound
2995

            
2996
        if target != .upperBound,
2997
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2998
            lowerBound = availableTimeRange.lowerBound
2999
            if target == .window {
3000
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
3001
            }
3002
        }
3003

            
3004
        if target != .lowerBound,
3005
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
3006
            upperBound = availableTimeRange.upperBound
3007
            if target == .window {
3008
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
3009
            }
3010
        }
3011

            
3012
        return normalizedSelectionRange(lowerBound...upperBound)
3013
    }
3014

            
3015
    private func edgeSnapInterval(
3016
        for totalWidth: CGFloat
3017
    ) -> TimeInterval {
3018
        guard totalWidth > 0 else { return minimumSelectionSpan }
3019

            
3020
        let snapWidth = min(
3021
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
3022
            totalWidth * 0.18
3023
        )
3024
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
3025
        return min(
3026
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
3027
            totalSpan / 4
3028
        )
3029
    }
3030

            
3031
    private func resolvedSelectionRange() -> ClosedRange<Date> {
3032
        guard let selectedTimeRange else { return availableTimeRange }
3033

            
3034
        if isPinnedToPresent {
3035
            switch presentTrackingMode {
3036
            case .keepDuration:
3037
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
3038
                return normalizedSelectionRange(
3039
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
3040
                )
3041
            case .keepStartTimestamp:
3042
                return normalizedSelectionRange(
3043
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
3044
                )
3045
            }
3046
        }
3047

            
3048
        return normalizedSelectionRange(selectedTimeRange)
3049
    }
3050

            
3051
    private func normalizedSelectionRange(
3052
        _ candidateRange: ClosedRange<Date>
3053
    ) -> ClosedRange<Date> {
3054
        let availableSpan = totalSpan
3055
        guard availableSpan > 0 else { return availableTimeRange }
3056

            
3057
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
3058
        let requestedSpan = min(
3059
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
3060
            availableSpan
3061
        )
3062

            
3063
        if requestedSpan >= availableSpan {
3064
            return availableTimeRange
3065
        }
3066

            
3067
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
3068
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
3069

            
3070
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
3071
            if lowerBound == availableTimeRange.lowerBound {
3072
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
3073
            } else {
3074
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
3075
            }
3076
        }
3077

            
3078
        if upperBound > availableTimeRange.upperBound {
3079
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
3080
            upperBound = availableTimeRange.upperBound
3081
            lowerBound = lowerBound.addingTimeInterval(-delta)
3082
        }
3083

            
3084
        if lowerBound < availableTimeRange.lowerBound {
3085
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
3086
            lowerBound = availableTimeRange.lowerBound
3087
            upperBound = upperBound.addingTimeInterval(delta)
3088
        }
3089

            
3090
        return lowerBound...upperBound
3091
    }
3092

            
3093
    private func shouldKeepPresentPin(
3094
        during target: DragTarget,
3095
        initialRange: ClosedRange<Date>,
3096
        resultingRange: ClosedRange<Date>
3097
    ) -> Bool {
3098
        let startedPinnedToPresent =
3099
            isPinnedToPresent ||
3100
            selectionCoversFullRange(initialRange)
3101

            
3102
        guard startedPinnedToPresent else {
3103
            return selectionTouchesPresent(resultingRange)
3104
        }
3105

            
3106
        switch target {
3107
        case .lowerBound:
3108
            return true
3109
        case .upperBound, .window:
3110
            return selectionTouchesPresent(resultingRange)
3111
        }
3112
    }
3113

            
3114
    private func applySelection(
3115
        _ candidateRange: ClosedRange<Date>,
3116
        pinToPresent: Bool
3117
    ) {
3118
        let normalizedRange = normalizedSelectionRange(candidateRange)
3119

            
3120
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
3121
            selectedTimeRange = nil
3122
        } else {
3123
            selectedTimeRange = normalizedRange
3124
        }
3125

            
3126
        isPinnedToPresent = pinToPresent
3127
    }
3128

            
Bogdan Timofte authored a month ago
3129
    private func resetSelectionState() {
3130
        selectedTimeRange = nil
3131
        isPinnedToPresent = false
3132
    }
3133

            
Bogdan Timofte authored 2 months ago
3134
    private func selectionTouchesPresent(
3135
        _ range: ClosedRange<Date>
3136
    ) -> Bool {
3137
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
3138
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
3139
    }
3140

            
3141
    private func selectionCoversFullRange(
3142
        _ range: ClosedRange<Date>
3143
    ) -> Bool {
3144
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
3145
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
3146
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
3147
    }
3148

            
3149
    private func selectionFrame(in size: CGSize) -> CGRect {
3150
        selectionFrame(for: currentRange, width: size.width)
3151
    }
3152

            
3153
    private func selectionFrame(
3154
        for range: ClosedRange<Date>,
3155
        width: CGFloat
3156
    ) -> CGRect {
3157
        guard width > 0, totalSpan > 0 else {
3158
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
3159
        }
3160

            
3161
        let minimumX = xPosition(for: range.lowerBound, width: width)
3162
        let maximumX = xPosition(for: range.upperBound, width: width)
3163
        return CGRect(
3164
            x: minimumX,
3165
            y: 0,
3166
            width: max(maximumX - minimumX, 2),
3167
            height: trackHeight
3168
        )
3169
    }
3170

            
3171
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
3172
        guard width > 0, totalSpan > 0 else { return 0 }
3173

            
3174
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
3175
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
3176
        return CGFloat(normalizedOffset) * width
3177
    }
3178

            
Bogdan Timofte authored a month ago
3179
    private var xAxisLabelsView: some View {
3180
        let timeFormat: String = {
3181
            switch context.size.width {
3182
            case 0..<3600: return "HH:mm:ss"
3183
            case 3600...86400: return "HH:mm"
3184
            default: return "E HH:mm"
3185
            }
3186
        }()
Bogdan Timofte authored 2 months ago
3187

            
Bogdan Timofte authored a month ago
3188
        let labelCount = max(xAxisLabelCount, 2)
3189
        let labels = (1...labelCount).map {
3190
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat)
Bogdan Timofte authored 2 months ago
3191
        }
Bogdan Timofte authored a month ago
3192
        let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold)
3193

            
3194
        return GeometryReader { geometry in
3195
            let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1)
3196

            
3197
            ZStack(alignment: .topLeading) {
3198
                Path { path in
3199
                    for labelIndex in 1...labelCount {
3200
                        let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
3201
                        path.move(to: CGPoint(x: x, y: 0))
3202
                        path.addLine(to: CGPoint(x: x, y: 5))
3203
                    }
3204
                }
3205
                .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
3206

            
3207
                ForEach(Array(labels.enumerated()), id: \.offset) { item in
3208
                    let labelIndex = item.offset + 1
3209
                    let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
Bogdan Timofte authored a month ago
3210
                    let halfWidth = labelWidth / 2
3211
                    let clampedX = min(max(centerX, halfWidth), geometry.size.width - halfWidth)
Bogdan Timofte authored a month ago
3212

            
3213
                    Text(item.element)
3214
                        .font(axisLabelFont)
3215
                        .monospacedDigit()
3216
                        .lineLimit(1)
3217
                        .minimumScaleFactor(0.74)
3218
                        .frame(width: labelWidth)
3219
                        .position(
Bogdan Timofte authored a month ago
3220
                            x: clampedX,
Bogdan Timofte authored a month ago
3221
                            y: geometry.size.height * 0.66
3222
                        )
3223
                }
3224
            }
3225
        }
3226
        .frame(height: compactLayout ? 18 : 20)
3227
    }
3228

            
3229
    private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
3230
        context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width)
Bogdan Timofte authored 2 months ago
3231
    }
3232
}