USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
2969 lines | 108.157kb
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 a month 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 a month ago
118
    private enum SeriesKind: Hashable {
Bogdan Timofte authored 2 months ago
119
        case power
Bogdan Timofte authored a month 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

            
Bogdan Timofte authored a month ago
125
        var displayName: String {
126
            switch self {
127
            case .power: return "Power"
128
            case .energy: return "Energy"
129
            case .voltage: return "Voltage"
130
            case .current: return "Current"
131
            case .temperature: return "Temperature"
132
            }
133
        }
134

            
Bogdan Timofte authored 2 months ago
135
        var unit: String {
136
            switch self {
137
            case .power: return "W"
Bogdan Timofte authored a month ago
138
            case .energy: return "Wh"
Bogdan Timofte authored 2 months ago
139
            case .voltage: return "V"
140
            case .current: return "A"
Bogdan Timofte authored 2 months ago
141
            case .temperature: return ""
Bogdan Timofte authored 2 months ago
142
            }
143
        }
144

            
145
        var tint: Color {
146
            switch self {
147
            case .power: return .red
Bogdan Timofte authored a month ago
148
            case .energy: return .teal
Bogdan Timofte authored 2 months ago
149
            case .voltage: return .green
150
            case .current: return .blue
Bogdan Timofte authored 2 months ago
151
            case .temperature: return .orange
Bogdan Timofte authored 2 months ago
152
            }
153
        }
154
    }
155

            
156
    private struct SeriesData {
157
        let kind: SeriesKind
158
        let points: [Measurements.Measurement.Point]
159
        let samplePoints: [Measurements.Measurement.Point]
160
        let context: ChartContext
161
        let autoLowerBound: Double
162
        let autoUpperBound: Double
163
        let maximumSampleValue: Double?
164
    }
165

            
Bogdan Timofte authored a month ago
166
    private struct SeriesLegendEntry: Identifiable {
167
        let id: SeriesKind
168
        let name: String
169
        let tint: Color
170
        let minimumText: String
171
        let averageText: String
172
        let maximumText: String
173
        let lastText: String
174
    }
175

            
Bogdan Timofte authored 2 months ago
176
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
177
    private let minimumVoltageSpan = 0.5
178
    private let minimumCurrentSpan = 0.5
179
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored a month ago
180
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
181
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored 2 months ago
182
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored a month ago
183
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
184

            
Bogdan Timofte authored a month ago
185
    let sizing: MeasurementChartSizing
Bogdan Timofte authored a month ago
186
    let showsRangeSelector: Bool
187
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored a month ago
188
    let extendsTimelineToPresent: Bool
Bogdan Timofte authored a month ago
189
    let showsTemperatureSeries: Bool
Bogdan Timofte authored a month ago
190
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored a month ago
191

            
Bogdan Timofte authored 2 months ago
192
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
193
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
194
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
195
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a month ago
196
    let timeRangeLowerBound: Date?
197
    let timeRangeUpperBound: Date?
Bogdan Timofte authored a month ago
198

            
199
    @State private var embeddedWidth: CGFloat = 760
200

            
201
    private var compactLayout: Bool {
202
        switch sizing {
203
        case .provided(_, let compact): return compact
204
        case .embedded: return embeddedWidth < 760
205
        }
206
    }
207

            
208
    private var availableSize: CGSize {
209
        switch sizing {
210
        case .provided(let size, _): return size
211
        case .embedded:
212
            let h = compactLayout ? 290 : 350
213
            return CGSize(width: embeddedWidth, height: CGFloat(h))
214
        }
215
    }
216

            
Bogdan Timofte authored 2 months ago
217
    @State var displayVoltage: Bool = false
218
    @State var displayCurrent: Bool = false
219
    @State var displayPower: Bool = true
Bogdan Timofte authored a month ago
220
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 2 months ago
221
    @State var displayTemperature: Bool = false
Bogdan Timofte authored a month ago
222
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored 2 months ago
223
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 2 months ago
224
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
225
    @State private var isPinnedToPresent: Bool = false
Bogdan Timofte authored a month ago
226
    @State private var presentTrackingMode: PresentTrackingMode = .keepStartTimestamp
Bogdan Timofte authored 2 months ago
227
    @State private var pinOrigin: Bool = false
228
    @State private var useSharedOrigin: Bool = false
229
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
230
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored 2 months ago
231
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored a month ago
232
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
233
    @State private var voltageAxisOrigin: Double = 0
234
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
235
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
236
    let xLabels: Int = 4
237
    let yLabels: Int = 4
238

            
Bogdan Timofte authored 2 months ago
239
    init(
Bogdan Timofte authored a month ago
240
        sizing: MeasurementChartSizing = .embedded,
Bogdan Timofte authored a month ago
241
        timeRange: ClosedRange<Date>? = nil,
Bogdan Timofte authored a month ago
242
        timeRangeLowerBound: Date? = nil,
243
        timeRangeUpperBound: Date? = nil,
Bogdan Timofte authored a month ago
244
        showsRangeSelector: Bool = true,
Bogdan Timofte authored a month ago
245
        rebasesEnergyToVisibleRangeStart: Bool = false,
246
        extendsTimelineToPresent: Bool = true,
Bogdan Timofte authored a month ago
247
        showsTemperatureSeries: Bool = true,
Bogdan Timofte authored a month ago
248
        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
Bogdan Timofte authored 2 months ago
249
    ) {
Bogdan Timofte authored a month ago
250
        self.sizing = sizing
Bogdan Timofte authored 2 months ago
251
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
252
        self.timeRangeLowerBound = timeRangeLowerBound
253
        self.timeRangeUpperBound = timeRangeUpperBound
Bogdan Timofte authored a month ago
254
        self.showsRangeSelector = showsRangeSelector
255
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored a month ago
256
        self.extendsTimelineToPresent = extendsTimelineToPresent
Bogdan Timofte authored a month ago
257
        self.showsTemperatureSeries = showsTemperatureSeries
Bogdan Timofte authored a month ago
258
        self.rangeSelectorConfiguration = rangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
259
    }
260

            
Bogdan Timofte authored a month ago
261
    private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
262
        let compact = width < 760
Bogdan Timofte authored a month ago
263
        let plotHeight: CGFloat = compact ? 240 : 300
264
        let toolbarHeight: CGFloat = width < 640
265
            ? (compact ? 92 : 104)
266
            : (compact ? 48 : 56)
267
        let legendHeight: CGFloat = compact ? 76 : 90
268
        let outerSpacing: CGFloat = 12
269
        let chartStackSpacing: CGFloat = compact ? 8 : 10
270
        let selectorHeight = showsRangeSelector
271
            ? TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
272
            : 0
273
        let selectorSpacing = showsRangeSelector ? chartStackSpacing : 0
274

            
275
        return toolbarHeight
276
            + outerSpacing
277
            + plotHeight
278
            + selectorSpacing
279
            + selectorHeight
280
            + chartStackSpacing
281
            + legendHeight
Bogdan Timofte authored a month ago
282
    }
283

            
Bogdan Timofte authored 2 months ago
284
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
285
        if compactLayout {
286
            return 38
287
        }
288
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
289
    }
290

            
291
    private var chartSectionSpacing: CGFloat {
292
        compactLayout ? 6 : 8
293
    }
294

            
295
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
296
        if compactLayout {
297
            return 24
298
        }
299
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
300
    }
301

            
Bogdan Timofte authored 2 months ago
302
    private var isPortraitLayout: Bool {
303
        guard availableSize != .zero else { return verticalSizeClass != .compact }
304
        return availableSize.height >= availableSize.width
305
    }
306

            
Bogdan Timofte authored 2 months ago
307
    private var isIPhone: Bool {
308
        #if os(iOS)
309
        return UIDevice.current.userInterfaceIdiom == .phone
310
        #else
311
        return false
312
        #endif
313
    }
314

            
Bogdan Timofte authored a month ago
315
    private var plotSectionHeight: CGFloat {
316
        if case .embedded = sizing {
317
            return compactLayout ? 240 : 300
Bogdan Timofte authored 2 months ago
318
        }
319

            
Bogdan Timofte authored 2 months ago
320
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
321
            return compactLayout ? 300 : 380
322
        }
323

            
324
        if isPortraitLayout {
325
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
326
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
327
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
328
        }
329

            
330
        if compactLayout {
331
            return min(max(availableSize.height * 0.36, 240), 300)
332
        }
333

            
334
        return min(max(availableSize.height * 0.5, 300), 440)
335
    }
336

            
337
    private var stackedToolbarLayout: Bool {
338
        if availableSize.width > 0 {
339
            return availableSize.width < 640
340
        }
341

            
342
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
343
    }
344

            
345
    private var showsLabeledOriginControls: Bool {
346
        !compactLayout && !stackedToolbarLayout
347
    }
348

            
Bogdan Timofte authored 2 months ago
349
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
350
        #if os(iOS)
351
        if UIDevice.current.userInterfaceIdiom == .phone {
352
            return false
353
        }
354
        #endif
355

            
Bogdan Timofte authored 2 months ago
356
        if availableSize.width > 0 {
357
            return availableSize.width >= 900 || availableSize.height >= 700
358
        }
359
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
360
    }
361

            
362
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
363
        if isIPhone && isPortraitLayout {
364
            return .caption
365
        }
366
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
367
    }
368

            
Bogdan Timofte authored 2 months ago
369
    private var usesCompactLandscapeOriginControls: Bool {
370
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
371
    }
372

            
Bogdan Timofte authored 2 months ago
373
    var body: some View {
Bogdan Timofte authored a month ago
374
        Group {
375
            switch sizing {
376
            case .provided:
377
                chartBody
Bogdan Timofte authored a month ago
378
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a month ago
379
            case .embedded:
380
                chartBody
381
                    .frame(maxWidth: .infinity, alignment: .topLeading)
382
                    .background(
383
                        GeometryReader { geometry in
384
                            Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
385
                        }
386
                    )
387
                    .onPreferenceChange(EmbeddedWidthKey.self) { width in
388
                        guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
389
                        embeddedWidth = width
Bogdan Timofte authored a month ago
390
                    }
Bogdan Timofte authored a month ago
391
            }
392
        }
393
        .onAppear(perform: resetHiddenTemperatureDisplay)
394
        .onChange(of: showsTemperatureSeries) { _ in
395
            resetHiddenTemperatureDisplay()
Bogdan Timofte authored a month ago
396
        }
397
    }
398

            
Bogdan Timofte authored a month ago
399
    private func resetHiddenTemperatureDisplay() {
400
        guard !showsTemperatureSeries, displayTemperature else { return }
401
        displayTemperature = false
402
    }
403

            
Bogdan Timofte authored a month ago
404
    @ViewBuilder
