USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
2823 lines | 103.583kb
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
Bogdan Timofte authored a month ago
169
    let showsTemperatureSeries: Bool
Bogdan Timofte authored a month ago
170
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored a month ago
171

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

            
179
    @State private var embeddedWidth: CGFloat = 760
180

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

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

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

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

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

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

            
255
    private var chartSectionSpacing: CGFloat {
256
        compactLayout ? 6 : 8
257
    }
258

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

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

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

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

            
289
    private enum OriginControlsPlacement {
290
        case aboveXAxisLegend
291
        case overXAxisLegend
292
        case belowXAxisLegend
293
    }
294

            
295
    private var originControlsPlacement: OriginControlsPlacement {
296
        if isIPhone {
297
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
298
        }
299
        return .belowXAxisLegend
300
    }
301

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

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

            
313
        if compactLayout {
314
            return min(max(availableSize.height * 0.36, 240), 300)
315
        }
316

            
317
        return min(max(availableSize.height * 0.5, 300), 440)
318
    }
319

            
320
    private var stackedToolbarLayout: Bool {
321
        if availableSize.width > 0 {
322
            return availableSize.width < 640
323
        }
324

            
325
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
326
    }
327

            
328
    private var showsLabeledOriginControls: Bool {
329
        !compactLayout && !stackedToolbarLayout
330
    }
331

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

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

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

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

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

            
Bogdan Timofte authored a month ago
383
    private func resetHiddenTemperatureDisplay() {
384
        guard !showsTemperatureSeries, displayTemperature else { return }
385
        displayTemperature = false
386
    }
387

            
Bogdan Timofte authored a month ago
388
    @ViewBuilder
389
    private var chartBody: some View {
Bogdan Timofte authored 2 months ago
390
        let availableTimeRange = availableSelectionTimeRange()
391
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
392
        let powerSeries = series(
393
            for: measurements.power,
394
            kind: .power,
395
            minimumYSpan: minimumPowerSpan,
396
            visibleTimeRange: visibleTimeRange
397
        )
Bogdan Timofte authored 2 months ago
398
        let energySeries = series(
399
            for: measurements.energy,
400
            kind: .energy,
401
            minimumYSpan: minimumEnergySpan,
402
            visibleTimeRange: visibleTimeRange
403
        )
Bogdan Timofte authored 2 months ago
404
        let voltageSeries = series(
405
            for: measurements.voltage,
406
            kind: .voltage,
407
            minimumYSpan: minimumVoltageSpan,
408
            visibleTimeRange: visibleTimeRange
409
        )
410
        let currentSeries = series(
411
            for: measurements.current,
412
            kind: .current,
413
            minimumYSpan: minimumCurrentSpan,
414
            visibleTimeRange: visibleTimeRange
415
        )
416
        let temperatureSeries = series(
417
            for: measurements.temperature,
418
            kind: .temperature,
419
            minimumYSpan: minimumTemperatureSpan,
420
            visibleTimeRange: visibleTimeRange
421
        )
Bogdan Timofte authored 2 months ago
422
        let primarySeries = displayedPrimarySeries(
423
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
424
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
425
            voltageSeries: voltageSeries,
426
            currentSeries: currentSeries
427
        )
Bogdan Timofte authored 2 months ago
428
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
429

            
Bogdan Timofte authored 2 months ago
430
        Group {
Bogdan Timofte authored 2 months ago
431
            if let primarySeries {
432
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
433
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
434

            
Bogdan Timofte authored a month ago
435
                    VStack(spacing: compactLayout ? 8 : 10) {
436
                        GeometryReader { geometry in
437
                            let reservedBottomHeight =
438
                                xAxisHeight
439
                                + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
440
                            let plotHeight = max(
441
                                geometry.size.height - reservedBottomHeight,
442
                                compactLayout ? 180 : 220
443
                            )
444

            
445
                            VStack(spacing: 6) {
446
                                HStack(spacing: chartSectionSpacing) {
447
                                    primaryAxisView(
448
                                        height: plotHeight,
449
                                        powerSeries: powerSeries,
450
                                        energySeries: energySeries,
451
                                        voltageSeries: voltageSeries,
452
                                        currentSeries: currentSeries
453
                                    )
454
                                    .frame(width: axisColumnWidth, height: plotHeight)
455

            
456
                                    ZStack {
457
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
458
                                            .fill(Color.primary.opacity(0.05))
459

            
460
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
461
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
462

            
463
                                        horizontalGuides(context: primarySeries.context)
464
                                        verticalGuides(context: primarySeries.context)
465
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
466
                                        renderedChart(
467
                                            powerSeries: powerSeries,
468
                                            energySeries: energySeries,
469
                                            voltageSeries: voltageSeries,
470
                                            currentSeries: currentSeries,
471
                                            temperatureSeries: temperatureSeries
472
                                        )
473
                                    }
474
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
475
                                    .frame(maxWidth: .infinity)
476
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
477

            
Bogdan Timofte authored a month ago
478
                                    secondaryAxisView(
479
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
480
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
481
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
482
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
483
                                        currentSeries: currentSeries,
484
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
485
                                    )
Bogdan Timofte authored a month ago
486
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
487
                                }
Bogdan Timofte authored a month ago
488
                                .overlay(alignment: .bottom) {
489
                                    if originControlsPlacement == .aboveXAxisLegend {
490
                                        scaleControlsPill(
491
                                            voltageSeries: voltageSeries,
492
                                            currentSeries: currentSeries
493
                                        )
494
                                        .padding(.bottom, compactLayout ? 6 : 10)
495
                                    }
Bogdan Timofte authored 2 months ago
496
                                }
Bogdan Timofte authored 2 months ago
497

            
Bogdan Timofte authored a month ago
498
                                switch originControlsPlacement {
499
                                case .aboveXAxisLegend:
500
                                    xAxisLabelsView(context: primarySeries.context)
501
                                        .frame(height: xAxisHeight)
502
                                case .overXAxisLegend:
503
                                    xAxisLabelsView(context: primarySeries.context)
504
                                        .frame(height: xAxisHeight)
505
                                        .overlay(alignment: .center) {
506
                                            scaleControlsPill(
507
                                                voltageSeries: voltageSeries,
508
                                                currentSeries: currentSeries
509
                                            )
510
                                            .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
511
                                        }
512
                                case .belowXAxisLegend:
513
                                    xAxisLabelsView(context: primarySeries.context)
514
                                        .frame(height: xAxisHeight)
515

            
516
                                    HStack {
517
                                        Spacer(minLength: 0)
Bogdan Timofte authored 2 months ago
518
                                        scaleControlsPill(
519
                                            voltageSeries: voltageSeries,
520
                                            currentSeries: currentSeries
521
                                        )
Bogdan Timofte authored a month ago
522
                                        Spacer(minLength: 0)
Bogdan Timofte authored 2 months ago
523
                                    }
524
                                }
525
                            }
Bogdan Timofte authored a month ago
526
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
527
                        }
528
                        .frame(height: plotSectionHeight)
529

            
530
                        if showsRangeSelector,
531
                           let availableTimeRange,
532
                           let selectorSeries,
533
                           shouldShowRangeSelector(
534
                            availableTimeRange: availableTimeRange,
535
                            series: selectorSeries
536
                           ) {
537
                            TimeRangeSelectorView(
538
                                points: selectorSeries.points,
539
                                context: selectorSeries.context,
Bogdan Timofte authored 2 months ago
540
                                availableTimeRange: availableTimeRange,
Bogdan Timofte authored a month ago
541
                                selectorTint: selectorTint,
542
                                compactLayout: compactLayout,
Bogdan Timofte authored a month ago
543
                                xAxisLabelCount: xLabels,
Bogdan Timofte authored a month ago
544
                                minimumSelectionSpan: minimumTimeSpan,
545
                                configuration: resolvedRangeSelectorConfiguration(),
546
                                selectedTimeRange: $selectedVisibleTimeRange,
547
                                isPinnedToPresent: $isPinnedToPresent,
548
                                presentTrackingMode: $presentTrackingMode
549
                            )
Bogdan Timofte authored 2 months ago
550
                        }
551
                    }
552
                }
Bogdan Timofte authored 2 months ago
553
            } else {
554
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
555
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
556
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
557
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
558
                }
559
            }
Bogdan Timofte authored 2 months ago
560
        }
Bogdan Timofte authored 2 months ago
561
        .font(chartBaseFont)
Bogdan Timofte authored 2 months ago
562
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
563
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
564
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
565
            chartNow = now
566
        }
Bogdan Timofte authored 2 months ago
567
    }
568

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

            
Bogdan Timofte authored 2 months ago
573
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
574
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
575
        }
576
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
577
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
578
        .background(
579
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
580
                .fill(Color.primary.opacity(0.045))
581
        )
582
        .overlay(
583
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
584
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
585
        )
Bogdan Timofte authored 2 months ago
586

            
Bogdan Timofte authored 2 months ago
587
        return Group {
Bogdan Timofte authored 2 months ago
588
            if stackedToolbarLayout {
Bogdan Timofte authored 2 months ago
589
                controlsPanel
Bogdan Timofte authored 2 months ago
590
            } else {
Bogdan Timofte authored 2 months ago
591
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
592
                    controlsPanel
Bogdan Timofte authored 2 months ago
593
                }
Bogdan Timofte authored 2 months ago
594
            }
595
        }
596
        .frame(maxWidth: .infinity, alignment: .leading)
597
    }
598

            
Bogdan Timofte authored 2 months ago
599
    private var shouldFloatScaleControlsOverChart: Bool {
600
        #if os(iOS)
601
        if availableSize.width > 0, availableSize.height > 0 {
602
            return availableSize.width > availableSize.height
603
        }
604
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
605
        #else
606
        return false
607
        #endif
608
    }
