USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
2928 lines | 106.743kb
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 MeasurementChartSelectorActionTone {
17
    case reversible
18
    case destructive
19
    case destructiveProminent
20
}
21

            
22
struct MeasurementChartSelectionAction {
23
    let title: String
24
    let systemName: String
25
    let tone: MeasurementChartSelectorActionTone
26
    let handler: (ClosedRange<Date>) -> Void
27
}
28

            
29
struct MeasurementChartResetAction {
30
    let title: String
31
    let systemName: String
32
    let tone: MeasurementChartSelectorActionTone
33
    let confirmationTitle: String
34
    let confirmationButtonTitle: String
35
    let handler: () -> Void
36
}
37

            
38
struct MeasurementChartRangeSelectorConfiguration {
39
    let keepAction: MeasurementChartSelectionAction
40
    let removeAction: MeasurementChartSelectionAction?
41
    let resetAction: MeasurementChartResetAction
42
}
43

            
Bogdan Timofte authored 2 months ago
44
struct MeasurementChartView: View {
Bogdan Timofte authored 2 months ago
45
    private enum SmoothingLevel: CaseIterable, Hashable {
46
        case off
47
        case light
48
        case medium
49
        case strong
50

            
51
        var label: String {
52
            switch self {
53
            case .off: return "Off"
54
            case .light: return "Light"
55
            case .medium: return "Medium"
56
            case .strong: return "Strong"
57
            }
58
        }
59

            
60
        var shortLabel: String {
61
            switch self {
62
            case .off: return "Off"
63
            case .light: return "Low"
64
            case .medium: return "Med"
65
            case .strong: return "High"
66
            }
67
        }
68

            
69
        var movingAverageWindowSize: Int {
70
            switch self {
71
            case .off: return 1
72
            case .light: return 5
73
            case .medium: return 11
74
            case .strong: return 21
75
            }
76
        }
77
    }
78

            
Bogdan Timofte authored 2 months ago
79
    private enum SeriesKind {
80
        case power
Bogdan Timofte authored 2 months ago
81
        case energy
Bogdan Timofte authored 2 months ago
82
        case voltage
83
        case current
Bogdan Timofte authored 2 months ago
84
        case temperature
Bogdan Timofte authored 2 months ago
85

            
86
        var unit: String {
87
            switch self {
88
            case .power: return "W"
Bogdan Timofte authored 2 months ago
89
            case .energy: return "Wh"
Bogdan Timofte authored 2 months ago
90
            case .voltage: return "V"
91
            case .current: return "A"
Bogdan Timofte authored 2 months ago
92
            case .temperature: return ""
Bogdan Timofte authored 2 months ago
93
            }
94
        }
95

            
96
        var tint: Color {
97
            switch self {
98
            case .power: return .red
Bogdan Timofte authored 2 months ago
99
            case .energy: return .teal
Bogdan Timofte authored 2 months ago
100
            case .voltage: return .green
101
            case .current: return .blue
Bogdan Timofte authored 2 months ago
102
            case .temperature: return .orange
Bogdan Timofte authored 2 months ago
103
            }
104
        }
105
    }
106

            
107
    private struct SeriesData {
108
        let kind: SeriesKind
109
        let points: [Measurements.Measurement.Point]
110
        let samplePoints: [Measurements.Measurement.Point]
111
        let context: ChartContext
112
        let autoLowerBound: Double
113
        let autoUpperBound: Double
114
        let maximumSampleValue: Double?
115
    }
116

            
Bogdan Timofte authored 2 months ago
117
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
118
    private let minimumVoltageSpan = 0.5
119
    private let minimumCurrentSpan = 0.5
120
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 2 months ago
121
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
122
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored 2 months ago
123
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 2 months ago
124
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
125

            
126
    let compactLayout: Bool
127
    let availableSize: CGSize
Bogdan Timofte authored a month ago
128
    let showsRangeSelector: Bool
129
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored a month ago
130
    let extendsTimelineToPresent: Bool
131
    let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
Bogdan Timofte authored 2 months ago
132

            
133
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
134
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
135
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
136
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a month ago
137
    let timeRangeLowerBound: Date?
138
    let timeRangeUpperBound: Date?
Bogdan Timofte authored 2 months ago
139

            
140
    @State var displayVoltage: Bool = false
141
    @State var displayCurrent: Bool = false
142
    @State var displayPower: Bool = true
Bogdan Timofte authored 2 months ago
143
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 2 months ago
144
    @State var displayTemperature: Bool = false
Bogdan Timofte authored 2 months ago
145
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored 2 months ago
146
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 2 months ago
147
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
148
    @State private var isPinnedToPresent: Bool = false
149
    @State private var presentTrackingMode: PresentTrackingMode = .keepDuration
Bogdan Timofte authored 2 months ago
150
    @State private var pinOrigin: Bool = false
151
    @State private var useSharedOrigin: Bool = false
152
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
153
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored 2 months ago
154
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
155
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
156
    @State private var voltageAxisOrigin: Double = 0
157
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
158
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored 2 months ago
159
    let xLabels: Int = 4
160
    let yLabels: Int = 4
161

            
Bogdan Timofte authored 2 months ago
162
    init(
163
        compactLayout: Bool = false,
164
        availableSize: CGSize = .zero,
Bogdan Timofte authored a month ago
165
        timeRange: ClosedRange<Date>? = nil,
Bogdan Timofte authored a month ago
166
        timeRangeLowerBound: Date? = nil,
167
        timeRangeUpperBound: Date? = nil,
Bogdan Timofte authored a month ago
168
        showsRangeSelector: Bool = true,
Bogdan Timofte authored a month ago
169
        rebasesEnergyToVisibleRangeStart: Bool = false,
170
        extendsTimelineToPresent: Bool = true,
171
        rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
Bogdan Timofte authored 2 months ago
172
    ) {
173
        self.compactLayout = compactLayout
174
        self.availableSize = availableSize
175
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
176
        self.timeRangeLowerBound = timeRangeLowerBound
177
        self.timeRangeUpperBound = timeRangeUpperBound
Bogdan Timofte authored a month ago
178
        self.showsRangeSelector = showsRangeSelector
179
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored a month ago
180
        self.extendsTimelineToPresent = extendsTimelineToPresent
181
        self.rangeSelectorConfiguration = rangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
182
    }
183

            
Bogdan Timofte authored a month ago
184
    static func prefersCompactEmbeddedLayout(forWidth width: CGFloat) -> Bool {
185
        width < 760
186
    }
187

            
188
    static func embeddedPlotReferenceHeight(compactLayout: Bool) -> CGFloat {
189
        compactLayout ? 290 : 350
190
    }
191

            
192
    static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
193
        let compactLayout = prefersCompactEmbeddedLayout(forWidth: width)
194
        let plotHeight = embeddedPlotReferenceHeight(compactLayout: compactLayout)
195
        guard showsRangeSelector else { return plotHeight }
196
        return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compactLayout)
197
    }
198

            
Bogdan Timofte authored 2 months ago
199
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
200
        if compactLayout {
201
            return 38
202
        }
203
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
204
    }
205

            
206
    private var chartSectionSpacing: CGFloat {
207
        compactLayout ? 6 : 8
208
    }
209

            
210
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
211
        if compactLayout {
212
            return 24
213
        }
214
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
215
    }
216

            
Bogdan Timofte authored a month ago
217
    private var belowXAxisControlsHeight: CGFloat {
218
        if usesCompactLandscapeOriginControls {
219
            return 40
220
        }
221
        if compactLayout {
222
            return 46
223
        }
224
        return isLargeDisplay ? 58 : 50
225
    }
226

            
Bogdan Timofte authored 2 months ago
227
    private var isPortraitLayout: Bool {
228
        guard availableSize != .zero else { return verticalSizeClass != .compact }
229
        return availableSize.height >= availableSize.width
230
    }
231

            
Bogdan Timofte authored 2 months ago
232
    private var isIPhone: Bool {
233
        #if os(iOS)
234
        return UIDevice.current.userInterfaceIdiom == .phone
235
        #else
236
        return false
237
        #endif
238
    }
239

            
240
    private enum OriginControlsPlacement {
241
        case aboveXAxisLegend
242
        case overXAxisLegend
243
        case belowXAxisLegend
244
    }
245

            
246
    private var originControlsPlacement: OriginControlsPlacement {
247
        if isIPhone {
248
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
249
        }
250
        return .belowXAxisLegend
251
    }
252

            
Bogdan Timofte authored 2 months ago
253
    private var plotSectionHeight: CGFloat {
254
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
255
            return compactLayout ? 300 : 380
256
        }
257

            
258
        if isPortraitLayout {
259
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
260
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
261
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
262
        }
263

            
264
        if compactLayout {
265
            return min(max(availableSize.height * 0.36, 240), 300)
266
        }
267

            
268
        return min(max(availableSize.height * 0.5, 300), 440)
269
    }
270

            
271
    private var stackedToolbarLayout: Bool {
272
        if availableSize.width > 0 {
273
            return availableSize.width < 640
274
        }
275

            
276
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
277
    }
278

            
279
    private var showsLabeledOriginControls: Bool {
280
        !compactLayout && !stackedToolbarLayout
281
    }
282

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

            
Bogdan Timofte authored 2 months ago
290
        if availableSize.width > 0 {
291
            return availableSize.width >= 900 || availableSize.height >= 700
292
        }
293
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
294
    }
295

            
296
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
297
        if isIPhone && isPortraitLayout {
298
            return .caption
299
        }
300
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
301
    }
302

            
Bogdan Timofte authored 2 months ago
303
    private var usesCompactLandscapeOriginControls: Bool {
304
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
305
    }