405
    private var chartBody: some View {
Bogdan Timofte authored 2 months ago
406
        let availableTimeRange = availableSelectionTimeRange()
407
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
408
        let powerSeries = series(
409
            for: measurements.power,
410
            kind: .power,
411
            minimumYSpan: minimumPowerSpan,
412
            visibleTimeRange: visibleTimeRange
413
        )
Bogdan Timofte authored a month ago
414
        let energySeries = series(
415
            for: measurements.energy,
416
            kind: .energy,
417
            minimumYSpan: minimumEnergySpan,
418
            visibleTimeRange: visibleTimeRange
419
        )
Bogdan Timofte authored 2 months ago
420
        let voltageSeries = series(
421
            for: measurements.voltage,
422
            kind: .voltage,
423
            minimumYSpan: minimumVoltageSpan,
424
            visibleTimeRange: visibleTimeRange
425
        )
426
        let currentSeries = series(
427
            for: measurements.current,
428
            kind: .current,
429
            minimumYSpan: minimumCurrentSpan,
430
            visibleTimeRange: visibleTimeRange
431
        )
432
        let temperatureSeries = series(
433
            for: measurements.temperature,
434
            kind: .temperature,
435
            minimumYSpan: minimumTemperatureSpan,
436
            visibleTimeRange: visibleTimeRange
437
        )
Bogdan Timofte authored 2 months ago
438
        let primarySeries = displayedPrimarySeries(
439
            powerSeries: powerSeries,
Bogdan Timofte authored a month ago
440
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
441
            voltageSeries: voltageSeries,
442
            currentSeries: currentSeries
443
        )
Bogdan Timofte authored 2 months ago
444
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
445

            
Bogdan Timofte authored 2 months ago
446
        Group {
Bogdan Timofte authored 2 months ago
447
            if let primarySeries {
448
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
449
                    chartTopToolbar(
450
                        voltageSeries: voltageSeries,
451
                        currentSeries: currentSeries
452
                    )
Bogdan Timofte authored 2 months ago
453

            
Bogdan Timofte authored a month ago
454
                    VStack(spacing: compactLayout ? 8 : 10) {
455
                        GeometryReader { geometry in
Bogdan Timofte authored a month ago
456
                            let minimumPlotHeight: CGFloat = compactLayout
457
                                ? (isPortraitLayout ? 180 : 120)
458
                                : 220
459
                            let plotHeight = max(geometry.size.height - xAxisHeight, minimumPlotHeight)
Bogdan Timofte authored a month ago
460

            
461
                            VStack(spacing: 6) {
462
                                HStack(spacing: chartSectionSpacing) {
463
                                    primaryAxisView(
464
                                        height: plotHeight,
465
                                        powerSeries: powerSeries,
466
                                        energySeries: energySeries,
467
                                        voltageSeries: voltageSeries,
468
                                        currentSeries: currentSeries
469
                                    )
470
                                    .frame(width: axisColumnWidth, height: plotHeight)
471

            
472
                                    ZStack {
473
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
474
                                            .fill(Color.primary.opacity(0.05))
475

            
476
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
477
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
478

            
479
                                        horizontalGuides(context: primarySeries.context)
480
                                        verticalGuides(context: primarySeries.context)
481
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
482
                                        renderedChart(
483
                                            powerSeries: powerSeries,
484
                                            energySeries: energySeries,
485
                                            voltageSeries: voltageSeries,
486
                                            currentSeries: currentSeries,
487
                                            temperatureSeries: temperatureSeries
488
                                        )
489
                                    }
490
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
491
                                    .frame(maxWidth: .infinity)
492
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
493

            
Bogdan Timofte authored a month ago
494
                                    secondaryAxisView(
495
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
496
                                        powerSeries: powerSeries,
Bogdan Timofte authored a month ago
497
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
498
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
499
                                        currentSeries: currentSeries,
500
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
501
                                    )
Bogdan Timofte authored a month ago
502
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
503
                                }
Bogdan Timofte authored 2 months ago
504

            
Bogdan Timofte authored a month ago
505
                                xAxisLabelsView(context: primarySeries.context)
506
                                    .frame(height: xAxisHeight)
Bogdan Timofte authored 2 months ago
507
                            }
Bogdan Timofte authored a month ago
508
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
509
                        }
510
                        .frame(height: plotSectionHeight)
511

            
Bogdan Timofte authored a month ago
512
                        chartLegend(
513
                            entries: chartLegendEntries(
514
                                powerSeries: powerSeries,
515
                                energySeries: energySeries,
516
                                voltageSeries: voltageSeries,
517
                                currentSeries: currentSeries,
518
                                temperatureSeries: temperatureSeries
519
                            )
520
                        )
521

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

            
Bogdan Timofte authored a month ago
564
    private func chartTopToolbar(
565
        voltageSeries: SeriesData,
566
        currentSeries: SeriesData
567
    ) -> some View {
Bogdan Timofte authored 2 months ago
568
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
569
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
Bogdan Timofte authored 2 months ago
570

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

            
Bogdan Timofte authored a month ago
585
        let controlPanel = chartControlsPanel(
586
            voltageSeries: voltageSeries,
587
            currentSeries: currentSeries,
588
            condensedLayout: condensedLayout
589
        )
590

            
Bogdan Timofte authored 2 months ago
591
        return Group {
Bogdan Timofte authored 2 months ago
592
            if stackedToolbarLayout {
Bogdan Timofte authored a month ago
593
                VStack(alignment: .leading, spacing: 8) {
594
                    seriesPanel
595
                    controlPanel
596
                }
Bogdan Timofte authored 2 months ago
597
            } else {
Bogdan Timofte authored a month ago
598
                HStack(alignment: .center, spacing: isLargeDisplay ? 14 : 12) {
599
                    seriesPanel
600
                    Spacer(minLength: 0)
601
                    controlPanel
Bogdan Timofte authored 2 months ago
602
                }
Bogdan Timofte authored 2 months ago
603
            }
604
        }
605
        .frame(maxWidth: .infinity, alignment: .leading)
606
    }
607

            
Bogdan Timofte authored a month ago
608
    private func chartControlsPanel(
Bogdan Timofte authored 2 months ago
609
        voltageSeries: SeriesData,
Bogdan Timofte authored a month ago
610
        currentSeries: SeriesData,
611
        condensedLayout: Bool
Bogdan Timofte authored 2 months ago
612
    ) -> some View {
Bogdan Timofte authored a month ago
613
        originControlsRow(
Bogdan Timofte authored 2 months ago
614
            voltageSeries: voltageSeries,
615
            currentSeries: currentSeries,
616
            condensedLayout: condensedLayout,
Bogdan Timofte authored a month ago
617
            showsLabel: showsLabeledOriginControls && !stackedToolbarLayout
Bogdan Timofte authored 2 months ago
618
        )
Bogdan Timofte authored a month ago
619
        .padding(.horizontal, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
620
        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
621
        .background(
Bogdan Timofte authored a month ago
622
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
623
                .fill(Color.primary.opacity(0.045))
Bogdan Timofte authored 2 months ago
624
        )
625
        .overlay(
Bogdan Timofte authored a month ago
626
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
627
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
Bogdan Timofte authored 2 months ago
628
        )
629
    }
630

            
Bogdan Timofte authored a month ago
631
    private func chartLegendEntries(
632
        powerSeries: SeriesData,
633
        energySeries: SeriesData,
634
        voltageSeries: SeriesData,
635
        currentSeries: SeriesData,
636
        temperatureSeries: SeriesData
637
    ) -> [SeriesLegendEntry] {
638
        var entries: [SeriesLegendEntry] = []
639

            
640
        if displayPower {
641
            entries.append(contentsOf: legendEntry(for: powerSeries))
642
        } else if displayEnergy {
643
            entries.append(contentsOf: legendEntry(for: energySeries))
644
        } else {
645
            if displayVoltage {
646
                entries.append(contentsOf: legendEntry(for: voltageSeries))
647
            }
648
            if displayCurrent {
649
                entries.append(contentsOf: legendEntry(for: currentSeries))
650
            }
651
        }
652

            
653
        if displayTemperature {
654
            entries.append(contentsOf: legendEntry(for: temperatureSeries))
655
        }
656

            
657
        return entries
658
    }
659

            
660
    private func legendEntry(for series: SeriesData) -> [SeriesLegendEntry] {
661
        let samples = series.samplePoints
662
        guard
663
            let minimumValue = samples.map(\.value).min(),
664
            let maximumValue = samples.map(\.value).max(),
665
            let lastValue = samples.last?.value
666
        else {
667
            return []
668
        }
669

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

            
672
        return [
673
            SeriesLegendEntry(
674
                id: series.kind,
675
                name: series.kind.displayName,
676
                tint: series.kind.tint,
677
                minimumText: legendValueText(minimumValue, for: series.kind),
678
                averageText: legendValueText(averageValue, for: series.kind),
679
                maximumText: legendValueText(maximumValue, for: series.kind),
680
                lastText: legendValueText(lastValue, for: series.kind)
681
            )
682
        ]
683
    }
684

            
685
    @ViewBuilder
686
    private func chartLegend(entries: [SeriesLegendEntry]) -> some View {
687
        if !entries.isEmpty {
688
            let nameWidth: CGFloat = compactLayout ? 88 : (isLargeDisplay ? 128 : 108)
689
            let valueWidth: CGFloat = compactLayout ? 78 : (isLargeDisplay ? 108 : 92)
690

            
691
            ScrollView(.horizontal, showsIndicators: false) {
692
                VStack(alignment: .leading, spacing: compactLayout ? 5 : 7) {
693
                    HStack(spacing: compactLayout ? 8 : 10) {
694
                        legendHeaderText("Measurement", width: nameWidth, alignment: .leading)
695
                        legendHeaderText("Min", width: valueWidth)
696
                        legendHeaderText("Avg", width: valueWidth)
697
                        legendHeaderText("Max", width: valueWidth)
698
                        legendHeaderText("Last", width: valueWidth)
699
                    }
700

            
701
                    ForEach(entries) { entry in
702
                        HStack(spacing: compactLayout ? 8 : 10) {
703
                            HStack(spacing: 6) {
704
                                Circle()
705
                                    .fill(entry.tint)
706
                                    .frame(width: compactLayout ? 7 : 8, height: compactLayout ? 7 : 8)
707

            
708
                                Text(entry.name)
709
                                    .lineLimit(1)
710
                                    .minimumScaleFactor(0.82)
711
                            }
712
                            .frame(width: nameWidth, alignment: .leading)
713

            
714
                            legendValueText(entry.minimumText, width: valueWidth)
715
                            legendValueText(entry.averageText, width: valueWidth)
716
                            legendValueText(entry.maximumText, width: valueWidth)
717
                            legendValueText(entry.lastText, width: valueWidth)
718
                        }
719
                    }
720
                }
721
                .padding(.horizontal, compactLayout ? 10 : 12)
722
                .padding(.vertical, compactLayout ? 8 : 10)
723
            }
724
            .background(
725
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
726
                    .fill(Color.primary.opacity(0.045))
727
            )
728
            .overlay(
729
                RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous)
730
                    .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
731
            )
732
        }
733
    }
734

            
735
    private func legendHeaderText(
736
        _ text: String,
737
        width: CGFloat,
738
        alignment: Alignment = .trailing
739
    ) -> some View {
740
        Text(text)
741
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
742
            .foregroundColor(.secondary)
743
            .textCase(.uppercase)
744
            .lineLimit(1)
745
            .frame(width: width, alignment: alignment)
746
    }
747

            
748
    private func legendValueText(
749
        _ text: String,
750
        width: CGFloat
751
    ) -> some View {
752
        Text(text)
753
            .font((compactLayout ? Font.caption2 : .caption).weight(.semibold))
754
            .monospacedDigit()
755
            .lineLimit(1)
756
            .minimumScaleFactor(0.78)
757
            .frame(width: width, alignment: .trailing)
758
    }
759

            
760
    private func legendValueText(
761
        _ value: Double,
762
        for kind: SeriesKind
763
    ) -> String {
764
        let decimalDigits: Int
765
        switch kind {
766
        case .power:
767
            decimalDigits = 2
768
        case .energy, .voltage, .current:
769
            decimalDigits = 3
770
        case .temperature:
771
            decimalDigits = 1
772
        }
773

            
774
        let formattedValue = value.format(decimalDigits: decimalDigits)
775
        let unit = measurementUnit(for: kind)
776
        guard !unit.isEmpty else { return formattedValue }
777

            
778
        if kind == .temperature {
779
            return "\(formattedValue)\(unit)"
780
        }
781
        return "\(formattedValue) \(unit)"
782
    }
783

            
Bogdan Timofte authored 2 months ago
784
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
785
        HStack(spacing: condensedLayout ? 6 : 8) {
786
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
787
                displayVoltage.toggle()
788
                if displayVoltage {
789
                    displayPower = false
Bogdan Timofte authored a month ago
790
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
791
                    if displayTemperature && displayCurrent {
792
                        displayCurrent = false
793
                    }
Bogdan Timofte authored 2 months ago
794
                }
795
            }
796

            
797
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
798
                displayCurrent.toggle()
799
                if displayCurrent {
800
                    displayPower = false
Bogdan Timofte authored a month ago
801
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
802
                    if displayTemperature && displayVoltage {
803
                        displayVoltage = false
804
                    }
Bogdan Timofte authored 2 months ago
805
                }
Bogdan Timofte authored 2 months ago
806
            }
807

            
808
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
809
                displayPower.toggle()
810
                if displayPower {
Bogdan Timofte authored a month ago
811
                    displayEnergy = false
812
                    displayCurrent = false
813
                    displayVoltage = false
814
                }
815
            }
816

            
817
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
818
                displayEnergy.toggle()
819
                if displayEnergy {
820
                    displayPower = false
Bogdan Timofte authored 2 months ago
821
                    displayCurrent = false
822
                    displayVoltage = false
823
                }
824
            }
Bogdan Timofte authored 2 months ago
825

            
Bogdan Timofte authored a month ago
826
            if showsTemperatureSeries {
827
                seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
828
                    displayTemperature.toggle()
829
                    if displayTemperature && displayVoltage && displayCurrent {
830
                        displayCurrent = false
831
                    }
Bogdan Timofte authored 2 months ago
832
                }
833
            }