609

            
610
    private func scaleControlsPill(
611
        voltageSeries: SeriesData,
612
        currentSeries: SeriesData
613
    ) -> some View {
614
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
615
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 2 months ago
616
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
617
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
618

            
619
        return originControlsRow(
620
            voltageSeries: voltageSeries,
621
            currentSeries: currentSeries,
622
            condensedLayout: condensedLayout,
623
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
624
        )
Bogdan Timofte authored 2 months ago
625
        .padding(.horizontal, horizontalPadding)
626
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 2 months ago
627
        .background(
628
            Capsule(style: .continuous)
Bogdan Timofte authored 2 months ago
629
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 2 months ago
630
        )
631
        .overlay(
632
            Capsule(style: .continuous)
633
                .stroke(
Bogdan Timofte authored 2 months ago
634
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 2 months ago
635
                    lineWidth: 1
636
                )
637
        )
638
    }
639

            
Bogdan Timofte authored 2 months ago
640
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
641
        HStack(spacing: condensedLayout ? 6 : 8) {
642
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
643
                displayVoltage.toggle()
644
                if displayVoltage {
645
                    displayPower = false
Bogdan Timofte authored 2 months ago
646
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
647
                    if displayTemperature && displayCurrent {
648
                        displayCurrent = false
649
                    }
Bogdan Timofte authored 2 months ago
650
                }
651
            }
652

            
653
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
654
                displayCurrent.toggle()
655
                if displayCurrent {
656
                    displayPower = false
Bogdan Timofte authored 2 months ago
657
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
658
                    if displayTemperature && displayVoltage {
659
                        displayVoltage = false
660
                    }
Bogdan Timofte authored 2 months ago
661
                }
Bogdan Timofte authored 2 months ago
662
            }
663

            
664
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
665
                displayPower.toggle()
666
                if displayPower {
Bogdan Timofte authored 2 months ago
667
                    displayEnergy = false
668
                    displayCurrent = false
669
                    displayVoltage = false
670
                }
671
            }
672

            
673
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
674
                displayEnergy.toggle()
675
                if displayEnergy {
676
                    displayPower = false
Bogdan Timofte authored 2 months ago
677
                    displayCurrent = false
678
                    displayVoltage = false
679
                }
680
            }
Bogdan Timofte authored 2 months ago
681

            
Bogdan Timofte authored a month ago
682
            if showsTemperatureSeries {
683
                seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
684
                    displayTemperature.toggle()
685
                    if displayTemperature && displayVoltage && displayCurrent {
686
                        displayCurrent = false
687
                    }
Bogdan Timofte authored 2 months ago
688
                }
689
            }
Bogdan Timofte authored 2 months ago
690
        }
691
    }
692

            
693
    private func originControlsRow(
694
        voltageSeries: SeriesData,
695
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
696
        condensedLayout: Bool,
697
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
698
    ) -> some View {
Bogdan Timofte authored 2 months ago
699
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
700
            if supportsSharedOrigin {
701
                symbolControlChip(
702
                    systemImage: "equal.circle",
703
                    enabled: true,
704
                    active: useSharedOrigin,
705
                    condensedLayout: condensedLayout,
706
                    showsLabel: showsLabel,
707
                    label: "Match Y Scale",
708
                    accessibilityLabel: "Match Y scale"
709
                ) {
710
                    toggleSharedOrigin(
711
                        voltageSeries: voltageSeries,
712
                        currentSeries: currentSeries
713
                    )
714
                }
Bogdan Timofte authored 2 months ago
715
            }
716

            
717
            symbolControlChip(
718
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
719
                enabled: true,
720
                active: pinOrigin,
721
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
722
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
723
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
724
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
725
            ) {
726
                togglePinnedOrigin(
727
                    voltageSeries: voltageSeries,
728
                    currentSeries: currentSeries
729
                )
730
            }
731

            
Bogdan Timofte authored 2 months ago
732
            if !pinnedOriginIsZero {
733
                symbolControlChip(
734
                    systemImage: "0.circle",
735
                    enabled: true,
736
                    active: false,
737
                    condensedLayout: condensedLayout,
738
                    showsLabel: showsLabel,
739
                    label: "Origin 0",
740
                    accessibilityLabel: "Set origin to zero"
741
                ) {
742
                    setVisibleOriginsToZero()
743
                }
Bogdan Timofte authored 2 months ago
744
            }
Bogdan Timofte authored 2 months ago
745

            
Bogdan Timofte authored 2 months ago
746
            smoothingControlChip(
747
                condensedLayout: condensedLayout,
748
                showsLabel: showsLabel
749
            )
750

            
Bogdan Timofte authored 2 months ago
751
        }
752
    }
753

            
Bogdan Timofte authored 2 months ago
754
    private func smoothingControlChip(
755
        condensedLayout: Bool,
756
        showsLabel: Bool
757
    ) -> some View {
758
        Menu {
759
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
760
                Button {
761
                    smoothingLevel = level
762
                } label: {
763
                    if smoothingLevel == level {
764
                        Label(level.label, systemImage: "checkmark")
765
                    } else {
766
                        Text(level.label)
Bogdan Timofte authored 2 months ago
767
                    }
768
                }
Bogdan Timofte authored 2 months ago
769
            }
770
        } label: {
771
            Group {
772
                if showsLabel {
773
                    VStack(alignment: .leading, spacing: 2) {
774
                        Label("Smoothing", systemImage: "waveform.path")
775
                            .font(controlChipFont(condensedLayout: condensedLayout))
776

            
777
                        Text(
Bogdan Timofte authored 2 months ago
778
                            smoothingLevel == .off
Bogdan Timofte authored 2 months ago
779
                            ? "Off"
780
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
781
                        )
Bogdan Timofte authored 2 months ago
782
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
783
                        .foregroundColor(.secondary)
784
                        .monospacedDigit()
785
                    }
786
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
787
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
788
                } else {
789
                    VStack(spacing: 1) {
790
                        Image(systemName: "waveform.path")
791
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
792

            
793
                        Text(smoothingLevel.shortLabel)
794
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
795
                            .monospacedDigit()
796
                    }
797
                    .frame(
798
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
799
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
800
                    )
801
                }
Bogdan Timofte authored 2 months ago
802
            }
Bogdan Timofte authored 2 months ago
803
            .background(
804
                Capsule(style: .continuous)
805
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
806
            )
807
            .overlay(
808
                Capsule(style: .continuous)
809
                    .stroke(
810
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
811
                        lineWidth: 1
812
                    )
813
            )
Bogdan Timofte authored 2 months ago
814
        }
Bogdan Timofte authored 2 months ago
815
        .buttonStyle(.plain)
816
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
817
    }
818

            
Bogdan Timofte authored 2 months ago
819
    private func seriesToggleButton(
820
        title: String,
821
        isOn: Bool,
822
        condensedLayout: Bool,
823
        action: @escaping () -> Void
824
    ) -> some View {
825
        Button(action: action) {
826
            Text(title)
Bogdan Timofte authored 2 months ago
827
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
828
                .lineLimit(1)
829
                .minimumScaleFactor(0.82)
830
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
831
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
832
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
833
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
834
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
835
                .background(
836
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
837
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
838
                )
839
                .overlay(
840
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
841
                        .stroke(Color.blue, lineWidth: 1.5)
842
                )
843
        }
844
        .buttonStyle(.plain)
845
    }
846

            
847
    private func symbolControlChip(
848
        systemImage: String,
849
        enabled: Bool,
850
        active: Bool,
851
        condensedLayout: Bool,
852
        showsLabel: Bool,
853
        label: String,
854
        accessibilityLabel: String,
855
        action: @escaping () -> Void
856
    ) -> some View {
857
        Button(action: {
858
            action()
859
        }) {
860
            Group {
861
                if showsLabel {
862
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
863
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
864
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
865
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
866
                } else {
867
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
868
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
869
                        .frame(
Bogdan Timofte authored 2 months ago
870
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
871
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
872
                        )
Bogdan Timofte authored 2 months ago
873
                }
874
            }
875
                .background(
876
                    Capsule(style: .continuous)
877
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
878
                )
879
        }
880
        .buttonStyle(.plain)
881
        .foregroundColor(enabled ? .primary : .secondary)
882
        .opacity(enabled ? 1 : 0.55)
883
        .accessibilityLabel(accessibilityLabel)
884
    }
885

            
Bogdan Timofte authored 2 months ago
886
    private func resetBuffer() {
887
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
888
    }
889

            
Bogdan Timofte authored a month ago
890
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
891
        if let rangeSelectorConfiguration {
892
            return rangeSelectorConfiguration
893
        }
894

            
895
        return MeasurementChartRangeSelectorConfiguration(
896
            keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
897
                title: "Keep Selection",
898
                shortTitle: "Keep",
Bogdan Timofte authored a month ago
899
                systemName: "scissors",
900
                tone: .destructive,
901
                handler: trimBufferToSelection
902
            ),
903
            removeAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
904
                title: "Remove Selection",
905
                shortTitle: "Cut",
Bogdan Timofte authored a month ago
906
                systemName: "minus.circle",
907
                tone: .destructive,
908
                handler: removeSelectionFromBuffer
909
            ),
910
            resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
911
                title: "Reset Buffer",
912
                shortTitle: "Reset",
Bogdan Timofte authored a month ago
913
                systemName: "trash",
914
                tone: .destructiveProminent,
915
                confirmationTitle: "Reset captured measurements?",
916
                confirmationButtonTitle: "Reset buffer",
917
                handler: resetBuffer
918
            )
919
        )
920
    }
921

            
Bogdan Timofte authored 2 months ago
922
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
923
        if isLargeDisplay {
924
            return .body.weight(.semibold)
925
        }
926
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
927
    }
928

            
929
    private func controlChipFont(condensedLayout: Bool) -> Font {
930
        if isLargeDisplay {
931
            return .callout.weight(.semibold)
932
        }
933
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
934
    }
935

            
Bogdan Timofte authored 2 months ago
936
    @ViewBuilder
