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

            
9
import SwiftUI
10

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 months ago
118
    private enum SeriesKind {
119
        case power
Bogdan Timofte authored 2 months ago
120
        case energy
Bogdan Timofte authored 2 months ago
121
        case voltage
122
        case current
Bogdan Timofte authored 2 months ago
123
        case temperature
Bogdan Timofte authored 2 months ago
124

            
125
        var unit: String {
126
            switch self {
127
            case .power: return "W"
Bogdan Timofte authored 2 months ago
128
            case .energy: return "Wh"
Bogdan Timofte authored 2 months ago
129
            case .voltage: return "V"
130
            case .current: return "A"
Bogdan Timofte authored 2 months ago
131
            case .temperature: return ""
Bogdan Timofte authored 2 months ago
132
            }
133
        }
134

            
135
        var tint: Color {
136
            switch self {
137
            case .power: return .red
Bogdan Timofte authored 2 months ago
138
            case .energy: return .teal
Bogdan Timofte authored 2 months ago
139
            case .voltage: return .green
140
            case .current: return .blue
Bogdan Timofte authored 2 months ago
141
            case .temperature: return .orange
Bogdan Timofte authored 2 months ago
142
            }
143
        }
144
    }
145

            
146
    private struct SeriesData {
147
        let kind: SeriesKind
148
        let points: [Measurements.Measurement.Point]
149
        let samplePoints: [Measurements.Measurement.Point]
150
        let context: ChartContext
151
        let autoLowerBound: Double
152
        let autoUpperBound: Double
153
        let maximumSampleValue: Double?
154
    }
155

            
Bogdan Timofte authored 2 months ago
156
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
157
    private let minimumVoltageSpan = 0.5
158
    private let minimumCurrentSpan = 0.5
159
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 2 months ago
160
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
161
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored 2 months ago
162
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 2 months ago
163
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
164

            
Bogdan Timofte authored a month ago
165
    let sizing: MeasurementChartSizing
Bogdan Timofte authored a month ago
166
    let showsRangeSelector: Bool
167
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored a month ago
168
    let extendsTimelineToPresent: Bool
169
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored a month ago
170

            
Bogdan Timofte authored 2 months ago
171
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
172
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
173
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
174
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a month ago
175
    let timeRangeLowerBound: Date?
176
    let timeRangeUpperBound: Date?
Bogdan Timofte authored a month ago
177

            
178
    @State private var embeddedWidth: CGFloat = 760
179

            
180
    private var compactLayout: Bool {
181
        switch sizing {
182
        case .provided(_, let compact): return compact
183
        case .embedded: return embeddedWidth < 760
184
        }
185
    }
186

            
187
    private var availableSize: CGSize {
188
        switch sizing {
189
        case .provided(let size, _): return size
190
        case .embedded:
191
            let h = compactLayout ? 290 : 350
192
            return CGSize(width: embeddedWidth, height: CGFloat(h))
193
        }
194
    }
195

            
Bogdan Timofte authored 2 months ago
196
    @State var displayVoltage: Bool = false
197
    @State var displayCurrent: Bool = false
198
    @State var displayPower: Bool = true
Bogdan Timofte authored 2 months ago
199
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 2 months ago
200
    @State var displayTemperature: Bool = false
Bogdan Timofte authored 2 months ago
201
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored 2 months ago
202
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 2 months ago
203
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
204
    @State private var isPinnedToPresent: Bool = false
205
    @State private var presentTrackingMode: PresentTrackingMode = .keepDuration
Bogdan Timofte authored 2 months ago
206
    @State private var pinOrigin: Bool = false
207
    @State private var useSharedOrigin: Bool = false
208
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
209
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored 2 months ago
210
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
211
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
212
    @State private var voltageAxisOrigin: Double = 0
213
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
214
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
215
    let xLabels: Int = 4
216
    let yLabels: Int = 4
217

            
Bogdan Timofte authored 2 months ago
218
    init(
Bogdan Timofte authored a month ago
219
        sizing: MeasurementChartSizing = .embedded,
Bogdan Timofte authored a month ago
220
        timeRange: ClosedRange<Date>? = nil,
Bogdan Timofte authored a month ago
221
        timeRangeLowerBound: Date? = nil,
222
        timeRangeUpperBound: Date? = nil,
Bogdan Timofte authored a month ago
223
        showsRangeSelector: Bool = true,
Bogdan Timofte authored a month ago
224
        rebasesEnergyToVisibleRangeStart: Bool = false,
225
        extendsTimelineToPresent: Bool = true,
226
        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
Bogdan Timofte authored 2 months ago
227
    ) {
Bogdan Timofte authored a month ago
228
        self.sizing = sizing
Bogdan Timofte authored 2 months ago
229
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
230
        self.timeRangeLowerBound = timeRangeLowerBound
231
        self.timeRangeUpperBound = timeRangeUpperBound
Bogdan Timofte authored a month ago
232
        self.showsRangeSelector = showsRangeSelector
233
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored a month ago
234
        self.extendsTimelineToPresent = extendsTimelineToPresent
235
        self.rangeSelectorConfiguration = rangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
236
    }
237

            
Bogdan Timofte authored a month ago
238
    private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
239
        let compact = width < 760
240
        let plotHeight: CGFloat = compact ? 290 : 350
Bogdan Timofte authored a month ago
241
        guard showsRangeSelector else { return plotHeight }
Bogdan Timofte authored a month ago
242
        return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
Bogdan Timofte authored a month ago
243
    }
244

            
Bogdan Timofte authored 2 months ago
245
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
246
        if compactLayout {
247
            return 38
248
        }
249
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
250
    }
251

            
252
    private var chartSectionSpacing: CGFloat {
253
        compactLayout ? 6 : 8
254
    }
255

            
256
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
257
        if compactLayout {
258
            return 24
259
        }
260
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
261
    }
262

            
Bogdan Timofte authored a month ago
263
    private var belowXAxisControlsHeight: CGFloat {
264
        if usesCompactLandscapeOriginControls {
265
            return 40
266
        }
267
        if compactLayout {
268
            return 46
269
        }
270
        return isLargeDisplay ? 58 : 50
271
    }
272

            
Bogdan Timofte authored 2 months ago
273
    private var isPortraitLayout: Bool {
274
        guard availableSize != .zero else { return verticalSizeClass != .compact }
275
        return availableSize.height >= availableSize.width
276
    }
277

            
Bogdan Timofte authored 2 months ago
278
    private var isIPhone: Bool {
279
        #if os(iOS)
280
        return UIDevice.current.userInterfaceIdiom == .phone
281
        #else
282
        return false
283
        #endif
284
    }
285

            
286
    private enum OriginControlsPlacement {
287
        case aboveXAxisLegend
288
        case overXAxisLegend
289
        case belowXAxisLegend
290
    }
291

            
292
    private var originControlsPlacement: OriginControlsPlacement {
293
        if isIPhone {
294
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
295
        }
296
        return .belowXAxisLegend
297
    }
298

            
Bogdan Timofte authored 2 months ago
299
    private var plotSectionHeight: CGFloat {
300
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
301
            return compactLayout ? 300 : 380
302
        }
303

            
304
        if isPortraitLayout {
305
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
306
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
307
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
308
        }
309

            
310
        if compactLayout {
311
            return min(max(availableSize.height * 0.36, 240), 300)
312
        }
313

            
314
        return min(max(availableSize.height * 0.5, 300), 440)
315
    }
316

            
317
    private var stackedToolbarLayout: Bool {
318
        if availableSize.width > 0 {
319
            return availableSize.width < 640
320
        }
321

            
322
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
323
    }
324

            
325
    private var showsLabeledOriginControls: Bool {
326
        !compactLayout && !stackedToolbarLayout
327
    }
328

            
Bogdan Timofte authored 2 months ago
329
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
330
        #if os(iOS)
331
        if UIDevice.current.userInterfaceIdiom == .phone {
332
            return false
333
        }
334
        #endif
335

            
Bogdan Timofte authored 2 months ago
336
        if availableSize.width > 0 {
337
            return availableSize.width >= 900 || availableSize.height >= 700
338
        }
339
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
340
    }
341

            
342
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
343
        if isIPhone && isPortraitLayout {
344
            return .caption
345
        }
346
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
347
    }
348

            
Bogdan Timofte authored 2 months ago
349
    private var usesCompactLandscapeOriginControls: Bool {
350
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
351
    }
352

            
Bogdan Timofte authored 2 months ago
353
    var body: some View {
Bogdan Timofte authored a month ago
354
        switch sizing {
355
        case .provided:
356
            chartBody
357
        case .embedded:
358
            let chartWidth = max(embeddedWidth, 1)
359
            chartBody
360
                .frame(maxWidth: .infinity, alignment: .topLeading)
361
                .frame(height: Self.embeddedContentHeight(width: chartWidth, showsRangeSelector: showsRangeSelector))
362
                .background(
363
                    GeometryReader { geometry in
364
                        Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
365
                    }
366
                )
367
                .onPreferenceChange(EmbeddedWidthKey.self) { width in
368
                    guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
369
                    embeddedWidth = width
370
                }
371
        }
372
    }
373

            
374
    @ViewBuilder