Bogdan Timofte authored 2 months ago
834
        }
835
    }
836

            
837
    private func originControlsRow(
838
        voltageSeries: SeriesData,
839
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
840
        condensedLayout: Bool,
841
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
842
    ) -> some View {
Bogdan Timofte authored 2 months ago
843
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
844
            if supportsSharedOrigin {
845
                symbolControlChip(
846
                    systemImage: "equal.circle",
847
                    enabled: true,
848
                    active: useSharedOrigin,
849
                    condensedLayout: condensedLayout,
850
                    showsLabel: showsLabel,
851
                    label: "Match Y Scale",
852
                    accessibilityLabel: "Match Y scale"
853
                ) {
854
                    toggleSharedOrigin(
855
                        voltageSeries: voltageSeries,
856
                        currentSeries: currentSeries
857
                    )
858
                }
Bogdan Timofte authored 2 months ago
859
            }
860

            
861
            symbolControlChip(
862
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
863
                enabled: true,
864
                active: pinOrigin,
865
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
866
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
867
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
868
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
869
            ) {
870
                togglePinnedOrigin(
871
                    voltageSeries: voltageSeries,
872
                    currentSeries: currentSeries
873
                )
874
            }
875

            
Bogdan Timofte authored 2 months ago
876
            if !pinnedOriginIsZero {
877
                symbolControlChip(
878
                    systemImage: "0.circle",
879
                    enabled: true,
880
                    active: false,
881
                    condensedLayout: condensedLayout,
882
                    showsLabel: showsLabel,
883
                    label: "Origin 0",
884
                    accessibilityLabel: "Set origin to zero"
885
                ) {
886
                    setVisibleOriginsToZero()
887
                }
Bogdan Timofte authored 2 months ago
888
            }
Bogdan Timofte authored 2 months ago
889

            
Bogdan Timofte authored a month ago
890
            smoothingControlChip(
891
                condensedLayout: condensedLayout,
892
                showsLabel: showsLabel
893
            )
894

            
Bogdan Timofte authored 2 months ago
895
        }
896
    }
897

            
Bogdan Timofte authored a month ago
898
    private func smoothingControlChip(
899
        condensedLayout: Bool,
900
        showsLabel: Bool
901
    ) -> some View {
902
        Menu {
903
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
904
                Button {
905
                    smoothingLevel = level
906
                } label: {
907
                    if smoothingLevel == level {
908
                        Label(level.label, systemImage: "checkmark")
909
                    } else {
910
                        Text(level.label)
Bogdan Timofte authored a month ago
911
                    }
912
                }
Bogdan Timofte authored a month ago
913
            }
914
        } label: {
915
            Group {
916
                if showsLabel {
917
                    VStack(alignment: .leading, spacing: 2) {
918
                        Label("Smoothing", systemImage: "waveform.path")
919
                            .font(controlChipFont(condensedLayout: condensedLayout))
920

            
921
                        Text(
Bogdan Timofte authored a month ago
922
                            smoothingLevel == .off
Bogdan Timofte authored a month ago
923
                            ? "Off"
924
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored a month ago
925
                        )
Bogdan Timofte authored a month ago
926
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
927
                        .foregroundColor(.secondary)
928
                        .monospacedDigit()
929
                    }
930
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
931
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
932
                } else {
933
                    VStack(spacing: 1) {
934
                        Image(systemName: "waveform.path")
935
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
936

            
937
                        Text(smoothingLevel.shortLabel)
938
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
939
                            .monospacedDigit()
940
                    }
941
                    .frame(
942
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
943
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
944
                    )
945
                }
Bogdan Timofte authored a month ago
946
            }
Bogdan Timofte authored a month ago
947
            .background(
948
                Capsule(style: .continuous)
949
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
950
            )
951
            .overlay(
952
                Capsule(style: .continuous)
953
                    .stroke(
954
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
955
                        lineWidth: 1
956
                    )
957
            )
Bogdan Timofte authored a month ago
958
        }
Bogdan Timofte authored a month ago
959
        .buttonStyle(.plain)
960
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored a month ago
961
    }
962

            
Bogdan Timofte authored 2 months ago
963
    private func seriesToggleButton(
964
        title: String,
965
        isOn: Bool,
966
        condensedLayout: Bool,
967
        action: @escaping () -> Void
968
    ) -> some View {
969
        Button(action: action) {
970
            Text(title)
Bogdan Timofte authored 2 months ago
971
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
972
                .lineLimit(1)
973
                .minimumScaleFactor(0.82)
974
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
975
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
976
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
977
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
978
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
979
                .background(
980
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
981
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
982
                )
983
                .overlay(
984
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
985
                        .stroke(Color.blue, lineWidth: 1.5)
986
                )
987
        }
988
        .buttonStyle(.plain)
989
    }
990

            
991
    private func symbolControlChip(
992
        systemImage: String,
993
        enabled: Bool,
994
        active: Bool,
995
        condensedLayout: Bool,
996
        showsLabel: Bool,
997
        label: String,
998
        accessibilityLabel: String,
999
        action: @escaping () -> Void
1000
    ) -> some View {
1001
        Button(action: {
1002
            action()
1003
        }) {
1004
            Group {
1005
                if showsLabel {
1006
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
1007
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
1008
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
1009
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
1010
                } else {
1011
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
1012
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
1013
                        .frame(
Bogdan Timofte authored 2 months ago
1014
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
1015
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
1016
                        )
Bogdan Timofte authored 2 months ago
1017
                }
1018
            }
1019
                .background(
1020
                    Capsule(style: .continuous)
1021
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
1022
                )
1023
        }
1024
        .buttonStyle(.plain)
1025
        .foregroundColor(enabled ? .primary : .secondary)
1026
        .opacity(enabled ? 1 : 0.55)
1027
        .accessibilityLabel(accessibilityLabel)
1028
    }
1029

            
Bogdan Timofte authored a month ago
1030
    private func resetBuffer() {
1031
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
1032
    }
1033

            
Bogdan Timofte authored a month ago
1034
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
1035
        if let rangeSelectorConfiguration {
1036
            return rangeSelectorConfiguration
1037
        }
1038

            
1039
        return MeasurementChartRangeSelectorConfiguration(
1040
            keepAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1041
                title: "Keep Selection",
1042
                shortTitle: "Keep",
Bogdan Timofte authored a month ago
1043
                systemName: "scissors",
1044
                tone: .destructive,
1045
                handler: trimBufferToSelection
1046
            ),
1047
            removeAction: MeasurementChartSelectionAction(
Bogdan Timofte authored a month ago
1048
                title: "Remove Selection",
1049
                shortTitle: "Cut",
Bogdan Timofte authored a month ago
1050
                systemName: "minus.circle",
1051
                tone: .destructive,
1052
                handler: removeSelectionFromBuffer
1053
            ),
1054
            resetAction: MeasurementChartResetAction(
Bogdan Timofte authored a month ago
1055
                title: "Reset Buffer",
1056
                shortTitle: "Reset",
Bogdan Timofte authored a month ago
1057
                systemName: "trash",
1058
                tone: .destructiveProminent,
1059
                confirmationTitle: "Reset captured measurements?",
1060
                confirmationButtonTitle: "Reset buffer",
1061
                handler: resetBuffer
1062
            )
1063
        )
1064
    }
1065

            
Bogdan Timofte authored 2 months ago
1066
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
1067
        if isLargeDisplay {
1068
            return .body.weight(.semibold)
1069
        }
1070
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
1071
    }
1072

            
1073
    private func controlChipFont(condensedLayout: Bool) -> Font {
1074
        if isLargeDisplay {
1075
            return .callout.weight(.semibold)
1076
        }
1077
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
1078
    }
1079

            
Bogdan Timofte authored 2 months ago
1080
    @ViewBuilder
1081
    private func primaryAxisView(
1082
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1083
        powerSeries: SeriesData,
Bogdan Timofte authored a month ago
1084
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1085
        voltageSeries: SeriesData,
1086
        currentSeries: SeriesData
Bogdan Timofte authored 2 months ago
1087
    ) -> some View {
1088
        if displayPower {
1089
            yAxisLabelsView(
1090
                height: height,
1091
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
1092
                seriesKind: .power,
1093
                measurementUnit: powerSeries.kind.unit,
1094
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
1095
            )
Bogdan Timofte authored a month ago
1096
        } else if displayEnergy {
1097
            yAxisLabelsView(
1098
                height: height,
1099
                context: energySeries.context,
1100
                seriesKind: .energy,
1101
                measurementUnit: energySeries.kind.unit,
1102
                tint: energySeries.kind.tint
1103
            )
Bogdan Timofte authored 2 months ago
1104
        } else if displayVoltage {
1105
            yAxisLabelsView(
1106
                height: height,
1107
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
1108
                seriesKind: .voltage,
1109
                measurementUnit: voltageSeries.kind.unit,
1110
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
1111
            )
1112
        } else if displayCurrent {
1113
            yAxisLabelsView(
1114
                height: height,
1115
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1116
                seriesKind: .current,
1117
                measurementUnit: currentSeries.kind.unit,
1118
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1119
            )
1120
        }
1121
    }
1122

            
1123
    @ViewBuilder
1124
    private func renderedChart(
Bogdan Timofte authored 2 months ago
1125
        powerSeries: SeriesData,
Bogdan Timofte authored a month ago
1126
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1127
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1128
        currentSeries: SeriesData,
1129
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
1130
    ) -> some View {
1131
        if self.displayPower {
Bogdan Timofte authored a month ago
1132
            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1133
                .opacity(0.72)
Bogdan Timofte authored a month ago
1134
        } else if self.displayEnergy {
Bogdan Timofte authored a month ago
1135
            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
Bogdan Timofte authored a month ago
1136
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
1137
        } else {
1138
            if self.displayVoltage {
Bogdan Timofte authored a month ago
1139
                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1140
                    .opacity(0.78)
1141
            }
1142
            if self.displayCurrent {
Bogdan Timofte authored a month ago
1143
                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1144
                    .opacity(0.78)
1145
            }
1146
        }
Bogdan Timofte authored 2 months ago
1147

            
1148
        if displayTemperature {
Bogdan Timofte authored a month ago
1149
            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
Bogdan Timofte authored 2 months ago
1150
                .opacity(0.86)
1151
        }
Bogdan Timofte authored 2 months ago
1152
    }
1153

            
1154
    @ViewBuilder
