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

            
9
import SwiftUI
10

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
178
    @State private var embeddedWidth: CGFloat = 760
179

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 months ago
734
        }
735
    }
736

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1177
        flushCurrentSegment()
1178
        return smoothedPoints
1179
    }
1180

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1335
        pinOrigin = true
1336
    }
1337

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1573
        return lowerBound...upperBound
1574
    }
1575

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1700
        pinOrigin = true
1701
    }
1702

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

            
1726
        pinOrigin = true
1727
    }
1728

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2030
}
2031

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

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

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

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

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

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

            
2070
    private var currentRange: ClosedRange<Date> {
2071
        resolvedSelectionRange()
2072
    }
2073

            
2074
    private var trackHeight: CGFloat {
2075
        compactLayout ? 72 : 86
2076
    }
2077

            
Bogdan Timofte authored a month ago
2078
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2079
        let rowHeight: CGFloat = compactLayout ? 28 : 32
2080
        let trackHeight: CGFloat = compactLayout ? 72 : 86
2081
        let boundaryHeight: CGFloat = compactLayout ? 16 : 18
2082
        let spacing: CGFloat = compactLayout ? 6 : 8
2083
        return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + boundaryHeight
2084
    }
2085

            
Bogdan Timofte authored 2 months ago
2086
    private var cornerRadius: CGFloat {
2087
        compactLayout ? 14 : 16
2088
    }
2089

            
2090
    private var boundaryFont: Font {
2091
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
2092
    }
2093

            
2094
    private var symbolButtonSize: CGFloat {
2095
        compactLayout ? 28 : 32
2096
    }
2097

            
2098
    var body: some View {
2099
        let coversFullRange = selectionCoversFullRange(currentRange)
2100

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

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

            
2118
                    Spacer(minLength: 0)
2119

            
2120
                    if isPinnedToPresent {
2121
                        trackingModeToggleButton()
2122
                    }
2123
                }
2124
            }
2125

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

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

            
2153
                Spacer(minLength: 0)
2154

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

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

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

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

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

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

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

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

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

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

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

            
2247
            HStack {
2248
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2249
                Spacer(minLength: 0)
2250
                Text(boundaryLabel(for: availableTimeRange.upperBound))
2251
            }
2252
            .font(boundaryFont)
2253
            .foregroundColor(.secondary)
2254
            .monospacedDigit()
2255
        }
2256
    }
2257

            
2258
    private func handleView(height: CGFloat) -> some View {
2259
        Capsule(style: .continuous)
2260
            .fill(Color.white.opacity(0.95))
2261
            .frame(width: 6, height: height)
2262
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2263
    }
2264

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

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

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

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

            
Bogdan Timofte authored a month ago
2348
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2349
        switch tone {
2350
        case .reversible:
2351
            return selectorTint
2352
        case .destructive, .destructiveProminent:
2353
            return .red
2354
        }
2355
    }
2356

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

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

            
Bogdan Timofte authored 2 months ago
2379
    private var trackingModeSymbolName: String {
2380
        switch presentTrackingMode {
2381
        case .keepDuration:
2382
            return "arrow.left.and.right"
2383
        case .keepStartTimestamp:
2384
            return "arrow.left.to.line.compact"
2385
        }
2386
    }
2387

            
2388
    private var trackingModeAccessibilityLabel: String {
2389
        switch presentTrackingMode {
2390
        case .keepDuration:
2391
            return "Follow present keeping span"
2392
        case .keepStartTimestamp:
2393
            return "Follow present keeping start"
2394
        }
2395
    }
2396

            
2397
    private func alignSelectionToLeadingEdge() {
2398
        let alignedRange = normalizedSelectionRange(
2399
            availableTimeRange.lowerBound...currentRange.upperBound
2400
        )
2401
        applySelection(alignedRange, pinToPresent: false)
2402
    }
2403

            
2404
    private func alignSelectionToTrailingEdge() {
2405
        let alignedRange = normalizedSelectionRange(
2406
            currentRange.lowerBound...availableTimeRange.upperBound
2407
        )
2408
        applySelection(alignedRange, pinToPresent: true)
2409
    }
2410

            
2411
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2412
        DragGesture(minimumDistance: 0)
2413
            .onChanged { value in
2414
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2415
            }
2416
            .onEnded { _ in
2417
                dragState = nil
2418
            }
2419
    }