375
    private var chartBody: some View {
Bogdan Timofte authored 2 months ago
376
        let availableTimeRange = availableSelectionTimeRange()
377
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
378
        let powerSeries = series(
379
            for: measurements.power,
380
            kind: .power,
381
            minimumYSpan: minimumPowerSpan,
382
            visibleTimeRange: visibleTimeRange
383
        )
Bogdan Timofte authored 2 months ago
384
        let energySeries = series(
385
            for: measurements.energy,
386
            kind: .energy,
387
            minimumYSpan: minimumEnergySpan,
388
            visibleTimeRange: visibleTimeRange
389
        )
Bogdan Timofte authored 2 months ago
390
        let voltageSeries = series(
391
            for: measurements.voltage,
392
            kind: .voltage,
393
            minimumYSpan: minimumVoltageSpan,
394
            visibleTimeRange: visibleTimeRange
395
        )
396
        let currentSeries = series(
397
            for: measurements.current,
398
            kind: .current,
399
            minimumYSpan: minimumCurrentSpan,
400
            visibleTimeRange: visibleTimeRange
401
        )
402
        let temperatureSeries = series(
403
            for: measurements.temperature,
404
            kind: .temperature,
405
            minimumYSpan: minimumTemperatureSpan,
406
            visibleTimeRange: visibleTimeRange
407
        )
Bogdan Timofte authored 2 months ago
408
        let primarySeries = displayedPrimarySeries(
409
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
410
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
411
            voltageSeries: voltageSeries,
412
            currentSeries: currentSeries
413
        )
Bogdan Timofte authored 2 months ago
414
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
415

            
Bogdan Timofte authored 2 months ago
416
        Group {
Bogdan Timofte authored 2 months ago
417
            if let primarySeries {
418
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
419
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
420

            
Bogdan Timofte authored a month ago
421
                    VStack(spacing: compactLayout ? 8 : 10) {
422
                        GeometryReader { geometry in
423
                            let reservedBottomHeight =
424
                                xAxisHeight
425
                                + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
426
                            let plotHeight = max(
427
                                geometry.size.height - reservedBottomHeight,
428
                                compactLayout ? 180 : 220
429
                            )
430

            
431
                            VStack(spacing: 6) {
432
                                HStack(spacing: chartSectionSpacing) {
433
                                    primaryAxisView(
434
                                        height: plotHeight,
435
                                        powerSeries: powerSeries,
436
                                        energySeries: energySeries,
437
                                        voltageSeries: voltageSeries,
438
                                        currentSeries: currentSeries
439
                                    )
440
                                    .frame(width: axisColumnWidth, height: plotHeight)
441

            
442
                                    ZStack {
443
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
444
                                            .fill(Color.primary.opacity(0.05))
445

            
446
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
447
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
448

            
449
                                        horizontalGuides(context: primarySeries.context)
450
                                        verticalGuides(context: primarySeries.context)
451
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
452
                                        renderedChart(
453
                                            powerSeries: powerSeries,
454
                                            energySeries: energySeries,
455
                                            voltageSeries: voltageSeries,
456
                                            currentSeries: currentSeries,
457
                                            temperatureSeries: temperatureSeries
458
                                        )
459
                                    }
460
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
461
                                    .frame(maxWidth: .infinity)
462
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
463

            
Bogdan Timofte authored a month ago
464
                                    secondaryAxisView(
465
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
466
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
467
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
468
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
469
                                        currentSeries: currentSeries,
470
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
471
                                    )
Bogdan Timofte authored a month ago
472
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
473
                                }
Bogdan Timofte authored a month ago
474
                                .overlay(alignment: .bottom) {
475
                                    if originControlsPlacement == .aboveXAxisLegend {
476
                                        scaleControlsPill(
477
                                            voltageSeries: voltageSeries,
478
                                            currentSeries: currentSeries
479
                                        )
480
                                        .padding(.bottom, compactLayout ? 6 : 10)
481
                                    }
Bogdan Timofte authored 2 months ago
482
                                }
Bogdan Timofte authored 2 months ago
483

            
Bogdan Timofte authored a month ago
484
                                switch originControlsPlacement {
485
                                case .aboveXAxisLegend:
486
                                    xAxisLabelsView(context: primarySeries.context)
487
                                        .frame(height: xAxisHeight)
488
                                case .overXAxisLegend:
489
                                    xAxisLabelsView(context: primarySeries.context)
490
                                        .frame(height: xAxisHeight)
491
                                        .overlay(alignment: .center) {
492
                                            scaleControlsPill(
493
                                                voltageSeries: voltageSeries,
494
                                                currentSeries: currentSeries
495
                                            )
496
                                            .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
497
                                        }
498
                                case .belowXAxisLegend:
499
                                    xAxisLabelsView(context: primarySeries.context)
500
                                        .frame(height: xAxisHeight)
501

            
502
                                    HStack {
503
                                        Spacer(minLength: 0)
Bogdan Timofte authored 2 months ago
504
                                        scaleControlsPill(
505
                                            voltageSeries: voltageSeries,
506
                                            currentSeries: currentSeries
507
                                        )
Bogdan Timofte authored a month ago
508
                                        Spacer(minLength: 0)
Bogdan Timofte authored 2 months ago
509
                                    }
510
                                }
511
                            }
Bogdan Timofte authored a month ago
512
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
513
                        }
514
                        .frame(height: plotSectionHeight)
515

            
516
                        if showsRangeSelector,
517
                           let availableTimeRange,
518
                           let selectorSeries,
519
                           shouldShowRangeSelector(
520
                            availableTimeRange: availableTimeRange,
521
                            series: selectorSeries
522
                           ) {
523
                            TimeRangeSelectorView(
524
                                points: selectorSeries.points,
525
                                context: selectorSeries.context,
Bogdan Timofte authored 2 months ago
526
                                availableTimeRange: availableTimeRange,
Bogdan Timofte authored a month ago
527
                                selectorTint: selectorTint,
528
                                compactLayout: compactLayout,
Bogdan Timofte authored a month ago
529
                                xAxisLabelCount: xLabels,
Bogdan Timofte authored a month ago
530
                                minimumSelectionSpan: minimumTimeSpan,
531
                                configuration: resolvedRangeSelectorConfiguration(),
532
                                selectedTimeRange: $selectedVisibleTimeRange,
533
                                isPinnedToPresent: $isPinnedToPresent,
534
                                presentTrackingMode: $presentTrackingMode
535
                            )
Bogdan Timofte authored 2 months ago
536
                        }
537
                    }
538
                }
Bogdan Timofte authored 2 months ago
539
            } else {
540
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
541
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
542
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
543
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
544
                }
545
            }
Bogdan Timofte authored 2 months ago
546
        }
Bogdan Timofte authored 2 months ago
547
        .font(chartBaseFont)
Bogdan Timofte authored 2 months ago
548
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
549
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
550
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
551
            chartNow = now
552
        }
Bogdan Timofte authored 2 months ago
553
    }
554

            
Bogdan Timofte authored 2 months ago
555
    private func chartToggleBar() -> some View {
Bogdan Timofte authored 2 months ago
556
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
557
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
Bogdan Timofte authored 2 months ago
558

            
Bogdan Timofte authored a month ago
559
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
560
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
561
        }
562
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
563
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
564
        .background(
565
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
566
                .fill(Color.primary.opacity(0.045))
567
        )
568
        .overlay(
569
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
570
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
571
        )
Bogdan Timofte authored 2 months ago
572

            
Bogdan Timofte authored 2 months ago
573
        return Group {
Bogdan Timofte authored 2 months ago
574
            if stackedToolbarLayout {
Bogdan Timofte authored 2 months ago
575
                controlsPanel
Bogdan Timofte authored 2 months ago
576
            } else {
Bogdan Timofte authored 2 months ago
577
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
578
                    controlsPanel
Bogdan Timofte authored 2 months ago
579
                }
Bogdan Timofte authored 2 months ago
580
            }
581
        }
582
        .frame(maxWidth: .infinity, alignment: .leading)
583
    }
584

            
Bogdan Timofte authored 2 months ago
585
    private var shouldFloatScaleControlsOverChart: Bool {
586
        #if os(iOS)
587
        if availableSize.width > 0, availableSize.height > 0 {
588
            return availableSize.width > availableSize.height
589
        }
590
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
591
        #else
592
        return false
593
        #endif
594
    }
595

            
596
    private func scaleControlsPill(
597
        voltageSeries: SeriesData,
598
        currentSeries: SeriesData
599
    ) -> some View {
600
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
601
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 2 months ago
602
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
603
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
604

            
605
        return originControlsRow(
606
            voltageSeries: voltageSeries,
607
            currentSeries: currentSeries,
608
            condensedLayout: condensedLayout,
609
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
610
        )
Bogdan Timofte authored 2 months ago
611
        .padding(.horizontal, horizontalPadding)
612
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 2 months ago
613
        .background(
614
            Capsule(style: .continuous)
Bogdan Timofte authored 2 months ago
615
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 2 months ago
616
        )
617
        .overlay(
618
            Capsule(style: .continuous)
619
                .stroke(
Bogdan Timofte authored 2 months ago
620
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 2 months ago
621
                    lineWidth: 1
622
                )
623
        )
624
    }
625

            
Bogdan Timofte authored 2 months ago
626
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
627
        HStack(spacing: condensedLayout ? 6 : 8) {
628
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
629
                displayVoltage.toggle()
630
                if displayVoltage {
631
                    displayPower = false
Bogdan Timofte authored 2 months ago
632
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
633
                    if displayTemperature && displayCurrent {
634
                        displayCurrent = false
635
                    }
Bogdan Timofte authored 2 months ago
636
                }
637
            }
638

            
639
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
640
                displayCurrent.toggle()
641
                if displayCurrent {
642
                    displayPower = false
Bogdan Timofte authored 2 months ago
643
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
644
                    if displayTemperature && displayVoltage {
645
                        displayVoltage = false
646
                    }
Bogdan Timofte authored 2 months ago
647
                }
Bogdan Timofte authored 2 months ago
648
            }
649

            
650
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
651
                displayPower.toggle()
652
                if displayPower {
Bogdan Timofte authored 2 months ago
653
                    displayEnergy = false
654
                    displayCurrent = false
655
                    displayVoltage = false
656
                }
657
            }
658

            
659
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
660
                displayEnergy.toggle()
661
                if displayEnergy {
662
                    displayPower = false
Bogdan Timofte authored 2 months ago
663
                    displayCurrent = false
664
                    displayVoltage = false
665
                }
666
            }
Bogdan Timofte authored 2 months ago
667

            
668
            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
669
                displayTemperature.toggle()
670
                if displayTemperature && displayVoltage && displayCurrent {
671
                    displayCurrent = false
672
                }
673
            }
Bogdan Timofte authored 2 months ago
674
        }
675
    }
676

            
677
    private func originControlsRow(
678
        voltageSeries: SeriesData,
679
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
680
        condensedLayout: Bool,
681
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
682
    ) -> some View {
Bogdan Timofte authored 2 months ago
683
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
684
            if supportsSharedOrigin {
685
                symbolControlChip(
686
                    systemImage: "equal.circle",
687
                    enabled: true,
688
                    active: useSharedOrigin,
689
                    condensedLayout: condensedLayout,
690
                    showsLabel: showsLabel,
691
                    label: "Match Y Scale",
692
                    accessibilityLabel: "Match Y scale"
693
                ) {
694
                    toggleSharedOrigin(
695
                        voltageSeries: voltageSeries,
696
                        currentSeries: currentSeries
697
                    )
698
                }
Bogdan Timofte authored 2 months ago
699
            }
700

            
701
            symbolControlChip(
702
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
703
                enabled: true,
704
                active: pinOrigin,
705
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
706
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
707
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
708
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
709
            ) {
710
                togglePinnedOrigin(
711
                    voltageSeries: voltageSeries,
712
                    currentSeries: currentSeries
713
                )
714
            }
715

            
Bogdan Timofte authored 2 months ago
716
            if !pinnedOriginIsZero {
717
                symbolControlChip(
718
                    systemImage: "0.circle",
719
                    enabled: true,
720
                    active: false,
721
                    condensedLayout: condensedLayout,
722
                    showsLabel: showsLabel,
723
                    label: "Origin 0",
724
                    accessibilityLabel: "Set origin to zero"
725
                ) {
726
                    setVisibleOriginsToZero()
727
                }
Bogdan Timofte authored 2 months ago
728
            }
Bogdan Timofte authored 2 months ago
729

            
Bogdan Timofte authored a month ago
730
            smoothingControlChip(
731
                condensedLayout: condensedLayout,
732
                showsLabel: showsLabel
733
            )
734

            
Bogdan Timofte authored 2 months ago
735
        }
736
    }