1155
    private func secondaryAxisView(
1156
        height: CGFloat,
Bogdan Timofte authored 2 months ago
1157
        powerSeries: SeriesData,
Bogdan Timofte authored a month ago
1158
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1159
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
1160
        currentSeries: SeriesData,
1161
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
1162
    ) -> some View {
Bogdan Timofte authored 2 months ago
1163
        if displayTemperature {
1164
            yAxisLabelsView(
1165
                height: height,
1166
                context: temperatureSeries.context,
1167
                seriesKind: .temperature,
1168
                measurementUnit: measurementUnit(for: .temperature),
1169
                tint: temperatureSeries.kind.tint
1170
            )
1171
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
1172
            yAxisLabelsView(
1173
                height: height,
1174
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
1175
                seriesKind: .current,
1176
                measurementUnit: currentSeries.kind.unit,
1177
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
1178
            )
1179
        } else {
1180
            primaryAxisView(
1181
                height: height,
1182
                powerSeries: powerSeries,
Bogdan Timofte authored a month ago
1183
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
1184
                voltageSeries: voltageSeries,
1185
                currentSeries: currentSeries
1186
            )
Bogdan Timofte authored 2 months ago
1187
        }
1188
    }
Bogdan Timofte authored 2 months ago
1189

            
1190
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
1191
        powerSeries: SeriesData,
Bogdan Timofte authored a month ago
1192
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
1193
        voltageSeries: SeriesData,
1194
        currentSeries: SeriesData
1195
    ) -> SeriesData? {
Bogdan Timofte authored 2 months ago
1196
        if displayPower {
Bogdan Timofte authored 2 months ago
1197
            return powerSeries
Bogdan Timofte authored 2 months ago
1198
        }
Bogdan Timofte authored a month ago
1199
        if displayEnergy {
1200
            return energySeries
1201
        }
Bogdan Timofte authored 2 months ago
1202
        if displayVoltage {
Bogdan Timofte authored 2 months ago
1203
            return voltageSeries
Bogdan Timofte authored 2 months ago
1204
        }
1205
        if displayCurrent {
Bogdan Timofte authored 2 months ago
1206
            return currentSeries
Bogdan Timofte authored 2 months ago
1207
        }
1208
        return nil
1209
    }
1210

            
1211
    private func series(
1212
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
1213
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
1214
        minimumYSpan: Double,
1215
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1216
    ) -> SeriesData {
Bogdan Timofte authored a month ago
1217
        let rawPoints = filteredPoints(
Bogdan Timofte authored 2 months ago
1218
            measurement,
1219
            visibleTimeRange: visibleTimeRange
1220
        )
Bogdan Timofte authored a month ago
1221
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
1222
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
1223
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
1224
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
1225

            
1226
        let autoBounds = automaticYBounds(
1227
            for: samplePoints,
1228
            minimumYSpan: minimumYSpan
1229
        )
Bogdan Timofte authored 2 months ago
1230
        let xBounds = xBounds(
1231
            for: samplePoints,
1232
            visibleTimeRange: visibleTimeRange
1233
        )
Bogdan Timofte authored 2 months ago
1234
        let lowerBound = resolvedLowerBound(
1235
            for: kind,
1236
            autoLowerBound: autoBounds.lowerBound
1237
        )
1238
        let upperBound = resolvedUpperBound(
1239
            for: kind,
1240
            lowerBound: lowerBound,
1241
            autoUpperBound: autoBounds.upperBound,
1242
            maximumSampleValue: samplePoints.map(\.value).max(),
1243
            minimumYSpan: minimumYSpan
1244
        )
1245

            
1246
        context.setBounds(
1247
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1248
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1249
            yMin: CGFloat(lowerBound),
1250
            yMax: CGFloat(upperBound)
1251
        )
1252

            
1253
        return SeriesData(
1254
            kind: kind,
1255
            points: points,
1256
            samplePoints: samplePoints,
1257
            context: context,
1258
            autoLowerBound: autoBounds.lowerBound,
1259
            autoUpperBound: autoBounds.upperBound,
1260
            maximumSampleValue: samplePoints.map(\.value).max()
1261
        )
1262
    }
1263

            
Bogdan Timofte authored a month ago
1264
    private func normalizedPoints(
1265
        _ points: [Measurements.Measurement.Point],
1266
        for kind: SeriesKind
1267
    ) -> [Measurements.Measurement.Point] {
1268
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1269
            return points
1270
        }
1271

            
1272
        guard let baseline = points.first(where: \.isSample)?.value else {
1273
            return points
1274
        }
1275

            
1276
        return points.enumerated().map { index, point in
1277
            Measurements.Measurement.Point(
1278
                id: point.id == index ? point.id : index,
1279
                timestamp: point.timestamp,
1280
                value: point.value - baseline,
1281
                kind: point.kind
1282
            )
1283
        }
1284
    }
1285

            
Bogdan Timofte authored 2 months ago
1286
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1287
        series(
1288
            for: measurement(for: kind),
1289
            kind: kind,
1290
            minimumYSpan: minimumYSpan(for: kind)
1291
        )
1292
    }
1293

            
Bogdan Timofte authored a month ago
1294
    private func smoothedPoints(
1295
        from points: [Measurements.Measurement.Point]
1296
    ) -> [Measurements.Measurement.Point] {
1297
        guard smoothingLevel != .off else { return points }
1298

            
1299
        var smoothedPoints: [Measurements.Measurement.Point] = []
1300
        var currentSegment: [Measurements.Measurement.Point] = []
1301

            
1302
        func flushCurrentSegment() {
1303
            guard !currentSegment.isEmpty else { return }
1304

            
1305
            for point in smoothedSegment(currentSegment) {
1306
                smoothedPoints.append(
1307
                    Measurements.Measurement.Point(
1308
                        id: smoothedPoints.count,
1309
                        timestamp: point.timestamp,
1310
                        value: point.value,
1311
                        kind: .sample
1312
                    )
1313
                )
1314
            }
1315

            
1316
            currentSegment.removeAll(keepingCapacity: true)
1317
        }
1318

            
1319
        for point in points {
1320
            if point.isDiscontinuity {
1321
                flushCurrentSegment()
1322

            
1323
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1324
                    smoothedPoints.append(
1325
                        Measurements.Measurement.Point(
1326
                            id: smoothedPoints.count,
1327
                            timestamp: point.timestamp,
1328
                            value: smoothedPoints.last?.value ?? point.value,
1329
                            kind: .discontinuity
1330
                        )
1331
                    )
1332
                }
1333
            } else {
1334
                currentSegment.append(point)
1335
            }
1336
        }
1337

            
1338
        flushCurrentSegment()
1339
        return smoothedPoints
1340
    }
1341

            
1342
    private func smoothedSegment(
1343
        _ segment: [Measurements.Measurement.Point]
1344
    ) -> [Measurements.Measurement.Point] {
1345
        let windowSize = smoothingLevel.movingAverageWindowSize
1346
        guard windowSize > 1, segment.count > 2 else { return segment }
1347

            
1348
        let radius = windowSize / 2
1349
        var prefixSums: [Double] = [0]
1350
        prefixSums.reserveCapacity(segment.count + 1)
1351

            
1352
        for point in segment {
1353
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1354
        }
1355

            
1356
        return segment.enumerated().map { index, point in
1357
            let lowerBound = max(0, index - radius)
1358
            let upperBound = min(segment.count - 1, index + radius)
1359
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1360
            let average = sum / Double(upperBound - lowerBound + 1)
1361

            
1362
            return Measurements.Measurement.Point(
1363
                id: point.id,
1364
                timestamp: point.timestamp,
1365
                value: average,
1366
                kind: .sample
1367
            )
1368
        }
1369
    }
1370

            
Bogdan Timofte authored 2 months ago
1371
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1372
        switch kind {
1373
        case .power:
1374
            return measurements.power
Bogdan Timofte authored a month ago
1375
        case .energy:
1376
            return measurements.energy
Bogdan Timofte authored 2 months ago
1377
        case .voltage:
1378
            return measurements.voltage
1379
        case .current:
1380
            return measurements.current
1381
        case .temperature:
1382
            return measurements.temperature
1383
        }
1384
    }
1385

            
1386
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1387
        switch kind {
1388
        case .power:
1389
            return minimumPowerSpan
Bogdan Timofte authored a month ago
1390
        case .energy:
1391
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1392
        case .voltage:
1393
            return minimumVoltageSpan
1394
        case .current:
1395
            return minimumCurrentSpan
1396
        case .temperature:
1397
            return minimumTemperatureSpan
1398
        }
1399
    }
1400

            
Bogdan Timofte authored 2 months ago
1401
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored a month ago
1402
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored 2 months ago
1403
    }
1404

            
Bogdan Timofte authored 2 months ago
1405
    private var minimumSharedScaleSpan: Double {
1406
        max(minimumVoltageSpan, minimumCurrentSpan)
1407
    }
1408

            
Bogdan Timofte authored 2 months ago
1409
    private var pinnedOriginIsZero: Bool {
1410
        if useSharedOrigin && supportsSharedOrigin {
1411
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1412
        }
Bogdan Timofte authored 2 months ago
1413

            
1414
        if displayPower {
1415
            return pinOrigin && powerAxisOrigin == 0
1416
        }
1417

            
Bogdan Timofte authored a month ago
1418
        if displayEnergy {
1419
            return pinOrigin && energyAxisOrigin == 0
1420
        }
1421

            
Bogdan Timofte authored 2 months ago
1422
        let visibleOrigins = [
1423
            displayVoltage ? voltageAxisOrigin : nil,
1424
            displayCurrent ? currentAxisOrigin : nil
1425
        ]
1426
        .compactMap { $0 }
1427

            
1428
        guard !visibleOrigins.isEmpty else { return false }
1429
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1430
    }
1431

            
1432
    private func toggleSharedOrigin(
1433
        voltageSeries: SeriesData,
1434
        currentSeries: SeriesData
1435
    ) {
1436
        guard supportsSharedOrigin else { return }
1437

            
1438
        if useSharedOrigin {
1439
            useSharedOrigin = false
1440
            return
1441
        }
1442

            
1443
        captureCurrentOrigins(
1444
            voltageSeries: voltageSeries,
1445
            currentSeries: currentSeries
1446
        )
1447
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1448
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1449
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1450
        useSharedOrigin = true
1451
        pinOrigin = true
1452
    }
1453

            
1454
    private func togglePinnedOrigin(
1455
        voltageSeries: SeriesData,
1456
        currentSeries: SeriesData
1457
    ) {
1458
        if pinOrigin {
1459
            pinOrigin = false
1460
            return
1461
        }
1462

            
1463
        captureCurrentOrigins(
1464
            voltageSeries: voltageSeries,
1465
            currentSeries: currentSeries
1466
        )
1467
        pinOrigin = true
1468
    }
1469

            
1470
    private func setVisibleOriginsToZero() {
1471
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1472
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1473
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1474
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1475
            voltageAxisOrigin = 0
1476
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1477
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1478
        } else {
1479
            if displayPower {
1480
                powerAxisOrigin = 0
1481
            }
Bogdan Timofte authored a month ago
1482
            if displayEnergy {
1483
                energyAxisOrigin = 0
1484
            }
Bogdan Timofte authored 2 months ago
1485
            if displayVoltage {
1486
                voltageAxisOrigin = 0
1487
            }
1488
            if displayCurrent {
1489
                currentAxisOrigin = 0
1490
            }
Bogdan Timofte authored 2 months ago
1491
            if displayTemperature {
1492
                temperatureAxisOrigin = 0
1493
            }
Bogdan Timofte authored 2 months ago
1494
        }
1495

            
1496
        pinOrigin = true
1497
    }
1498

            
1499
    private func captureCurrentOrigins(
1500
        voltageSeries: SeriesData,
1501
        currentSeries: SeriesData
1502
    ) {
1503
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored a month ago
1504
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1505
        voltageAxisOrigin = voltageSeries.autoLowerBound
1506
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1507
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored 2 months ago
1508
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1509
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1510
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1511
    }
1512

            
1513
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1514
        let visibleTimeRange = activeVisibleTimeRange