2420

            
2421
    private func updateSelectionDrag(
2422
        value: DragGesture.Value,
2423
        totalWidth: CGFloat
2424
    ) {
2425
        let startingRange = resolvedSelectionRange()
2426

            
2427
        if dragState == nil {
2428
            dragState = DragState(
2429
                target: dragTarget(
2430
                    for: value.startLocation.x,
2431
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2432
                ),
2433
                initialRange: startingRange
2434
            )
2435
        }
2436

            
2437
        guard let dragState else { return }
2438

            
2439
        let resultingRange = snappedToEdges(
2440
            adjustedRange(
2441
                from: dragState.initialRange,
2442
                target: dragState.target,
2443
                translationX: value.translation.width,
2444
                totalWidth: totalWidth
2445
            ),
2446
            target: dragState.target,
2447
            totalWidth: totalWidth
2448
        )
2449

            
2450
        applySelection(
2451
            resultingRange,
2452
            pinToPresent: shouldKeepPresentPin(
2453
                during: dragState.target,
2454
                initialRange: dragState.initialRange,
2455
                resultingRange: resultingRange
2456
            ),
2457
        )
2458
    }
2459

            
2460
    private func dragTarget(
2461
        for startX: CGFloat,
2462
        selectionFrame: CGRect
2463
    ) -> DragTarget {
2464
        let handleZone: CGFloat = compactLayout ? 20 : 24
2465

            
2466
        if abs(startX - selectionFrame.minX) <= handleZone {
2467
            return .lowerBound
2468
        }
2469

            
2470
        if abs(startX - selectionFrame.maxX) <= handleZone {
2471
            return .upperBound
2472
        }
2473

            
2474
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2475
            return .window
2476
        }
2477

            
2478
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2479
    }
2480

            
2481
    private func adjustedRange(
2482
        from initialRange: ClosedRange<Date>,
2483
        target: DragTarget,
2484
        translationX: CGFloat,
2485
        totalWidth: CGFloat
2486
    ) -> ClosedRange<Date> {
2487
        guard totalSpan > 0, totalWidth > 0 else {
2488
            return availableTimeRange
2489
        }
2490

            
2491
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2492
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2493

            
2494
        switch target {
2495
        case .lowerBound:
2496
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2497
            let newLowerBound = min(
2498
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2499
                maximumLowerBound
2500
            )
2501
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2502

            
2503
        case .upperBound:
2504
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2505
            let newUpperBound = max(
2506
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2507
                minimumUpperBound
2508
            )
2509
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2510

            
2511
        case .window:
2512
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2513
            guard span < totalSpan else { return availableTimeRange }
2514

            
2515
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2516
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2517

            
2518
            if lowerBound < availableTimeRange.lowerBound {
2519
                upperBound = upperBound.addingTimeInterval(
2520
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2521
                )
2522
                lowerBound = availableTimeRange.lowerBound
2523
            }
2524

            
2525
            if upperBound > availableTimeRange.upperBound {
2526
                lowerBound = lowerBound.addingTimeInterval(
2527
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2528
                )
2529
                upperBound = availableTimeRange.upperBound
2530
            }
2531

            
2532
            return normalizedSelectionRange(lowerBound...upperBound)
2533
        }
2534
    }
2535

            
2536
    private func snappedToEdges(
2537
        _ candidateRange: ClosedRange<Date>,
2538
        target: DragTarget,
2539
        totalWidth: CGFloat
2540
    ) -> ClosedRange<Date> {
2541
        guard totalSpan > 0 else {
2542
            return availableTimeRange
2543
        }
2544

            
2545
        let snapInterval = edgeSnapInterval(for: totalWidth)
2546
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2547
        var lowerBound = candidateRange.lowerBound
2548
        var upperBound = candidateRange.upperBound
2549

            
2550
        if target != .upperBound,
2551
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2552
            lowerBound = availableTimeRange.lowerBound
2553
            if target == .window {
2554
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2555
            }
2556
        }
2557

            
2558
        if target != .lowerBound,
2559
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2560
            upperBound = availableTimeRange.upperBound
2561
            if target == .window {
2562
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2563
            }
2564
        }
2565

            
2566
        return normalizedSelectionRange(lowerBound...upperBound)
2567
    }
2568

            
2569
    private func edgeSnapInterval(
2570
        for totalWidth: CGFloat
2571
    ) -> TimeInterval {
2572
        guard totalWidth > 0 else { return minimumSelectionSpan }
2573

            
2574
        let snapWidth = min(
2575
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2576
            totalWidth * 0.18
2577
        )
2578
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2579
        return min(
2580
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2581
            totalSpan / 4
2582
        )
2583
    }
2584

            
2585
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2586
        guard let selectedTimeRange else { return availableTimeRange }
2587

            
2588
        if isPinnedToPresent {
2589
            switch presentTrackingMode {
2590
            case .keepDuration:
2591
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2592
                return normalizedSelectionRange(
2593
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2594
                )
2595
            case .keepStartTimestamp:
2596
                return normalizedSelectionRange(
2597
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2598
                )
2599
            }
2600
        }