737

            
Bogdan Timofte authored a month ago
738
    private func smoothingControlChip(
739
        condensedLayout: Bool,
740
        showsLabel: Bool
741
    ) -> some View {
742
        Menu {
743
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
744
                Button {
745
                    smoothingLevel = level
746
                } label: {
747
                    if smoothingLevel == level {
748
                        Label(level.label, systemImage: "checkmark")
749
                    } else {
750
                        Text(level.label)
Bogdan Timofte authored 2 months ago
751
                    }
752
                }
Bogdan Timofte authored a month ago
753
            }
754
        } label: {
755
            Group {
756
                if showsLabel {
757
                    VStack(alignment: .leading, spacing: 2) {
758
                        Label("Smoothing", systemImage: "waveform.path")
759
                            .font(controlChipFont(condensedLayout: condensedLayout))
760

            
761
                        Text(
Bogdan Timofte authored 2 months ago
762
                            smoothingLevel == .off
Bogdan Timofte authored a month ago
763
                            ? "Off"
764
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
765
                        )
Bogdan Timofte authored a month ago
766
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
767
                        .foregroundColor(.secondary)
768
                        .monospacedDigit()
769
                    }
770
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
771
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
772
                } else {
773
                    VStack(spacing: 1) {
774
                        Image(systemName: "waveform.path")
775
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
776

            
777
                        Text(smoothingLevel.shortLabel)
778
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
779
                            .monospacedDigit()
780
                    }
781
                    .frame(
782
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
783
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
784
                    )
785
                }
Bogdan Timofte authored 2 months ago
786
            }
Bogdan Timofte authored a month ago
787
            .background(
788
                Capsule(style: .continuous)
789
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
790
            )
791
            .overlay(
792
                Capsule(style: .continuous)
793
                    .stroke(
794
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
795
                        lineWidth: 1
796
                    )
797
            )
Bogdan Timofte authored 2 months ago
798
        }
Bogdan Timofte authored a month ago
799
        .buttonStyle(.plain)
800
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
801
    }
802

            
Bogdan Timofte authored 2 months ago
803
    private func seriesToggleButton(
804
        title: String,
805
        isOn: Bool,
806
        condensedLayout: Bool,
807
        action: @escaping () -> Void
808
    ) -> some View {
809
        Button(action: action) {
810
            Text(title)
Bogdan Timofte authored 2 months ago
811
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
812
                .lineLimit(1)
813
                .minimumScaleFactor(0.82)
814
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
815
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
816
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
817
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
818
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
819
                .background(
820
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
821
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
822
                )
823
                .overlay(
824
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
825
                        .stroke(Color.blue, lineWidth: 1.5)
826
                )
827
        }
828
        .buttonStyle(.plain)
829
    }
830

            
831
    private func symbolControlChip(
832
        systemImage: String,
833
        enabled: Bool,
834
        active: Bool,
835
        condensedLayout: Bool,
836
        showsLabel: Bool,
837
        label: String,
838
        accessibilityLabel: String,
839
        action: @escaping () -> Void
840
    ) -> some View {
841
        Button(action: {
842
            action()
843
        }) {
844
            Group {
845
                if showsLabel {
846
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
847
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
848
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
849
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
850
                } else {
851
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
852
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
853
                        .frame(
Bogdan Timofte authored 2 months ago
854
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
855
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
856
                        )
Bogdan Timofte authored 2 months ago
857
                }
858
            }
859
                .background(
860
                    Capsule(style: .continuous)
861
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
862
                )
863
        }
864
        .buttonStyle(.plain)
865
        .foregroundColor(enabled ? .primary : .secondary)
866
        .opacity(enabled ? 1 : 0.55)
867
        .accessibilityLabel(accessibilityLabel)
868
    }
869

            
Bogdan Timofte authored 2 months ago
870
    private func resetBuffer() {
871
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
872
    }
873

            
Bogdan Timofte authored a month ago
874
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
875
        if let rangeSelectorConfiguration {
876
            return rangeSelectorConfiguration
877
        }
878

            
879
        return MeasurementChartRangeSelectorConfiguration(
880
            keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
881
                title: "Keep Selection",
882
                shortTitle: "Keep",
Bogdan Timofte authored a month ago
883
                systemName: "scissors",
884
                tone: .destructive,
885
                handler: trimBufferToSelection
886
            ),
887
            removeAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
888
                title: "Remove Selection",
889
                shortTitle: "Cut",
Bogdan Timofte authored a month ago
890
                systemName: "minus.circle",
891
                tone: .destructive,
892
                handler: removeSelectionFromBuffer
893
            ),
894
            resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
895
                title: "Reset Buffer",
896
                shortTitle: "Reset",
Bogdan Timofte authored a month ago
897
                systemName: "trash",
898
                tone: .destructiveProminent,
899
                confirmationTitle: "Reset captured measurements?",
900
                confirmationButtonTitle: "Reset buffer",
901
                handler: resetBuffer
902
            )
903
        )
904
    }
905

            
Bogdan Timofte authored 2 months ago
906
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
907
        if isLargeDisplay {
908
            return .body.weight(.semibold)
909
        }
910
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
911
    }
912

            
913
    private func controlChipFont(condensedLayout: Bool) -> Font {
914
        if isLargeDisplay {
915
            return .callout.weight(.semibold)
916
        }
917
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
918
    }
919

            
Bogdan Timofte authored 2 months ago
920
    @ViewBuilder
921
    private func primaryAxisView(
922
        height: CGFloat,
Bogdan Timofte authored 2 months ago
923
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
924
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
925
        voltageSeries: SeriesData,
926
        currentSeries: SeriesData
Bogdan Timofte authored 2 months ago
927
    ) -> some View {
928
        if displayPower {
929
            yAxisLabelsView(
930
                height: height,
931
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
932
                seriesKind: .power,
933
                measurementUnit: powerSeries.kind.unit,
934
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
935
            )
Bogdan Timofte authored 2 months ago
936
        } else if displayEnergy {
937
            yAxisLabelsView(
938
                height: height,
939
                context: energySeries.context,
940
                seriesKind: .energy,
941
                measurementUnit: energySeries.kind.unit,
942
                tint: energySeries.kind.tint
943
            )
Bogdan Timofte authored 2 months ago
944
        } else if displayVoltage {
945
            yAxisLabelsView(
946
                height: height,
947
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
948
                seriesKind: .voltage,
949
                measurementUnit: voltageSeries.kind.unit,
950
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
951
            )
952
        } else if displayCurrent {
953
            yAxisLabelsView(
954
                height: height,
955
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
956
                seriesKind: .current,
957
                measurementUnit: currentSeries.kind.unit,
958
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
959
            )
960
        }
961
    }
962

            
963
    @ViewBuilder
964
    private func renderedChart(
Bogdan Timofte authored 2 months ago
965
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
966
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
967
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
968
        currentSeries: SeriesData,
969
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
970
    ) -> some View {
971
        if self.displayPower {
Bogdan Timofte authored a month ago
972
            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
Bogdan Timofte authored 2 months ago
973
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
974
        } else if self.displayEnergy {
Bogdan Timofte authored a month ago
975
            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
Bogdan Timofte authored 2 months ago
976
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
977
        } else {
978
            if self.displayVoltage {
Bogdan Timofte authored a month ago
979
                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
Bogdan Timofte authored 2 months ago
980
                    .opacity(0.78)
981
            }
982
            if self.displayCurrent {
Bogdan Timofte authored a month ago
983
                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
Bogdan Timofte authored 2 months ago
984
                    .opacity(0.78)
985
            }
986
        }
Bogdan Timofte authored 2 months ago
987

            
988
        if displayTemperature {
Bogdan Timofte authored a month ago
989
            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
Bogdan Timofte authored 2 months ago
990
                .opacity(0.86)
991
        }
Bogdan Timofte authored 2 months ago
992
    }
993

            
994
    @ViewBuilder
995
    private func secondaryAxisView(
996
        height: CGFloat,
Bogdan Timofte authored 2 months ago
997
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
998
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
999
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1000
        currentSeries: SeriesData,
1001
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
1002
    ) -> some View {
Bogdan Timofte authored 2 months ago
1003
        if displayTemperature {
1004
            yAxisLabelsView(
1005
                height: height,
1006
                context: temperatureSeries.context,
1007
                seriesKind: .temperature,
1008
                measurementUnit: measurementUnit(for: .temperature),
1009
                tint: temperatureSeries.kind.tint
1010
            )
1011
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
1012
            yAxisLabelsView(
1013
                height: height,
1014
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1015
                seriesKind: .current,
1016
                measurementUnit: currentSeries.kind.unit,
1017
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1018
            )
1019
        } else {
1020
            primaryAxisView(
1021
                height: height,
1022
                powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
1023
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
1024
                voltageSeries: voltageSeries,
1025
                currentSeries: currentSeries
1026
            )
Bogdan Timofte authored 2 months ago
1027
        }
1028
    }
Bogdan Timofte authored 2 months ago
1029

            
1030
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
1031
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1032
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1033
        voltageSeries: SeriesData,
1034
        currentSeries: SeriesData
1035
    ) -> SeriesData? {
Bogdan Timofte authored 2 months ago
1036
        if displayPower {
Bogdan Timofte authored 2 months ago
1037
            return powerSeries
Bogdan Timofte authored 2 months ago
1038
        }
Bogdan Timofte authored 2 months ago
1039
        if displayEnergy {
1040
            return energySeries
1041
        }
Bogdan Timofte authored 2 months ago
1042
        if displayVoltage {
Bogdan Timofte authored 2 months ago
1043
            return voltageSeries
Bogdan Timofte authored 2 months ago
1044
        }
1045
        if displayCurrent {
Bogdan Timofte authored 2 months ago
1046
            return currentSeries
Bogdan Timofte authored 2 months ago
1047
        }
1048
        return nil
1049
    }
1050

            
1051
    private func series(
1052
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
1053
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
1054
        minimumYSpan: Double,
1055
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1056
    ) -> SeriesData {
Bogdan Timofte authored 2 months ago
1057
        let rawPoints = filteredPoints(
Bogdan Timofte authored 2 months ago
1058
            measurement,
1059
            visibleTimeRange: visibleTimeRange
1060
        )
Bogdan Timofte authored a month ago
1061
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1062
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
1063
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1064
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
1065

            
1066
        let autoBounds = automaticYBounds(
1067
            for: samplePoints,
1068
            minimumYSpan: minimumYSpan
1069
        )
Bogdan Timofte authored 2 months ago
1070
        let xBounds = xBounds(
1071
            for: samplePoints,
1072
            visibleTimeRange: visibleTimeRange
1073
        )
Bogdan Timofte authored 2 months ago
1074
        let lowerBound = resolvedLowerBound(
1075
            for: kind,
1076
            autoLowerBound: autoBounds.lowerBound
1077
        )
1078
        let upperBound = resolvedUpperBound(
1079
            for: kind,
1080
            lowerBound: lowerBound,
1081
            autoUpperBound: autoBounds.upperBound,
1082
            maximumSampleValue: samplePoints.map(\.value).max(),
1083
            minimumYSpan: minimumYSpan
1084
        )
1085

            
1086
        context.setBounds(
1087
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1088
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1089
            yMin: CGFloat(lowerBound),
1090
            yMax: CGFloat(upperBound)
1091
        )
1092

            
1093
        return SeriesData(
1094
            kind: kind,
1095
            points: points,
1096
            samplePoints: samplePoints,
1097
            context: context,
1098
            autoLowerBound: autoBounds.lowerBound,
1099
            autoUpperBound: autoBounds.upperBound,
1100
            maximumSampleValue: samplePoints.map(\.value).max()
1101
        )
1102
    }