937
    private func primaryAxisView(
938
        height: CGFloat,
Bogdan Timofte authored 2 months ago
939
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
940
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
941
        voltageSeries: SeriesData,
942
        currentSeries: SeriesData
Bogdan Timofte authored 2 months ago
943
    ) -> some View {
944
        if displayPower {
945
            yAxisLabelsView(
946
                height: height,
947
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
948
                seriesKind: .power,
949
                measurementUnit: powerSeries.kind.unit,
950
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
951
            )
Bogdan Timofte authored 2 months ago
952
        } else if displayEnergy {
953
            yAxisLabelsView(
954
                height: height,
955
                context: energySeries.context,
956
                seriesKind: .energy,
957
                measurementUnit: energySeries.kind.unit,
958
                tint: energySeries.kind.tint
959
            )
Bogdan Timofte authored 2 months ago
960
        } else if displayVoltage {
961
            yAxisLabelsView(
962
                height: height,
963
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
964
                seriesKind: .voltage,
965
                measurementUnit: voltageSeries.kind.unit,
966
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
967
            )
968
        } else if displayCurrent {
969
            yAxisLabelsView(
970
                height: height,
971
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
972
                seriesKind: .current,
973
                measurementUnit: currentSeries.kind.unit,
974
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
975
            )
976
        }
977
    }
978

            
979
    @ViewBuilder
980
    private func renderedChart(
Bogdan Timofte authored 2 months ago
981
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
982
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
983
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
984
        currentSeries: SeriesData,
985
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
986
    ) -> some View {
987
        if self.displayPower {
Bogdan Timofte authored a month ago
988
            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
Bogdan Timofte authored 2 months ago
989
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
990
        } else if self.displayEnergy {
Bogdan Timofte authored a month ago
991
            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
Bogdan Timofte authored 2 months ago
992
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
993
        } else {
994
            if self.displayVoltage {
Bogdan Timofte authored a month ago
995
                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
Bogdan Timofte authored 2 months ago
996
                    .opacity(0.78)
997
            }
998
            if self.displayCurrent {
Bogdan Timofte authored a month ago
999
                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1000
                    .opacity(0.78)
1001
            }
1002
        }
Bogdan Timofte authored 2 months ago
1003

            
1004
        if displayTemperature {
Bogdan Timofte authored a month ago
1005
            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1006
                .opacity(0.86)
1007
        }
Bogdan Timofte authored 2 months ago
1008
    }
1009

            
1010
    @ViewBuilder
1011
    private func secondaryAxisView(
1012
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1013
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1014
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1015
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1016
        currentSeries: SeriesData,
1017
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
1018
    ) -> some View {
Bogdan Timofte authored 2 months ago
1019
        if displayTemperature {
1020
            yAxisLabelsView(
1021
                height: height,
1022
                context: temperatureSeries.context,
1023
                seriesKind: .temperature,
1024
                measurementUnit: measurementUnit(for: .temperature),
1025
                tint: temperatureSeries.kind.tint
1026
            )
1027
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
1028
            yAxisLabelsView(
1029
                height: height,
1030
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1031
                seriesKind: .current,
1032
                measurementUnit: currentSeries.kind.unit,
1033
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1034
            )
1035
        } else {
1036
            primaryAxisView(
1037
                height: height,
1038
                powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
1039
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
1040
                voltageSeries: voltageSeries,
1041
                currentSeries: currentSeries
1042
            )
Bogdan Timofte authored 2 months ago
1043
        }
1044
    }
Bogdan Timofte authored 2 months ago
1045

            
1046
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
1047
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1048
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1049
        voltageSeries: SeriesData,
1050
        currentSeries: SeriesData
1051
    ) -> SeriesData? {
Bogdan Timofte authored 2 months ago
1052
        if displayPower {
Bogdan Timofte authored 2 months ago
1053
            return powerSeries
Bogdan Timofte authored 2 months ago
1054
        }
Bogdan Timofte authored 2 months ago
1055
        if displayEnergy {
1056
            return energySeries
1057
        }
Bogdan Timofte authored 2 months ago
1058
        if displayVoltage {
Bogdan Timofte authored 2 months ago
1059
            return voltageSeries
Bogdan Timofte authored 2 months ago
1060
        }
1061
        if displayCurrent {
Bogdan Timofte authored 2 months ago
1062
            return currentSeries
Bogdan Timofte authored 2 months ago
1063
        }
1064
        return nil
1065
    }
1066

            
1067
    private func series(
1068
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
1069
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
1070
        minimumYSpan: Double,
1071
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1072
    ) -> SeriesData {
Bogdan Timofte authored 2 months ago
1073
        let rawPoints = filteredPoints(
Bogdan Timofte authored 2 months ago
1074
            measurement,
1075
            visibleTimeRange: visibleTimeRange
1076
        )
Bogdan Timofte authored a month ago
1077
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1078
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
1079
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1080
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
1081

            
1082
        let autoBounds = automaticYBounds(
1083
            for: samplePoints,
1084
            minimumYSpan: minimumYSpan
1085
        )
Bogdan Timofte authored 2 months ago
1086
        let xBounds = xBounds(
1087
            for: samplePoints,
1088
            visibleTimeRange: visibleTimeRange
1089
        )
Bogdan Timofte authored 2 months ago
1090
        let lowerBound = resolvedLowerBound(
1091
            for: kind,
1092
            autoLowerBound: autoBounds.lowerBound
1093
        )
1094
        let upperBound = resolvedUpperBound(
1095
            for: kind,
1096
            lowerBound: lowerBound,
1097
            autoUpperBound: autoBounds.upperBound,
1098
            maximumSampleValue: samplePoints.map(\.value).max(),
1099
            minimumYSpan: minimumYSpan
1100
        )
1101

            
1102
        context.setBounds(
1103
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1104
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1105
            yMin: CGFloat(lowerBound),
1106
            yMax: CGFloat(upperBound)
1107
        )
1108

            
1109
        return SeriesData(
1110
            kind: kind,
1111
            points: points,
1112
            samplePoints: samplePoints,
1113
            context: context,
1114
            autoLowerBound: autoBounds.lowerBound,
1115
            autoUpperBound: autoBounds.upperBound,
1116
            maximumSampleValue: samplePoints.map(\.value).max()
1117
        )
1118
    }
1119

            
Bogdan Timofte authored a month ago
1120
    private func normalizedPoints(
1121
        _ points: [Measurements.Measurement.Point],
1122
        for kind: SeriesKind
1123
    ) -> [Measurements.Measurement.Point] {
1124
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1125
            return points
1126
        }
1127

            
1128
        guard let baseline = points.first(where: \.isSample)?.value else {
1129
            return points
1130
        }
1131

            
1132
        return points.enumerated().map { index, point in
1133
            Measurements.Measurement.Point(
1134
                id: point.id == index ? point.id : index,
1135
                timestamp: point.timestamp,
1136
                value: point.value - baseline,
1137
                kind: point.kind
1138
            )
1139
        }
1140
    }
1141

            
Bogdan Timofte authored 2 months ago
1142
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1143
        series(
1144
            for: measurement(for: kind),
1145
            kind: kind,
1146
            minimumYSpan: minimumYSpan(for: kind)
1147
        )
1148
    }
1149

            
Bogdan Timofte authored 2 months ago
1150
    private func smoothedPoints(
1151
        from points: [Measurements.Measurement.Point]
1152
    ) -> [Measurements.Measurement.Point] {
1153
        guard smoothingLevel != .off else { return points }
1154

            
1155
        var smoothedPoints: [Measurements.Measurement.Point] = []
1156
        var currentSegment: [Measurements.Measurement.Point] = []
1157

            
1158
        func flushCurrentSegment() {
1159
            guard !currentSegment.isEmpty else { return }
1160

            
1161
            for point in smoothedSegment(currentSegment) {
1162
                smoothedPoints.append(
1163
                    Measurements.Measurement.Point(
1164
                        id: smoothedPoints.count,
1165
                        timestamp: point.timestamp,
1166
                        value: point.value,
1167
                        kind: .sample
1168
                    )
1169
                )
1170
            }
1171

            
1172
            currentSegment.removeAll(keepingCapacity: true)
1173
        }
1174

            
1175
        for point in points {
1176
            if point.isDiscontinuity {
1177
                flushCurrentSegment()
1178

            
1179
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1180
                    smoothedPoints.append(
1181
                        Measurements.Measurement.Point(
1182
                            id: smoothedPoints.count,
1183
                            timestamp: point.timestamp,
1184
                            value: smoothedPoints.last?.value ?? point.value,
1185
                            kind: .discontinuity
1186
                        )
1187
                    )
1188
                }
1189
            } else {
1190
                currentSegment.append(point)
1191
            }
1192
        }
1193

            
1194
        flushCurrentSegment()
1195
        return smoothedPoints
1196
    }
1197

            
1198
    private func smoothedSegment(
1199
        _ segment: [Measurements.Measurement.Point]
1200
    ) -> [Measurements.Measurement.Point] {
1201
        let windowSize = smoothingLevel.movingAverageWindowSize
1202
        guard windowSize > 1, segment.count > 2 else { return segment }
1203

            
1204
        let radius = windowSize / 2
1205
        var prefixSums: [Double] = [0]
1206
        prefixSums.reserveCapacity(segment.count + 1)
1207

            
1208
        for point in segment {
1209
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1210
        }
1211

            
1212
        return segment.enumerated().map { index, point in
1213
            let lowerBound = max(0, index - radius)
1214
            let upperBound = min(segment.count - 1, index + radius)
1215
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1216
            let average = sum / Double(upperBound - lowerBound + 1)
1217

            
1218
            return Measurements.Measurement.Point(
1219
                id: point.id,
1220
                timestamp: point.timestamp,
1221
                value: average,
1222
                kind: .sample
1223
            )
1224
        }
1225
    }
1226

            
Bogdan Timofte authored 2 months ago
1227
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1228
        switch kind {
1229
        case .power:
1230
            return measurements.power
Bogdan Timofte authored 2 months ago
1231
        case .energy:
1232
            return measurements.energy
Bogdan Timofte authored 2 months ago
1233
        case .voltage:
1234
            return measurements.voltage
1235
        case .current:
1236
            return measurements.current
1237
        case .temperature:
1238
            return measurements.temperature
1239
        }
1240
    }
1241

            
1242
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1243
        switch kind {
1244
        case .power:
1245
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1246
        case .energy:
1247
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1248
        case .voltage:
1249
            return minimumVoltageSpan
1250
        case .current:
1251
            return minimumCurrentSpan
1252
        case .temperature:
1253
            return minimumTemperatureSpan
1254
        }
1255
    }