306

            
Bogdan Timofte authored 2 months ago
307
    var body: some View {
Bogdan Timofte authored 2 months ago
308
        let availableTimeRange = availableSelectionTimeRange()
309
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
310
        let powerSeries = series(
311
            for: measurements.power,
312
            kind: .power,
313
            minimumYSpan: minimumPowerSpan,
314
            visibleTimeRange: visibleTimeRange
315
        )
Bogdan Timofte authored 2 months ago
316
        let energySeries = series(
317
            for: measurements.energy,
318
            kind: .energy,
319
            minimumYSpan: minimumEnergySpan,
320
            visibleTimeRange: visibleTimeRange
321
        )
Bogdan Timofte authored 2 months ago
322
        let voltageSeries = series(
323
            for: measurements.voltage,
324
            kind: .voltage,
325
            minimumYSpan: minimumVoltageSpan,
326
            visibleTimeRange: visibleTimeRange
327
        )
328
        let currentSeries = series(
329
            for: measurements.current,
330
            kind: .current,
331
            minimumYSpan: minimumCurrentSpan,
332
            visibleTimeRange: visibleTimeRange
333
        )
334
        let temperatureSeries = series(
335
            for: measurements.temperature,
336
            kind: .temperature,
337
            minimumYSpan: minimumTemperatureSpan,
338
            visibleTimeRange: visibleTimeRange
339
        )
Bogdan Timofte authored 2 months ago
340
        let primarySeries = displayedPrimarySeries(
341
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
342
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
343
            voltageSeries: voltageSeries,
344
            currentSeries: currentSeries
345
        )
Bogdan Timofte authored 2 months ago
346
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
347

            
Bogdan Timofte authored 2 months ago
348
        Group {
Bogdan Timofte authored 2 months ago
349
            if let primarySeries {
350
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
351
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
352

            
Bogdan Timofte authored a month ago
353
                    VStack(spacing: compactLayout ? 8 : 10) {
354
                        GeometryReader { geometry in
355
                            let reservedBottomHeight =
356
                                xAxisHeight
357
                                + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
358
                            let plotHeight = max(
359
                                geometry.size.height - reservedBottomHeight,
360
                                compactLayout ? 180 : 220
361
                            )
362

            
363
                            VStack(spacing: 6) {
364
                                HStack(spacing: chartSectionSpacing) {
365
                                    primaryAxisView(
366
                                        height: plotHeight,
367
                                        powerSeries: powerSeries,
368
                                        energySeries: energySeries,
369
                                        voltageSeries: voltageSeries,
370
                                        currentSeries: currentSeries
371
                                    )
372
                                    .frame(width: axisColumnWidth, height: plotHeight)
373

            
374
                                    ZStack {
375
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
376
                                            .fill(Color.primary.opacity(0.05))
377

            
378
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
379
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
380

            
381
                                        horizontalGuides(context: primarySeries.context)
382
                                        verticalGuides(context: primarySeries.context)
383
                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
384
                                        renderedChart(
385
                                            powerSeries: powerSeries,
386
                                            energySeries: energySeries,
387
                                            voltageSeries: voltageSeries,
388
                                            currentSeries: currentSeries,
389
                                            temperatureSeries: temperatureSeries
390
                                        )
391
                                    }
392
                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
393
                                    .frame(maxWidth: .infinity)
394
                                    .frame(height: plotHeight)
Bogdan Timofte authored 2 months ago
395

            
Bogdan Timofte authored a month ago
396
                                    secondaryAxisView(
397
                                        height: plotHeight,
Bogdan Timofte authored 2 months ago
398
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
399
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
400
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
401
                                        currentSeries: currentSeries,
402
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
403
                                    )
Bogdan Timofte authored a month ago
404
                                    .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
405
                                }
Bogdan Timofte authored a month ago
406
                                .overlay(alignment: .bottom) {
407
                                    if originControlsPlacement == .aboveXAxisLegend {
408
                                        scaleControlsPill(
409
                                            voltageSeries: voltageSeries,
410
                                            currentSeries: currentSeries
411
                                        )
412
                                        .padding(.bottom, compactLayout ? 6 : 10)
413
                                    }
Bogdan Timofte authored 2 months ago
414
                                }
Bogdan Timofte authored 2 months ago
415

            
Bogdan Timofte authored a month ago
416
                                switch originControlsPlacement {
417
                                case .aboveXAxisLegend:
418
                                    xAxisLabelsView(context: primarySeries.context)
419
                                        .frame(height: xAxisHeight)
420
                                case .overXAxisLegend:
421
                                    xAxisLabelsView(context: primarySeries.context)
422
                                        .frame(height: xAxisHeight)
423
                                        .overlay(alignment: .center) {
424
                                            scaleControlsPill(
425
                                                voltageSeries: voltageSeries,
426
                                                currentSeries: currentSeries
427
                                            )
428
                                            .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
429
                                        }
430
                                case .belowXAxisLegend:
431
                                    xAxisLabelsView(context: primarySeries.context)
432
                                        .frame(height: xAxisHeight)
433

            
434
                                    HStack {
435
                                        Spacer(minLength: 0)
Bogdan Timofte authored 2 months ago
436
                                        scaleControlsPill(
437
                                            voltageSeries: voltageSeries,
438
                                            currentSeries: currentSeries
439
                                        )
Bogdan Timofte authored a month ago
440
                                        Spacer(minLength: 0)
Bogdan Timofte authored 2 months ago
441
                                    }
442
                                }
443
                            }
Bogdan Timofte authored a month ago
444
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
445
                        }
446
                        .frame(height: plotSectionHeight)
447

            
448
                        if showsRangeSelector,
449
                           let availableTimeRange,
450
                           let selectorSeries,
451
                           shouldShowRangeSelector(
452
                            availableTimeRange: availableTimeRange,
453
                            series: selectorSeries
454
                           ) {
455
                            TimeRangeSelectorView(
456
                                points: selectorSeries.points,
457
                                context: selectorSeries.context,
Bogdan Timofte authored 2 months ago
458
                                availableTimeRange: availableTimeRange,
Bogdan Timofte authored a month ago
459
                                selectorTint: selectorTint,
460
                                compactLayout: compactLayout,
461
                                minimumSelectionSpan: minimumTimeSpan,
462
                                configuration: resolvedRangeSelectorConfiguration(),
463
                                selectedTimeRange: $selectedVisibleTimeRange,
464
                                isPinnedToPresent: $isPinnedToPresent,
465
                                presentTrackingMode: $presentTrackingMode
466
                            )
Bogdan Timofte authored 2 months ago
467
                        }
468
                    }
469
                }
Bogdan Timofte authored 2 months ago
470
            } else {
471
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
472
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
473
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
474
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
475
                }
476
            }
Bogdan Timofte authored 2 months ago
477
        }
Bogdan Timofte authored 2 months ago
478
        .font(chartBaseFont)
Bogdan Timofte authored 2 months ago
479
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
480
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
481
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
482
            chartNow = now
483
        }
Bogdan Timofte authored 2 months ago
484
    }
485

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

            
Bogdan Timofte authored 2 months ago
490
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
491
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
492
        }
493
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
494
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
495
        .background(
496
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
497
                .fill(Color.primary.opacity(0.045))
498
        )
499
        .overlay(
500
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
501
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
502
        )
Bogdan Timofte authored 2 months ago
503

            
Bogdan Timofte authored 2 months ago
504
        return Group {
Bogdan Timofte authored 2 months ago
505
            if stackedToolbarLayout {
Bogdan Timofte authored 2 months ago
506
                controlsPanel
Bogdan Timofte authored 2 months ago
507
            } else {
Bogdan Timofte authored 2 months ago
508
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
509
                    controlsPanel
Bogdan Timofte authored 2 months ago
510
                }
Bogdan Timofte authored 2 months ago
511
            }
512
        }
513
        .frame(maxWidth: .infinity, alignment: .leading)
514
    }
515

            
Bogdan Timofte authored 2 months ago
516
    private var shouldFloatScaleControlsOverChart: Bool {
517
        #if os(iOS)
518
        if availableSize.width > 0, availableSize.height > 0 {
519
            return availableSize.width > availableSize.height
520
        }
521
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
522
        #else
523
        return false
524
        #endif
525
    }
526

            
527
    private func scaleControlsPill(
528
        voltageSeries: SeriesData,
529
        currentSeries: SeriesData
530
    ) -> some View {
531
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
532
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 2 months ago
533
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
534
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
535

            
536
        return originControlsRow(
537
            voltageSeries: voltageSeries,
538
            currentSeries: currentSeries,
539
            condensedLayout: condensedLayout,
540
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
541
        )
Bogdan Timofte authored 2 months ago
542
        .padding(.horizontal, horizontalPadding)
543
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 2 months ago
544
        .background(
545
            Capsule(style: .continuous)
Bogdan Timofte authored 2 months ago
546
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 2 months ago
547
        )
548
        .overlay(
549
            Capsule(style: .continuous)
550
                .stroke(
Bogdan Timofte authored 2 months ago
551
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 2 months ago
552
                    lineWidth: 1
553
                )
554
        )
555
    }
556

            
Bogdan Timofte authored 2 months ago
557
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
558
        HStack(spacing: condensedLayout ? 6 : 8) {
559
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
560
                displayVoltage.toggle()
561
                if displayVoltage {
562
                    displayPower = false
Bogdan Timofte authored 2 months ago
563
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
564
                    if displayTemperature && displayCurrent {
565
                        displayCurrent = false
566
                    }
Bogdan Timofte authored 2 months ago
567
                }
568
            }
569

            
570
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
571
                displayCurrent.toggle()
572
                if displayCurrent {
573
                    displayPower = false
Bogdan Timofte authored 2 months ago
574
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
575
                    if displayTemperature && displayVoltage {
576
                        displayVoltage = false
577
                    }
Bogdan Timofte authored 2 months ago
578
                }
Bogdan Timofte authored 2 months ago
579
            }
580

            
581
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
582
                displayPower.toggle()
583
                if displayPower {
Bogdan Timofte authored 2 months ago
584
                    displayEnergy = false
585
                    displayCurrent = false
586
                    displayVoltage = false
587
                }
588
            }
589

            
590
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
591
                displayEnergy.toggle()
592
                if displayEnergy {
593
                    displayPower = false
Bogdan Timofte authored 2 months ago
594
                    displayCurrent = false
595
                    displayVoltage = false
596
                }
597
            }
Bogdan Timofte authored 2 months ago
598

            
599
            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
600
                displayTemperature.toggle()
601
                if displayTemperature && displayVoltage && displayCurrent {
602
                    displayCurrent = false
603
                }
604
            }
Bogdan Timofte authored 2 months ago
605
        }
606
    }
607

            
608
    private func originControlsRow(
609
        voltageSeries: SeriesData,
610
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
611
        condensedLayout: Bool,
612
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
613
    ) -> some View {
Bogdan Timofte authored 2 months ago
614
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
615
            if supportsSharedOrigin {
616
                symbolControlChip(
617
                    systemImage: "equal.circle",
618
                    enabled: true,
619
                    active: useSharedOrigin,
620
                    condensedLayout: condensedLayout,
621
                    showsLabel: showsLabel,
622
                    label: "Match Y Scale",
623
                    accessibilityLabel: "Match Y scale"
624
                ) {
625
                    toggleSharedOrigin(
626
                        voltageSeries: voltageSeries,
627
                        currentSeries: currentSeries
628
                    )
629
                }
Bogdan Timofte authored 2 months ago
630
            }
631

            
632
            symbolControlChip(
633
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
634
                enabled: true,
635
                active: pinOrigin,
636
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
637
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
638
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
639
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
640
            ) {
641
                togglePinnedOrigin(
642
                    voltageSeries: voltageSeries,
643
                    currentSeries: currentSeries
644
                )
645
            }
646

            
Bogdan Timofte authored 2 months ago
647
            if !pinnedOriginIsZero {
648
                symbolControlChip(
649
                    systemImage: "0.circle",
650
                    enabled: true,
651
                    active: false,
652
                    condensedLayout: condensedLayout,
653
                    showsLabel: showsLabel,
654
                    label: "Origin 0",
655
                    accessibilityLabel: "Set origin to zero"
656
                ) {
657
                    setVisibleOriginsToZero()
658
                }
Bogdan Timofte authored 2 months ago
659
            }
Bogdan Timofte authored 2 months ago
660

            
Bogdan Timofte authored 2 months ago
661
            smoothingControlChip(
662
                condensedLayout: condensedLayout,
663
                showsLabel: showsLabel
664
            )
665

            
Bogdan Timofte authored 2 months ago
666
        }
667
    }
668

            
Bogdan Timofte authored 2 months ago
669
    private func smoothingControlChip(
670
        condensedLayout: Bool,
671
        showsLabel: Bool
672
    ) -> some View {
673
        Menu {
674
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
675
                Button {
676
                    smoothingLevel = level
677
                } label: {
678
                    if smoothingLevel == level {
679
                        Label(level.label, systemImage: "checkmark")
680
                    } else {
681
                        Text(level.label)
Bogdan Timofte authored 2 months ago
682
                    }
683
                }
Bogdan Timofte authored 2 months ago
684
            }
685
        } label: {
686
            Group {
687
                if showsLabel {
688
                    VStack(alignment: .leading, spacing: 2) {
689
                        Label("Smoothing", systemImage: "waveform.path")
690
                            .font(controlChipFont(condensedLayout: condensedLayout))
691

            
692
                        Text(
Bogdan Timofte authored 2 months ago
693
                            smoothingLevel == .off
Bogdan Timofte authored 2 months ago
694
                            ? "Off"
695
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
696
                        )
Bogdan Timofte authored 2 months ago
697
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
698
                        .foregroundColor(.secondary)
699
                        .monospacedDigit()
700
                    }
701
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
702
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
703
                } else {
704
                    VStack(spacing: 1) {
705
                        Image(systemName: "waveform.path")
706
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
707

            
708
                        Text(smoothingLevel.shortLabel)
709
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
710
                            .monospacedDigit()
711
                    }
712
                    .frame(
713
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
714
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
715
                    )
716
                }
Bogdan Timofte authored 2 months ago
717
            }
Bogdan Timofte authored 2 months ago
718
            .background(
719
                Capsule(style: .continuous)
720
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
721
            )
722
            .overlay(
723
                Capsule(style: .continuous)
724
                    .stroke(
725
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
726
                        lineWidth: 1
727
                    )
728
            )
Bogdan Timofte authored 2 months ago
729
        }