1103

            
Bogdan Timofte authored a month ago
1104
    private func normalizedPoints(
1105
        _ points: [Measurements.Measurement.Point],
1106
        for kind: SeriesKind
1107
    ) -> [Measurements.Measurement.Point] {
1108
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1109
            return points
1110
        }
1111

            
1112
        guard let baseline = points.first(where: \.isSample)?.value else {
1113
            return points
1114
        }
1115

            
1116
        return points.enumerated().map { index, point in
1117
            Measurements.Measurement.Point(
1118
                id: point.id == index ? point.id : index,
1119
                timestamp: point.timestamp,
1120
                value: point.value - baseline,
1121
                kind: point.kind
1122
            )
1123
        }
1124
    }
1125

            
Bogdan Timofte authored 2 months ago
1126
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1127
        series(
1128
            for: measurement(for: kind),
1129
            kind: kind,
1130
            minimumYSpan: minimumYSpan(for: kind)
1131
        )
1132
    }
1133

            
Bogdan Timofte authored 2 months ago
1134
    private func smoothedPoints(
1135
        from points: [Measurements.Measurement.Point]
1136
    ) -> [Measurements.Measurement.Point] {
1137
        guard smoothingLevel != .off else { return points }
1138

            
1139
        var smoothedPoints: [Measurements.Measurement.Point] = []
1140
        var currentSegment: [Measurements.Measurement.Point] = []
1141

            
1142
        func flushCurrentSegment() {
1143
            guard !currentSegment.isEmpty else { return }
1144

            
1145
            for point in smoothedSegment(currentSegment) {
1146
                smoothedPoints.append(
1147
                    Measurements.Measurement.Point(
1148
                        id: smoothedPoints.count,
1149
                        timestamp: point.timestamp,
1150
                        value: point.value,
1151
                        kind: .sample
1152
                    )
1153
                )
1154
            }
1155

            
1156
            currentSegment.removeAll(keepingCapacity: true)
1157
        }
1158

            
1159
        for point in points {
1160
            if point.isDiscontinuity {
1161
                flushCurrentSegment()
1162

            
1163
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1164
                    smoothedPoints.append(
1165
                        Measurements.Measurement.Point(
1166
                            id: smoothedPoints.count,
1167
                            timestamp: point.timestamp,
1168
                            value: smoothedPoints.last?.value ?? point.value,
1169
                            kind: .discontinuity
1170
                        )
1171
                    )
1172
                }
1173
            } else {
1174
                currentSegment.append(point)
1175
            }
1176
        }
1177

            
1178
        flushCurrentSegment()
1179
        return smoothedPoints
1180
    }
1181

            
1182
    private func smoothedSegment(
1183
        _ segment: [Measurements.Measurement.Point]
1184
    ) -> [Measurements.Measurement.Point] {
1185
        let windowSize = smoothingLevel.movingAverageWindowSize
1186
        guard windowSize > 1, segment.count > 2 else { return segment }
1187

            
1188
        let radius = windowSize / 2
1189
        var prefixSums: [Double] = [0]
1190
        prefixSums.reserveCapacity(segment.count + 1)
1191

            
1192
        for point in segment {
1193
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1194
        }
1195

            
1196
        return segment.enumerated().map { index, point in
1197
            let lowerBound = max(0, index - radius)
1198
            let upperBound = min(segment.count - 1, index + radius)
1199
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1200
            let average = sum / Double(upperBound - lowerBound + 1)
1201

            
1202
            return Measurements.Measurement.Point(
1203
                id: point.id,
1204
                timestamp: point.timestamp,
1205
                value: average,
1206
                kind: .sample
1207
            )
1208
        }
1209
    }
1210

            
Bogdan Timofte authored 2 months ago
1211
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1212
        switch kind {
1213
        case .power:
1214
            return measurements.power
Bogdan Timofte authored 2 months ago
1215
        case .energy:
1216
            return measurements.energy
Bogdan Timofte authored 2 months ago
1217
        case .voltage:
1218
            return measurements.voltage
1219
        case .current:
1220
            return measurements.current
1221
        case .temperature:
1222
            return measurements.temperature
1223
        }
1224
    }
1225

            
1226
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1227
        switch kind {
1228
        case .power:
1229
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1230
        case .energy:
1231
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1232
        case .voltage:
1233
            return minimumVoltageSpan
1234
        case .current:
1235
            return minimumCurrentSpan
1236
        case .temperature:
1237
            return minimumTemperatureSpan
1238
        }
1239
    }
1240

            
Bogdan Timofte authored 2 months ago
1241
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 2 months ago
1242
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored 2 months ago
1243
    }
1244

            
Bogdan Timofte authored 2 months ago
1245
    private var minimumSharedScaleSpan: Double {
1246
        max(minimumVoltageSpan, minimumCurrentSpan)
1247
    }
1248

            
Bogdan Timofte authored 2 months ago
1249
    private var pinnedOriginIsZero: Bool {
1250
        if useSharedOrigin && supportsSharedOrigin {
1251
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1252
        }
Bogdan Timofte authored 2 months ago
1253

            
1254
        if displayPower {
1255
            return pinOrigin && powerAxisOrigin == 0
1256
        }
1257

            
Bogdan Timofte authored 2 months ago
1258
        if displayEnergy {
1259
            return pinOrigin && energyAxisOrigin == 0
1260
        }
1261

            
Bogdan Timofte authored 2 months ago
1262
        let visibleOrigins = [
1263
            displayVoltage ? voltageAxisOrigin : nil,
1264
            displayCurrent ? currentAxisOrigin : nil
1265
        ]
1266
        .compactMap { $0 }
1267

            
1268
        guard !visibleOrigins.isEmpty else { return false }
1269
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1270
    }
1271

            
1272
    private func toggleSharedOrigin(
1273
        voltageSeries: SeriesData,
1274
        currentSeries: SeriesData
1275
    ) {
1276
        guard supportsSharedOrigin else { return }
1277

            
1278
        if useSharedOrigin {
1279
            useSharedOrigin = false
1280
            return
1281
        }
1282

            
1283
        captureCurrentOrigins(
1284
            voltageSeries: voltageSeries,
1285
            currentSeries: currentSeries
1286
        )
1287
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1288
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1289
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1290
        useSharedOrigin = true
1291
        pinOrigin = true
1292
    }
1293

            
1294
    private func togglePinnedOrigin(
1295
        voltageSeries: SeriesData,
1296
        currentSeries: SeriesData
1297
    ) {
1298
        if pinOrigin {
1299
            pinOrigin = false
1300
            return
1301
        }
1302

            
1303
        captureCurrentOrigins(
1304
            voltageSeries: voltageSeries,
1305
            currentSeries: currentSeries
1306
        )
1307
        pinOrigin = true
1308
    }
1309

            
1310
    private func setVisibleOriginsToZero() {
1311
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1312
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1313
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1314
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1315
            voltageAxisOrigin = 0
1316
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1317
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1318
        } else {
1319
            if displayPower {
1320
                powerAxisOrigin = 0
1321
            }
Bogdan Timofte authored 2 months ago
1322
            if displayEnergy {
1323
                energyAxisOrigin = 0
1324
            }
Bogdan Timofte authored 2 months ago
1325
            if displayVoltage {
1326
                voltageAxisOrigin = 0
1327
            }
1328
            if displayCurrent {
1329
                currentAxisOrigin = 0
1330
            }
Bogdan Timofte authored 2 months ago
1331
            if displayTemperature {
1332
                temperatureAxisOrigin = 0
1333
            }
Bogdan Timofte authored 2 months ago
1334
        }
1335

            
1336
        pinOrigin = true
1337
    }
1338

            
1339
    private func captureCurrentOrigins(
1340
        voltageSeries: SeriesData,
1341
        currentSeries: SeriesData
1342
    ) {
1343
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1344
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1345
        voltageAxisOrigin = voltageSeries.autoLowerBound
1346
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1347
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored 2 months ago
1348
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1349
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1350
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1351
    }
1352

            
1353
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1354
        let visibleTimeRange = activeVisibleTimeRange
1355

            
Bogdan Timofte authored 2 months ago
1356
        switch kind {
1357
        case .power:
Bogdan Timofte authored 2 months ago
1358
            return pinOrigin
1359
                ? powerAxisOrigin
1360
                : automaticYBounds(
1361
                    for: filteredSamplePoints(
1362
                        measurements.power,
1363
                        visibleTimeRange: visibleTimeRange
1364
                    ),
1365
                    minimumYSpan: minimumPowerSpan
1366
                ).lowerBound
Bogdan Timofte authored 2 months ago
1367
        case .energy:
1368
            return pinOrigin
1369
                ? energyAxisOrigin
1370
                : automaticYBounds(
1371
                    for: filteredSamplePoints(
1372
                        measurements.energy,
1373
                        visibleTimeRange: visibleTimeRange
1374
                    ),
1375
                    minimumYSpan: minimumEnergySpan
1376
                ).lowerBound
Bogdan Timofte authored 2 months ago
1377
        case .voltage:
1378
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1379
                return sharedAxisOrigin
1380
            }
Bogdan Timofte authored 2 months ago
1381
            return pinOrigin
1382
                ? voltageAxisOrigin
1383
                : automaticYBounds(
1384
                    for: filteredSamplePoints(
1385
                        measurements.voltage,
1386
                        visibleTimeRange: visibleTimeRange
1387
                    ),
1388
                    minimumYSpan: minimumVoltageSpan
1389
                ).lowerBound
Bogdan Timofte authored 2 months ago
1390
        case .current:
1391
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1392
                return sharedAxisOrigin
1393
            }
Bogdan Timofte authored 2 months ago
1394
            return pinOrigin
1395
                ? currentAxisOrigin
1396
                : automaticYBounds(
1397
                    for: filteredSamplePoints(
1398
                        measurements.current,
1399
                        visibleTimeRange: visibleTimeRange
1400
                    ),
1401
                    minimumYSpan: minimumCurrentSpan
1402
                ).lowerBound
Bogdan Timofte authored 2 months ago
1403
        case .temperature:
Bogdan Timofte authored 2 months ago
1404
            return pinOrigin
1405
                ? temperatureAxisOrigin