1256

            
Bogdan Timofte authored 2 months ago
1257
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 2 months ago
1258
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored 2 months ago
1259
    }
1260

            
Bogdan Timofte authored 2 months ago
1261
    private var minimumSharedScaleSpan: Double {
1262
        max(minimumVoltageSpan, minimumCurrentSpan)
1263
    }
1264

            
Bogdan Timofte authored 2 months ago
1265
    private var pinnedOriginIsZero: Bool {
1266
        if useSharedOrigin && supportsSharedOrigin {
1267
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1268
        }
Bogdan Timofte authored 2 months ago
1269

            
1270
        if displayPower {
1271
            return pinOrigin && powerAxisOrigin == 0
1272
        }
1273

            
Bogdan Timofte authored 2 months ago
1274
        if displayEnergy {
1275
            return pinOrigin && energyAxisOrigin == 0
1276
        }
1277

            
Bogdan Timofte authored 2 months ago
1278
        let visibleOrigins = [
1279
            displayVoltage ? voltageAxisOrigin : nil,
1280
            displayCurrent ? currentAxisOrigin : nil
1281
        ]
1282
        .compactMap { $0 }
1283

            
1284
        guard !visibleOrigins.isEmpty else { return false }
1285
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1286
    }
1287

            
1288
    private func toggleSharedOrigin(
1289
        voltageSeries: SeriesData,
1290
        currentSeries: SeriesData
1291
    ) {
1292
        guard supportsSharedOrigin else { return }
1293

            
1294
        if useSharedOrigin {
1295
            useSharedOrigin = false
1296
            return
1297
        }
1298

            
1299
        captureCurrentOrigins(
1300
            voltageSeries: voltageSeries,
1301
            currentSeries: currentSeries
1302
        )
1303
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1304
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1305
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1306
        useSharedOrigin = true
1307
        pinOrigin = true
1308
    }
1309

            
1310
    private func togglePinnedOrigin(
1311
        voltageSeries: SeriesData,
1312
        currentSeries: SeriesData
1313
    ) {
1314
        if pinOrigin {
1315
            pinOrigin = false
1316
            return
1317
        }
1318

            
1319
        captureCurrentOrigins(
1320
            voltageSeries: voltageSeries,
1321
            currentSeries: currentSeries
1322
        )
1323
        pinOrigin = true
1324
    }
1325

            
1326
    private func setVisibleOriginsToZero() {
1327
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1328
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1329
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1330
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1331
            voltageAxisOrigin = 0
1332
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1333
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1334
        } else {
1335
            if displayPower {
1336
                powerAxisOrigin = 0
1337
            }
Bogdan Timofte authored 2 months ago
1338
            if displayEnergy {
1339
                energyAxisOrigin = 0
1340
            }
Bogdan Timofte authored 2 months ago
1341
            if displayVoltage {
1342
                voltageAxisOrigin = 0
1343
            }
1344
            if displayCurrent {
1345
                currentAxisOrigin = 0
1346
            }
Bogdan Timofte authored 2 months ago
1347
            if displayTemperature {
1348
                temperatureAxisOrigin = 0
1349
            }
Bogdan Timofte authored 2 months ago
1350
        }
1351

            
1352
        pinOrigin = true
1353
    }
1354

            
1355
    private func captureCurrentOrigins(
1356
        voltageSeries: SeriesData,
1357
        currentSeries: SeriesData
1358
    ) {
1359
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1360
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1361
        voltageAxisOrigin = voltageSeries.autoLowerBound
1362
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1363
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored 2 months ago
1364
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1365
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1366
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1367
    }
1368

            
1369
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1370
        let visibleTimeRange = activeVisibleTimeRange
1371

            
Bogdan Timofte authored 2 months ago
1372
        switch kind {
1373
        case .power:
Bogdan Timofte authored 2 months ago
1374
            return pinOrigin
1375
                ? powerAxisOrigin
1376
                : automaticYBounds(
1377
                    for: filteredSamplePoints(
1378
                        measurements.power,
1379
                        visibleTimeRange: visibleTimeRange
1380
                    ),
1381
                    minimumYSpan: minimumPowerSpan
1382
                ).lowerBound
Bogdan Timofte authored 2 months ago
1383
        case .energy:
1384
            return pinOrigin
1385
                ? energyAxisOrigin
1386
                : automaticYBounds(
1387
                    for: filteredSamplePoints(
1388
                        measurements.energy,
1389
                        visibleTimeRange: visibleTimeRange
1390
                    ),
1391
                    minimumYSpan: minimumEnergySpan
1392
                ).lowerBound
Bogdan Timofte authored 2 months ago
1393
        case .voltage:
1394
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1395
                return sharedAxisOrigin
1396
            }
Bogdan Timofte authored 2 months ago
1397
            return pinOrigin
1398
                ? voltageAxisOrigin
1399
                : automaticYBounds(
1400
                    for: filteredSamplePoints(
1401
                        measurements.voltage,
1402
                        visibleTimeRange: visibleTimeRange
1403
                    ),
1404
                    minimumYSpan: minimumVoltageSpan
1405
                ).lowerBound
Bogdan Timofte authored 2 months ago
1406
        case .current:
1407
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1408
                return sharedAxisOrigin
1409
            }
Bogdan Timofte authored 2 months ago
1410
            return pinOrigin
1411
                ? currentAxisOrigin
1412
                : automaticYBounds(
1413
                    for: filteredSamplePoints(
1414
                        measurements.current,
1415
                        visibleTimeRange: visibleTimeRange
1416
                    ),
1417
                    minimumYSpan: minimumCurrentSpan
1418
                ).lowerBound
Bogdan Timofte authored 2 months ago
1419
        case .temperature:
Bogdan Timofte authored 2 months ago
1420
            return pinOrigin
1421
                ? temperatureAxisOrigin
1422
                : automaticYBounds(
1423
                    for: filteredSamplePoints(
1424
                        measurements.temperature,
1425
                        visibleTimeRange: visibleTimeRange
1426
                    ),
1427
                    minimumYSpan: minimumTemperatureSpan
1428
                ).lowerBound
Bogdan Timofte authored 2 months ago
1429
        }
1430
    }
1431

            
Bogdan Timofte authored 2 months ago
1432
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1433
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1434
    }
1435

            
1436
    private func filteredPoints(
1437
        _ measurement: Measurements.Measurement,
1438
        visibleTimeRange: ClosedRange<Date>? = nil
1439
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1440
        let resolvedRange: ClosedRange<Date>?
1441

            
1442
        switch (timeRange, visibleTimeRange) {
1443
        case let (baseRange?, visibleRange?):
1444
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1445
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1446
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1447
        case let (baseRange?, nil):
1448
            resolvedRange = baseRange
1449
        case let (nil, visibleRange?):
1450
            resolvedRange = visibleRange
1451
        case (nil, nil):
1452
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1453
        }
Bogdan Timofte authored 2 months ago
1454

            
1455
        guard let resolvedRange else {
1456
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1457
        }
1458

            
1459
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1460
    }
1461

            
1462
    private func filteredSamplePoints(
1463
        _ measurement: Measurements.Measurement,
1464
        visibleTimeRange: ClosedRange<Date>? = nil
1465
    ) -> [Measurements.Measurement.Point] {
1466
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1467
            point.isSample
Bogdan Timofte authored 2 months ago
1468
        }
1469
    }
1470

            
1471
    private func xBounds(
Bogdan Timofte authored 2 months ago
1472
        for samplePoints: [Measurements.Measurement.Point],
1473
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1474
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1475
        if let visibleTimeRange {
1476
            return normalizedTimeRange(visibleTimeRange)
1477
        }
1478

            
Bogdan Timofte authored 2 months ago
1479
        if let timeRange {
Bogdan Timofte authored 2 months ago
1480
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1481
        }
1482

            
1483
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1484
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1485

            
Bogdan Timofte authored 2 months ago
1486
        return normalizedTimeRange(lowerBound...upperBound)
1487
    }
1488

            
1489
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1490
        if let timeRange {
1491
            return normalizedTimeRange(timeRange)
1492
        }
1493

            
1494
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1495
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1496
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1497
            return nil
1498
        }
1499

            
Bogdan Timofte authored a month ago
1500
        let latestSampleTimestamp = samplePoints.last?.timestamp
1501
        let resolvedUpperBound = timeRangeUpperBound ?? {
1502
            guard extendsTimelineToPresent else {
1503
                return latestSampleTimestamp ?? lowerBound
1504
            }
1505
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1506
        }()
1507
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1508
        return normalizedTimeRange(lowerBound...upperBound)
1509
    }
1510

            
1511
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1512
        let candidates = [
1513
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1514
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1515
            filteredSamplePoints(measurements.voltage),
1516
            filteredSamplePoints(measurements.current),
1517
            filteredSamplePoints(measurements.temperature)
1518
        ]
1519

            
1520
        return candidates.first(where: { !$0.isEmpty }) ?? []
1521
    }
1522

            
1523
    private func resolvedVisibleTimeRange(
1524
        within availableTimeRange: ClosedRange<Date>?
1525
    ) -> ClosedRange<Date>? {
1526
        guard let availableTimeRange else { return nil }
1527
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1528

            
1529
        if isPinnedToPresent {
1530
            let pinnedRange: ClosedRange<Date>
1531

            
1532
            switch presentTrackingMode {
1533
            case .keepDuration:
1534
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1535
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1536
            case .keepStartTimestamp:
1537
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1538
            }
1539

            
1540
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1541
        }
1542

            
1543
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1544
    }
1545

            
1546
    private func clampedTimeRange(
1547
        _ candidateRange: ClosedRange<Date>,
1548
        within bounds: ClosedRange<Date>
1549
    ) -> ClosedRange<Date> {
1550
        let normalizedBounds = normalizedTimeRange(bounds)
1551
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1552

            
1553
        guard boundsSpan > 0 else {
1554
            return normalizedBounds
1555
        }
1556

            
1557
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1558
        let requestedSpan = min(
1559
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1560
            boundsSpan
1561
        )
1562

            
1563
        if requestedSpan >= boundsSpan {
1564
            return normalizedBounds
1565
        }
1566

            
1567
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1568
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1569

            
1570
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1571
            if lowerBound == normalizedBounds.lowerBound {
1572
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1573
            } else {
1574
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1575
            }
1576
        }