Bogdan Timofte authored 2 months ago
730
        .buttonStyle(.plain)
731
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
732
    }
733

            
Bogdan Timofte authored 2 months ago
734
    private func seriesToggleButton(
735
        title: String,
736
        isOn: Bool,
737
        condensedLayout: Bool,
738
        action: @escaping () -> Void
739
    ) -> some View {
740
        Button(action: action) {
741
            Text(title)
Bogdan Timofte authored 2 months ago
742
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
743
                .lineLimit(1)
744
                .minimumScaleFactor(0.82)
745
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
746
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
747
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
748
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
749
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
750
                .background(
751
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
752
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
753
                )
754
                .overlay(
755
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
756
                        .stroke(Color.blue, lineWidth: 1.5)
757
                )
758
        }
759
        .buttonStyle(.plain)
760
    }
761

            
762
    private func symbolControlChip(
763
        systemImage: String,
764
        enabled: Bool,
765
        active: Bool,
766
        condensedLayout: Bool,
767
        showsLabel: Bool,
768
        label: String,
769
        accessibilityLabel: String,
770
        action: @escaping () -> Void
771
    ) -> some View {
772
        Button(action: {
773
            action()
774
        }) {
775
            Group {
776
                if showsLabel {
777
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
778
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
779
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
780
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
781
                } else {
782
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
783
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
784
                        .frame(
Bogdan Timofte authored 2 months ago
785
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
786
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
787
                        )
Bogdan Timofte authored 2 months ago
788
                }
789
            }
790
                .background(
791
                    Capsule(style: .continuous)
792
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
793
                )
794
        }
795
        .buttonStyle(.plain)
796
        .foregroundColor(enabled ? .primary : .secondary)
797
        .opacity(enabled ? 1 : 0.55)
798
        .accessibilityLabel(accessibilityLabel)
799
    }
800

            
Bogdan Timofte authored 2 months ago
801
    private func resetBuffer() {
802
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
803
    }
804

            
Bogdan Timofte authored a month ago
805
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
806
        if let rangeSelectorConfiguration {
807
            return rangeSelectorConfiguration
808
        }
809

            
810
        return MeasurementChartRangeSelectorConfiguration(
811
            keepAction: MeasurementChartSelectionAction(
812
                title: compactLayout ? "Keep" : "Keep Selection",
813
                systemName: "scissors",
814
                tone: .destructive,
815
                handler: trimBufferToSelection
816
            ),
817
            removeAction: MeasurementChartSelectionAction(
818
                title: compactLayout ? "Cut" : "Remove Selection",
819
                systemName: "minus.circle",
820
                tone: .destructive,
821
                handler: removeSelectionFromBuffer
822
            ),
823
            resetAction: MeasurementChartResetAction(
824
                title: compactLayout ? "Reset" : "Reset Buffer",
825
                systemName: "trash",
826
                tone: .destructiveProminent,
827
                confirmationTitle: "Reset captured measurements?",
828
                confirmationButtonTitle: "Reset buffer",
829
                handler: resetBuffer
830
            )
831
        )
832
    }
833

            
Bogdan Timofte authored 2 months ago
834
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
835
        if isLargeDisplay {
836
            return .body.weight(.semibold)
837
        }
838
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
839
    }
840

            
841
    private func controlChipFont(condensedLayout: Bool) -> Font {
842
        if isLargeDisplay {
843
            return .callout.weight(.semibold)
844
        }
845
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
846
    }
847

            
Bogdan Timofte authored 2 months ago
848
    @ViewBuilder
849
    private func primaryAxisView(
850
        height: CGFloat,
Bogdan Timofte authored 2 months ago
851
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
852
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
853
        voltageSeries: SeriesData,
854
        currentSeries: SeriesData
Bogdan Timofte authored 2 months ago
855
    ) -> some View {
856
        if displayPower {
857
            yAxisLabelsView(
858
                height: height,
859
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
860
                seriesKind: .power,
861
                measurementUnit: powerSeries.kind.unit,
862
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
863
            )
Bogdan Timofte authored 2 months ago
864
        } else if displayEnergy {
865
            yAxisLabelsView(
866
                height: height,
867
                context: energySeries.context,
868
                seriesKind: .energy,
869
                measurementUnit: energySeries.kind.unit,
870
                tint: energySeries.kind.tint
871
            )
Bogdan Timofte authored 2 months ago
872
        } else if displayVoltage {
873
            yAxisLabelsView(
874
                height: height,
875
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
876
                seriesKind: .voltage,
877
                measurementUnit: voltageSeries.kind.unit,
878
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
879
            )
880
        } else if displayCurrent {
881
            yAxisLabelsView(
882
                height: height,
883
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
884
                seriesKind: .current,
885
                measurementUnit: currentSeries.kind.unit,
886
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
887
            )
888
        }
889
    }
890

            
891
    @ViewBuilder
892
    private func renderedChart(
Bogdan Timofte authored 2 months ago
893
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
894
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
895
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
896
        currentSeries: SeriesData,
897
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
898
    ) -> some View {
899
        if self.displayPower {
900
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
901
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
902
        } else if self.displayEnergy {
903
            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
904
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
905
        } else {
906
            if self.displayVoltage {
907
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
908
                    .opacity(0.78)
909
            }
910
            if self.displayCurrent {
911
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
912
                    .opacity(0.78)
913
            }
914
        }
Bogdan Timofte authored 2 months ago
915

            
916
        if displayTemperature {
917
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
918
                .opacity(0.86)
919
        }
Bogdan Timofte authored 2 months ago
920
    }
921

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

            
958
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
959
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
960
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
961
        voltageSeries: SeriesData,
962
        currentSeries: SeriesData
963
    ) -> SeriesData? {
Bogdan Timofte authored 2 months ago
964
        if displayPower {
Bogdan Timofte authored 2 months ago
965
            return powerSeries
Bogdan Timofte authored 2 months ago
966
        }
Bogdan Timofte authored 2 months ago
967
        if displayEnergy {
968
            return energySeries
969
        }
Bogdan Timofte authored 2 months ago
970
        if displayVoltage {
Bogdan Timofte authored 2 months ago
971
            return voltageSeries
Bogdan Timofte authored 2 months ago
972
        }
973
        if displayCurrent {
Bogdan Timofte authored 2 months ago
974
            return currentSeries
Bogdan Timofte authored 2 months ago
975
        }
976
        return nil
977
    }
978

            
979
    private func series(
980
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
981
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
982
        minimumYSpan: Double,
983
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
984
    ) -> SeriesData {
Bogdan Timofte authored 2 months ago
985
        let rawPoints = filteredPoints(
Bogdan Timofte authored 2 months ago
986
            measurement,
987
            visibleTimeRange: visibleTimeRange
988
        )
Bogdan Timofte authored a month ago
989
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
990
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
991
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
992
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
993

            
994
        let autoBounds = automaticYBounds(
995
            for: samplePoints,
996
            minimumYSpan: minimumYSpan
997
        )
Bogdan Timofte authored 2 months ago
998
        let xBounds = xBounds(
999
            for: samplePoints,
1000
            visibleTimeRange: visibleTimeRange
1001
        )
Bogdan Timofte authored 2 months ago
1002
        let lowerBound = resolvedLowerBound(
1003
            for: kind,
1004
            autoLowerBound: autoBounds.lowerBound
1005
        )
1006
        let upperBound = resolvedUpperBound(
1007
            for: kind,
1008
            lowerBound: lowerBound,
1009
            autoUpperBound: autoBounds.upperBound,
1010
            maximumSampleValue: samplePoints.map(\.value).max(),
1011
            minimumYSpan: minimumYSpan
1012
        )
1013

            
1014
        context.setBounds(
1015
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
1016
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1017
            yMin: CGFloat(lowerBound),
1018
            yMax: CGFloat(upperBound)
1019
        )
1020

            
1021
        return SeriesData(
1022
            kind: kind,
1023
            points: points,
1024
            samplePoints: samplePoints,
1025
            context: context,
1026
            autoLowerBound: autoBounds.lowerBound,
1027
            autoUpperBound: autoBounds.upperBound,
1028
            maximumSampleValue: samplePoints.map(\.value).max()
1029
        )
1030
    }
1031

            
Bogdan Timofte authored a month ago
1032
    private func normalizedPoints(
1033
        _ points: [Measurements.Measurement.Point],
1034
        for kind: SeriesKind
1035
    ) -> [Measurements.Measurement.Point] {
1036
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1037
            return points
1038
        }
1039

            
1040
        guard let baseline = points.first(where: \.isSample)?.value else {
1041
            return points
1042
        }
1043

            
1044
        return points.enumerated().map { index, point in
1045
            Measurements.Measurement.Point(
1046
                id: point.id == index ? point.id : index,
1047
                timestamp: point.timestamp,
1048
                value: point.value - baseline,
1049
                kind: point.kind
1050
            )
1051
        }
1052
    }
1053

            
Bogdan Timofte authored 2 months ago
1054
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1055
        series(
1056
            for: measurement(for: kind),
1057
            kind: kind,
1058
            minimumYSpan: minimumYSpan(for: kind)
1059
        )
1060
    }
1061

            
Bogdan Timofte authored 2 months ago
1062
    private func smoothedPoints(
1063
        from points: [Measurements.Measurement.Point]
1064
    ) -> [Measurements.Measurement.Point] {
1065
        guard smoothingLevel != .off else { return points }
1066

            
1067
        var smoothedPoints: [Measurements.Measurement.Point] = []
1068
        var currentSegment: [Measurements.Measurement.Point] = []
1069

            
1070
        func flushCurrentSegment() {
1071
            guard !currentSegment.isEmpty else { return }
1072

            
1073
            for point in smoothedSegment(currentSegment) {
1074
                smoothedPoints.append(
1075
                    Measurements.Measurement.Point(
1076
                        id: smoothedPoints.count,
1077
                        timestamp: point.timestamp,
1078
                        value: point.value,
1079
                        kind: .sample
1080
                    )
1081
                )
1082
            }
1083

            
1084
            currentSegment.removeAll(keepingCapacity: true)
1085
        }
1086

            
1087
        for point in points {
1088
            if point.isDiscontinuity {
1089
                flushCurrentSegment()
1090

            
1091
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1092
                    smoothedPoints.append(
1093
                        Measurements.Measurement.Point(
1094
                            id: smoothedPoints.count,
1095
                            timestamp: point.timestamp,
1096
                            value: smoothedPoints.last?.value ?? point.value,
1097
                            kind: .discontinuity
1098
                        )
1099
                    )
1100
                }
1101
            } else {
1102
                currentSegment.append(point)
1103
            }
1104
        }
1105

            
1106
        flushCurrentSegment()