1406
                : automaticYBounds(
1407
                    for: filteredSamplePoints(
1408
                        measurements.temperature,
1409
                        visibleTimeRange: visibleTimeRange
1410
                    ),
1411
                    minimumYSpan: minimumTemperatureSpan
1412
                ).lowerBound
Bogdan Timofte authored 2 months ago
1413
        }
1414
    }
1415

            
Bogdan Timofte authored 2 months ago
1416
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1417
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1418
    }
1419

            
1420
    private func filteredPoints(
1421
        _ measurement: Measurements.Measurement,
1422
        visibleTimeRange: ClosedRange<Date>? = nil
1423
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1424
        let resolvedRange: ClosedRange<Date>?
1425

            
1426
        switch (timeRange, visibleTimeRange) {
1427
        case let (baseRange?, visibleRange?):
1428
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1429
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1430
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1431
        case let (baseRange?, nil):
1432
            resolvedRange = baseRange
1433
        case let (nil, visibleRange?):
1434
            resolvedRange = visibleRange
1435
        case (nil, nil):
1436
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1437
        }
Bogdan Timofte authored 2 months ago
1438

            
1439
        guard let resolvedRange else {
1440
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1441
        }
1442

            
1443
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1444
    }
1445

            
1446
    private func filteredSamplePoints(
1447
        _ measurement: Measurements.Measurement,
1448
        visibleTimeRange: ClosedRange<Date>? = nil
1449
    ) -> [Measurements.Measurement.Point] {
1450
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1451
            point.isSample
Bogdan Timofte authored 2 months ago
1452
        }
1453
    }
1454

            
1455
    private func xBounds(
Bogdan Timofte authored 2 months ago
1456
        for samplePoints: [Measurements.Measurement.Point],
1457
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1458
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1459
        if let visibleTimeRange {
1460
            return normalizedTimeRange(visibleTimeRange)
1461
        }
1462

            
Bogdan Timofte authored 2 months ago
1463
        if let timeRange {
Bogdan Timofte authored 2 months ago
1464
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1465
        }
1466

            
1467
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1468
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1469

            
Bogdan Timofte authored 2 months ago
1470
        return normalizedTimeRange(lowerBound...upperBound)
1471
    }
1472

            
1473
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1474
        if let timeRange {
1475
            return normalizedTimeRange(timeRange)
1476
        }
1477

            
1478
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1479
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1480
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1481
            return nil
1482
        }
1483

            
Bogdan Timofte authored a month ago
1484
        let latestSampleTimestamp = samplePoints.last?.timestamp
1485
        let resolvedUpperBound = timeRangeUpperBound ?? {
1486
            guard extendsTimelineToPresent else {
1487
                return latestSampleTimestamp ?? lowerBound
1488
            }
1489
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1490
        }()
1491
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1492
        return normalizedTimeRange(lowerBound...upperBound)
1493
    }
1494

            
1495
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1496
        let candidates = [
1497
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1498
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1499
            filteredSamplePoints(measurements.voltage),
1500
            filteredSamplePoints(measurements.current),
1501
            filteredSamplePoints(measurements.temperature)
1502
        ]
1503

            
1504
        return candidates.first(where: { !$0.isEmpty }) ?? []
1505
    }
1506

            
1507
    private func resolvedVisibleTimeRange(
1508
        within availableTimeRange: ClosedRange<Date>?
1509
    ) -> ClosedRange<Date>? {
1510
        guard let availableTimeRange else { return nil }
1511
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1512

            
1513
        if isPinnedToPresent {
1514
            let pinnedRange: ClosedRange<Date>
1515

            
1516
            switch presentTrackingMode {
1517
            case .keepDuration:
1518
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1519
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1520
            case .keepStartTimestamp:
1521
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1522
            }
1523

            
1524
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1525
        }
1526

            
1527
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1528
    }
1529

            
1530
    private func clampedTimeRange(
1531
        _ candidateRange: ClosedRange<Date>,
1532
        within bounds: ClosedRange<Date>
1533
    ) -> ClosedRange<Date> {
1534
        let normalizedBounds = normalizedTimeRange(bounds)
1535
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1536

            
1537
        guard boundsSpan > 0 else {
1538
            return normalizedBounds
1539
        }
1540

            
1541
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1542
        let requestedSpan = min(
1543
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1544
            boundsSpan
1545
        )
1546

            
1547
        if requestedSpan >= boundsSpan {
1548
            return normalizedBounds
1549
        }
1550

            
1551
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1552
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1553

            
1554
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1555
            if lowerBound == normalizedBounds.lowerBound {
1556
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1557
            } else {
1558
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1559
            }
1560
        }
1561

            
1562
        if upperBound > normalizedBounds.upperBound {
1563
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1564
            upperBound = normalizedBounds.upperBound
1565
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1566
        }
1567

            
Bogdan Timofte authored 2 months ago
1568
        if lowerBound < normalizedBounds.lowerBound {
1569
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1570
            lowerBound = normalizedBounds.lowerBound
1571
            upperBound = upperBound.addingTimeInterval(delta)
1572
        }
1573

            
1574
        return lowerBound...upperBound
1575
    }
1576

            
1577
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1578
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1579
        guard span < minimumTimeSpan else { return range }
1580

            
1581
        let expansion = (minimumTimeSpan - span) / 2
1582
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1583
    }
1584

            
1585
    private func shouldShowRangeSelector(
1586
        availableTimeRange: ClosedRange<Date>,
1587
        series: SeriesData
1588
    ) -> Bool {
1589
        series.samplePoints.count > 1 &&
1590
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1591
    }
1592

            
1593
    private func automaticYBounds(
1594
        for samplePoints: [Measurements.Measurement.Point],
1595
        minimumYSpan: Double
1596
    ) -> (lowerBound: Double, upperBound: Double) {
1597
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1598

            
1599
        guard
1600
            let minimumSampleValue = samplePoints.map(\.value).min(),
1601
            let maximumSampleValue = samplePoints.map(\.value).max()
1602
        else {
1603
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1604
        }
Bogdan Timofte authored 2 months ago
1605

            
1606
        var lowerBound = minimumSampleValue
1607
        var upperBound = maximumSampleValue
1608
        let currentSpan = upperBound - lowerBound
1609

            
1610
        if currentSpan < minimumYSpan {
1611
            let expansion = (minimumYSpan - currentSpan) / 2
1612
            lowerBound -= expansion
1613
            upperBound += expansion
1614
        }
1615

            
1616
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1617
            let shift = -negativeAllowance - lowerBound
1618
            lowerBound += shift
1619
            upperBound += shift
1620
        }
1621

            
1622
        let snappedLowerBound = snappedOriginValue(lowerBound)
1623
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1624
        return (snappedLowerBound, resolvedUpperBound)
1625
    }
1626

            
1627
    private func resolvedLowerBound(
1628
        for kind: SeriesKind,
1629
        autoLowerBound: Double
1630
    ) -> Double {
1631
        guard pinOrigin else { return autoLowerBound }
1632

            
1633
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1634
            return sharedAxisOrigin
1635
        }
1636

            
1637
        switch kind {
1638
        case .power:
1639
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
1640
        case .energy:
1641
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1642
        case .voltage:
1643
            return voltageAxisOrigin
1644
        case .current:
1645
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1646
        case .temperature:
1647
            return temperatureAxisOrigin
Bogdan Timofte authored 2 months ago
1648
        }
1649
    }
1650

            
1651
    private func resolvedUpperBound(
1652
        for kind: SeriesKind,
1653
        lowerBound: Double,
1654
        autoUpperBound: Double,
1655
        maximumSampleValue: Double?,
1656
        minimumYSpan: Double
1657
    ) -> Double {
1658
        guard pinOrigin else {
1659
            return autoUpperBound
1660
        }
1661

            
Bogdan Timofte authored 2 months ago
1662
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1663
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1664
        }
1665

            
Bogdan Timofte authored 2 months ago
1666
        if kind == .temperature {
1667
            return autoUpperBound
1668
        }
1669

            
Bogdan Timofte authored 2 months ago
1670
        return max(
1671
            maximumSampleValue ?? lowerBound,
1672
            lowerBound + minimumYSpan,
1673
            autoUpperBound
1674
        )
1675
    }
1676

            
Bogdan Timofte authored 2 months ago
1677
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
1678
        let baseline = displayedLowerBoundForSeries(kind)
1679
        let proposedOrigin = snappedOriginValue(baseline + delta)
1680

            
1681
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
1682
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1683
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
1684
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1685
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1686
        } else {
1687
            switch kind {
1688
            case .power:
1689
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
1690
            case .energy:
1691
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
1692
            case .voltage:
1693
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1694
            case .current:
1695
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
1696
            case .temperature:
1697
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored 2 months ago
1698
            }
1699
        }
1700

            
1701
        pinOrigin = true
1702
    }
1703

            
Bogdan Timofte authored 2 months ago
1704
    private func clearOriginOffset(for kind: SeriesKind) {
1705
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1706
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1707
            sharedAxisOrigin = 0
1708
            sharedAxisUpperBound = currentSpan
1709
            ensureSharedScaleSpan()
1710
            voltageAxisOrigin = 0
1711
            currentAxisOrigin = 0
1712
        } else {
1713
            switch kind {
1714
            case .power:
1715
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1716
            case .energy:
1717
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1718
            case .voltage:
1719
                voltageAxisOrigin = 0
1720
            case .current:
1721
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1722
            case .temperature:
1723
                temperatureAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1724
            }
1725
        }
1726

            
1727
        pinOrigin = true
1728
    }
1729

            
1730
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1731
        guard totalHeight > 1 else { return }
1732

            
1733
        let normalized = max(0, min(1, locationY / totalHeight))
1734
        if normalized < (1.0 / 3.0) {
1735
            applyOriginDelta(-1, kind: kind)
1736
        } else if normalized < (2.0 / 3.0) {
1737
            clearOriginOffset(for: kind)
1738
        } else {
1739
            applyOriginDelta(1, kind: kind)
1740
        }
1741
    }
1742

            
Bogdan Timofte authored 2 months ago
1743
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1744
        let visibleTimeRange = activeVisibleTimeRange
1745

            
Bogdan Timofte authored 2 months ago
1746
        switch kind {
1747
        case .power:
Bogdan Timofte authored 2 months ago
1748
            return snappedOriginValue(
1749
                filteredSamplePoints(
1750
                    measurements.power,
1751
                    visibleTimeRange: visibleTimeRange
1752
                ).map(\.value).min() ?? 0
1753
            )
Bogdan Timofte authored 2 months ago
1754
        case .energy:
1755
            return snappedOriginValue(
1756
                filteredSamplePoints(
1757
                    measurements.energy,
1758
                    visibleTimeRange: visibleTimeRange
1759
                ).map(\.value).min() ?? 0
1760
            )
Bogdan Timofte authored 2 months ago
1761
        case .voltage:
Bogdan Timofte authored 2 months ago
1762
            return snappedOriginValue(
1763
                filteredSamplePoints(
1764
                    measurements.voltage,
1765
                    visibleTimeRange: visibleTimeRange
1766
                ).map(\.value).min() ?? 0
1767
            )
Bogdan Timofte authored 2 months ago
1768
        case .current:
Bogdan Timofte authored 2 months ago
1769
            return snappedOriginValue(
1770
                filteredSamplePoints(
1771
                    measurements.current,
1772
                    visibleTimeRange: visibleTimeRange
1773
                ).map(\.value).min() ?? 0
1774
            )
Bogdan Timofte authored 2 months ago
1775
        case .temperature:
Bogdan Timofte authored 2 months ago
1776
            return snappedOriginValue(
1777
                filteredSamplePoints(
1778
                    measurements.temperature,
1779
                    visibleTimeRange: visibleTimeRange
1780
                ).map(\.value).min() ?? 0
1781
            )
Bogdan Timofte authored 2 months ago
1782
        }