1577

            
1578
        if upperBound > normalizedBounds.upperBound {
1579
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1580
            upperBound = normalizedBounds.upperBound
1581
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1582
        }
1583

            
Bogdan Timofte authored 2 months ago
1584
        if lowerBound < normalizedBounds.lowerBound {
1585
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1586
            lowerBound = normalizedBounds.lowerBound
1587
            upperBound = upperBound.addingTimeInterval(delta)
1588
        }
1589

            
1590
        return lowerBound...upperBound
1591
    }
1592

            
1593
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1594
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1595
        guard span < minimumTimeSpan else { return range }
1596

            
1597
        let expansion = (minimumTimeSpan - span) / 2
1598
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1599
    }
1600

            
1601
    private func shouldShowRangeSelector(
1602
        availableTimeRange: ClosedRange<Date>,
1603
        series: SeriesData
1604
    ) -> Bool {
1605
        series.samplePoints.count > 1 &&
1606
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1607
    }
1608

            
1609
    private func automaticYBounds(
1610
        for samplePoints: [Measurements.Measurement.Point],
1611
        minimumYSpan: Double
1612
    ) -> (lowerBound: Double, upperBound: Double) {
1613
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1614

            
1615
        guard
1616
            let minimumSampleValue = samplePoints.map(\.value).min(),
1617
            let maximumSampleValue = samplePoints.map(\.value).max()
1618
        else {
1619
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1620
        }
Bogdan Timofte authored 2 months ago
1621

            
1622
        var lowerBound = minimumSampleValue
1623
        var upperBound = maximumSampleValue
1624
        let currentSpan = upperBound - lowerBound
1625

            
1626
        if currentSpan < minimumYSpan {
1627
            let expansion = (minimumYSpan - currentSpan) / 2
1628
            lowerBound -= expansion
1629
            upperBound += expansion
1630
        }
1631

            
1632
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1633
            let shift = -negativeAllowance - lowerBound
1634
            lowerBound += shift
1635
            upperBound += shift
1636
        }
1637

            
1638
        let snappedLowerBound = snappedOriginValue(lowerBound)
1639
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1640
        return (snappedLowerBound, resolvedUpperBound)
1641
    }
1642

            
1643
    private func resolvedLowerBound(
1644
        for kind: SeriesKind,
1645
        autoLowerBound: Double
1646
    ) -> Double {
1647
        guard pinOrigin else { return autoLowerBound }
1648

            
1649
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1650
            return sharedAxisOrigin
1651
        }
1652

            
1653
        switch kind {
1654
        case .power:
1655
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
1656
        case .energy:
1657
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1658
        case .voltage:
1659
            return voltageAxisOrigin
1660
        case .current:
1661
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1662
        case .temperature:
1663
            return temperatureAxisOrigin
Bogdan Timofte authored 2 months ago
1664
        }
1665
    }
1666

            
1667
    private func resolvedUpperBound(
1668
        for kind: SeriesKind,
1669
        lowerBound: Double,
1670
        autoUpperBound: Double,
1671
        maximumSampleValue: Double?,
1672
        minimumYSpan: Double
1673
    ) -> Double {
1674
        guard pinOrigin else {
1675
            return autoUpperBound
1676
        }
1677

            
Bogdan Timofte authored 2 months ago
1678
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1679
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1680
        }
1681

            
Bogdan Timofte authored 2 months ago
1682
        if kind == .temperature {
1683
            return autoUpperBound
1684
        }
1685

            
Bogdan Timofte authored 2 months ago
1686
        return max(
1687
            maximumSampleValue ?? lowerBound,
1688
            lowerBound + minimumYSpan,
1689
            autoUpperBound
1690
        )
1691
    }
1692

            
Bogdan Timofte authored 2 months ago
1693
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
1694
        let baseline = displayedLowerBoundForSeries(kind)
1695
        let proposedOrigin = snappedOriginValue(baseline + delta)
1696

            
1697
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
1698
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1699
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
1700
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1701
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1702
        } else {
1703
            switch kind {
1704
            case .power:
1705
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
1706
            case .energy:
1707
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
1708
            case .voltage:
1709
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1710
            case .current:
1711
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
1712
            case .temperature:
1713
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored 2 months ago
1714
            }
1715
        }
1716

            
1717
        pinOrigin = true
1718
    }
1719

            
Bogdan Timofte authored 2 months ago
1720
    private func clearOriginOffset(for kind: SeriesKind) {
1721
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1722
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1723
            sharedAxisOrigin = 0
1724
            sharedAxisUpperBound = currentSpan
1725
            ensureSharedScaleSpan()
1726
            voltageAxisOrigin = 0
1727
            currentAxisOrigin = 0
1728
        } else {
1729
            switch kind {
1730
            case .power:
1731
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1732
            case .energy:
1733
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1734
            case .voltage:
1735
                voltageAxisOrigin = 0
1736
            case .current:
1737
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1738
            case .temperature:
1739
                temperatureAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1740
            }
1741
        }
1742

            
1743
        pinOrigin = true
1744
    }
1745

            
1746
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1747
        guard totalHeight > 1 else { return }
1748

            
1749
        let normalized = max(0, min(1, locationY / totalHeight))
1750
        if normalized < (1.0 / 3.0) {
1751
            applyOriginDelta(-1, kind: kind)
1752
        } else if normalized < (2.0 / 3.0) {
1753
            clearOriginOffset(for: kind)
1754
        } else {
1755
            applyOriginDelta(1, kind: kind)
1756
        }
1757
    }
1758

            
Bogdan Timofte authored 2 months ago
1759
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1760
        let visibleTimeRange = activeVisibleTimeRange
1761

            
Bogdan Timofte authored 2 months ago
1762
        switch kind {
1763
        case .power:
Bogdan Timofte authored 2 months ago
1764
            return snappedOriginValue(
1765
                filteredSamplePoints(
1766
                    measurements.power,
1767
                    visibleTimeRange: visibleTimeRange
1768
                ).map(\.value).min() ?? 0
1769
            )
Bogdan Timofte authored 2 months ago
1770
        case .energy:
1771
            return snappedOriginValue(
1772
                filteredSamplePoints(
1773
                    measurements.energy,
1774
                    visibleTimeRange: visibleTimeRange
1775
                ).map(\.value).min() ?? 0
1776
            )
Bogdan Timofte authored 2 months ago
1777
        case .voltage:
Bogdan Timofte authored 2 months ago
1778
            return snappedOriginValue(
1779
                filteredSamplePoints(
1780
                    measurements.voltage,
1781
                    visibleTimeRange: visibleTimeRange
1782
                ).map(\.value).min() ?? 0
1783
            )
Bogdan Timofte authored 2 months ago
1784
        case .current:
Bogdan Timofte authored 2 months ago
1785
            return snappedOriginValue(
1786
                filteredSamplePoints(
1787
                    measurements.current,
1788
                    visibleTimeRange: visibleTimeRange
1789
                ).map(\.value).min() ?? 0
1790
            )
Bogdan Timofte authored 2 months ago
1791
        case .temperature:
Bogdan Timofte authored 2 months ago
1792
            return snappedOriginValue(
1793
                filteredSamplePoints(
1794
                    measurements.temperature,
1795
                    visibleTimeRange: visibleTimeRange
1796
                ).map(\.value).min() ?? 0
1797
            )
Bogdan Timofte authored 2 months ago
1798
        }
1799
    }
1800

            
1801
    private func maximumVisibleSharedOrigin() -> Double {
1802
        min(
1803
            maximumVisibleOrigin(for: .voltage),
1804
            maximumVisibleOrigin(for: .current)
1805
        )
1806
    }
1807

            
Bogdan Timofte authored 2 months ago
1808
    private func measurementUnit(for kind: SeriesKind) -> String {
1809
        switch kind {
1810
        case .temperature:
1811
            let locale = Locale.autoupdatingCurrent
1812
            if #available(iOS 16.0, *) {
1813
                switch locale.measurementSystem {
1814
                case .us:
1815
                    return "°F"
1816
                default:
1817
                    return "°C"
1818
                }
1819
            }
1820

            
1821
            let regionCode = locale.regionCode ?? ""
1822
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1823
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1824
        default:
1825
            return kind.unit
1826
        }
1827
    }
1828

            
Bogdan Timofte authored 2 months ago
1829
    private func ensureSharedScaleSpan() {
1830
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1831
    }
1832

            
Bogdan Timofte authored 2 months ago
1833
    private func snappedOriginValue(_ value: Double) -> Double {
1834
        if value >= 0 {
1835
            return value.rounded(.down)
1836
        }
1837

            
1838
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
1839
    }
Bogdan Timofte authored 2 months ago
1840

            
Bogdan Timofte authored 2 months ago
1841
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1842
        measurements.keepOnly(in: range)
1843
        selectedVisibleTimeRange = nil
1844
        isPinnedToPresent = false
1845
    }
1846

            
1847
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1848
        measurements.removeValues(in: range)
1849
        selectedVisibleTimeRange = nil
1850
        isPinnedToPresent = false
1851
    }
1852

            
Bogdan Timofte authored 2 months ago
1853
    private func yGuidePosition(
1854
        for labelIndex: Int,
1855
        context: ChartContext,
1856
        height: CGFloat
1857
    ) -> CGFloat {
Bogdan Timofte authored a month ago
1858
        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
Bogdan Timofte authored 2 months ago
1859
    }
1860

            
1861
    private func xGuidePosition(
1862
        for labelIndex: Int,
1863
        context: ChartContext,
1864
        width: CGFloat
1865
    ) -> CGFloat {
Bogdan Timofte authored a month ago
1866
        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
Bogdan Timofte authored 2 months ago
1867
    }