1107
        return smoothedPoints
1108
    }
1109

            
1110
    private func smoothedSegment(
1111
        _ segment: [Measurements.Measurement.Point]
1112
    ) -> [Measurements.Measurement.Point] {
1113
        let windowSize = smoothingLevel.movingAverageWindowSize
1114
        guard windowSize > 1, segment.count > 2 else { return segment }
1115

            
1116
        let radius = windowSize / 2
1117
        var prefixSums: [Double] = [0]
1118
        prefixSums.reserveCapacity(segment.count + 1)
1119

            
1120
        for point in segment {
1121
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1122
        }
1123

            
1124
        return segment.enumerated().map { index, point in
1125
            let lowerBound = max(0, index - radius)
1126
            let upperBound = min(segment.count - 1, index + radius)
1127
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1128
            let average = sum / Double(upperBound - lowerBound + 1)
1129

            
1130
            return Measurements.Measurement.Point(
1131
                id: point.id,
1132
                timestamp: point.timestamp,
1133
                value: average,
1134
                kind: .sample
1135
            )
1136
        }
1137
    }
1138

            
Bogdan Timofte authored 2 months ago
1139
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1140
        switch kind {
1141
        case .power:
1142
            return measurements.power
Bogdan Timofte authored 2 months ago
1143
        case .energy:
1144
            return measurements.energy
Bogdan Timofte authored 2 months ago
1145
        case .voltage:
1146
            return measurements.voltage
1147
        case .current:
1148
            return measurements.current
1149
        case .temperature:
1150
            return measurements.temperature
1151
        }
1152
    }
1153

            
1154
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1155
        switch kind {
1156
        case .power:
1157
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1158
        case .energy:
1159
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1160
        case .voltage:
1161
            return minimumVoltageSpan
1162
        case .current:
1163
            return minimumCurrentSpan
1164
        case .temperature:
1165
            return minimumTemperatureSpan
1166
        }
1167
    }
1168

            
Bogdan Timofte authored 2 months ago
1169
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 2 months ago
1170
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored 2 months ago
1171
    }
1172

            
Bogdan Timofte authored 2 months ago
1173
    private var minimumSharedScaleSpan: Double {
1174
        max(minimumVoltageSpan, minimumCurrentSpan)
1175
    }
1176

            
Bogdan Timofte authored 2 months ago
1177
    private var pinnedOriginIsZero: Bool {
1178
        if useSharedOrigin && supportsSharedOrigin {
1179
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1180
        }
Bogdan Timofte authored 2 months ago
1181

            
1182
        if displayPower {
1183
            return pinOrigin && powerAxisOrigin == 0
1184
        }
1185

            
Bogdan Timofte authored 2 months ago
1186
        if displayEnergy {
1187
            return pinOrigin && energyAxisOrigin == 0
1188
        }
1189

            
Bogdan Timofte authored 2 months ago
1190
        let visibleOrigins = [
1191
            displayVoltage ? voltageAxisOrigin : nil,
1192
            displayCurrent ? currentAxisOrigin : nil
1193
        ]
1194
        .compactMap { $0 }
1195

            
1196
        guard !visibleOrigins.isEmpty else { return false }
1197
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1198
    }
1199

            
1200
    private func toggleSharedOrigin(
1201
        voltageSeries: SeriesData,
1202
        currentSeries: SeriesData
1203
    ) {
1204
        guard supportsSharedOrigin else { return }
1205

            
1206
        if useSharedOrigin {
1207
            useSharedOrigin = false
1208
            return
1209
        }
1210

            
1211
        captureCurrentOrigins(
1212
            voltageSeries: voltageSeries,
1213
            currentSeries: currentSeries
1214
        )
1215
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1216
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1217
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1218
        useSharedOrigin = true
1219
        pinOrigin = true
1220
    }
1221

            
1222
    private func togglePinnedOrigin(
1223
        voltageSeries: SeriesData,
1224
        currentSeries: SeriesData
1225
    ) {
1226
        if pinOrigin {
1227
            pinOrigin = false
1228
            return
1229
        }
1230

            
1231
        captureCurrentOrigins(
1232
            voltageSeries: voltageSeries,
1233
            currentSeries: currentSeries
1234
        )
1235
        pinOrigin = true
1236
    }
1237

            
1238
    private func setVisibleOriginsToZero() {
1239
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1240
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1241
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1242
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1243
            voltageAxisOrigin = 0
1244
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1245
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1246
        } else {
1247
            if displayPower {
1248
                powerAxisOrigin = 0
1249
            }
Bogdan Timofte authored 2 months ago
1250
            if displayEnergy {
1251
                energyAxisOrigin = 0
1252
            }
Bogdan Timofte authored 2 months ago
1253
            if displayVoltage {
1254
                voltageAxisOrigin = 0
1255
            }
1256
            if displayCurrent {
1257
                currentAxisOrigin = 0
1258
            }
Bogdan Timofte authored 2 months ago
1259
            if displayTemperature {
1260
                temperatureAxisOrigin = 0
1261
            }
Bogdan Timofte authored 2 months ago
1262
        }
1263

            
1264
        pinOrigin = true
1265
    }
1266

            
1267
    private func captureCurrentOrigins(
1268
        voltageSeries: SeriesData,
1269
        currentSeries: SeriesData
1270
    ) {
1271
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1272
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1273
        voltageAxisOrigin = voltageSeries.autoLowerBound
1274
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1275
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored 2 months ago
1276
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1277
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1278
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1279
    }
1280

            
1281
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1282
        let visibleTimeRange = activeVisibleTimeRange
1283

            
Bogdan Timofte authored 2 months ago
1284
        switch kind {
1285
        case .power:
Bogdan Timofte authored 2 months ago
1286
            return pinOrigin
1287
                ? powerAxisOrigin
1288
                : automaticYBounds(
1289
                    for: filteredSamplePoints(
1290
                        measurements.power,
1291
                        visibleTimeRange: visibleTimeRange
1292
                    ),
1293
                    minimumYSpan: minimumPowerSpan
1294
                ).lowerBound
Bogdan Timofte authored 2 months ago
1295
        case .energy:
1296
            return pinOrigin
1297
                ? energyAxisOrigin
1298
                : automaticYBounds(
1299
                    for: filteredSamplePoints(
1300
                        measurements.energy,
1301
                        visibleTimeRange: visibleTimeRange
1302
                    ),
1303
                    minimumYSpan: minimumEnergySpan
1304
                ).lowerBound
Bogdan Timofte authored 2 months ago
1305
        case .voltage:
1306
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1307
                return sharedAxisOrigin
1308
            }
Bogdan Timofte authored 2 months ago
1309
            return pinOrigin
1310
                ? voltageAxisOrigin
1311
                : automaticYBounds(
1312
                    for: filteredSamplePoints(
1313
                        measurements.voltage,
1314
                        visibleTimeRange: visibleTimeRange
1315
                    ),
1316
                    minimumYSpan: minimumVoltageSpan
1317
                ).lowerBound
Bogdan Timofte authored 2 months ago
1318
        case .current:
1319
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1320
                return sharedAxisOrigin
1321
            }
Bogdan Timofte authored 2 months ago
1322
            return pinOrigin
1323
                ? currentAxisOrigin
1324
                : automaticYBounds(
1325
                    for: filteredSamplePoints(
1326
                        measurements.current,
1327
                        visibleTimeRange: visibleTimeRange
1328
                    ),
1329
                    minimumYSpan: minimumCurrentSpan
1330
                ).lowerBound
Bogdan Timofte authored 2 months ago
1331
        case .temperature:
Bogdan Timofte authored 2 months ago
1332
            return pinOrigin
1333
                ? temperatureAxisOrigin
1334
                : automaticYBounds(
1335
                    for: filteredSamplePoints(
1336
                        measurements.temperature,
1337
                        visibleTimeRange: visibleTimeRange
1338
                    ),
1339
                    minimumYSpan: minimumTemperatureSpan
1340
                ).lowerBound
Bogdan Timofte authored 2 months ago
1341
        }
1342
    }
1343

            
Bogdan Timofte authored 2 months ago
1344
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1345
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1346
    }
1347

            
1348
    private func filteredPoints(
1349
        _ measurement: Measurements.Measurement,
1350
        visibleTimeRange: ClosedRange<Date>? = nil
1351
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1352
        let resolvedRange: ClosedRange<Date>?
1353

            
1354
        switch (timeRange, visibleTimeRange) {
1355
        case let (baseRange?, visibleRange?):
1356
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1357
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1358
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1359
        case let (baseRange?, nil):
1360
            resolvedRange = baseRange
1361
        case let (nil, visibleRange?):
1362
            resolvedRange = visibleRange
1363
        case (nil, nil):
1364
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1365
        }
Bogdan Timofte authored 2 months ago
1366

            
1367
        guard let resolvedRange else {
1368
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1369
        }
1370

            
1371
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1372
    }
1373

            
1374
    private func filteredSamplePoints(
1375
        _ measurement: Measurements.Measurement,
1376
        visibleTimeRange: ClosedRange<Date>? = nil
1377
    ) -> [Measurements.Measurement.Point] {
1378
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1379
            point.isSample
Bogdan Timofte authored 2 months ago
1380
        }
1381
    }
1382

            
1383
    private func xBounds(
Bogdan Timofte authored 2 months ago
1384
        for samplePoints: [Measurements.Measurement.Point],
1385
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1386
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1387
        if let visibleTimeRange {
1388
            return normalizedTimeRange(visibleTimeRange)
1389
        }
1390

            
Bogdan Timofte authored 2 months ago
1391
        if let timeRange {
Bogdan Timofte authored 2 months ago
1392
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1393
        }
1394

            
1395
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1396
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1397

            
Bogdan Timofte authored 2 months ago
1398
        return normalizedTimeRange(lowerBound...upperBound)
1399
    }
1400

            
1401
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1402
        if let timeRange {
1403
            return normalizedTimeRange(timeRange)
1404
        }
1405

            
1406
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1407
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1408
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1409
            return nil
1410
        }
1411

            
Bogdan Timofte authored a month ago
1412
        let latestSampleTimestamp = samplePoints.last?.timestamp
1413
        let resolvedUpperBound = timeRangeUpperBound ?? {
1414
            guard extendsTimelineToPresent else {
1415
                return latestSampleTimestamp ?? lowerBound
1416
            }
1417
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1418
        }()
1419
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1420
        return normalizedTimeRange(lowerBound...upperBound)
1421
    }
1422

            
1423
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1424
        let candidates = [
1425
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1426
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1427
            filteredSamplePoints(measurements.voltage),
1428
            filteredSamplePoints(measurements.current),
1429
            filteredSamplePoints(measurements.temperature)
1430
        ]
1431

            
1432
        return candidates.first(where: { !$0.isEmpty }) ?? []
1433
    }
1434

            
1435
    private func resolvedVisibleTimeRange(
1436
        within availableTimeRange: ClosedRange<Date>?
1437
    ) -> ClosedRange<Date>? {
1438
        guard let availableTimeRange else { return nil }
1439
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1440

            
1441
        if isPinnedToPresent {
1442
            let pinnedRange: ClosedRange<Date>
1443

            
1444
            switch presentTrackingMode {
1445
            case .keepDuration:
1446
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1447
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1448
            case .keepStartTimestamp:
1449
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1450
            }
1451

            
1452
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1453
        }
1454

            
1455
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1456
    }