1783
    }
1784

            
1785
    private func maximumVisibleSharedOrigin() -> Double {
1786
        min(
1787
            maximumVisibleOrigin(for: .voltage),
1788
            maximumVisibleOrigin(for: .current)
1789
        )
1790
    }
1791

            
Bogdan Timofte authored 2 months ago
1792
    private func measurementUnit(for kind: SeriesKind) -> String {
1793
        switch kind {
1794
        case .temperature:
1795
            let locale = Locale.autoupdatingCurrent
1796
            if #available(iOS 16.0, *) {
1797
                switch locale.measurementSystem {
1798
                case .us:
1799
                    return "°F"
1800
                default:
1801
                    return "°C"
1802
                }
1803
            }
1804

            
1805
            let regionCode = locale.regionCode ?? ""
1806
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1807
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1808
        default:
1809
            return kind.unit
1810
        }
1811
    }
1812

            
Bogdan Timofte authored 2 months ago
1813
    private func ensureSharedScaleSpan() {
1814
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1815
    }
1816

            
Bogdan Timofte authored 2 months ago
1817
    private func snappedOriginValue(_ value: Double) -> Double {
1818
        if value >= 0 {
1819
            return value.rounded(.down)
1820
        }
1821

            
1822
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
1823
    }
Bogdan Timofte authored 2 months ago
1824

            
Bogdan Timofte authored 2 months ago
1825
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1826
        measurements.keepOnly(in: range)
1827
        selectedVisibleTimeRange = nil
1828
        isPinnedToPresent = false
1829
    }
1830

            
1831
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1832
        measurements.removeValues(in: range)
1833
        selectedVisibleTimeRange = nil
1834
        isPinnedToPresent = false
1835
    }
1836

            
Bogdan Timofte authored 2 months ago
1837
    private func yGuidePosition(
1838
        for labelIndex: Int,
1839
        context: ChartContext,
1840
        height: CGFloat
1841
    ) -> CGFloat {
Bogdan Timofte authored a month ago
1842
        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
Bogdan Timofte authored 2 months ago
1843
    }
1844

            
1845
    private func xGuidePosition(
1846
        for labelIndex: Int,
1847
        context: ChartContext,
1848
        width: CGFloat
1849
    ) -> CGFloat {
Bogdan Timofte authored a month ago
1850
        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
Bogdan Timofte authored 2 months ago
1851
    }
Bogdan Timofte authored 2 months ago
1852

            
1853
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
1854
    fileprivate func xAxisLabelsView(
1855
        context: ChartContext
1856
    ) -> some View {
Bogdan Timofte authored 2 months ago
1857
        var timeFormat: String?
1858
        switch context.size.width {
1859
        case 0..<3600: timeFormat = "HH:mm:ss"
1860
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
1861
        default: timeFormat = "E HH:mm"
1862
        }
1863
        let labels = (1...xLabels).map {
1864
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
1865
        }
Bogdan Timofte authored 2 months ago
1866
        let axisLabelFont: Font = {
1867
            if isIPhone && isPortraitLayout {
1868
                return .caption2.weight(.semibold)
1869
            }
1870
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1871
        }()
Bogdan Timofte authored 2 months ago
1872

            
1873
        return HStack(spacing: chartSectionSpacing) {
1874
            Color.clear
1875
                .frame(width: axisColumnWidth)
1876

            
1877
            GeometryReader { geometry in
1878
                let labelWidth = max(
1879
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1880
                    1
1881
                )
1882

            
1883
                ZStack(alignment: .topLeading) {
1884
                    Path { path in
1885
                        for labelIndex in 1...self.xLabels {
1886
                            let x = xGuidePosition(
1887
                                for: labelIndex,
1888
                                context: context,
1889
                                width: geometry.size.width
1890
                            )
1891
                            path.move(to: CGPoint(x: x, y: 0))
1892
                            path.addLine(to: CGPoint(x: x, y: 6))
1893
                        }
1894
                    }
1895
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1896

            
1897
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1898
                        let labelIndex = item.offset + 1
1899
                        let centerX = xGuidePosition(
1900
                            for: labelIndex,
1901
                            context: context,
1902
                            width: geometry.size.width
1903
                        )
1904

            
1905
                        Text(item.element)
Bogdan Timofte authored 2 months ago
1906
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
1907
                            .monospacedDigit()
1908
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
1909
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
1910
                            .frame(width: labelWidth)
1911
                            .position(
1912
                                x: centerX,
1913
                                y: geometry.size.height * 0.7
1914
                            )
Bogdan Timofte authored 2 months ago
1915
                    }
1916
                }
Bogdan Timofte authored 2 months ago
1917
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
1918
            }
Bogdan Timofte authored 2 months ago
1919

            
1920
            Color.clear
1921
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
1922
        }
1923
    }
1924

            
Bogdan Timofte authored 2 months ago
1925
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
1926
        height: CGFloat,
1927
        context: ChartContext,
Bogdan Timofte authored 2 months ago
1928
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
1929
        measurementUnit: String,
1930
        tint: Color
1931
    ) -> some View {
Bogdan Timofte authored 2 months ago
1932
        let yAxisFont: Font = {
1933
            if isIPhone && isPortraitLayout {
1934
                return .caption2.weight(.semibold)
1935
            }
1936
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1937
        }()
1938

            
1939
        let unitFont: Font = {
1940
            if isIPhone && isPortraitLayout {
1941
                return .caption2.weight(.bold)
1942
            }
1943
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1944
        }()
1945

            
1946
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
1947
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1948
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1949
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1950

            
Bogdan Timofte authored 2 months ago
1951
            ZStack(alignment: .top) {
1952
                ForEach(0..<yLabels, id: \.self) { row in
1953
                    let labelIndex = yLabels - row
1954

            
1955
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
1956
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
1957
                        .monospacedDigit()
1958
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
1959
                        .minimumScaleFactor(0.8)
1960
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
1961
                        .position(
1962
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
1963
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
1964
                                for: labelIndex,
1965
                                context: context,
Bogdan Timofte authored 2 months ago
1966
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
1967
                            )
1968
                        )
Bogdan Timofte authored 2 months ago
1969
                }
Bogdan Timofte authored 2 months ago
1970

            
Bogdan Timofte authored 2 months ago
1971
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
1972
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
1973
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
1974
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1975
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
1976
                    .background(
1977
                        Capsule(style: .continuous)
1978
                            .fill(tint.opacity(0.14))
1979
                    )
Bogdan Timofte authored 2 months ago
1980
                    .padding(.top, 8)
1981

            
Bogdan Timofte authored 2 months ago
1982
            }
1983
        }
Bogdan Timofte authored 2 months ago
1984
        .frame(height: height)
1985
        .background(
1986
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1987
                .fill(tint.opacity(0.12))
1988
        )
1989
        .overlay(
1990
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1991
                .stroke(tint.opacity(0.20), lineWidth: 1)
1992
        )
Bogdan Timofte authored 2 months ago
1993
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1994
        .gesture(
Bogdan Timofte authored 2 months ago
1995
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
1996
                .onEnded { value in
Bogdan Timofte authored 2 months ago
1997
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
1998
                }
1999
        )
Bogdan Timofte authored 2 months ago
2000
    }
2001

            
Bogdan Timofte authored 2 months ago
2002
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2003
        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
Bogdan Timofte authored 2 months ago
2004
    }
2005

            
Bogdan Timofte authored 2 months ago
2006
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2007
        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
Bogdan Timofte authored 2 months ago
2008
    }
Bogdan Timofte authored 2 months ago
2009

            
2010
    fileprivate func discontinuityMarkers(
2011
        points: [Measurements.Measurement.Point],
2012
        context: ChartContext
2013
    ) -> some View {
2014
        GeometryReader { geometry in
2015
            Path { path in
2016
                for point in points where point.isDiscontinuity {
2017
                    let markerX = context.placeInRect(
2018
                        point: CGPoint(
2019
                            x: point.timestamp.timeIntervalSince1970,
2020
                            y: context.origin.y
2021
                        )
2022
                    ).x * geometry.size.width
2023
                    path.move(to: CGPoint(x: markerX, y: 0))
2024
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
2025
                }
2026
            }
2027
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
2028
        }
2029
    }
Bogdan Timofte authored 2 months ago
2030

            
2031
}
2032

            
Bogdan Timofte authored a month ago
2033
private struct EmbeddedWidthKey: PreferenceKey {
2034
    static let defaultValue: CGFloat = 760
2035
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2036
        let next = nextValue()
2037
        if next > 0 { value = next }
2038
    }
2039
}
2040

            
Bogdan Timofte authored 2 months ago
2041
private struct TimeRangeSelectorView: View {
2042
    private enum DragTarget {
2043
        case lowerBound
2044
        case upperBound
2045
        case window
2046
    }
2047

            
2048
    private struct DragState {
2049
        let target: DragTarget
2050
        let initialRange: ClosedRange<Date>
2051
    }
2052

            
2053
    let points: [Measurements.Measurement.Point]
2054
    let context: ChartContext
2055
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
2056
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2057
    let compactLayout: Bool
Bogdan Timofte authored a month ago
2058
    let xAxisLabelCount: Int
Bogdan Timofte authored 2 months ago
2059
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2060
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2061

            
2062
    @Binding var selectedTimeRange: ClosedRange<Date>?
2063
    @Binding var isPinnedToPresent: Bool
2064
    @Binding var presentTrackingMode: PresentTrackingMode
2065
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
2066
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
2067

            
2068
    private var totalSpan: TimeInterval {
2069
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2070
    }
2071

            
2072
    private var currentRange: ClosedRange<Date> {
2073
        resolvedSelectionRange()
2074
    }
2075

            
2076
    private var trackHeight: CGFloat {
Bogdan Timofte authored a month ago
2077
        Self.trackHeight(compactLayout: compactLayout)
2078
    }
2079

            
2080
    private static func trackHeight(compactLayout: Bool) -> CGFloat {
2081
        compactLayout ? 42 : 50
Bogdan Timofte authored 2 months ago
2082
    }
2083

            
Bogdan Timofte authored a month ago
2084
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2085
        let rowHeight: CGFloat = compactLayout ? 28 : 32
Bogdan Timofte authored a month ago
2086
        let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2087
        let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
Bogdan Timofte authored a month ago
2088
        let spacing: CGFloat = compactLayout ? 6 : 8
Bogdan Timofte authored a month ago
2089
        return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
Bogdan Timofte authored a month ago
2090
    }