1515

            
Bogdan Timofte authored 2 months ago
1516
        switch kind {
1517
        case .power:
Bogdan Timofte authored 2 months ago
1518
            return pinOrigin
1519
                ? powerAxisOrigin
1520
                : automaticYBounds(
1521
                    for: filteredSamplePoints(
1522
                        measurements.power,
1523
                        visibleTimeRange: visibleTimeRange
1524
                    ),
1525
                    minimumYSpan: minimumPowerSpan
1526
                ).lowerBound
Bogdan Timofte authored a month ago
1527
        case .energy:
1528
            return pinOrigin
1529
                ? energyAxisOrigin
1530
                : automaticYBounds(
1531
                    for: filteredSamplePoints(
1532
                        measurements.energy,
1533
                        visibleTimeRange: visibleTimeRange
1534
                    ),
1535
                    minimumYSpan: minimumEnergySpan
1536
                ).lowerBound
Bogdan Timofte authored 2 months ago
1537
        case .voltage:
1538
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1539
                return sharedAxisOrigin
1540
            }
Bogdan Timofte authored 2 months ago
1541
            return pinOrigin
1542
                ? voltageAxisOrigin
1543
                : automaticYBounds(
1544
                    for: filteredSamplePoints(
1545
                        measurements.voltage,
1546
                        visibleTimeRange: visibleTimeRange
1547
                    ),
1548
                    minimumYSpan: minimumVoltageSpan
1549
                ).lowerBound
Bogdan Timofte authored 2 months ago
1550
        case .current:
1551
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1552
                return sharedAxisOrigin
1553
            }
Bogdan Timofte authored 2 months ago
1554
            return pinOrigin
1555
                ? currentAxisOrigin
1556
                : automaticYBounds(
1557
                    for: filteredSamplePoints(
1558
                        measurements.current,
1559
                        visibleTimeRange: visibleTimeRange
1560
                    ),
1561
                    minimumYSpan: minimumCurrentSpan
1562
                ).lowerBound
Bogdan Timofte authored 2 months ago
1563
        case .temperature:
Bogdan Timofte authored 2 months ago
1564
            return pinOrigin
1565
                ? temperatureAxisOrigin
1566
                : automaticYBounds(
1567
                    for: filteredSamplePoints(
1568
                        measurements.temperature,
1569
                        visibleTimeRange: visibleTimeRange
1570
                    ),
1571
                    minimumYSpan: minimumTemperatureSpan
1572
                ).lowerBound
Bogdan Timofte authored 2 months ago
1573
        }
1574
    }
1575

            
Bogdan Timofte authored 2 months ago
1576
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1577
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1578
    }
1579

            
1580
    private func filteredPoints(
1581
        _ measurement: Measurements.Measurement,
1582
        visibleTimeRange: ClosedRange<Date>? = nil
1583
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored a month ago
1584
        let resolvedRange: ClosedRange<Date>?
1585

            
1586
        switch (timeRange, visibleTimeRange) {
1587
        case let (baseRange?, visibleRange?):
1588
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1589
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1590
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1591
        case let (baseRange?, nil):
1592
            resolvedRange = baseRange
1593
        case let (nil, visibleRange?):
1594
            resolvedRange = visibleRange
1595
        case (nil, nil):
1596
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1597
        }
Bogdan Timofte authored a month ago
1598

            
1599
        guard let resolvedRange else {
1600
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1601
        }
1602

            
1603
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1604
    }
1605

            
1606
    private func filteredSamplePoints(
1607
        _ measurement: Measurements.Measurement,
1608
        visibleTimeRange: ClosedRange<Date>? = nil
1609
    ) -> [Measurements.Measurement.Point] {
1610
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1611
            point.isSample
Bogdan Timofte authored 2 months ago
1612
        }
1613
    }
1614

            
1615
    private func xBounds(
Bogdan Timofte authored 2 months ago
1616
        for samplePoints: [Measurements.Measurement.Point],
1617
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1618
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1619
        if let visibleTimeRange {
1620
            return normalizedTimeRange(visibleTimeRange)
1621
        }
1622

            
Bogdan Timofte authored 2 months ago
1623
        if let timeRange {
Bogdan Timofte authored 2 months ago
1624
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1625
        }
1626

            
1627
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1628
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1629

            
Bogdan Timofte authored 2 months ago
1630
        return normalizedTimeRange(lowerBound...upperBound)
1631
    }
1632

            
1633
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1634
        if let timeRange {
1635
            return normalizedTimeRange(timeRange)
1636
        }
1637

            
1638
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1639
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1640
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1641
            return nil
1642
        }
1643

            
Bogdan Timofte authored a month ago
1644
        let latestSampleTimestamp = samplePoints.last?.timestamp
1645
        let resolvedUpperBound = timeRangeUpperBound ?? {
1646
            guard extendsTimelineToPresent else {
1647
                return latestSampleTimestamp ?? lowerBound
1648
            }
1649
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1650
        }()
1651
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1652
        return normalizedTimeRange(lowerBound...upperBound)
1653
    }
1654

            
1655
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1656
        let candidates = [
1657
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored a month ago
1658
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1659
            filteredSamplePoints(measurements.voltage),
1660
            filteredSamplePoints(measurements.current),
1661
            filteredSamplePoints(measurements.temperature)
1662
        ]
1663

            
1664
        return candidates.first(where: { !$0.isEmpty }) ?? []
1665
    }
1666

            
1667
    private func resolvedVisibleTimeRange(
1668
        within availableTimeRange: ClosedRange<Date>?
1669
    ) -> ClosedRange<Date>? {
1670
        guard let availableTimeRange else { return nil }
1671
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1672

            
1673
        if isPinnedToPresent {
1674
            let pinnedRange: ClosedRange<Date>
1675

            
1676
            switch presentTrackingMode {
1677
            case .keepDuration:
1678
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1679
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1680
            case .keepStartTimestamp:
1681
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1682
            }
1683

            
1684
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1685
        }
1686

            
1687
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1688
    }
1689

            
1690
    private func clampedTimeRange(
1691
        _ candidateRange: ClosedRange<Date>,
1692
        within bounds: ClosedRange<Date>
1693
    ) -> ClosedRange<Date> {
1694
        let normalizedBounds = normalizedTimeRange(bounds)
1695
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1696

            
1697
        guard boundsSpan > 0 else {
1698
            return normalizedBounds
1699
        }
1700

            
1701
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1702
        let requestedSpan = min(
1703
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1704
            boundsSpan
1705
        )
1706

            
1707
        if requestedSpan >= boundsSpan {
1708
            return normalizedBounds
1709
        }
1710

            
1711
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1712
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1713

            
1714
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1715
            if lowerBound == normalizedBounds.lowerBound {
1716
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1717
            } else {
1718
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1719
            }
1720
        }
1721

            
1722
        if upperBound > normalizedBounds.upperBound {
1723
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1724
            upperBound = normalizedBounds.upperBound
1725
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1726
        }
1727

            
Bogdan Timofte authored 2 months ago
1728
        if lowerBound < normalizedBounds.lowerBound {
1729
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1730
            lowerBound = normalizedBounds.lowerBound
1731
            upperBound = upperBound.addingTimeInterval(delta)
1732
        }
1733

            
1734
        return lowerBound...upperBound
1735
    }
1736

            
1737
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1738
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1739
        guard span < minimumTimeSpan else { return range }
1740

            
1741
        let expansion = (minimumTimeSpan - span) / 2
1742
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1743
    }
1744

            
1745
    private func shouldShowRangeSelector(
1746
        availableTimeRange: ClosedRange<Date>,
1747
        series: SeriesData
1748
    ) -> Bool {
1749
        series.samplePoints.count > 1 &&
1750
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1751
    }
1752

            
1753
    private func automaticYBounds(
1754
        for samplePoints: [Measurements.Measurement.Point],
1755
        minimumYSpan: Double
1756
    ) -> (lowerBound: Double, upperBound: Double) {
1757
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1758

            
1759
        guard
1760
            let minimumSampleValue = samplePoints.map(\.value).min(),
1761
            let maximumSampleValue = samplePoints.map(\.value).max()
1762
        else {
1763
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1764
        }
Bogdan Timofte authored 2 months ago
1765

            
1766
        var lowerBound = minimumSampleValue
1767
        var upperBound = maximumSampleValue
1768
        let currentSpan = upperBound - lowerBound
1769

            
1770
        if currentSpan < minimumYSpan {
1771
            let expansion = (minimumYSpan - currentSpan) / 2
1772
            lowerBound -= expansion
1773
            upperBound += expansion
1774
        }
1775

            
1776
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1777
            let shift = -negativeAllowance - lowerBound
1778
            lowerBound += shift
1779
            upperBound += shift
1780
        }
1781

            
1782
        let snappedLowerBound = snappedOriginValue(lowerBound)
1783
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1784
        return (snappedLowerBound, resolvedUpperBound)
1785
    }
1786

            
1787
    private func resolvedLowerBound(
1788
        for kind: SeriesKind,
1789
        autoLowerBound: Double
1790
    ) -> Double {
1791
        guard pinOrigin else { return autoLowerBound }
1792

            
1793
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1794
            return sharedAxisOrigin
1795
        }
1796

            
1797
        switch kind {
1798
        case .power:
1799
            return powerAxisOrigin
Bogdan Timofte authored a month ago
1800
        case .energy:
1801
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1802
        case .voltage:
1803
            return voltageAxisOrigin
1804
        case .current:
1805
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1806
        case .temperature:
1807
            return temperatureAxisOrigin
Bogdan Timofte authored 2 months ago
1808
        }
1809
    }
1810

            
1811
    private func resolvedUpperBound(
1812
        for kind: SeriesKind,
1813
        lowerBound: Double,
1814
        autoUpperBound: Double,
1815
        maximumSampleValue: Double?,
1816
        minimumYSpan: Double
1817
    ) -> Double {
1818
        guard pinOrigin else {
1819
            return autoUpperBound
1820
        }
1821

            
Bogdan Timofte authored 2 months ago
1822
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1823
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1824
        }
1825

            
Bogdan Timofte authored 2 months ago
1826
        if kind == .temperature {
1827
            return autoUpperBound
1828
        }
1829

            
Bogdan Timofte authored 2 months ago
1830
        return max(
1831
            maximumSampleValue ?? lowerBound,
1832
            lowerBound + minimumYSpan,
1833
            autoUpperBound
1834
        )
1835
    }
1836

            
Bogdan Timofte authored 2 months ago
1837
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
1838
        let baseline = displayedLowerBoundForSeries(kind)
1839
        let proposedOrigin = snappedOriginValue(baseline + delta)
1840

            
1841
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
1842
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1843
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
1844
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1845
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1846
        } else {
1847
            switch kind {
1848
            case .power:
1849
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored a month ago
1850
            case .energy:
1851
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
1852
            case .voltage:
1853
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1854
            case .current:
1855
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
1856
            case .temperature:
1857
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored 2 months ago
1858
            }
1859
        }
1860

            
1861
        pinOrigin = true
1862
    }
1863

            
Bogdan Timofte authored 2 months ago
1864
    private func clearOriginOffset(for kind: SeriesKind) {
1865
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1866
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1867
            sharedAxisOrigin = 0
1868
            sharedAxisUpperBound = currentSpan
1869
            ensureSharedScaleSpan()
1870
            voltageAxisOrigin = 0
1871
            currentAxisOrigin = 0
1872
        } else {
1873
            switch kind {
1874
            case .power:
1875
                powerAxisOrigin = 0
Bogdan Timofte authored a month ago
1876
            case .energy:
1877
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1878
            case .voltage:
1879
                voltageAxisOrigin = 0
1880
            case .current:
1881
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1882
            case .temperature:
1883
                temperatureAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1884
            }
1885
        }
1886

            
1887
        pinOrigin = true
1888
    }
1889

            
1890
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1891
        guard totalHeight > 1 else { return }
1892

            
1893
        let normalized = max(0, min(1, locationY / totalHeight))
1894
        if normalized < (1.0 / 3.0) {
1895
            applyOriginDelta(-1, kind: kind)
1896
        } else if normalized < (2.0 / 3.0) {
1897
            clearOriginOffset(for: kind)
1898
        } else {
1899
            applyOriginDelta(1, kind: kind)
1900
        }