1457

            
1458
    private func clampedTimeRange(
1459
        _ candidateRange: ClosedRange<Date>,
1460
        within bounds: ClosedRange<Date>
1461
    ) -> ClosedRange<Date> {
1462
        let normalizedBounds = normalizedTimeRange(bounds)
1463
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1464

            
1465
        guard boundsSpan > 0 else {
1466
            return normalizedBounds
1467
        }
1468

            
1469
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1470
        let requestedSpan = min(
1471
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1472
            boundsSpan
1473
        )
1474

            
1475
        if requestedSpan >= boundsSpan {
1476
            return normalizedBounds
1477
        }
1478

            
1479
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1480
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1481

            
1482
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1483
            if lowerBound == normalizedBounds.lowerBound {
1484
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1485
            } else {
1486
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1487
            }
1488
        }
1489

            
1490
        if upperBound > normalizedBounds.upperBound {
1491
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1492
            upperBound = normalizedBounds.upperBound
1493
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1494
        }
1495

            
Bogdan Timofte authored 2 months ago
1496
        if lowerBound < normalizedBounds.lowerBound {
1497
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1498
            lowerBound = normalizedBounds.lowerBound
1499
            upperBound = upperBound.addingTimeInterval(delta)
1500
        }
1501

            
1502
        return lowerBound...upperBound
1503
    }
1504

            
1505
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1506
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1507
        guard span < minimumTimeSpan else { return range }
1508

            
1509
        let expansion = (minimumTimeSpan - span) / 2
1510
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1511
    }
1512

            
1513
    private func shouldShowRangeSelector(
1514
        availableTimeRange: ClosedRange<Date>,
1515
        series: SeriesData
1516
    ) -> Bool {
1517
        series.samplePoints.count > 1 &&
1518
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1519
    }
1520

            
1521
    private func automaticYBounds(
1522
        for samplePoints: [Measurements.Measurement.Point],
1523
        minimumYSpan: Double
1524
    ) -> (lowerBound: Double, upperBound: Double) {
1525
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1526

            
1527
        guard
1528
            let minimumSampleValue = samplePoints.map(\.value).min(),
1529
            let maximumSampleValue = samplePoints.map(\.value).max()
1530
        else {
1531
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1532
        }
Bogdan Timofte authored 2 months ago
1533

            
1534
        var lowerBound = minimumSampleValue
1535
        var upperBound = maximumSampleValue
1536
        let currentSpan = upperBound - lowerBound
1537

            
1538
        if currentSpan < minimumYSpan {
1539
            let expansion = (minimumYSpan - currentSpan) / 2
1540
            lowerBound -= expansion
1541
            upperBound += expansion
1542
        }
1543

            
1544
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1545
            let shift = -negativeAllowance - lowerBound
1546
            lowerBound += shift
1547
            upperBound += shift
1548
        }
1549

            
1550
        let snappedLowerBound = snappedOriginValue(lowerBound)
1551
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1552
        return (snappedLowerBound, resolvedUpperBound)
1553
    }
1554

            
1555
    private func resolvedLowerBound(
1556
        for kind: SeriesKind,
1557
        autoLowerBound: Double
1558
    ) -> Double {
1559
        guard pinOrigin else { return autoLowerBound }
1560

            
1561
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1562
            return sharedAxisOrigin
1563
        }
1564

            
1565
        switch kind {
1566
        case .power:
1567
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
1568
        case .energy:
1569
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1570
        case .voltage:
1571
            return voltageAxisOrigin
1572
        case .current:
1573
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1574
        case .temperature:
1575
            return temperatureAxisOrigin
Bogdan Timofte authored 2 months ago
1576
        }
1577
    }
1578

            
1579
    private func resolvedUpperBound(
1580
        for kind: SeriesKind,
1581
        lowerBound: Double,
1582
        autoUpperBound: Double,
1583
        maximumSampleValue: Double?,
1584
        minimumYSpan: Double
1585
    ) -> Double {
1586
        guard pinOrigin else {
1587
            return autoUpperBound
1588
        }
1589

            
Bogdan Timofte authored 2 months ago
1590
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1591
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1592
        }
1593

            
Bogdan Timofte authored 2 months ago
1594
        if kind == .temperature {
1595
            return autoUpperBound
1596
        }
1597

            
Bogdan Timofte authored 2 months ago
1598
        return max(
1599
            maximumSampleValue ?? lowerBound,
1600
            lowerBound + minimumYSpan,
1601
            autoUpperBound
1602
        )
1603
    }
1604

            
Bogdan Timofte authored 2 months ago
1605
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
1606
        let baseline = displayedLowerBoundForSeries(kind)
1607
        let proposedOrigin = snappedOriginValue(baseline + delta)
1608

            
1609
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
1610
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1611
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
1612
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1613
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1614
        } else {
1615
            switch kind {
1616
            case .power:
1617
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
1618
            case .energy:
1619
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
1620
            case .voltage:
1621
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1622
            case .current:
1623
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
1624
            case .temperature:
1625
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored 2 months ago
1626
            }
1627
        }
1628

            
1629
        pinOrigin = true
1630
    }
1631

            
Bogdan Timofte authored 2 months ago
1632
    private func clearOriginOffset(for kind: SeriesKind) {
1633
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1634
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1635
            sharedAxisOrigin = 0
1636
            sharedAxisUpperBound = currentSpan
1637
            ensureSharedScaleSpan()
1638
            voltageAxisOrigin = 0
1639
            currentAxisOrigin = 0
1640
        } else {
1641
            switch kind {
1642
            case .power:
1643
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1644
            case .energy:
1645
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1646
            case .voltage:
1647
                voltageAxisOrigin = 0
1648
            case .current:
1649
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1650
            case .temperature:
1651
                temperatureAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1652
            }
1653
        }
1654

            
1655
        pinOrigin = true
1656
    }
1657

            
1658
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1659
        guard totalHeight > 1 else { return }
1660

            
1661
        let normalized = max(0, min(1, locationY / totalHeight))
1662
        if normalized < (1.0 / 3.0) {
1663
            applyOriginDelta(-1, kind: kind)
1664
        } else if normalized < (2.0 / 3.0) {
1665
            clearOriginOffset(for: kind)
1666
        } else {
1667
            applyOriginDelta(1, kind: kind)
1668
        }
1669
    }
1670

            
Bogdan Timofte authored 2 months ago
1671
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1672
        let visibleTimeRange = activeVisibleTimeRange
1673

            
Bogdan Timofte authored 2 months ago
1674
        switch kind {
1675
        case .power:
Bogdan Timofte authored 2 months ago
1676
            return snappedOriginValue(
1677
                filteredSamplePoints(
1678
                    measurements.power,
1679
                    visibleTimeRange: visibleTimeRange
1680
                ).map(\.value).min() ?? 0
1681
            )
Bogdan Timofte authored 2 months ago
1682
        case .energy:
1683
            return snappedOriginValue(
1684
                filteredSamplePoints(
1685
                    measurements.energy,
1686
                    visibleTimeRange: visibleTimeRange
1687
                ).map(\.value).min() ?? 0
1688
            )
Bogdan Timofte authored 2 months ago
1689
        case .voltage:
Bogdan Timofte authored 2 months ago
1690
            return snappedOriginValue(
1691
                filteredSamplePoints(
1692
                    measurements.voltage,
1693
                    visibleTimeRange: visibleTimeRange
1694
                ).map(\.value).min() ?? 0
1695
            )
Bogdan Timofte authored 2 months ago
1696
        case .current:
Bogdan Timofte authored 2 months ago
1697
            return snappedOriginValue(
1698
                filteredSamplePoints(
1699
                    measurements.current,
1700
                    visibleTimeRange: visibleTimeRange
1701
                ).map(\.value).min() ?? 0
1702
            )
Bogdan Timofte authored 2 months ago
1703
        case .temperature:
Bogdan Timofte authored 2 months ago
1704
            return snappedOriginValue(
1705
                filteredSamplePoints(
1706
                    measurements.temperature,
1707
                    visibleTimeRange: visibleTimeRange
1708
                ).map(\.value).min() ?? 0
1709
            )
Bogdan Timofte authored 2 months ago
1710
        }
1711
    }
1712

            
1713
    private func maximumVisibleSharedOrigin() -> Double {
1714
        min(
1715
            maximumVisibleOrigin(for: .voltage),
1716
            maximumVisibleOrigin(for: .current)
1717
        )
1718
    }
1719

            
Bogdan Timofte authored 2 months ago
1720
    private func measurementUnit(for kind: SeriesKind) -> String {
1721
        switch kind {
1722
        case .temperature:
1723
            let locale = Locale.autoupdatingCurrent
1724
            if #available(iOS 16.0, *) {
1725
                switch locale.measurementSystem {
1726
                case .us:
1727
                    return "°F"
1728
                default:
1729
                    return "°C"
1730
                }
1731
            }
1732

            
1733
            let regionCode = locale.regionCode ?? ""
1734
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1735
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1736
        default:
1737
            return kind.unit
1738
        }
1739
    }
1740

            
Bogdan Timofte authored 2 months ago
1741
    private func ensureSharedScaleSpan() {
1742
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1743
    }
1744

            
Bogdan Timofte authored 2 months ago
1745
    private func snappedOriginValue(_ value: Double) -> Double {
1746
        if value >= 0 {
1747
            return value.rounded(.down)
1748
        }
1749

            
1750
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
1751
    }
Bogdan Timofte authored 2 months ago
1752

            
Bogdan Timofte authored 2 months ago
1753
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1754
        measurements.keepOnly(in: range)
1755
        selectedVisibleTimeRange = nil
1756
        isPinnedToPresent = false
1757
    }
1758

            
1759
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1760
        measurements.removeValues(in: range)
1761
        selectedVisibleTimeRange = nil
1762
        isPinnedToPresent = false
1763
    }
1764

            
Bogdan Timofte authored 2 months ago
1765
    private func yGuidePosition(
1766
        for labelIndex: Int,
1767
        context: ChartContext,
1768
        height: CGFloat
1769
    ) -> CGFloat {
1770
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1771
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1772
        return context.placeInRect(point: anchorPoint).y * height
1773
    }
1774

            
1775
    private func xGuidePosition(
1776
        for labelIndex: Int,
1777
        context: ChartContext,
1778
        width: CGFloat
1779
    ) -> CGFloat {
1780
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1781
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1782
        return context.placeInRect(point: anchorPoint).x * width
1783
    }
Bogdan Timofte authored 2 months ago
1784

            
1785
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
1786
    fileprivate func xAxisLabelsView(
1787
        context: ChartContext
1788
    ) -> some View {
Bogdan Timofte authored 2 months ago
1789
        var timeFormat: String?
1790
        switch context.size.width {
1791
        case 0..<3600: timeFormat = "HH:mm:ss"
1792
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
1793
        default: timeFormat = "E HH:mm"
1794
        }
1795
        let labels = (1...xLabels).map {
1796
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
1797
        }
Bogdan Timofte authored 2 months ago
1798
        let axisLabelFont: Font = {
1799
            if isIPhone && isPortraitLayout {
1800
                return .caption2.weight(.semibold)
1801
            }
1802
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1803
        }()
Bogdan Timofte authored 2 months ago
1804

            
1805
        return HStack(spacing: chartSectionSpacing) {
1806
            Color.clear
1807
                .frame(width: axisColumnWidth)
1808

            
1809
            GeometryReader { geometry in
1810
                let labelWidth = max(
1811
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1812
                    1
1813
                )
1814

            
1815
                ZStack(alignment: .topLeading) {
1816
                    Path { path in
1817
                        for labelIndex in 1...self.xLabels {
1818
                            let x = xGuidePosition(
1819
                                for: labelIndex,
1820
                                context: context,
1821
                                width: geometry.size.width
1822
                            )
1823
                            path.move(to: CGPoint(x: x, y: 0))
1824
                            path.addLine(to: CGPoint(x: x, y: 6))
1825
                        }
1826
                    }
1827
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1828

            
1829
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1830
                        let labelIndex = item.offset + 1
1831
                        let centerX = xGuidePosition(
1832
                            for: labelIndex,
1833
                            context: context,
1834
                            width: geometry.size.width
1835
                        )
1836

            
1837
                        Text(item.element)
Bogdan Timofte authored 2 months ago
1838
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
1839
                            .monospacedDigit()
1840
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
1841
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
1842
                            .frame(width: labelWidth)
1843
                            .position(
1844
                                x: centerX,
1845
                                y: geometry.size.height * 0.7
1846
                            )
Bogdan Timofte authored 2 months ago
1847
                    }
1848
                }
Bogdan Timofte authored 2 months ago
1849
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
1850
            }
Bogdan Timofte authored 2 months ago
1851

            
1852
            Color.clear
1853
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
1854
        }