2091

            
Bogdan Timofte authored 2 months ago
2092
    private var cornerRadius: CGFloat {
2093
        compactLayout ? 14 : 16
2094
    }
2095

            
2096
    private var symbolButtonSize: CGFloat {
2097
        compactLayout ? 28 : 32
2098
    }
2099

            
2100
    var body: some View {
2101
        let coversFullRange = selectionCoversFullRange(currentRange)
2102

            
2103
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
2104
            if !coversFullRange || isPinnedToPresent {
2105
                HStack(spacing: 8) {
2106
                    alignmentButton(
2107
                        systemName: "arrow.left.to.line.compact",
2108
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2109
                        action: alignSelectionToLeadingEdge,
2110
                        accessibilityLabel: "Align selection to start"
2111
                    )
2112

            
2113
                    alignmentButton(
2114
                        systemName: "arrow.right.to.line.compact",
2115
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2116
                        action: alignSelectionToTrailingEdge,
2117
                        accessibilityLabel: "Align selection to present"
2118
                    )
2119

            
2120
                    Spacer(minLength: 0)
2121

            
2122
                    if isPinnedToPresent {
2123
                        trackingModeToggleButton()
2124
                    }
2125
                }
2126
            }
2127

            
Bogdan Timofte authored 2 months ago
2128
            HStack(spacing: 8) {
2129
                if !coversFullRange {
2130
                    actionButton(
Bogdan Timofte authored a month ago
2131
                        title: configuration.keepAction.title,
Bogdan Timofte authored a month ago
2132
                        shortTitle: configuration.keepAction.shortTitle,
Bogdan Timofte authored a month ago
2133
                        systemName: configuration.keepAction.systemName,
2134
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2135
                        action: {
Bogdan Timofte authored a month ago
2136
                            configuration.keepAction.handler(currentRange)
2137
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2138
                        }
2139
                    )
2140

            
Bogdan Timofte authored a month ago
2141
                    if let removeAction = configuration.removeAction {
2142
                        actionButton(
2143
                            title: removeAction.title,
Bogdan Timofte authored a month ago
2144
                            shortTitle: removeAction.shortTitle,
Bogdan Timofte authored a month ago
2145
                            systemName: removeAction.systemName,
2146
                            tone: removeAction.tone,
2147
                            action: {
2148
                                removeAction.handler(currentRange)
2149
                                resetSelectionState()
2150
                            }
2151
                        )
2152
                    }
Bogdan Timofte authored 2 months ago
2153
                }
2154

            
2155
                Spacer(minLength: 0)
2156

            
2157
                actionButton(
Bogdan Timofte authored a month ago
2158
                    title: configuration.resetAction.title,
Bogdan Timofte authored a month ago
2159
                    shortTitle: configuration.resetAction.shortTitle,
Bogdan Timofte authored a month ago
2160
                    systemName: configuration.resetAction.systemName,
2161
                    tone: configuration.resetAction.tone,
Bogdan Timofte authored 2 months ago
2162
                    action: {
2163
                        showResetConfirmation = true
2164
                    }
2165
                )
2166
            }
Bogdan Timofte authored a month ago
2167
            .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2168
                Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2169
                    configuration.resetAction.handler()
2170
                    resetSelectionState()
Bogdan Timofte authored 2 months ago
2171
                }
2172
                Button("Cancel", role: .cancel) {}
2173
            }
2174

            
Bogdan Timofte authored 2 months ago
2175
            GeometryReader { geometry in
2176
                let selectionFrame = selectionFrame(in: geometry.size)
2177
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2178

            
2179
                ZStack(alignment: .topLeading) {
2180
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2181
                        .fill(Color.primary.opacity(0.05))
2182

            
Bogdan Timofte authored a month ago
2183
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2184
                        points: points,
2185
                        context: context,
2186
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2187
                        strokeColor: selectorTint,
2188
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2189
                    )
2190
                    .opacity(0.94)
2191
                    .allowsHitTesting(false)
2192

            
Bogdan Timofte authored a month ago
2193
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2194
                        points: points,
2195
                        context: context,
Bogdan Timofte authored 2 months ago
2196
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2197
                    )
2198
                    .opacity(0.82)
2199
                    .allowsHitTesting(false)
2200

            
2201
                    if selectionFrame.minX > 0 {
2202
                        Rectangle()
2203
                            .fill(dimmingColor)
2204
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2205
                            .allowsHitTesting(false)
2206
                    }
2207

            
2208
                    if selectionFrame.maxX < geometry.size.width {
2209
                        Rectangle()
2210
                            .fill(dimmingColor)
2211
                            .frame(
2212
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2213
                                height: geometry.size.height
2214
                            )
2215
                            .offset(x: selectionFrame.maxX)
2216
                            .allowsHitTesting(false)
2217
                    }
2218

            
2219
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2220
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2221
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2222
                        .offset(x: selectionFrame.minX)
2223
                        .allowsHitTesting(false)
2224

            
2225
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2226
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2227
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2228
                        .offset(x: selectionFrame.minX)
2229
                        .allowsHitTesting(false)
2230

            
2231
                    handleView(height: max(geometry.size.height - 18, 16))
2232
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2233
                        .allowsHitTesting(false)
2234

            
2235
                    handleView(height: max(geometry.size.height - 18, 16))
2236
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2237
                        .allowsHitTesting(false)
2238
                }
2239
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2240
                .overlay(
2241
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
Bogdan Timofte authored a month ago
2242
                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2243
                )
2244
                .contentShape(Rectangle())
2245
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2246
            }
2247
            .frame(height: trackHeight)
2248

            
Bogdan Timofte authored a month ago
2249
            xAxisLabelsView
Bogdan Timofte authored 2 months ago
2250
        }
2251
    }
2252

            
2253
    private func handleView(height: CGFloat) -> some View {
2254
        Capsule(style: .continuous)
2255
            .fill(Color.white.opacity(0.95))
2256
            .frame(width: 6, height: height)
2257
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2258
    }
2259

            
2260
    private func alignmentButton(
2261
        systemName: String,
2262
        isActive: Bool,
2263
        action: @escaping () -> Void,
2264
        accessibilityLabel: String
2265
    ) -> some View {
2266
        Button(action: action) {
2267
            Image(systemName: systemName)
2268
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2269
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2270
        }
2271
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2272
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2273
        .background(
2274
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2275
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2276
        )
2277
        .overlay(
2278
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2279
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2280
        )
2281
        .accessibilityLabel(accessibilityLabel)
2282
    }
2283

            
2284
    private func trackingModeToggleButton() -> some View {
2285
        Button {
2286
            presentTrackingMode = presentTrackingMode == .keepDuration
2287
                ? .keepStartTimestamp
2288
                : .keepDuration
2289
        } label: {
2290
            Image(systemName: trackingModeSymbolName)
2291
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2292
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2293
        }
2294
        .buttonStyle(.plain)
2295
        .foregroundColor(.white)
2296
        .background(
2297
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2298
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2299
        )
2300
        .overlay(
2301
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2302
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2303
        )
2304
        .accessibilityLabel(trackingModeAccessibilityLabel)
2305
        .accessibilityHint("Toggles how the interval follows the present")
2306
    }
2307

            
Bogdan Timofte authored 2 months ago
2308
    private func actionButton(
2309
        title: String,
Bogdan Timofte authored a month ago
2310
        shortTitle: String? = nil,
Bogdan Timofte authored 2 months ago
2311
        systemName: String,
Bogdan Timofte authored a month ago
2312
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2313
        action: @escaping () -> Void
2314
    ) -> some View {
2315
        let foregroundColor: Color = {
2316
            switch tone {
2317
            case .reversible, .destructive:
2318
                return toneColor(for: tone)
2319
            case .destructiveProminent:
2320
                return .white
2321
            }
2322
        }()
Bogdan Timofte authored a month ago
2323
        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
Bogdan Timofte authored 2 months ago
2324

            
2325
        return Button(action: action) {
Bogdan Timofte authored a month ago
2326
            Label(displayTitle, systemImage: systemName)
Bogdan Timofte authored 2 months ago
2327
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2328
                .padding(.horizontal, compactLayout ? 10 : 12)
2329
                .padding(.vertical, compactLayout ? 7 : 8)
2330
        }
2331
        .buttonStyle(.plain)
2332
        .foregroundColor(foregroundColor)
2333
        .background(
2334
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2335
                .fill(actionButtonBackground(for: tone))
2336
        )
2337
        .overlay(
2338
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2339
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2340
        )
2341
    }
2342

            
Bogdan Timofte authored a month ago
2343
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2344
        switch tone {
2345
        case .reversible:
2346
            return selectorTint
2347
        case .destructive, .destructiveProminent:
2348
            return .red
2349
        }
2350
    }
2351

            
Bogdan Timofte authored a month ago
2352
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2353
        switch tone {
2354
        case .reversible:
2355
            return selectorTint.opacity(0.12)
2356
        case .destructive:
2357
            return Color.red.opacity(0.12)
2358
        case .destructiveProminent:
2359
            return Color.red.opacity(0.82)
2360
        }
2361
    }
2362

            
Bogdan Timofte authored a month ago
2363
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2364
        switch tone {
2365
        case .reversible:
2366
            return selectorTint.opacity(0.22)
2367
        case .destructive:
2368
            return Color.red.opacity(0.22)
2369
        case .destructiveProminent:
2370
            return Color.red.opacity(0.72)
2371
        }
2372
    }
2373

            
Bogdan Timofte authored 2 months ago
2374
    private var trackingModeSymbolName: String {
2375
        switch presentTrackingMode {
2376
        case .keepDuration:
2377
            return "arrow.left.and.right"
2378
        case .keepStartTimestamp:
2379
            return "arrow.left.to.line.compact"
2380
        }
2381
    }
2382

            
2383
    private var trackingModeAccessibilityLabel: String {
2384
        switch presentTrackingMode {
2385
        case .keepDuration:
2386
            return "Follow present keeping span"
2387
        case .keepStartTimestamp:
2388
            return "Follow present keeping start"
2389
        }
2390
    }
2391

            
2392
    private func alignSelectionToLeadingEdge() {
2393
        let alignedRange = normalizedSelectionRange(
2394
            availableTimeRange.lowerBound...currentRange.upperBound
2395
        )
2396
        applySelection(alignedRange, pinToPresent: false)
2397
    }
2398

            
2399
    private func alignSelectionToTrailingEdge() {
2400
        let alignedRange = normalizedSelectionRange(
2401
            currentRange.lowerBound...availableTimeRange.upperBound
2402
        )
2403
        applySelection(alignedRange, pinToPresent: true)
2404
    }
2405

            
2406
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2407
        DragGesture(minimumDistance: 0)
2408
            .onChanged { value in
2409
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2410
            }