1901
    }
1902

            
Bogdan Timofte authored 2 months ago
1903
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1904
        let visibleTimeRange = activeVisibleTimeRange
1905

            
Bogdan Timofte authored 2 months ago
1906
        switch kind {
1907
        case .power:
Bogdan Timofte authored 2 months ago
1908
            return snappedOriginValue(
1909
                filteredSamplePoints(
1910
                    measurements.power,
1911
                    visibleTimeRange: visibleTimeRange
1912
                ).map(\.value).min() ?? 0
1913
            )
Bogdan Timofte authored a month ago
1914
        case .energy:
1915
            return snappedOriginValue(
1916
                filteredSamplePoints(
1917
                    measurements.energy,
1918
                    visibleTimeRange: visibleTimeRange
1919
                ).map(\.value).min() ?? 0
1920
            )
Bogdan Timofte authored 2 months ago
1921
        case .voltage:
Bogdan Timofte authored 2 months ago
1922
            return snappedOriginValue(
1923
                filteredSamplePoints(
1924
                    measurements.voltage,
1925
                    visibleTimeRange: visibleTimeRange
1926
                ).map(\.value).min() ?? 0
1927
            )
Bogdan Timofte authored 2 months ago
1928
        case .current:
Bogdan Timofte authored 2 months ago
1929
            return snappedOriginValue(
1930
                filteredSamplePoints(
1931
                    measurements.current,
1932
                    visibleTimeRange: visibleTimeRange
1933
                ).map(\.value).min() ?? 0
1934
            )
Bogdan Timofte authored 2 months ago
1935
        case .temperature:
Bogdan Timofte authored 2 months ago
1936
            return snappedOriginValue(
1937
                filteredSamplePoints(
1938
                    measurements.temperature,
1939
                    visibleTimeRange: visibleTimeRange
1940
                ).map(\.value).min() ?? 0
1941
            )
Bogdan Timofte authored 2 months ago
1942
        }
1943
    }
1944

            
1945
    private func maximumVisibleSharedOrigin() -> Double {
1946
        min(
1947
            maximumVisibleOrigin(for: .voltage),
1948
            maximumVisibleOrigin(for: .current)
1949
        )
1950
    }
1951

            
Bogdan Timofte authored 2 months ago
1952
    private func measurementUnit(for kind: SeriesKind) -> String {
1953
        switch kind {
1954
        case .temperature:
1955
            let locale = Locale.autoupdatingCurrent
1956
            if #available(iOS 16.0, *) {
1957
                switch locale.measurementSystem {
1958
                case .us:
1959
                    return "°F"
1960
                default:
1961
                    return "°C"
1962
                }
1963
            }
1964

            
1965
            let regionCode = locale.regionCode ?? ""
1966
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1967
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1968
        default:
1969
            return kind.unit
1970
        }
1971
    }
1972

            
Bogdan Timofte authored 2 months ago
1973
    private func ensureSharedScaleSpan() {
1974
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1975
    }
1976

            
Bogdan Timofte authored 2 months ago
1977
    private func snappedOriginValue(_ value: Double) -> Double {
1978
        if value >= 0 {
1979
            return value.rounded(.down)
1980
        }
1981

            
1982
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
1983
    }
Bogdan Timofte authored 2 months ago
1984

            
Bogdan Timofte authored a month ago
1985
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1986
        measurements.keepOnly(in: range)
1987
        selectedVisibleTimeRange = nil
1988
        isPinnedToPresent = false
1989
    }
1990

            
1991
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1992
        measurements.removeValues(in: range)
1993
        selectedVisibleTimeRange = nil
1994
        isPinnedToPresent = false
1995
    }
1996

            
Bogdan Timofte authored 2 months ago
1997
    private func yGuidePosition(
1998
        for labelIndex: Int,
1999
        context: ChartContext,
2000
        height: CGFloat
2001
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2002
        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
Bogdan Timofte authored 2 months ago
2003
    }
2004

            
2005
    private func xGuidePosition(
2006
        for labelIndex: Int,
2007
        context: ChartContext,
2008
        width: CGFloat
2009
    ) -> CGFloat {
Bogdan Timofte authored a month ago
2010
        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
Bogdan Timofte authored 2 months ago
2011
    }
Bogdan Timofte authored 2 months ago
2012

            
2013
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
2014
    fileprivate func xAxisLabelsView(
2015
        context: ChartContext
2016
    ) -> some View {
Bogdan Timofte authored 2 months ago
2017
        var timeFormat: String?
2018
        switch context.size.width {
2019
        case 0..<3600: timeFormat = "HH:mm:ss"
2020
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
2021
        default: timeFormat = "E HH:mm"
2022
        }
2023
        let labels = (1...xLabels).map {
2024
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
2025
        }
Bogdan Timofte authored 2 months ago
2026
        let axisLabelFont: Font = {
2027
            if isIPhone && isPortraitLayout {
2028
                return .caption2.weight(.semibold)
2029
            }
2030
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
2031
        }()
Bogdan Timofte authored 2 months ago
2032

            
2033
        return HStack(spacing: chartSectionSpacing) {
2034
            Color.clear
2035
                .frame(width: axisColumnWidth)
2036

            
2037
            GeometryReader { geometry in
2038
                let labelWidth = max(
2039
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
2040
                    1
2041
                )
2042

            
2043
                ZStack(alignment: .topLeading) {
2044
                    Path { path in
2045
                        for labelIndex in 1...self.xLabels {
2046
                            let x = xGuidePosition(
2047
                                for: labelIndex,
2048
                                context: context,
2049
                                width: geometry.size.width
2050
                            )
2051
                            path.move(to: CGPoint(x: x, y: 0))
2052
                            path.addLine(to: CGPoint(x: x, y: 6))
2053
                        }
2054
                    }
2055
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
2056

            
2057
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
2058
                        let labelIndex = item.offset + 1
2059
                        let centerX = xGuidePosition(
2060
                            for: labelIndex,
2061
                            context: context,
2062
                            width: geometry.size.width
2063
                        )
2064

            
2065
                        Text(item.element)
Bogdan Timofte authored 2 months ago
2066
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
2067
                            .monospacedDigit()
2068
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
2069
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
2070
                            .frame(width: labelWidth)
2071
                            .position(
2072
                                x: centerX,
2073
                                y: geometry.size.height * 0.7
2074
                            )
Bogdan Timofte authored 2 months ago
2075
                    }
2076
                }
Bogdan Timofte authored 2 months ago
2077
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
2078
            }
Bogdan Timofte authored 2 months ago
2079

            
2080
            Color.clear
2081
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
2082
        }
2083
    }
2084

            
Bogdan Timofte authored 2 months ago
2085
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
2086
        height: CGFloat,
2087
        context: ChartContext,
Bogdan Timofte authored 2 months ago
2088
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
2089
        measurementUnit: String,
2090
        tint: Color
2091
    ) -> some View {
Bogdan Timofte authored 2 months ago
2092
        let yAxisFont: Font = {
2093
            if isIPhone && isPortraitLayout {
2094
                return .caption2.weight(.semibold)
2095
            }
2096
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
2097
        }()
2098

            
2099
        let unitFont: Font = {
2100
            if isIPhone && isPortraitLayout {
2101
                return .caption2.weight(.bold)
2102
            }
2103
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
2104
        }()
2105

            
2106
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
2107
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
2108
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
2109
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
2110

            
Bogdan Timofte authored 2 months ago
2111
            ZStack(alignment: .top) {
2112
                ForEach(0..<yLabels, id: \.self) { row in
2113
                    let labelIndex = yLabels - row
2114

            
2115
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
2116
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
2117
                        .monospacedDigit()
2118
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
2119
                        .minimumScaleFactor(0.8)
2120
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
2121
                        .position(
2122
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
2123
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
2124
                                for: labelIndex,
2125
                                context: context,
Bogdan Timofte authored 2 months ago
2126
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
2127
                            )
2128
                        )
Bogdan Timofte authored 2 months ago
2129
                }
Bogdan Timofte authored 2 months ago
2130

            
Bogdan Timofte authored 2 months ago
2131
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
2132
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
2133
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
2134
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
2135
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
2136
                    .background(
2137
                        Capsule(style: .continuous)
2138
                            .fill(tint.opacity(0.14))
2139
                    )
Bogdan Timofte authored 2 months ago
2140
                    .padding(.top, 8)
2141

            
Bogdan Timofte authored 2 months ago
2142
            }
2143
        }
Bogdan Timofte authored 2 months ago
2144
        .frame(height: height)
2145
        .background(
2146
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2147
                .fill(tint.opacity(0.12))
2148
        )
2149
        .overlay(
2150
            RoundedRectangle(cornerRadius: 16, style: .continuous)
2151
                .stroke(tint.opacity(0.20), lineWidth: 1)
2152
        )
Bogdan Timofte authored 2 months ago
2153
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
2154
        .gesture(
Bogdan Timofte authored 2 months ago
2155
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
2156
                .onEnded { value in
Bogdan Timofte authored 2 months ago
2157
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
2158
                }
2159
        )
Bogdan Timofte authored 2 months ago
2160
    }
2161

            
Bogdan Timofte authored 2 months ago
2162
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2163
        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
Bogdan Timofte authored 2 months ago
2164
    }
2165

            
Bogdan Timofte authored 2 months ago
2166
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored a month ago
2167
        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
Bogdan Timofte authored 2 months ago
2168
    }
Bogdan Timofte authored 2 months ago
2169

            
2170
    fileprivate func discontinuityMarkers(
2171
        points: [Measurements.Measurement.Point],
2172
        context: ChartContext
2173
    ) -> some View {
2174
        GeometryReader { geometry in
2175
            Path { path in
2176
                for point in points where point.isDiscontinuity {
2177
                    let markerX = context.placeInRect(
2178
                        point: CGPoint(
2179
                            x: point.timestamp.timeIntervalSince1970,
2180
                            y: context.origin.y
2181
                        )
2182
                    ).x * geometry.size.width
2183
                    path.move(to: CGPoint(x: markerX, y: 0))
2184
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
2185
                }
2186
            }
2187
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
2188
        }
2189
    }
Bogdan Timofte authored 2 months ago
2190

            
2191
}
2192

            
Bogdan Timofte authored a month ago
2193
private struct EmbeddedWidthKey: PreferenceKey {
2194
    static let defaultValue: CGFloat = 760
2195
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2196
        let next = nextValue()
2197
        if next > 0 { value = next }
2198
    }
2199
}
2200

            
Bogdan Timofte authored 2 months ago
2201
private struct TimeRangeSelectorView: View {
2202
    private enum DragTarget {
2203
        case lowerBound
2204
        case upperBound
2205
        case window
2206
    }
2207

            
2208
    private struct DragState {
2209
        let target: DragTarget
2210
        let initialRange: ClosedRange<Date>
2211
    }
2212

            
2213
    let points: [Measurements.Measurement.Point]
2214
    let context: ChartContext
2215
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored a month ago
2216
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2217
    let compactLayout: Bool
Bogdan Timofte authored a month ago
2218
    let xAxisLabelCount: Int
Bogdan Timofte authored 2 months ago
2219
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2220
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2221

            
2222
    @Binding var selectedTimeRange: ClosedRange<Date>?
2223
    @Binding var isPinnedToPresent: Bool
2224
    @Binding var presentTrackingMode: PresentTrackingMode
2225
    @State private var dragState: DragState?
Bogdan Timofte authored a month ago
2226
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
2227

            
2228
    private var totalSpan: TimeInterval {
2229
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2230
    }
2231

            
2232
    private var currentRange: ClosedRange<Date> {
2233
        resolvedSelectionRange()
2234
    }
2235

            
2236
    private var trackHeight: CGFloat {
Bogdan Timofte authored a month ago
2237
        Self.trackHeight(compactLayout: compactLayout)
2238
    }
2239

            
2240
    private static func trackHeight(compactLayout: Bool) -> CGFloat {
2241
        compactLayout ? 42 : 50
Bogdan Timofte authored 2 months ago
2242
    }
2243

            
Bogdan Timofte authored a month ago
2244
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2245
        let rowHeight: CGFloat = compactLayout ? 28 : 32
Bogdan Timofte authored a month ago
2246
        let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2247
        let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
Bogdan Timofte authored a month ago
2248
        let spacing: CGFloat = compactLayout ? 6 : 8
Bogdan Timofte authored a month ago
2249
        // Single row of controls instead of two
2250
        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
Bogdan Timofte authored a month ago
2251
    }