Bogdan Timofte authored 2 months ago
1868

            
1869
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
1870
    fileprivate func xAxisLabelsView(
1871
        context: ChartContext
1872
    ) -> some View {
Bogdan Timofte authored 2 months ago
1873
        var timeFormat: String?
1874
        switch context.size.width {
1875
        case 0..<3600: timeFormat = "HH:mm:ss"
1876
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
1877
        default: timeFormat = "E HH:mm"
1878
        }
1879
        let labels = (1...xLabels).map {
1880
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
1881
        }
Bogdan Timofte authored 2 months ago
1882
        let axisLabelFont: Font = {
1883
            if isIPhone && isPortraitLayout {
1884
                return .caption2.weight(.semibold)
1885
            }
1886
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1887
        }()
Bogdan Timofte authored 2 months ago
1888

            
1889
        return HStack(spacing: chartSectionSpacing) {
1890
            Color.clear
1891
                .frame(width: axisColumnWidth)
1892

            
1893
            GeometryReader { geometry in
1894
                let labelWidth = max(
1895
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1896
                    1
1897
                )
1898

            
1899
                ZStack(alignment: .topLeading) {
1900
                    Path { path in
1901
                        for labelIndex in 1...self.xLabels {
1902
                            let x = xGuidePosition(
1903
                                for: labelIndex,
1904
                                context: context,
1905
                                width: geometry.size.width
1906
                            )
1907
                            path.move(to: CGPoint(x: x, y: 0))
1908
                            path.addLine(to: CGPoint(x: x, y: 6))
1909
                        }
1910
                    }
1911
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1912

            
1913
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1914
                        let labelIndex = item.offset + 1
1915
                        let centerX = xGuidePosition(
1916
                            for: labelIndex,
1917
                            context: context,
1918
                            width: geometry.size.width
1919
                        )
1920

            
1921
                        Text(item.element)
Bogdan Timofte authored 2 months ago
1922
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
1923
                            .monospacedDigit()
1924
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
1925
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
1926
                            .frame(width: labelWidth)
1927
                            .position(
1928
                                x: centerX,
1929
                                y: geometry.size.height * 0.7
1930
                            )
Bogdan Timofte authored 2 months ago
1931
                    }
1932
                }
Bogdan Timofte authored 2 months ago
1933
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
1934
            }
Bogdan Timofte authored 2 months ago
1935

            
1936
            Color.clear
1937
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
1938
        }
1939
    }
1940

            
Bogdan Timofte authored 2 months ago
1941
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
1942
        height: CGFloat,
1943
        context: ChartContext,
Bogdan Timofte authored 2 months ago
1944
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
1945
        measurementUnit: String,
1946
        tint: Color
1947
    ) -> some View {
Bogdan Timofte authored 2 months ago
1948
        let yAxisFont: Font = {
1949
            if isIPhone && isPortraitLayout {
1950
                return .caption2.weight(.semibold)
1951
            }
1952
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1953
        }()
1954

            
1955
        let unitFont: Font = {
1956
            if isIPhone && isPortraitLayout {
1957
                return .caption2.weight(.bold)
1958
            }
1959
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1960
        }()
1961

            
1962
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
1963
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1964
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1965
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1966

            
Bogdan Timofte authored 2 months ago
1967
            ZStack(alignment: .top) {
1968
                ForEach(0..<yLabels, id: \.self) { row in
1969
                    let labelIndex = yLabels - row
1970

            
1971
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
1972
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
1973
                        .monospacedDigit()
1974
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
1975
                        .minimumScaleFactor(0.8)
1976
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
1977
                        .position(
1978
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
1979
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
1980
                                for: labelIndex,
1981
                                context: context,
Bogdan Timofte authored 2 months ago
1982
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
1983
                            )
1984
                        )
Bogdan Timofte authored 2 months ago
1985
                }
Bogdan Timofte authored 2 months ago
1986

            
Bogdan Timofte authored 2 months ago
1987
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
1988
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
1989
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
1990
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1991
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
1992
                    .background(
1993
                        Capsule(style: .continuous)
1994
                            .fill(tint.opacity(0.14))
1995
                    )
Bogdan Timofte authored 2 months ago
1996
                    .padding(.top, 8)
1997

            
Bogdan Timofte authored 2 months ago
1998
            }
1999
        }
Bogdan Timofte authored 2 months ago
2000
        .frame(height: height)
2001
        .background(
2002
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2003
                .fill(tint.opacity(0.12))
2004
        )
2005
        .overlay(
2006
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2007
                .stroke(tint.opacity(0.20), lineWidth: 1)
2008
        )
Bogdan Timofte authored 2 months ago
2009
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
2010
        .gesture(
Bogdan Timofte authored 2 months ago
2011
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
2012
                .onEnded { value in
Bogdan Timofte authored 2 months ago
2013
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
2014
                }
2015
        )
Bogdan Timofte authored 2 months ago
2016
    }
2017

            
Bogdan Timofte authored 2 months ago
2018
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2019
        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
Bogdan Timofte authored 2 months ago
2020
    }
2021

            
Bogdan Timofte authored 2 months ago
2022
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2023
        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
Bogdan Timofte authored 2 months ago
2024
    }
Bogdan Timofte authored 2 months ago
2025

            
2026
    fileprivate func discontinuityMarkers(
2027
        points: [Measurements.Measurement.Point],
2028
        context: ChartContext
2029
    ) -> some View {
2030
        GeometryReader { geometry in
2031
            Path { path in
2032
                for point in points where point.isDiscontinuity {
2033
                    let markerX = context.placeInRect(
2034
                        point: CGPoint(
2035
                            x: point.timestamp.timeIntervalSince1970,
2036
                            y: context.origin.y
2037
                        )
2038
                    ).x * geometry.size.width
2039
                    path.move(to: CGPoint(x: markerX, y: 0))
2040
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
2041
                }
2042
            }
2043
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
2044
        }
2045
    }
Bogdan Timofte authored 2 months ago
2046

            
2047
}
2048

            
Bogdan Timofte authored a month ago
2049
private struct EmbeddedWidthKey: PreferenceKey {
2050
    static let defaultValue: CGFloat = 760
2051
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2052
        let next = nextValue()
2053
        if next > 0 { value = next }
2054
    }
2055
}
2056

            
Bogdan Timofte authored 2 months ago
2057
private struct TimeRangeSelectorView: View {
2058
    private enum DragTarget {
2059
        case lowerBound
2060
        case upperBound
2061
        case window
2062
    }
2063

            
2064
    private struct DragState {
2065
        let target: DragTarget
2066
        let initialRange: ClosedRange<Date>
2067
    }
2068

            
2069
    let points: [Measurements.Measurement.Point]
2070
    let context: ChartContext
2071
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
2072
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2073
    let compactLayout: Bool
Bogdan Timofte authored a month ago
2074
    let xAxisLabelCount: Int
Bogdan Timofte authored 2 months ago
2075
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2076
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2077

            
2078
    @Binding var selectedTimeRange: ClosedRange<Date>?
2079
    @Binding var isPinnedToPresent: Bool
2080
    @Binding var presentTrackingMode: PresentTrackingMode
2081
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
2082
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
2083

            
2084
    private var totalSpan: TimeInterval {
2085
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2086
    }
2087

            
2088
    private var currentRange: ClosedRange<Date> {
2089
        resolvedSelectionRange()
2090
    }
2091

            
2092
    private var trackHeight: CGFloat {
Bogdan Timofte authored a month ago
2093
        Self.trackHeight(compactLayout: compactLayout)
2094
    }
2095

            
2096
    private static func trackHeight(compactLayout: Bool) -> CGFloat {
2097
        compactLayout ? 42 : 50
Bogdan Timofte authored 2 months ago
2098
    }
2099

            
Bogdan Timofte authored a month ago
2100
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2101
        let rowHeight: CGFloat = compactLayout ? 28 : 32
Bogdan Timofte authored a month ago
2102
        let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2103
        let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
Bogdan Timofte authored a month ago
2104
        let spacing: CGFloat = compactLayout ? 6 : 8
Bogdan Timofte authored a month ago
2105
        // Single row of controls instead of two
2106
        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
Bogdan Timofte authored a month ago
2107
    }
2108

            
Bogdan Timofte authored 2 months ago
2109
    private var cornerRadius: CGFloat {
2110
        compactLayout ? 14 : 16
2111
    }
2112

            
2113
    private var symbolButtonSize: CGFloat {
2114
        compactLayout ? 28 : 32
2115
    }
2116

            
2117
    var body: some View {
2118
        let coversFullRange = selectionCoversFullRange(currentRange)
2119

            
2120
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
Bogdan Timofte authored a month ago
2121
            HStack(spacing: 8) {
2122
                // Alignment controls
2123
                if !coversFullRange || isPinnedToPresent {
Bogdan Timofte authored 2 months ago
2124
                    alignmentButton(
2125
                        systemName: "arrow.left.to.line.compact",
2126
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2127
                        action: alignSelectionToLeadingEdge,
2128
                        accessibilityLabel: "Align selection to start"
2129
                    )
2130

            
2131
                    alignmentButton(
2132
                        systemName: "arrow.right.to.line.compact",
2133
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2134
                        action: alignSelectionToTrailingEdge,
2135
                        accessibilityLabel: "Align selection to present"
2136
                    )
2137

            
2138
                    if isPinnedToPresent {
2139
                        trackingModeToggleButton()
2140
                    }
2141
                }
2142

            
Bogdan Timofte authored a month ago
2143
                Spacer(minLength: 0)
2144

            
2145
                // Trim/Save actions
Bogdan Timofte authored 2 months ago
2146
                if !coversFullRange {
Bogdan Timofte authored a month ago
2147
                    iconButton(
Bogdan Timofte authored a month ago
2148
                        systemName: configuration.keepAction.systemName,
2149
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2150
                        action: {
Bogdan Timofte authored a month ago
2151
                            configuration.keepAction.handler(currentRange)
2152
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2153
                        }
2154
                    )
Bogdan Timofte authored a month ago
2155
                    .help(configuration.keepAction.title)
Bogdan Timofte authored 2 months ago
2156

            
Bogdan Timofte authored a month ago
2157
                    if let removeAction = configuration.removeAction {
Bogdan Timofte authored a month ago
2158
                        iconButton(
Bogdan Timofte authored a month ago
2159
                            systemName: removeAction.systemName,
2160
                            tone: removeAction.tone,
2161
                            action: {
2162
                                removeAction.handler(currentRange)
2163
                                resetSelectionState()
2164
                            }
2165
                        )
Bogdan Timofte authored a month ago
2166
                        .help(removeAction.title)
Bogdan Timofte authored a month ago
2167
                    }
Bogdan Timofte authored 2 months ago
2168

            
Bogdan Timofte authored a month ago
2169
                    // Reset action (only show when there's a trim to reset)
2170
                    iconButton(
2171
                        systemName: configuration.resetAction.systemName,
2172
                        tone: configuration.resetAction.tone,
2173
                        action: {
2174
                            showResetConfirmation = true
2175
                        }
2176
                    )
2177
                    .help(configuration.resetAction.title)
2178
                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2179
                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2180
                            configuration.resetAction.handler()
2181
                            resetSelectionState()
2182
                        }
2183
                        Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 months ago
2184
                    }
2185
                }