1855
    }
1856

            
Bogdan Timofte authored 2 months ago
1857
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
1858
        height: CGFloat,
1859
        context: ChartContext,
Bogdan Timofte authored 2 months ago
1860
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
1861
        measurementUnit: String,
1862
        tint: Color
1863
    ) -> some View {
Bogdan Timofte authored 2 months ago
1864
        let yAxisFont: Font = {
1865
            if isIPhone && isPortraitLayout {
1866
                return .caption2.weight(.semibold)
1867
            }
1868
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1869
        }()
1870

            
1871
        let unitFont: Font = {
1872
            if isIPhone && isPortraitLayout {
1873
                return .caption2.weight(.bold)
1874
            }
1875
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1876
        }()
1877

            
1878
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
1879
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1880
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1881
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1882

            
Bogdan Timofte authored 2 months ago
1883
            ZStack(alignment: .top) {
1884
                ForEach(0..<yLabels, id: \.self) { row in
1885
                    let labelIndex = yLabels - row
1886

            
1887
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
1888
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
1889
                        .monospacedDigit()
1890
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
1891
                        .minimumScaleFactor(0.8)
1892
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
1893
                        .position(
1894
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
1895
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
1896
                                for: labelIndex,
1897
                                context: context,
Bogdan Timofte authored 2 months ago
1898
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
1899
                            )
1900
                        )
Bogdan Timofte authored 2 months ago
1901
                }
Bogdan Timofte authored 2 months ago
1902

            
Bogdan Timofte authored 2 months ago
1903
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
1904
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
1905
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
1906
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1907
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
1908
                    .background(
1909
                        Capsule(style: .continuous)
1910
                            .fill(tint.opacity(0.14))
1911
                    )
Bogdan Timofte authored 2 months ago
1912
                    .padding(.top, 8)
1913

            
Bogdan Timofte authored 2 months ago
1914
            }
1915
        }
Bogdan Timofte authored 2 months ago
1916
        .frame(height: height)
1917
        .background(
1918
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1919
                .fill(tint.opacity(0.12))
1920
        )
1921
        .overlay(
1922
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1923
                .stroke(tint.opacity(0.20), lineWidth: 1)
1924
        )
Bogdan Timofte authored 2 months ago
1925
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1926
        .gesture(
Bogdan Timofte authored 2 months ago
1927
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
1928
                .onEnded { value in
Bogdan Timofte authored 2 months ago
1929
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
1930
                }
1931
        )
Bogdan Timofte authored 2 months ago
1932
    }
1933

            
Bogdan Timofte authored 2 months ago
1934
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 months ago
1935
        GeometryReader { geometry in
1936
            Path { path in
Bogdan Timofte authored 2 months ago
1937
                for labelIndex in 1...self.yLabels {
1938
                    let y = yGuidePosition(
1939
                        for: labelIndex,
1940
                        context: context,
1941
                        height: geometry.size.height
1942
                    )
1943
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 months ago
1944
                }
Bogdan Timofte authored 2 months ago
1945
            }
1946
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 months ago
1947
        }
1948
    }
1949

            
Bogdan Timofte authored 2 months ago
1950
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 months ago
1951
        GeometryReader { geometry in
1952
            Path { path in
1953

            
Bogdan Timofte authored 2 months ago
1954
                for labelIndex in 2..<self.xLabels {
1955
                    let x = xGuidePosition(
1956
                        for: labelIndex,
1957
                        context: context,
1958
                        width: geometry.size.width
1959
                    )
1960
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 months ago
1961
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1962
                }
Bogdan Timofte authored 2 months ago
1963
            }
1964
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 months ago
1965
        }
1966
    }
Bogdan Timofte authored 2 months ago
1967

            
1968
    fileprivate func discontinuityMarkers(
1969
        points: [Measurements.Measurement.Point],
1970
        context: ChartContext
1971
    ) -> some View {
1972
        GeometryReader { geometry in
1973
            Path { path in
1974
                for point in points where point.isDiscontinuity {
1975
                    let markerX = context.placeInRect(
1976
                        point: CGPoint(
1977
                            x: point.timestamp.timeIntervalSince1970,
1978
                            y: context.origin.y
1979
                        )
1980
                    ).x * geometry.size.width
1981
                    path.move(to: CGPoint(x: markerX, y: 0))
1982
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1983
                }
1984
            }
1985
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1986
        }
1987
    }
Bogdan Timofte authored 2 months ago
1988

            
1989
}
1990

            
Bogdan Timofte authored 2 months ago
1991
private struct TimeRangeSelectorView: View {
1992
    private enum DragTarget {
1993
        case lowerBound
1994
        case upperBound
1995
        case window
1996
    }
1997

            
1998
    private struct DragState {
1999
        let target: DragTarget
2000
        let initialRange: ClosedRange<Date>
2001
    }
2002

            
2003
    let points: [Measurements.Measurement.Point]
2004
    let context: ChartContext
2005
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
2006
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
2007
    let compactLayout: Bool
2008
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
2009
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
2010

            
2011
    @Binding var selectedTimeRange: ClosedRange<Date>?
2012
    @Binding var isPinnedToPresent: Bool
2013
    @Binding var presentTrackingMode: PresentTrackingMode
2014
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
2015
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
2016

            
2017
    private var totalSpan: TimeInterval {
2018
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2019
    }
2020

            
2021
    private var currentRange: ClosedRange<Date> {
2022
        resolvedSelectionRange()
2023
    }
2024

            
2025
    private var trackHeight: CGFloat {
2026
        compactLayout ? 72 : 86
2027
    }
2028

            
Bogdan Timofte authored a month ago
2029
    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2030
        let rowHeight: CGFloat = compactLayout ? 28 : 32
2031
        let trackHeight: CGFloat = compactLayout ? 72 : 86
2032
        let boundaryHeight: CGFloat = compactLayout ? 16 : 18
2033
        let spacing: CGFloat = compactLayout ? 6 : 8
2034
        return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + boundaryHeight
2035
    }
2036

            
Bogdan Timofte authored 2 months ago
2037
    private var cornerRadius: CGFloat {
2038
        compactLayout ? 14 : 16
2039
    }
2040

            
2041
    private var boundaryFont: Font {
2042
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
2043
    }
2044

            
2045
    private var symbolButtonSize: CGFloat {
2046
        compactLayout ? 28 : 32
2047
    }
2048

            
2049
    var body: some View {
2050
        let coversFullRange = selectionCoversFullRange(currentRange)
2051

            
2052
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
2053
            if !coversFullRange || isPinnedToPresent {
2054
                HStack(spacing: 8) {
2055
                    alignmentButton(
2056
                        systemName: "arrow.left.to.line.compact",
2057
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2058
                        action: alignSelectionToLeadingEdge,
2059
                        accessibilityLabel: "Align selection to start"
2060
                    )
2061

            
2062
                    alignmentButton(
2063
                        systemName: "arrow.right.to.line.compact",
2064
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2065
                        action: alignSelectionToTrailingEdge,
2066
                        accessibilityLabel: "Align selection to present"
2067
                    )
2068

            
2069
                    Spacer(minLength: 0)
2070

            
2071
                    if isPinnedToPresent {
2072
                        trackingModeToggleButton()
2073
                    }
2074
                }
2075
            }
2076

            
Bogdan Timofte authored 2 months ago
2077
            HStack(spacing: 8) {
2078
                if !coversFullRange {
2079
                    actionButton(
Bogdan Timofte authored a month ago
2080
                        title: configuration.keepAction.title,
2081
                        systemName: configuration.keepAction.systemName,
2082
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2083
                        action: {
Bogdan Timofte authored a month ago
2084
                            configuration.keepAction.handler(currentRange)
2085
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2086
                        }
2087
                    )
2088

            
Bogdan Timofte authored a month ago
2089
                    if let removeAction = configuration.removeAction {
2090
                        actionButton(
2091
                            title: removeAction.title,
2092
                            systemName: removeAction.systemName,
2093
                            tone: removeAction.tone,
2094
                            action: {
2095
                                removeAction.handler(currentRange)
2096
                                resetSelectionState()
2097
                            }
2098
                        )
2099
                    }
Bogdan Timofte authored 2 months ago
2100
                }
2101

            
2102
                Spacer(minLength: 0)
2103

            
2104
                actionButton(
Bogdan Timofte authored a month ago
2105
                    title: configuration.resetAction.title,
2106
                    systemName: configuration.resetAction.systemName,
2107
                    tone: configuration.resetAction.tone,
Bogdan Timofte authored 2 months ago
2108
                    action: {
2109
                        showResetConfirmation = true
2110
                    }
2111
                )
2112
            }
Bogdan Timofte authored a month ago
2113
            .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2114
                Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2115
                    configuration.resetAction.handler()
2116
                    resetSelectionState()
Bogdan Timofte authored 2 months ago
2117
                }
2118
                Button("Cancel", role: .cancel) {}
2119
            }
2120

            
Bogdan Timofte authored 2 months ago
2121
            GeometryReader { geometry in
2122
                let selectionFrame = selectionFrame(in: geometry.size)
2123
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2124

            
2125
                ZStack(alignment: .topLeading) {
2126
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2127
                        .fill(Color.primary.opacity(0.05))
2128

            
2129
                    Chart(
2130
                        points: points,
2131
                        context: context,
2132
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2133
                        strokeColor: selectorTint,
2134
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2135
                    )
2136
                    .opacity(0.94)
2137
                    .allowsHitTesting(false)
2138

            
2139
                    Chart(
2140
                        points: points,
2141
                        context: context,
Bogdan Timofte authored 2 months ago
2142
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2143
                    )
2144
                    .opacity(0.82)
2145
                    .allowsHitTesting(false)
2146

            
2147
                    if selectionFrame.minX > 0 {
2148
                        Rectangle()
2149
                            .fill(dimmingColor)
2150
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2151
                            .allowsHitTesting(false)
2152
                    }
2153

            
2154
                    if selectionFrame.maxX < geometry.size.width {
2155
                        Rectangle()
2156
                            .fill(dimmingColor)
2157
                            .frame(
2158
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2159
                                height: geometry.size.height
2160
                            )
2161
                            .offset(x: selectionFrame.maxX)
2162
                            .allowsHitTesting(false)
2163
                    }
2164

            
2165
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2166
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2167
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2168
                        .offset(x: selectionFrame.minX)
2169
                        .allowsHitTesting(false)
2170

            
2171
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2172
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2173
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2174
                        .offset(x: selectionFrame.minX)
2175
                        .allowsHitTesting(false)
2176

            
2177
                    handleView(height: max(geometry.size.height - 18, 16))
2178
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2179
                        .allowsHitTesting(false)
2180

            
2181
                    handleView(height: max(geometry.size.height - 18, 16))
2182
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2183
                        .allowsHitTesting(false)
2184
                }
2185
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2186
                .overlay(
2187
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2188
                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
2189
                )
2190
                .contentShape(Rectangle())
2191
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2192
            }