2252

            
Bogdan Timofte authored 2 months ago
2253
    private var cornerRadius: CGFloat {
2254
        compactLayout ? 14 : 16
2255
    }
2256

            
2257
    private var symbolButtonSize: CGFloat {
2258
        compactLayout ? 28 : 32
2259
    }
2260

            
2261
    var body: some View {
2262
        let coversFullRange = selectionCoversFullRange(currentRange)
2263

            
2264
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
Bogdan Timofte authored a month ago
2265
            HStack(spacing: 8) {
2266
                // Alignment controls
2267
                if !coversFullRange || isPinnedToPresent {
Bogdan Timofte authored 2 months ago
2268
                    alignmentButton(
2269
                        systemName: "arrow.left.to.line.compact",
2270
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2271
                        action: alignSelectionToLeadingEdge,
2272
                        accessibilityLabel: "Align selection to start"
2273
                    )
2274

            
2275
                    alignmentButton(
2276
                        systemName: "arrow.right.to.line.compact",
2277
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2278
                        action: alignSelectionToTrailingEdge,
2279
                        accessibilityLabel: "Align selection to present"
2280
                    )
2281

            
2282
                    if isPinnedToPresent {
2283
                        trackingModeToggleButton()
2284
                    }
2285
                }
2286

            
Bogdan Timofte authored a month ago
2287
                Spacer(minLength: 0)
2288

            
2289
                // Trim/Save actions
Bogdan Timofte authored a month ago
2290
                if !coversFullRange {
Bogdan Timofte authored a month ago
2291
                    iconButton(
Bogdan Timofte authored a month ago
2292
                        systemName: configuration.keepAction.systemName,
2293
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored a month ago
2294
                        action: {
Bogdan Timofte authored a month ago
2295
                            configuration.keepAction.handler(currentRange)
2296
                            resetSelectionState()
Bogdan Timofte authored a month ago
2297
                        }
2298
                    )
Bogdan Timofte authored a month ago
2299
                    .help(configuration.keepAction.title)
Bogdan Timofte authored a month ago
2300

            
Bogdan Timofte authored a month ago
2301
                    if let removeAction = configuration.removeAction {
Bogdan Timofte authored a month ago
2302
                        iconButton(
Bogdan Timofte authored a month ago
2303
                            systemName: removeAction.systemName,
2304
                            tone: removeAction.tone,
2305
                            action: {
2306
                                removeAction.handler(currentRange)
2307
                                resetSelectionState()
2308
                            }
2309
                        )
Bogdan Timofte authored a month ago
2310
                        .help(removeAction.title)
Bogdan Timofte authored a month ago
2311
                    }
Bogdan Timofte authored a month ago
2312

            
Bogdan Timofte authored a month ago
2313
                    // Reset action (only show when there's a trim to reset)
2314
                    iconButton(
2315
                        systemName: configuration.resetAction.systemName,
2316
                        tone: configuration.resetAction.tone,
2317
                        action: {
2318
                            showResetConfirmation = true
2319
                        }
2320
                    )
2321
                    .help(configuration.resetAction.title)
2322
                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2323
                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2324
                            configuration.resetAction.handler()
2325
                            resetSelectionState()
2326
                        }
2327
                        Button("Cancel", role: .cancel) {}
Bogdan Timofte authored a month ago
2328
                    }
2329
                }
2330
            }
2331

            
Bogdan Timofte authored 2 months ago
2332
            GeometryReader { geometry in
2333
                let selectionFrame = selectionFrame(in: geometry.size)
2334
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2335

            
2336
                ZStack(alignment: .topLeading) {
2337
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2338
                        .fill(Color.primary.opacity(0.05))
2339

            
Bogdan Timofte authored a month ago
2340
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2341
                        points: points,
2342
                        context: context,
2343
                        areaChart: true,
Bogdan Timofte authored a month ago
2344
                        strokeColor: selectorTint,
2345
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2346
                    )
2347
                    .opacity(0.94)
2348
                    .allowsHitTesting(false)
2349

            
Bogdan Timofte authored a month ago
2350
                    TimeSeriesChart(
Bogdan Timofte authored 2 months ago
2351
                        points: points,
2352
                        context: context,
Bogdan Timofte authored a month ago
2353
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2354
                    )
2355
                    .opacity(0.82)
2356
                    .allowsHitTesting(false)
2357

            
2358
                    if selectionFrame.minX > 0 {
2359
                        Rectangle()
2360
                            .fill(dimmingColor)
2361
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2362
                            .allowsHitTesting(false)
2363
                    }
2364

            
2365
                    if selectionFrame.maxX < geometry.size.width {
2366
                        Rectangle()
2367
                            .fill(dimmingColor)
2368
                            .frame(
2369
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2370
                                height: geometry.size.height
2371
                            )
2372
                            .offset(x: selectionFrame.maxX)
2373
                            .allowsHitTesting(false)
2374
                    }
2375

            
2376
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored a month ago
2377
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2378
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2379
                        .offset(x: selectionFrame.minX)
2380
                        .allowsHitTesting(false)
2381

            
2382
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored a month ago
2383
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2384
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2385
                        .offset(x: selectionFrame.minX)
2386
                        .allowsHitTesting(false)
2387

            
2388
                    handleView(height: max(geometry.size.height - 18, 16))
2389
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2390
                        .allowsHitTesting(false)
2391

            
2392
                    handleView(height: max(geometry.size.height - 18, 16))
2393
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2394
                        .allowsHitTesting(false)
2395
                }
2396
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2397
                .overlay(
2398
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
Bogdan Timofte authored a month ago
2399
                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2400
                )
2401
                .contentShape(Rectangle())
2402
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2403
            }
2404
            .frame(height: trackHeight)
2405

            
Bogdan Timofte authored a month ago
2406
            xAxisLabelsView
Bogdan Timofte authored 2 months ago
2407
        }
2408
    }
2409

            
2410
    private func handleView(height: CGFloat) -> some View {
2411
        Capsule(style: .continuous)
2412
            .fill(Color.white.opacity(0.95))
2413
            .frame(width: 6, height: height)
2414
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2415
    }
2416

            
2417
    private func alignmentButton(
2418
        systemName: String,
2419
        isActive: Bool,
2420
        action: @escaping () -> Void,
2421
        accessibilityLabel: String
2422
    ) -> some View {
2423
        Button(action: action) {
2424
            Image(systemName: systemName)
2425
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2426
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2427
        }
2428
        .buttonStyle(.plain)
Bogdan Timofte authored a month ago
2429
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2430
        .background(
2431
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored a month ago
2432
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2433
        )
2434
        .overlay(
2435
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored a month ago
2436
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2437
        )
2438
        .accessibilityLabel(accessibilityLabel)
2439
    }
2440

            
2441
    private func trackingModeToggleButton() -> some View {
2442
        Button {
2443
            presentTrackingMode = presentTrackingMode == .keepDuration
2444
                ? .keepStartTimestamp
2445
                : .keepDuration
2446
        } label: {
2447
            Image(systemName: trackingModeSymbolName)
2448
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2449
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2450
        }
2451
        .buttonStyle(.plain)
2452
        .foregroundColor(.white)
2453
        .background(
2454
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored a month ago
2455
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2456
        )
2457
        .overlay(
2458
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored a month ago
2459
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2460
        )
2461
        .accessibilityLabel(trackingModeAccessibilityLabel)
2462
        .accessibilityHint("Toggles how the interval follows the present")
2463
    }
2464

            
Bogdan Timofte authored a month ago
2465
    private func actionButton(
2466
        title: String,
Bogdan Timofte authored a month ago
2467
        shortTitle: String? = nil,
Bogdan Timofte authored a month ago
2468
        systemName: String,
Bogdan Timofte authored a month ago
2469
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored a month ago
2470
        action: @escaping () -> Void
2471
    ) -> some View {
2472
        let foregroundColor: Color = {
2473
            switch tone {
2474
            case .reversible, .destructive:
2475
                return toneColor(for: tone)
2476
            case .destructiveProminent:
2477
                return .white
2478
            }
2479
        }()
Bogdan Timofte authored a month ago
2480
        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
Bogdan Timofte authored a month ago
2481

            
2482
        return Button(action: action) {
Bogdan Timofte authored a month ago
2483
            Label(displayTitle, systemImage: systemName)
Bogdan Timofte authored a month ago
2484
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2485
                .padding(.horizontal, compactLayout ? 10 : 12)
2486
                .padding(.vertical, compactLayout ? 7 : 8)
2487
        }
2488
        .buttonStyle(.plain)
2489
        .foregroundColor(foregroundColor)
2490
        .background(
2491
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2492
                .fill(actionButtonBackground(for: tone))
2493
        )
2494
        .overlay(
2495
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2496
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2497
        )
2498
    }
2499

            
Bogdan Timofte authored a month ago
2500
    private func iconButton(
2501
        systemName: String,
2502
        tone: MeasurementChartSelectorActionTone,
2503
        action: @escaping () -> Void
2504
    ) -> some View {
2505
        let foregroundColor: Color = {
2506
            switch tone {
2507
            case .reversible, .destructive:
2508
                return toneColor(for: tone)
2509
            case .destructiveProminent:
2510
                return .white
2511
            }
2512
        }()
2513

            
2514
        return Button(action: action) {
2515
            Image(systemName: systemName)
2516
                .font(.subheadline.weight(.semibold))
2517
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2518
        }
2519
        .buttonStyle(.plain)
2520
        .foregroundColor(foregroundColor)
2521
        .background(
2522
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2523
                .fill(actionButtonBackground(for: tone))
2524
        )
2525
        .overlay(
2526
            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2527
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2528
        )
2529
    }
2530

            
Bogdan Timofte authored a month ago
2531
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored a month ago
2532
        switch tone {
2533
        case .reversible:
2534
            return selectorTint
2535
        case .destructive, .destructiveProminent:
2536
            return .red
2537
        }
2538
    }
2539

            
Bogdan Timofte authored a month ago
2540
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored a month ago
2541
        switch tone {
2542
        case .reversible:
2543
            return selectorTint.opacity(0.12)
2544
        case .destructive:
2545
            return Color.red.opacity(0.12)
2546
        case .destructiveProminent:
2547
            return Color.red.opacity(0.82)
2548
        }
2549
    }
2550

            
Bogdan Timofte authored a month ago
2551
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored a month ago
2552
        switch tone {
2553
        case .reversible:
2554
            return selectorTint.opacity(0.22)
2555
        case .destructive:
2556
            return Color.red.opacity(0.22)
2557
        case .destructiveProminent:
2558
            return Color.red.opacity(0.72)
2559
        }
2560
    }
2561

            
Bogdan Timofte authored 2 months ago
2562
    private var trackingModeSymbolName: String {
2563
        switch presentTrackingMode {
2564
        case .keepDuration:
2565
            return "arrow.left.and.right"
2566
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2567
            return "arrow.right"
Bogdan Timofte authored 2 months ago
2568
        }
2569
    }
2570

            
2571
    private var trackingModeAccessibilityLabel: String {
2572
        switch presentTrackingMode {
2573
        case .keepDuration:
Bogdan Timofte authored a month ago
2574
            return "Keep fixed duration"
Bogdan Timofte authored 2 months ago
2575
        case .keepStartTimestamp:
Bogdan Timofte authored a month ago
2576
            return "Keep start fixed"
Bogdan Timofte authored 2 months ago
2577
        }
2578
    }