2186
            }
2187

            
Bogdan Timofte authored 2 months ago
2188
            GeometryReader { geometry in
2189
                let selectionFrame = selectionFrame(in: geometry.size)
2190
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2191

            
2192
                ZStack(alignment: .topLeading) {
2193
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2194
                        .fill(Color.primary.opacity(0.05))
2195

            
Bogdan Timofte authored a month ago
2196
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2197
                        points: points,
2198
                        context: context,
2199
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2200
                        strokeColor: selectorTint,
2201
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2202
                    )
2203
                    .opacity(0.94)
2204
                    .allowsHitTesting(false)
2205

            
Bogdan Timofte authored a month ago
2206
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2207
                        points: points,
2208
                        context: context,
Bogdan Timofte authored 2 months ago
2209
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2210
                    )
2211
                    .opacity(0.82)
2212
                    .allowsHitTesting(false)
2213

            
2214
                    if selectionFrame.minX > 0 {
2215
                        Rectangle()
2216
                            .fill(dimmingColor)
2217
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2218
                            .allowsHitTesting(false)
2219
                    }
2220

            
2221
                    if selectionFrame.maxX < geometry.size.width {
2222
                        Rectangle()
2223
                            .fill(dimmingColor)
2224
                            .frame(
2225
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2226
                                height: geometry.size.height
2227
                            )
2228
                            .offset(x: selectionFrame.maxX)
2229
                            .allowsHitTesting(false)
2230
                    }
2231

            
2232
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2233
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2234
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2235
                        .offset(x: selectionFrame.minX)
2236
                        .allowsHitTesting(false)
2237

            
2238
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2239
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2240
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2241
                        .offset(x: selectionFrame.minX)
2242
                        .allowsHitTesting(false)
2243

            
2244
                    handleView(height: max(geometry.size.height - 18, 16))
2245
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2246
                        .allowsHitTesting(false)
2247

            
2248
                    handleView(height: max(geometry.size.height - 18, 16))
2249
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2250
                        .allowsHitTesting(false)
2251
                }
2252
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2253
                .overlay(
2254
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
Bogdan Timofte authored a month ago
2255
                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2256
                )
2257
                .contentShape(Rectangle())
2258
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2259
            }
2260
            .frame(height: trackHeight)
2261

            
Bogdan Timofte authored a month ago
2262
            xAxisLabelsView
Bogdan Timofte authored 2 months ago
2263
        }
2264
    }
2265

            
2266
    private func handleView(height: CGFloat) -> some View {
2267
        Capsule(style: .continuous)
2268
            .fill(Color.white.opacity(0.95))
2269
            .frame(width: 6, height: height)
2270
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2271
    }
2272

            
2273
    private func alignmentButton(
2274
        systemName: String,
2275
        isActive: Bool,
2276
        action: @escaping () -> Void,
2277
        accessibilityLabel: String
2278
    ) -> some View {
2279
        Button(action: action) {
2280
            Image(systemName: systemName)
2281
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2282
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2283
        }
2284
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2285
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2286
        .background(
2287
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2288
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2289
        )
2290
        .overlay(
2291
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2292
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2293
        )
2294
        .accessibilityLabel(accessibilityLabel)
2295
    }
2296

            
2297
    private func trackingModeToggleButton() -> some View {
2298
        Button {
2299
            presentTrackingMode = presentTrackingMode == .keepDuration
2300
                ? .keepStartTimestamp
2301
                : .keepDuration
2302
        } label: {
2303
            Image(systemName: trackingModeSymbolName)
2304
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2305
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2306
        }
2307
        .buttonStyle(.plain)
2308
        .foregroundColor(.white)
2309
        .background(
2310
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2311
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2312
        )
2313
        .overlay(
2314
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2315
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2316
        )
2317
        .accessibilityLabel(trackingModeAccessibilityLabel)
2318
        .accessibilityHint("Toggles how the interval follows the present")
2319
    }
2320

            
Bogdan Timofte authored 2 months ago
2321
    private func actionButton(
2322
        title: String,
Bogdan Timofte authored a month ago
2323
        shortTitle: String? = nil,
Bogdan Timofte authored 2 months ago
2324
        systemName: String,
Bogdan Timofte authored a month ago
2325
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2326
        action: @escaping () -> Void
2327
    ) -> some View {
2328
        let foregroundColor: Color = {
2329
            switch tone {
2330
            case .reversible, .destructive:
2331
                return toneColor(for: tone)
2332
            case .destructiveProminent:
2333
                return .white
2334
            }
2335
        }()
Bogdan Timofte authored a month ago
2336
        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
Bogdan Timofte authored 2 months ago
2337

            
2338
        return Button(action: action) {
Bogdan Timofte authored a month ago
2339
            Label(displayTitle, systemImage: systemName)
Bogdan Timofte authored 2 months ago
2340
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2341
                .padding(.horizontal, compactLayout ? 10 : 12)
2342
                .padding(.vertical, compactLayout ? 7 : 8)
2343
        }
2344
        .buttonStyle(.plain)
2345
        .foregroundColor(foregroundColor)
2346
        .background(
2347
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2348
                .fill(actionButtonBackground(for: tone))
2349
        )
2350
        .overlay(
2351
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2352
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2353
        )
2354
    }
2355

            
Bogdan Timofte authored a month ago
2356
    private func iconButton(
2357
        systemName: String,
2358
        tone: MeasurementChartSelectorActionTone,
2359
        action: @escaping () -> Void
2360
    ) -> some View {
2361
        let foregroundColor: Color = {
2362
            switch tone {
2363
            case .reversible, .destructive:
2364
                return toneColor(for: tone)
2365
            case .destructiveProminent:
2366
                return .white
2367
            }
2368
        }()
2369

            
2370
        return Button(action: action) {
2371
            Image(systemName: systemName)
2372
                .font(.subheadline.weight(.semibold))
2373
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2374
        }
2375
        .buttonStyle(.plain)
2376
        .foregroundColor(foregroundColor)
2377
        .background(
2378
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2379
                .fill(actionButtonBackground(for: tone))
2380
        )
2381
        .overlay(
2382
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2383
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2384
        )
2385
    }
2386

            
Bogdan Timofte authored a month ago
2387
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2388
        switch tone {
2389
        case .reversible:
2390
            return selectorTint
2391
        case .destructive, .destructiveProminent:
2392
            return .red
2393
        }
2394
    }
2395

            
Bogdan Timofte authored a month ago
2396
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2397
        switch tone {
2398
        case .reversible:
2399
            return selectorTint.opacity(0.12)
2400
        case .destructive:
2401
            return Color.red.opacity(0.12)
2402
        case .destructiveProminent:
2403
            return Color.red.opacity(0.82)
2404
        }
2405
    }
2406

            
Bogdan Timofte authored a month ago
2407
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2408
        switch tone {
2409
        case .reversible:
2410
            return selectorTint.opacity(0.22)
2411
        case .destructive:
2412
            return Color.red.opacity(0.22)
2413
        case .destructiveProminent:
2414
            return Color.red.opacity(0.72)
2415
        }
2416
    }
2417

            
Bogdan Timofte authored 2 months ago
2418
    private var trackingModeSymbolName: String {
2419
        switch presentTrackingMode {
2420
        case .keepDuration:
2421
            return "arrow.left.and.right"
2422
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2423
            return "arrow.right"
Bogdan Timofte authored 2 months ago
2424
        }
2425
    }
2426

            
2427
    private var trackingModeAccessibilityLabel: String {
2428
        switch presentTrackingMode {
2429
        case .keepDuration:
Bogdan Timofte authored a month ago
2430
            return "Keep fixed duration"
Bogdan Timofte authored 2 months ago
2431
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2432
            return "Keep start fixed"
Bogdan Timofte authored 2 months ago
2433
        }
2434
    }
2435

            
2436
    private func alignSelectionToLeadingEdge() {
2437
        let alignedRange = normalizedSelectionRange(
2438
            availableTimeRange.lowerBound...currentRange.upperBound
2439
        )
2440
        applySelection(alignedRange, pinToPresent: false)
2441
    }
2442

            
2443
    private func alignSelectionToTrailingEdge() {
2444
        let alignedRange = normalizedSelectionRange(
2445
            currentRange.lowerBound...availableTimeRange.upperBound
2446
        )
2447
        applySelection(alignedRange, pinToPresent: true)
2448
    }
2449

            
2450
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2451
        DragGesture(minimumDistance: 0)
2452
            .onChanged { value in
2453
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2454
            }
2455
            .onEnded { _ in
2456
                dragState = nil
2457
            }
2458
    }
2459

            
2460
    private func updateSelectionDrag(
2461
        value: DragGesture.Value,
2462
        totalWidth: CGFloat
2463
    ) {
2464
        let startingRange = resolvedSelectionRange()
2465

            
2466
        if dragState == nil {
2467
            dragState = DragState(
2468
                target: dragTarget(
2469
                    for: value.startLocation.x,
2470
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2471
                ),
2472
                initialRange: startingRange
2473
            )
2474
        }
2475

            
2476
        guard let dragState else { return }
2477

            
2478
        let resultingRange = snappedToEdges(
2479
            adjustedRange(
2480
                from: dragState.initialRange,
2481
                target: dragState.target,
2482
                translationX: value.translation.width,
2483
                totalWidth: totalWidth
2484
            ),
2485
            target: dragState.target,
2486
            totalWidth: totalWidth
2487
        )
2488

            
2489
        applySelection(
2490
            resultingRange,
2491
            pinToPresent: shouldKeepPresentPin(
2492
                during: dragState.target,
2493
                initialRange: dragState.initialRange,
2494
                resultingRange: resultingRange
2495
            ),
2496
        )
2497
    }