2193
            .frame(height: trackHeight)
2194

            
2195
            HStack {
2196
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2197
                Spacer(minLength: 0)
2198
                Text(boundaryLabel(for: availableTimeRange.upperBound))
2199
            }
2200
            .font(boundaryFont)
2201
            .foregroundColor(.secondary)
2202
            .monospacedDigit()
2203
        }
2204
    }
2205

            
2206
    private func handleView(height: CGFloat) -> some View {
2207
        Capsule(style: .continuous)
2208
            .fill(Color.white.opacity(0.95))
2209
            .frame(width: 6, height: height)
2210
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2211
    }
2212

            
2213
    private func alignmentButton(
2214
        systemName: String,
2215
        isActive: Bool,
2216
        action: @escaping () -> Void,
2217
        accessibilityLabel: String
2218
    ) -> some View {
2219
        Button(action: action) {
2220
            Image(systemName: systemName)
2221
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2222
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2223
        }
2224
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2225
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2226
        .background(
2227
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2228
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2229
        )
2230
        .overlay(
2231
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2232
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2233
        )
2234
        .accessibilityLabel(accessibilityLabel)
2235
    }
2236

            
2237
    private func trackingModeToggleButton() -> some View {
2238
        Button {
2239
            presentTrackingMode = presentTrackingMode == .keepDuration
2240
                ? .keepStartTimestamp
2241
                : .keepDuration
2242
        } label: {
2243
            Image(systemName: trackingModeSymbolName)
2244
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2245
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2246
        }
2247
        .buttonStyle(.plain)
2248
        .foregroundColor(.white)
2249
        .background(
2250
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2251
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2252
        )
2253
        .overlay(
2254
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2255
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2256
        )
2257
        .accessibilityLabel(trackingModeAccessibilityLabel)
2258
        .accessibilityHint("Toggles how the interval follows the present")
2259
    }
2260

            
Bogdan Timofte authored 2 months ago
2261
    private func actionButton(
2262
        title: String,
2263
        systemName: String,
Bogdan Timofte authored a month ago
2264
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2265
        action: @escaping () -> Void
2266
    ) -> some View {
2267
        let foregroundColor: Color = {
2268
            switch tone {
2269
            case .reversible, .destructive:
2270
                return toneColor(for: tone)
2271
            case .destructiveProminent:
2272
                return .white
2273
            }
2274
        }()
2275

            
2276
        return Button(action: action) {
2277
            Label(title, systemImage: systemName)
2278
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2279
                .padding(.horizontal, compactLayout ? 10 : 12)
2280
                .padding(.vertical, compactLayout ? 7 : 8)
2281
        }
2282
        .buttonStyle(.plain)
2283
        .foregroundColor(foregroundColor)
2284
        .background(
2285
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2286
                .fill(actionButtonBackground(for: tone))
2287
        )
2288
        .overlay(
2289
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2290
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2291
        )
2292
    }
2293

            
Bogdan Timofte authored a month ago
2294
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2295
        switch tone {
2296
        case .reversible:
2297
            return selectorTint
2298
        case .destructive, .destructiveProminent:
2299
            return .red
2300
        }
2301
    }
2302

            
Bogdan Timofte authored a month ago
2303
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2304
        switch tone {
2305
        case .reversible:
2306
            return selectorTint.opacity(0.12)
2307
        case .destructive:
2308
            return Color.red.opacity(0.12)
2309
        case .destructiveProminent:
2310
            return Color.red.opacity(0.82)
2311
        }
2312
    }
2313

            
Bogdan Timofte authored a month ago
2314
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2315
        switch tone {
2316
        case .reversible:
2317
            return selectorTint.opacity(0.22)
2318
        case .destructive:
2319
            return Color.red.opacity(0.22)
2320
        case .destructiveProminent:
2321
            return Color.red.opacity(0.72)
2322
        }
2323
    }
2324

            
Bogdan Timofte authored 2 months ago
2325
    private var trackingModeSymbolName: String {
2326
        switch presentTrackingMode {
2327
        case .keepDuration:
2328
            return "arrow.left.and.right"
2329
        case .keepStartTimestamp:
2330
            return "arrow.left.to.line.compact"
2331
        }
2332
    }
2333

            
2334
    private var trackingModeAccessibilityLabel: String {
2335
        switch presentTrackingMode {
2336
        case .keepDuration:
2337
            return "Follow present keeping span"
2338
        case .keepStartTimestamp:
2339
            return "Follow present keeping start"
2340
        }
2341
    }
2342

            
2343
    private func alignSelectionToLeadingEdge() {
2344
        let alignedRange = normalizedSelectionRange(
2345
            availableTimeRange.lowerBound...currentRange.upperBound
2346
        )
2347
        applySelection(alignedRange, pinToPresent: false)
2348
    }
2349

            
2350
    private func alignSelectionToTrailingEdge() {
2351
        let alignedRange = normalizedSelectionRange(
2352
            currentRange.lowerBound...availableTimeRange.upperBound
2353
        )
2354
        applySelection(alignedRange, pinToPresent: true)
2355
    }
2356

            
2357
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2358
        DragGesture(minimumDistance: 0)
2359
            .onChanged { value in
2360
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2361
            }
2362
            .onEnded { _ in
2363
                dragState = nil
2364
            }
2365
    }
2366

            
2367
    private func updateSelectionDrag(
2368
        value: DragGesture.Value,
2369
        totalWidth: CGFloat
2370
    ) {
2371
        let startingRange = resolvedSelectionRange()
2372

            
2373
        if dragState == nil {
2374
            dragState = DragState(
2375
                target: dragTarget(
2376
                    for: value.startLocation.x,
2377
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2378
                ),
2379
                initialRange: startingRange
2380
            )
2381
        }
2382

            
2383
        guard let dragState else { return }
2384

            
2385
        let resultingRange = snappedToEdges(
2386
            adjustedRange(
2387
                from: dragState.initialRange,
2388
                target: dragState.target,
2389
                translationX: value.translation.width,
2390
                totalWidth: totalWidth
2391
            ),
2392
            target: dragState.target,
2393
            totalWidth: totalWidth
2394
        )
2395

            
2396
        applySelection(
2397
            resultingRange,
2398
            pinToPresent: shouldKeepPresentPin(
2399
                during: dragState.target,
2400
                initialRange: dragState.initialRange,
2401
                resultingRange: resultingRange
2402
            ),
2403
        )
2404
    }
2405

            
2406
    private func dragTarget(
2407
        for startX: CGFloat,
2408
        selectionFrame: CGRect
2409
    ) -> DragTarget {
2410
        let handleZone: CGFloat = compactLayout ? 20 : 24
2411

            
2412
        if abs(startX - selectionFrame.minX) <= handleZone {
2413
            return .lowerBound
2414
        }
2415

            
2416
        if abs(startX - selectionFrame.maxX) <= handleZone {
2417
            return .upperBound
2418
        }
2419

            
2420
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2421
            return .window
2422
        }
2423

            
2424
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2425
    }
2426

            
2427
    private func adjustedRange(
2428
        from initialRange: ClosedRange<Date>,
2429
        target: DragTarget,
2430
        translationX: CGFloat,
2431
        totalWidth: CGFloat
2432
    ) -> ClosedRange<Date> {
2433
        guard totalSpan > 0, totalWidth > 0 else {
2434
            return availableTimeRange
2435
        }
2436

            
2437
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2438
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2439

            
2440
        switch target {
2441
        case .lowerBound:
2442
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2443
            let newLowerBound = min(
2444
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2445
                maximumLowerBound
2446
            )
2447
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2448

            
2449
        case .upperBound:
2450
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2451
            let newUpperBound = max(
2452
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2453
                minimumUpperBound
2454
            )
2455
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2456

            
2457
        case .window:
2458
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2459
            guard span < totalSpan else { return availableTimeRange }
2460

            
2461
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2462
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2463

            
2464
            if lowerBound < availableTimeRange.lowerBound {
2465
                upperBound = upperBound.addingTimeInterval(
2466
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2467
                )
2468
                lowerBound = availableTimeRange.lowerBound
2469
            }
2470

            
2471
            if upperBound > availableTimeRange.upperBound {
2472
                lowerBound = lowerBound.addingTimeInterval(
2473
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2474
                )
2475
                upperBound = availableTimeRange.upperBound
2476
            }
2477

            
2478
            return normalizedSelectionRange(lowerBound...upperBound)
2479
        }
2480
    }
2481

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

            
2491
        let snapInterval = edgeSnapInterval(for: totalWidth)
2492
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2493
        var lowerBound = candidateRange.lowerBound
2494
        var upperBound = candidateRange.upperBound
2495

            
2496
        if target != .upperBound,
2497
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2498
            lowerBound = availableTimeRange.lowerBound
2499
            if target == .window {
2500
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2501
            }
2502
        }
2503

            
2504
        if target != .lowerBound,
2505
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2506
            upperBound = availableTimeRange.upperBound
2507
            if target == .window {
2508
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2509
            }
2510
        }
2511

            
2512
        return normalizedSelectionRange(lowerBound...upperBound)
2513
    }
2514

            
2515
    private func edgeSnapInterval(
2516
        for totalWidth: CGFloat
2517
    ) -> TimeInterval {
2518
        guard totalWidth > 0 else { return minimumSelectionSpan }
2519

            
2520
        let snapWidth = min(
2521
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2522
            totalWidth * 0.18
2523
        )
2524
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2525
        return min(
2526
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2527
            totalSpan / 4
2528
        )
2529
    }
2530

            
2531
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2532
        guard let selectedTimeRange else { return availableTimeRange }
2533

            
2534
        if isPinnedToPresent {
2535
            switch presentTrackingMode {
2536
            case .keepDuration:
2537
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2538
                return normalizedSelectionRange(
2539
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2540
                )
2541
            case .keepStartTimestamp:
2542
                return normalizedSelectionRange(
2543
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2544
                )
2545
            }
2546
        }
2547

            
2548
        return normalizedSelectionRange(selectedTimeRange)
2549
    }
2550

            
2551
    private func normalizedSelectionRange(
2552
        _ candidateRange: ClosedRange<Date>
2553
    ) -> ClosedRange<Date> {
2554
        let availableSpan = totalSpan
2555
        guard availableSpan > 0 else { return availableTimeRange }
2556

            
2557
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2558
        let requestedSpan = min(
2559
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2560
            availableSpan
2561
        )
2562

            
2563
        if requestedSpan >= availableSpan {
2564
            return availableTimeRange
2565
        }
2566

            
2567
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2568
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2569

            
2570
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2571
            if lowerBound == availableTimeRange.lowerBound {
2572
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2573
            } else {
2574
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2575
            }
2576
        }
2577

            
2578
        if upperBound > availableTimeRange.upperBound {
2579
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2580
            upperBound = availableTimeRange.upperBound
2581
            lowerBound = lowerBound.addingTimeInterval(-delta)
2582
        }
2583

            
2584
        if lowerBound < availableTimeRange.lowerBound {
2585
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2586
            lowerBound = availableTimeRange.lowerBound
2587
            upperBound = upperBound.addingTimeInterval(delta)
2588
        }
2589

            
2590
        return lowerBound...upperBound
2591
    }