2411
            .onEnded { _ in
2412
                dragState = nil
2413
            }
2414
    }
2415

            
2416
    private func updateSelectionDrag(
2417
        value: DragGesture.Value,
2418
        totalWidth: CGFloat
2419
    ) {
2420
        let startingRange = resolvedSelectionRange()
2421

            
2422
        if dragState == nil {
2423
            dragState = DragState(
2424
                target: dragTarget(
2425
                    for: value.startLocation.x,
2426
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2427
                ),
2428
                initialRange: startingRange
2429
            )
2430
        }
2431

            
2432
        guard let dragState else { return }
2433

            
2434
        let resultingRange = snappedToEdges(
2435
            adjustedRange(
2436
                from: dragState.initialRange,
2437
                target: dragState.target,
2438
                translationX: value.translation.width,
2439
                totalWidth: totalWidth
2440
            ),
2441
            target: dragState.target,
2442
            totalWidth: totalWidth
2443
        )
2444

            
2445
        applySelection(
2446
            resultingRange,
2447
            pinToPresent: shouldKeepPresentPin(
2448
                during: dragState.target,
2449
                initialRange: dragState.initialRange,
2450
                resultingRange: resultingRange
2451
            ),
2452
        )
2453
    }
2454

            
2455
    private func dragTarget(
2456
        for startX: CGFloat,
2457
        selectionFrame: CGRect
2458
    ) -> DragTarget {
2459
        let handleZone: CGFloat = compactLayout ? 20 : 24
2460

            
2461
        if abs(startX - selectionFrame.minX) <= handleZone {
2462
            return .lowerBound
2463
        }
2464

            
2465
        if abs(startX - selectionFrame.maxX) <= handleZone {
2466
            return .upperBound
2467
        }
2468

            
2469
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2470
            return .window
2471
        }
2472

            
2473
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2474
    }
2475

            
2476
    private func adjustedRange(
2477
        from initialRange: ClosedRange<Date>,
2478
        target: DragTarget,
2479
        translationX: CGFloat,
2480
        totalWidth: CGFloat
2481
    ) -> ClosedRange<Date> {
2482
        guard totalSpan > 0, totalWidth > 0 else {
2483
            return availableTimeRange
2484
        }
2485

            
2486
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2487
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2488

            
2489
        switch target {
2490
        case .lowerBound:
2491
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2492
            let newLowerBound = min(
2493
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2494
                maximumLowerBound
2495
            )
2496
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2497

            
2498
        case .upperBound:
2499
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2500
            let newUpperBound = max(
2501
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2502
                minimumUpperBound
2503
            )
2504
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2505

            
2506
        case .window:
2507
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2508
            guard span < totalSpan else { return availableTimeRange }
2509

            
2510
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2511
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2512

            
2513
            if lowerBound < availableTimeRange.lowerBound {
2514
                upperBound = upperBound.addingTimeInterval(
2515
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2516
                )
2517
                lowerBound = availableTimeRange.lowerBound
2518
            }
2519

            
2520
            if upperBound > availableTimeRange.upperBound {
2521
                lowerBound = lowerBound.addingTimeInterval(
2522
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2523
                )
2524
                upperBound = availableTimeRange.upperBound
2525
            }
2526

            
2527
            return normalizedSelectionRange(lowerBound...upperBound)
2528
        }
2529
    }
2530

            
2531
    private func snappedToEdges(
2532
        _ candidateRange: ClosedRange<Date>,
2533
        target: DragTarget,
2534
        totalWidth: CGFloat
2535
    ) -> ClosedRange<Date> {
2536
        guard totalSpan > 0 else {
2537
            return availableTimeRange
2538
        }
2539

            
2540
        let snapInterval = edgeSnapInterval(for: totalWidth)
2541
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2542
        var lowerBound = candidateRange.lowerBound
2543
        var upperBound = candidateRange.upperBound
2544

            
2545
        if target != .upperBound,
2546
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2547
            lowerBound = availableTimeRange.lowerBound
2548
            if target == .window {
2549
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2550
            }
2551
        }
2552

            
2553
        if target != .lowerBound,
2554
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2555
            upperBound = availableTimeRange.upperBound
2556
            if target == .window {
2557
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2558
            }
2559
        }
2560

            
2561
        return normalizedSelectionRange(lowerBound...upperBound)
2562
    }
2563

            
2564
    private func edgeSnapInterval(
2565
        for totalWidth: CGFloat
2566
    ) -> TimeInterval {
2567
        guard totalWidth > 0 else { return minimumSelectionSpan }
2568

            
2569
        let snapWidth = min(
2570
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2571
            totalWidth * 0.18
2572
        )
2573
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2574
        return min(
2575
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2576
            totalSpan / 4
2577
        )
2578
    }
2579

            
2580
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2581
        guard let selectedTimeRange else { return availableTimeRange }
2582

            
2583
        if isPinnedToPresent {
2584
            switch presentTrackingMode {
2585
            case .keepDuration:
2586
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2587
                return normalizedSelectionRange(
2588
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2589
                )
2590
            case .keepStartTimestamp:
2591
                return normalizedSelectionRange(
2592
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2593
                )
2594
            }
2595
        }
2596

            
2597
        return normalizedSelectionRange(selectedTimeRange)
2598
    }
2599

            
2600
    private func normalizedSelectionRange(
2601
        _ candidateRange: ClosedRange<Date>
2602
    ) -> ClosedRange<Date> {
2603
        let availableSpan = totalSpan
2604
        guard availableSpan > 0 else { return availableTimeRange }
2605

            
2606
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2607
        let requestedSpan = min(
2608
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2609
            availableSpan
2610
        )
2611

            
2612
        if requestedSpan >= availableSpan {
2613
            return availableTimeRange
2614
        }
2615

            
2616
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2617
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2618

            
2619
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2620
            if lowerBound == availableTimeRange.lowerBound {
2621
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2622
            } else {
2623
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2624
            }
2625
        }
2626

            
2627
        if upperBound > availableTimeRange.upperBound {
2628
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2629
            upperBound = availableTimeRange.upperBound
2630
            lowerBound = lowerBound.addingTimeInterval(-delta)
2631
        }
2632

            
2633
        if lowerBound < availableTimeRange.lowerBound {
2634
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2635
            lowerBound = availableTimeRange.lowerBound
2636
            upperBound = upperBound.addingTimeInterval(delta)
2637
        }
2638

            
2639
        return lowerBound...upperBound
2640
    }
2641

            
2642
    private func shouldKeepPresentPin(
2643
        during target: DragTarget,
2644
        initialRange: ClosedRange<Date>,
2645
        resultingRange: ClosedRange<Date>
2646
    ) -> Bool {
2647
        let startedPinnedToPresent =
2648
            isPinnedToPresent ||
2649
            selectionCoversFullRange(initialRange)
2650

            
2651
        guard startedPinnedToPresent else {
2652
            return selectionTouchesPresent(resultingRange)
2653
        }
2654

            
2655
        switch target {
2656
        case .lowerBound:
2657
            return true
2658
        case .upperBound, .window:
2659
            return selectionTouchesPresent(resultingRange)
2660
        }
2661
    }
2662

            
2663
    private func applySelection(
2664
        _ candidateRange: ClosedRange<Date>,
2665
        pinToPresent: Bool
2666
    ) {
2667
        let normalizedRange = normalizedSelectionRange(candidateRange)
2668

            
2669
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2670
            selectedTimeRange = nil
2671
        } else {
2672
            selectedTimeRange = normalizedRange
2673
        }
2674

            
2675
        isPinnedToPresent = pinToPresent
2676
    }
2677

            
Bogdan Timofte authored a month ago
2678
    private func resetSelectionState() {
2679
        selectedTimeRange = nil
2680
        isPinnedToPresent = false
2681
    }
2682

            
Bogdan Timofte authored 2 months ago
2683
    private func selectionTouchesPresent(
2684
        _ range: ClosedRange<Date>
2685
    ) -> Bool {
2686
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2687
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2688
    }
2689

            
2690
    private func selectionCoversFullRange(
2691
        _ range: ClosedRange<Date>
2692
    ) -> Bool {
2693
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2694
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2695
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2696
    }
2697

            
2698
    private func selectionFrame(in size: CGSize) -> CGRect {
2699
        selectionFrame(for: currentRange, width: size.width)
2700
    }
2701

            
2702
    private func selectionFrame(
2703
        for range: ClosedRange<Date>,
2704
        width: CGFloat
2705
    ) -> CGRect {
2706
        guard width > 0, totalSpan > 0 else {
2707
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2708
        }
2709

            
2710
        let minimumX = xPosition(for: range.lowerBound, width: width)
2711
        let maximumX = xPosition(for: range.upperBound, width: width)
2712
        return CGRect(
2713
            x: minimumX,
2714
            y: 0,
2715
            width: max(maximumX - minimumX, 2),
2716
            height: trackHeight
2717
        )
2718
    }
2719

            
2720
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2721
        guard width > 0, totalSpan > 0 else { return 0 }
2722

            
2723
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2724
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2725
        return CGFloat(normalizedOffset) * width
2726
    }
2727

            
Bogdan Timofte authored a month ago
2728
    private var xAxisLabelsView: some View {
2729
        let timeFormat: String = {
2730
            switch context.size.width {
2731
            case 0..<3600: return "HH:mm:ss"
2732
            case 3600...86400: return "HH:mm"
2733
            default: return "E HH:mm"
2734
            }
2735
        }()
Bogdan Timofte authored 2 months ago
2736

            
Bogdan Timofte authored a month ago
2737
        let labelCount = max(xAxisLabelCount, 2)
2738
        let labels = (1...labelCount).map {
2739
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat)
Bogdan Timofte authored 2 months ago
2740
        }
Bogdan Timofte authored a month ago
2741
        let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold)
2742

            
2743
        return GeometryReader { geometry in
2744
            let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1)
2745

            
2746
            ZStack(alignment: .topLeading) {
2747
                Path { path in
2748
                    for labelIndex in 1...labelCount {
2749
                        let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
2750
                        path.move(to: CGPoint(x: x, y: 0))
2751
                        path.addLine(to: CGPoint(x: x, y: 5))
2752
                    }
2753
                }
2754
                .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
2755

            
2756
                ForEach(Array(labels.enumerated()), id: \.offset) { item in
2757
                    let labelIndex = item.offset + 1
2758
                    let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
2759

            
2760
                    Text(item.element)
2761
                        .font(axisLabelFont)
2762
                        .monospacedDigit()
2763
                        .lineLimit(1)
2764
                        .minimumScaleFactor(0.74)
2765
                        .frame(width: labelWidth)
2766
                        .position(
2767
                            x: centerX,
2768
                            y: geometry.size.height * 0.66
2769
                        )
2770
                }
2771
            }
2772
        }
2773
        .frame(height: compactLayout ? 18 : 20)
2774
    }
2775

            
2776
    private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
2777
        context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width)
Bogdan Timofte authored 2 months ago
2778
    }
2779
}