2498

            
2499
    private func dragTarget(
2500
        for startX: CGFloat,
2501
        selectionFrame: CGRect
2502
    ) -> DragTarget {
2503
        let handleZone: CGFloat = compactLayout ? 20 : 24
2504

            
2505
        if abs(startX - selectionFrame.minX) <= handleZone {
2506
            return .lowerBound
2507
        }
2508

            
2509
        if abs(startX - selectionFrame.maxX) <= handleZone {
2510
            return .upperBound
2511
        }
2512

            
2513
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2514
            return .window
2515
        }
2516

            
2517
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2518
    }
2519

            
2520
    private func adjustedRange(
2521
        from initialRange: ClosedRange<Date>,
2522
        target: DragTarget,
2523
        translationX: CGFloat,
2524
        totalWidth: CGFloat
2525
    ) -> ClosedRange<Date> {
2526
        guard totalSpan > 0, totalWidth > 0 else {
2527
            return availableTimeRange
2528
        }
2529

            
2530
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2531
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2532

            
2533
        switch target {
2534
        case .lowerBound:
2535
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2536
            let newLowerBound = min(
2537
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2538
                maximumLowerBound
2539
            )
2540
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2541

            
2542
        case .upperBound:
2543
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2544
            let newUpperBound = max(
2545
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2546
                minimumUpperBound
2547
            )
2548
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2549

            
2550
        case .window:
2551
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2552
            guard span < totalSpan else { return availableTimeRange }
2553

            
2554
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2555
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2556

            
2557
            if lowerBound < availableTimeRange.lowerBound {
2558
                upperBound = upperBound.addingTimeInterval(
2559
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2560
                )
2561
                lowerBound = availableTimeRange.lowerBound
2562
            }
2563

            
2564
            if upperBound > availableTimeRange.upperBound {
2565
                lowerBound = lowerBound.addingTimeInterval(
2566
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2567
                )
2568
                upperBound = availableTimeRange.upperBound
2569
            }
2570

            
2571
            return normalizedSelectionRange(lowerBound...upperBound)
2572
        }
2573
    }
2574

            
2575
    private func snappedToEdges(
2576
        _ candidateRange: ClosedRange<Date>,
2577
        target: DragTarget,
2578
        totalWidth: CGFloat
2579
    ) -> ClosedRange<Date> {
2580
        guard totalSpan > 0 else {
2581
            return availableTimeRange
2582
        }
2583

            
2584
        let snapInterval = edgeSnapInterval(for: totalWidth)
2585
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2586
        var lowerBound = candidateRange.lowerBound
2587
        var upperBound = candidateRange.upperBound
2588

            
2589
        if target != .upperBound,
2590
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2591
            lowerBound = availableTimeRange.lowerBound
2592
            if target == .window {
2593
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2594
            }
2595
        }
2596

            
2597
        if target != .lowerBound,
2598
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2599
            upperBound = availableTimeRange.upperBound
2600
            if target == .window {
2601
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2602
            }
2603
        }
2604

            
2605
        return normalizedSelectionRange(lowerBound...upperBound)
2606
    }
2607

            
2608
    private func edgeSnapInterval(
2609
        for totalWidth: CGFloat
2610
    ) -> TimeInterval {
2611
        guard totalWidth > 0 else { return minimumSelectionSpan }
2612

            
2613
        let snapWidth = min(
2614
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2615
            totalWidth * 0.18
2616
        )
2617
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2618
        return min(
2619
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2620
            totalSpan / 4
2621
        )
2622
    }
2623

            
2624
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2625
        guard let selectedTimeRange else { return availableTimeRange }
2626

            
2627
        if isPinnedToPresent {
2628
            switch presentTrackingMode {
2629
            case .keepDuration:
2630
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2631
                return normalizedSelectionRange(
2632
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2633
                )
2634
            case .keepStartTimestamp:
2635
                return normalizedSelectionRange(
2636
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2637
                )
2638
            }
2639
        }
2640

            
2641
        return normalizedSelectionRange(selectedTimeRange)
2642
    }
2643

            
2644
    private func normalizedSelectionRange(
2645
        _ candidateRange: ClosedRange<Date>
2646
    ) -> ClosedRange<Date> {
2647
        let availableSpan = totalSpan
2648
        guard availableSpan > 0 else { return availableTimeRange }
2649

            
2650
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2651
        let requestedSpan = min(
2652
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2653
            availableSpan
2654
        )
2655

            
2656
        if requestedSpan >= availableSpan {
2657
            return availableTimeRange
2658
        }
2659

            
2660
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2661
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2662

            
2663
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2664
            if lowerBound == availableTimeRange.lowerBound {
2665
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2666
            } else {
2667
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2668
            }
2669
        }
2670

            
2671
        if upperBound > availableTimeRange.upperBound {
2672
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2673
            upperBound = availableTimeRange.upperBound
2674
            lowerBound = lowerBound.addingTimeInterval(-delta)
2675
        }
2676

            
2677
        if lowerBound < availableTimeRange.lowerBound {
2678
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2679
            lowerBound = availableTimeRange.lowerBound
2680
            upperBound = upperBound.addingTimeInterval(delta)
2681
        }
2682

            
2683
        return lowerBound...upperBound
2684
    }
2685

            
2686
    private func shouldKeepPresentPin(
2687
        during target: DragTarget,
2688
        initialRange: ClosedRange<Date>,
2689
        resultingRange: ClosedRange<Date>
2690
    ) -> Bool {
2691
        let startedPinnedToPresent =
2692
            isPinnedToPresent ||
2693
            selectionCoversFullRange(initialRange)
2694

            
2695
        guard startedPinnedToPresent else {
2696
            return selectionTouchesPresent(resultingRange)
2697
        }
2698

            
2699
        switch target {
2700
        case .lowerBound:
2701
            return true
2702
        case .upperBound, .window:
2703
            return selectionTouchesPresent(resultingRange)
2704
        }
2705
    }
2706

            
2707
    private func applySelection(
2708
        _ candidateRange: ClosedRange<Date>,
2709
        pinToPresent: Bool
2710
    ) {
2711
        let normalizedRange = normalizedSelectionRange(candidateRange)
2712

            
2713
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2714
            selectedTimeRange = nil
2715
        } else {
2716
            selectedTimeRange = normalizedRange
2717
        }
2718

            
2719
        isPinnedToPresent = pinToPresent
2720
    }
2721

            
Bogdan Timofte authored a month ago
2722
    private func resetSelectionState() {
2723
        selectedTimeRange = nil
2724
        isPinnedToPresent = false
2725
    }
2726

            
Bogdan Timofte authored 2 months ago
2727
    private func selectionTouchesPresent(
2728
        _ range: ClosedRange<Date>
2729
    ) -> Bool {
2730
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2731
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2732
    }
2733

            
2734
    private func selectionCoversFullRange(
2735
        _ range: ClosedRange<Date>
2736
    ) -> Bool {
2737
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2738
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2739
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2740
    }
2741

            
2742
    private func selectionFrame(in size: CGSize) -> CGRect {
2743
        selectionFrame(for: currentRange, width: size.width)
2744
    }
2745

            
2746
    private func selectionFrame(
2747
        for range: ClosedRange<Date>,
2748
        width: CGFloat
2749
    ) -> CGRect {
2750
        guard width > 0, totalSpan > 0 else {
2751
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2752
        }
2753

            
2754
        let minimumX = xPosition(for: range.lowerBound, width: width)
2755
        let maximumX = xPosition(for: range.upperBound, width: width)
2756
        return CGRect(
2757
            x: minimumX,
2758
            y: 0,
2759
            width: max(maximumX - minimumX, 2),
2760
            height: trackHeight
2761
        )
2762
    }
2763

            
2764
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2765
        guard width > 0, totalSpan > 0 else { return 0 }
2766

            
2767
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2768
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2769
        return CGFloat(normalizedOffset) * width
2770
    }
2771

            
Bogdan Timofte authored a month ago
2772
    private var xAxisLabelsView: some View {
2773
        let timeFormat: String = {
2774
            switch context.size.width {
2775
            case 0..<3600: return "HH:mm:ss"
2776
            case 3600...86400: return "HH:mm"
2777
            default: return "E HH:mm"
2778
            }
2779
        }()
Bogdan Timofte authored 2 months ago
2780

            
Bogdan Timofte authored a month ago
2781
        let labelCount = max(xAxisLabelCount, 2)
2782
        let labels = (1...labelCount).map {
2783
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat)
Bogdan Timofte authored 2 months ago
2784
        }
Bogdan Timofte authored a month ago
2785
        let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold)
2786

            
2787
        return GeometryReader { geometry in
2788
            let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1)
2789

            
2790
            ZStack(alignment: .topLeading) {
2791
                Path { path in
2792
                    for labelIndex in 1...labelCount {
2793
                        let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
2794
                        path.move(to: CGPoint(x: x, y: 0))
2795
                        path.addLine(to: CGPoint(x: x, y: 5))
2796
                    }
2797
                }
2798
                .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
2799

            
2800
                ForEach(Array(labels.enumerated()), id: \.offset) { item in
2801
                    let labelIndex = item.offset + 1
2802
                    let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
2803

            
2804
                    Text(item.element)
2805
                        .font(axisLabelFont)
2806
                        .monospacedDigit()
2807
                        .lineLimit(1)
2808
                        .minimumScaleFactor(0.74)
2809
                        .frame(width: labelWidth)
2810
                        .position(
2811
                            x: centerX,
2812
                            y: geometry.size.height * 0.66
2813
                        )
2814
                }
2815
            }
2816
        }
2817
        .frame(height: compactLayout ? 18 : 20)
2818
    }
2819

            
2820
    private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
2821
        context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width)
Bogdan Timofte authored 2 months ago
2822
    }
2823
}