2592

            
2593
    private func shouldKeepPresentPin(
2594
        during target: DragTarget,
2595
        initialRange: ClosedRange<Date>,
2596
        resultingRange: ClosedRange<Date>
2597
    ) -> Bool {
2598
        let startedPinnedToPresent =
2599
            isPinnedToPresent ||
2600
            selectionCoversFullRange(initialRange)
2601

            
2602
        guard startedPinnedToPresent else {
2603
            return selectionTouchesPresent(resultingRange)
2604
        }
2605

            
2606
        switch target {
2607
        case .lowerBound:
2608
            return true
2609
        case .upperBound, .window:
2610
            return selectionTouchesPresent(resultingRange)
2611
        }
2612
    }
2613

            
2614
    private func applySelection(
2615
        _ candidateRange: ClosedRange<Date>,
2616
        pinToPresent: Bool
2617
    ) {
2618
        let normalizedRange = normalizedSelectionRange(candidateRange)
2619

            
2620
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2621
            selectedTimeRange = nil
2622
        } else {
2623
            selectedTimeRange = normalizedRange
2624
        }
2625

            
2626
        isPinnedToPresent = pinToPresent
2627
    }
2628

            
Bogdan Timofte authored a month ago
2629
    private func resetSelectionState() {
2630
        selectedTimeRange = nil
2631
        isPinnedToPresent = false
2632
    }
2633

            
Bogdan Timofte authored 2 months ago
2634
    private func selectionTouchesPresent(
2635
        _ range: ClosedRange<Date>
2636
    ) -> Bool {
2637
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2638
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2639
    }
2640

            
2641
    private func selectionCoversFullRange(
2642
        _ range: ClosedRange<Date>
2643
    ) -> Bool {
2644
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2645
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2646
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2647
    }
2648

            
2649
    private func selectionFrame(in size: CGSize) -> CGRect {
2650
        selectionFrame(for: currentRange, width: size.width)
2651
    }
2652

            
2653
    private func selectionFrame(
2654
        for range: ClosedRange<Date>,
2655
        width: CGFloat
2656
    ) -> CGRect {
2657
        guard width > 0, totalSpan > 0 else {
2658
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2659
        }
2660

            
2661
        let minimumX = xPosition(for: range.lowerBound, width: width)
2662
        let maximumX = xPosition(for: range.upperBound, width: width)
2663
        return CGRect(
2664
            x: minimumX,
2665
            y: 0,
2666
            width: max(maximumX - minimumX, 2),
2667
            height: trackHeight
2668
        )
2669
    }
2670

            
2671
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2672
        guard width > 0, totalSpan > 0 else { return 0 }
2673

            
2674
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2675
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2676
        return CGFloat(normalizedOffset) * width
2677
    }
2678

            
2679
    private func boundaryLabel(for date: Date) -> String {
2680
        date.format(as: boundaryDateFormat)
2681
    }
2682

            
2683
    private var boundaryDateFormat: String {
2684
        switch totalSpan {
2685
        case 0..<86400:
2686
            return "HH:mm"
2687
        case 86400..<604800:
2688
            return "MMM d HH:mm"
2689
        default:
2690
            return "MMM d"
2691
        }
2692
    }
2693
}
2694

            
Bogdan Timofte authored 2 months ago
2695
struct Chart : View {
2696

            
Bogdan Timofte authored 2 months ago
2697
    @Environment(\.displayScale) private var displayScale
2698

            
Bogdan Timofte authored 2 months ago
2699
    let points: [Measurements.Measurement.Point]
2700
    let context: ChartContext
Bogdan Timofte authored 2 months ago
2701
    var areaChart: Bool = false
2702
    var strokeColor: Color = .black
Bogdan Timofte authored 2 months ago
2703
    var areaFillColor: Color? = nil
Bogdan Timofte authored 2 months ago
2704

            
2705
    var body : some View {
2706
        GeometryReader { geometry in
2707
            if self.areaChart {
Bogdan Timofte authored 2 months ago
2708
                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
Bogdan Timofte authored 2 months ago
2709
                self.path( geometry: geometry )
Bogdan Timofte authored 2 months ago
2710
                    .fill(
2711
                        LinearGradient(
2712
                            gradient: .init(
2713
                                colors: [
2714
                                    fillColor.opacity(0.72),
2715
                                    fillColor.opacity(0.18)
2716
                                ]
2717
                            ),
2718
                            startPoint: .init(x: 0.5, y: 0.08),
2719
                            endPoint: .init(x: 0.5, y: 0.92)
2720
                        )
2721
                    )
Bogdan Timofte authored 2 months ago
2722
            } else {
2723
                self.path( geometry: geometry )
2724
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2725
            }
2726
        }
2727
    }
2728

            
2729
    fileprivate func path(geometry: GeometryProxy) -> Path {
Bogdan Timofte authored 2 months ago
2730
        let displayedPoints = scaledPoints(for: geometry.size.width)
2731
        let baselineY = context.placeInRect(
2732
            point: CGPoint(x: context.origin.x, y: context.origin.y)
2733
        ).y * geometry.size.height
2734

            
Bogdan Timofte authored 2 months ago
2735
        return Path { path in
Bogdan Timofte authored 2 months ago
2736
            var firstRenderedPoint: CGPoint?
2737
            var lastRenderedPoint: CGPoint?
Bogdan Timofte authored 2 months ago
2738
            var needsMove = true
2739

            
Bogdan Timofte authored 2 months ago
2740
            for point in displayedPoints {
Bogdan Timofte authored 2 months ago
2741
                if point.isDiscontinuity {
Bogdan Timofte authored 2 months ago
2742
                    closeAreaSegment(
2743
                        in: &path,
2744
                        firstPoint: firstRenderedPoint,
2745
                        lastPoint: lastRenderedPoint,
2746
                        baselineY: baselineY
2747
                    )
2748
                    firstRenderedPoint = nil
2749
                    lastRenderedPoint = nil
Bogdan Timofte authored 2 months ago
2750
                    needsMove = true
2751
                    continue
2752
                }
2753

            
2754
                let item = context.placeInRect(point: point.point())
2755
                let renderedPoint = CGPoint(
2756
                    x: item.x * geometry.size.width,
2757
                    y: item.y * geometry.size.height
2758
                )
2759

            
2760
                if needsMove {
2761
                    path.move(to: renderedPoint)
Bogdan Timofte authored 2 months ago
2762
                    firstRenderedPoint = renderedPoint
Bogdan Timofte authored 2 months ago
2763
                    needsMove = false
2764
                } else {
2765
                    path.addLine(to: renderedPoint)
2766
                }
Bogdan Timofte authored 2 months ago
2767

            
2768
                lastRenderedPoint = renderedPoint
Bogdan Timofte authored 2 months ago
2769
            }
Bogdan Timofte authored 2 months ago
2770

            
Bogdan Timofte authored 2 months ago
2771
            closeAreaSegment(
2772
                in: &path,
2773
                firstPoint: firstRenderedPoint,
2774
                lastPoint: lastRenderedPoint,
2775
                baselineY: baselineY
2776
            )
2777
        }
2778
    }
2779

            
2780
    private func closeAreaSegment(
2781
        in path: inout Path,
2782
        firstPoint: CGPoint?,
2783
        lastPoint: CGPoint?,
2784
        baselineY: CGFloat
2785
    ) {
2786
        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2787

            
2788
        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2789
        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2790
        path.closeSubpath()
2791
    }
2792

            
2793
    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2794
        let sampleCount = points.reduce(into: 0) { partialResult, point in
2795
            if point.isSample {
2796
                partialResult += 1
2797
            }
2798
        }
2799

            
2800
        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2801
        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2802

            
2803
        guard sampleCount > maximumSamplesToRender, context.isValid else {
2804
            return points
2805
        }
2806

            
2807
        var scaledPoints: [Measurements.Measurement.Point] = []
2808
        var currentSegment: [Measurements.Measurement.Point] = []
2809

            
2810
        for point in points {
2811
            if point.isDiscontinuity {
2812
                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2813
                currentSegment.removeAll(keepingCapacity: true)
2814

            
2815
                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2816
                    appendScaledPoint(point, to: &scaledPoints)
2817
                }
2818
            } else {
2819
                currentSegment.append(point)
2820
            }
2821
        }
2822

            
2823
        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2824
        return scaledPoints.isEmpty ? points : scaledPoints
2825
    }
2826

            
2827
    private func appendScaledSegment(
2828
        _ segment: [Measurements.Measurement.Point],
2829
        to scaledPoints: inout [Measurements.Measurement.Point],
2830
        displayColumns: Int
2831
    ) {
2832
        guard !segment.isEmpty else { return }
2833

            
2834
        if segment.count <= max(displayColumns * 2, 120) {
2835
            for point in segment {
2836
                appendScaledPoint(point, to: &scaledPoints)
2837
            }
2838
            return
2839
        }
2840

            
2841
        var bucket: [Measurements.Measurement.Point] = []
2842
        var currentColumn: Int?
2843

            
2844
        for point in segment {
2845
            let column = displayColumn(for: point, totalColumns: displayColumns)
2846

            
2847
            if let currentColumn, currentColumn != column {
2848
                appendBucket(bucket, to: &scaledPoints)
2849
                bucket.removeAll(keepingCapacity: true)
Bogdan Timofte authored 2 months ago
2850
            }
Bogdan Timofte authored 2 months ago
2851

            
2852
            bucket.append(point)
2853
            currentColumn = column
2854
        }
2855

            
2856
        appendBucket(bucket, to: &scaledPoints)
2857
    }
2858

            
2859
    private func appendBucket(
2860
        _ bucket: [Measurements.Measurement.Point],
2861
        to scaledPoints: inout [Measurements.Measurement.Point]
2862
    ) {
2863
        guard !bucket.isEmpty else { return }
2864

            
2865
        if bucket.count <= 2 {
2866
            for point in bucket {
2867
                appendScaledPoint(point, to: &scaledPoints)
2868
            }
2869
            return
2870
        }
2871

            
2872
        let firstPoint = bucket.first!
2873
        let lastPoint = bucket.last!
2874
        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2875
        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2876

            
2877
        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2878
            .sorted { lhs, rhs in
2879
                if lhs.timestamp == rhs.timestamp {
2880
                    return lhs.id < rhs.id
2881
                }
2882
                return lhs.timestamp < rhs.timestamp
2883
            }
2884

            
2885
        var emittedPointIDs: Set<Int> = []
2886
        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2887
            appendScaledPoint(point, to: &scaledPoints)
Bogdan Timofte authored 2 months ago
2888
        }
2889
    }
Bogdan Timofte authored 2 months ago
2890

            
2891
    private func appendScaledPoint(
2892
        _ point: Measurements.Measurement.Point,
2893
        to scaledPoints: inout [Measurements.Measurement.Point]
2894
    ) {
2895
        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2896
                scaledPoints.last?.value == point.value &&
2897
                scaledPoints.last?.kind == point.kind) else {
2898
            return
2899
        }
2900

            
2901
        scaledPoints.append(
2902
            Measurements.Measurement.Point(
2903
                id: scaledPoints.count,
2904
                timestamp: point.timestamp,
2905
                value: point.value,
2906
                kind: point.kind
2907
            )
2908
        )
2909
    }
2910

            
2911
    private func displayColumn(
2912
        for point: Measurements.Measurement.Point,
2913
        totalColumns: Int
2914
    ) -> Int {
2915
        let totalColumns = max(totalColumns, 1)
2916
        let timeSpan = max(Double(context.size.width), 1)
2917
        let normalizedOffset = min(
2918
            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2919
            1
2920
        )
2921

            
2922
        return min(
2923
            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2924
            totalColumns - 1
2925
        )
2926
    }
Bogdan Timofte authored 2 months ago
2927

            
2928
}