2601

            
2602
        return normalizedSelectionRange(selectedTimeRange)
2603
    }
2604

            
2605
    private func normalizedSelectionRange(
2606
        _ candidateRange: ClosedRange<Date>
2607
    ) -> ClosedRange<Date> {
2608
        let availableSpan = totalSpan
2609
        guard availableSpan > 0 else { return availableTimeRange }
2610

            
2611
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2612
        let requestedSpan = min(
2613
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2614
            availableSpan
2615
        )
2616

            
2617
        if requestedSpan >= availableSpan {
2618
            return availableTimeRange
2619
        }
2620

            
2621
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2622
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2623

            
2624
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2625
            if lowerBound == availableTimeRange.lowerBound {
2626
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2627
            } else {
2628
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2629
            }
2630
        }
2631

            
2632
        if upperBound > availableTimeRange.upperBound {
2633
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2634
            upperBound = availableTimeRange.upperBound
2635
            lowerBound = lowerBound.addingTimeInterval(-delta)
2636
        }
2637

            
2638
        if lowerBound < availableTimeRange.lowerBound {
2639
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2640
            lowerBound = availableTimeRange.lowerBound
2641
            upperBound = upperBound.addingTimeInterval(delta)
2642
        }
2643

            
2644
        return lowerBound...upperBound
2645
    }
2646

            
2647
    private func shouldKeepPresentPin(
2648
        during target: DragTarget,
2649
        initialRange: ClosedRange<Date>,
2650
        resultingRange: ClosedRange<Date>
2651
    ) -> Bool {
2652
        let startedPinnedToPresent =
2653
            isPinnedToPresent ||
2654
            selectionCoversFullRange(initialRange)
2655

            
2656
        guard startedPinnedToPresent else {
2657
            return selectionTouchesPresent(resultingRange)
2658
        }
2659

            
2660
        switch target {
2661
        case .lowerBound:
2662
            return true
2663
        case .upperBound, .window:
2664
            return selectionTouchesPresent(resultingRange)
2665
        }
2666
    }
2667

            
2668
    private func applySelection(
2669
        _ candidateRange: ClosedRange<Date>,
2670
        pinToPresent: Bool
2671
    ) {
2672
        let normalizedRange = normalizedSelectionRange(candidateRange)
2673

            
2674
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2675
            selectedTimeRange = nil
2676
        } else {
2677
            selectedTimeRange = normalizedRange
2678
        }
2679

            
2680
        isPinnedToPresent = pinToPresent
2681
    }
2682

            
Bogdan Timofte authored a month ago
2683
    private func resetSelectionState() {
2684
        selectedTimeRange = nil
2685
        isPinnedToPresent = false
2686
    }
2687

            
Bogdan Timofte authored 2 months ago
2688
    private func selectionTouchesPresent(
2689
        _ range: ClosedRange<Date>
2690
    ) -> Bool {
2691
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2692
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2693
    }
2694

            
2695
    private func selectionCoversFullRange(
2696
        _ range: ClosedRange<Date>
2697
    ) -> Bool {
2698
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2699
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2700
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2701
    }
2702

            
2703
    private func selectionFrame(in size: CGSize) -> CGRect {
2704
        selectionFrame(for: currentRange, width: size.width)
2705
    }
2706

            
2707
    private func selectionFrame(
2708
        for range: ClosedRange<Date>,
2709
        width: CGFloat
2710
    ) -> CGRect {
2711
        guard width > 0, totalSpan > 0 else {
2712
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2713
        }
2714

            
2715
        let minimumX = xPosition(for: range.lowerBound, width: width)
2716
        let maximumX = xPosition(for: range.upperBound, width: width)
2717
        return CGRect(
2718
            x: minimumX,
2719
            y: 0,
2720
            width: max(maximumX - minimumX, 2),
2721
            height: trackHeight
2722
        )
2723
    }
2724

            
2725
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2726
        guard width > 0, totalSpan > 0 else { return 0 }
2727

            
2728
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2729
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2730
        return CGFloat(normalizedOffset) * width
2731
    }
2732

            
2733
    private func boundaryLabel(for date: Date) -> String {
2734
        date.format(as: boundaryDateFormat)
2735
    }
2736

            
2737
    private var boundaryDateFormat: String {
2738
        switch totalSpan {
2739
        case 0..<86400:
2740
            return "HH:mm"
2741
        case 86400..<604800:
2742
            return "MMM d HH:mm"
2743
        default:
2744
            return "MMM d"
2745
        }
2746
    }
2747
}