2579

            
2580
    private func alignSelectionToLeadingEdge() {
2581
        let alignedRange = normalizedSelectionRange(
2582
            availableTimeRange.lowerBound...currentRange.upperBound
2583
        )
2584
        applySelection(alignedRange, pinToPresent: false)
2585
    }
2586

            
2587
    private func alignSelectionToTrailingEdge() {
2588
        let alignedRange = normalizedSelectionRange(
2589
            currentRange.lowerBound...availableTimeRange.upperBound
2590
        )
2591
        applySelection(alignedRange, pinToPresent: true)
2592
    }
2593

            
2594
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2595
        DragGesture(minimumDistance: 0)
2596
            .onChanged { value in
2597
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2598
            }
2599
            .onEnded { _ in
2600
                dragState = nil
2601
            }
2602
    }
2603

            
2604
    private func updateSelectionDrag(
2605
        value: DragGesture.Value,
2606
        totalWidth: CGFloat
2607
    ) {
2608
        let startingRange = resolvedSelectionRange()
2609

            
2610
        if dragState == nil {
2611
            dragState = DragState(
2612
                target: dragTarget(
2613
                    for: value.startLocation.x,
2614
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2615
                ),
2616
                initialRange: startingRange
2617
            )
2618
        }
2619

            
2620
        guard let dragState else { return }
2621

            
2622
        let resultingRange = snappedToEdges(
2623
            adjustedRange(
2624
                from: dragState.initialRange,
2625
                target: dragState.target,
2626
                translationX: value.translation.width,
2627
                totalWidth: totalWidth
2628
            ),
2629
            target: dragState.target,
2630
            totalWidth: totalWidth
2631
        )
2632

            
2633
        applySelection(
2634
            resultingRange,
2635
            pinToPresent: shouldKeepPresentPin(
2636
                during: dragState.target,
2637
                initialRange: dragState.initialRange,
2638
                resultingRange: resultingRange
2639
            ),
2640
        )
2641
    }
2642

            
2643
    private func dragTarget(
2644
        for startX: CGFloat,
2645
        selectionFrame: CGRect
2646
    ) -> DragTarget {
2647
        let handleZone: CGFloat = compactLayout ? 20 : 24
2648

            
2649
        if abs(startX - selectionFrame.minX) <= handleZone {
2650
            return .lowerBound
2651
        }
2652

            
2653
        if abs(startX - selectionFrame.maxX) <= handleZone {
2654
            return .upperBound
2655
        }
2656

            
2657
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2658
            return .window
2659
        }
2660

            
2661
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2662
    }
2663

            
2664
    private func adjustedRange(
2665
        from initialRange: ClosedRange<Date>,
2666
        target: DragTarget,
2667
        translationX: CGFloat,
2668
        totalWidth: CGFloat
2669
    ) -> ClosedRange<Date> {
2670
        guard totalSpan > 0, totalWidth > 0 else {
2671
            return availableTimeRange
2672
        }
2673

            
2674
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2675
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2676

            
2677
        switch target {
2678
        case .lowerBound:
2679
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2680
            let newLowerBound = min(
2681
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2682
                maximumLowerBound
2683
            )
2684
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2685

            
2686
        case .upperBound:
2687
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2688
            let newUpperBound = max(
2689
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2690
                minimumUpperBound
2691
            )
2692
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2693

            
2694
        case .window:
2695
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2696
            guard span < totalSpan else { return availableTimeRange }
2697

            
2698
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2699
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2700

            
2701
            if lowerBound < availableTimeRange.lowerBound {
2702
                upperBound = upperBound.addingTimeInterval(
2703
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2704
                )
2705
                lowerBound = availableTimeRange.lowerBound
2706
            }
2707

            
2708
            if upperBound > availableTimeRange.upperBound {
2709
                lowerBound = lowerBound.addingTimeInterval(
2710
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2711
                )
2712
                upperBound = availableTimeRange.upperBound
2713
            }
2714

            
2715
            return normalizedSelectionRange(lowerBound...upperBound)
2716
        }
2717
    }
2718

            
2719
    private func snappedToEdges(
2720
        _ candidateRange: ClosedRange<Date>,
2721
        target: DragTarget,
2722
        totalWidth: CGFloat
2723
    ) -> ClosedRange<Date> {
2724
        guard totalSpan > 0 else {
2725
            return availableTimeRange
2726
        }
2727

            
2728
        let snapInterval = edgeSnapInterval(for: totalWidth)
2729
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2730
        var lowerBound = candidateRange.lowerBound
2731
        var upperBound = candidateRange.upperBound
2732

            
2733
        if target != .upperBound,
2734
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2735
            lowerBound = availableTimeRange.lowerBound
2736
            if target == .window {
2737
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2738
            }
2739
        }
2740

            
2741
        if target != .lowerBound,
2742
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2743
            upperBound = availableTimeRange.upperBound
2744
            if target == .window {
2745
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2746
            }
2747
        }
2748

            
2749
        return normalizedSelectionRange(lowerBound...upperBound)
2750
    }
2751

            
2752
    private func edgeSnapInterval(
2753
        for totalWidth: CGFloat
2754
    ) -> TimeInterval {
2755
        guard totalWidth > 0 else { return minimumSelectionSpan }
2756

            
2757
        let snapWidth = min(
2758
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2759
            totalWidth * 0.18
2760
        )
2761
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2762
        return min(
2763
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2764
            totalSpan / 4
2765
        )
2766
    }
2767

            
2768
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2769
        guard let selectedTimeRange else { return availableTimeRange }
2770

            
2771
        if isPinnedToPresent {
2772
            switch presentTrackingMode {
2773
            case .keepDuration:
2774
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2775
                return normalizedSelectionRange(
2776
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2777
                )
2778
            case .keepStartTimestamp:
2779
                return normalizedSelectionRange(
2780
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2781
                )
2782
            }
2783
        }
2784

            
2785
        return normalizedSelectionRange(selectedTimeRange)
2786
    }
2787

            
2788
    private func normalizedSelectionRange(
2789
        _ candidateRange: ClosedRange<Date>
2790
    ) -> ClosedRange<Date> {
2791
        let availableSpan = totalSpan
2792
        guard availableSpan > 0 else { return availableTimeRange }
2793

            
2794
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2795
        let requestedSpan = min(
2796
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2797
            availableSpan
2798
        )
2799

            
2800
        if requestedSpan >= availableSpan {
2801
            return availableTimeRange
2802
        }
2803

            
2804
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2805
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2806

            
2807
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2808
            if lowerBound == availableTimeRange.lowerBound {
2809
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2810
            } else {
2811
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2812
            }
2813
        }
2814

            
2815
        if upperBound > availableTimeRange.upperBound {
2816
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2817
            upperBound = availableTimeRange.upperBound
2818
            lowerBound = lowerBound.addingTimeInterval(-delta)
2819
        }
2820

            
2821
        if lowerBound < availableTimeRange.lowerBound {
2822
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2823
            lowerBound = availableTimeRange.lowerBound
2824
            upperBound = upperBound.addingTimeInterval(delta)
2825
        }
2826

            
2827
        return lowerBound...upperBound
2828
    }
2829

            
2830
    private func shouldKeepPresentPin(
2831
        during target: DragTarget,
2832
        initialRange: ClosedRange<Date>,
2833
        resultingRange: ClosedRange<Date>
2834
    ) -> Bool {
2835
        let startedPinnedToPresent =
2836
            isPinnedToPresent ||
2837
            selectionCoversFullRange(initialRange)
2838

            
2839
        guard startedPinnedToPresent else {
2840
            return selectionTouchesPresent(resultingRange)
2841
        }
2842

            
2843
        switch target {
2844
        case .lowerBound:
2845
            return true
2846
        case .upperBound, .window:
2847
            return selectionTouchesPresent(resultingRange)
2848
        }
2849
    }
2850

            
2851
    private func applySelection(
2852
        _ candidateRange: ClosedRange<Date>,
2853
        pinToPresent: Bool
2854
    ) {
2855
        let normalizedRange = normalizedSelectionRange(candidateRange)
2856

            
2857
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2858
            selectedTimeRange = nil
2859
        } else {
2860
            selectedTimeRange = normalizedRange
2861
        }
2862

            
2863
        isPinnedToPresent = pinToPresent
2864
    }
2865

            
Bogdan Timofte authored a month ago
2866
    private func resetSelectionState() {
2867
        selectedTimeRange = nil
2868
        isPinnedToPresent = false
2869
    }
2870

            
Bogdan Timofte authored 2 months ago
2871
    private func selectionTouchesPresent(
2872
        _ range: ClosedRange<Date>
2873
    ) -> Bool {
2874
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2875
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2876
    }
2877

            
2878
    private func selectionCoversFullRange(
2879
        _ range: ClosedRange<Date>
2880
    ) -> Bool {
2881
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2882
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2883
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2884
    }
2885

            
2886
    private func selectionFrame(in size: CGSize) -> CGRect {
2887
        selectionFrame(for: currentRange, width: size.width)
2888
    }
2889

            
2890
    private func selectionFrame(
2891
        for range: ClosedRange<Date>,
2892
        width: CGFloat
2893
    ) -> CGRect {
2894
        guard width > 0, totalSpan > 0 else {
2895
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2896
        }
2897

            
2898
        let minimumX = xPosition(for: range.lowerBound, width: width)
2899
        let maximumX = xPosition(for: range.upperBound, width: width)
2900
        return CGRect(
2901
            x: minimumX,
2902
            y: 0,
2903
            width: max(maximumX - minimumX, 2),
2904
            height: trackHeight
2905
        )
2906
    }
2907

            
2908
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2909
        guard width > 0, totalSpan > 0 else { return 0 }
2910

            
2911
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2912
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2913
        return CGFloat(normalizedOffset) * width
2914
    }
2915

            
Bogdan Timofte authored a month ago
2916
    private var xAxisLabelsView: some View {
2917
        let timeFormat: String = {
2918
            switch context.size.width {
2919
            case 0..<3600: return "HH:mm:ss"
2920
            case 3600...86400: return "HH:mm"
2921
            default: return "E HH:mm"
2922
            }
2923
        }()
Bogdan Timofte authored 2 months ago
2924

            
Bogdan Timofte authored a month ago
2925
        let labelCount = max(xAxisLabelCount, 2)
2926
        let labels = (1...labelCount).map {
2927
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat)
Bogdan Timofte authored 2 months ago
2928
        }
Bogdan Timofte authored a month ago
2929
        let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold)
2930

            
2931
        return GeometryReader { geometry in
2932
            let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1)
2933

            
2934
            ZStack(alignment: .topLeading) {
2935
                Path { path in
2936
                    for labelIndex in 1...labelCount {
2937
                        let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
2938
                        path.move(to: CGPoint(x: x, y: 0))
2939
                        path.addLine(to: CGPoint(x: x, y: 5))
2940
                    }
2941
                }
2942
                .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
2943

            
2944
                ForEach(Array(labels.enumerated()), id: \.offset) { item in
2945
                    let labelIndex = item.offset + 1
2946
                    let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width)
Bogdan Timofte authored a month ago
2947
                    let halfWidth = labelWidth / 2
2948
                    let clampedX = min(max(centerX, halfWidth), geometry.size.width - halfWidth)
Bogdan Timofte authored a month ago
2949

            
2950
                    Text(item.element)
2951
                        .font(axisLabelFont)
2952
                        .monospacedDigit()
2953
                        .lineLimit(1)
2954
                        .minimumScaleFactor(0.74)
2955
                        .frame(width: labelWidth)
2956
                        .position(
Bogdan Timofte authored a month ago
2957
                            x: clampedX,
Bogdan Timofte authored a month ago
2958
                            y: geometry.size.height * 0.66
2959
                        )
2960
                }
2961
            }
2962
        }
2963
        .frame(height: compactLayout ? 18 : 20)
2964
    }
2965

            
2966
    private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
2967
        context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width)
Bogdan Timofte authored 2 months ago
2968
    }
2969
}