USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
2903 lines | 105.334kb
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

            
184
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
185
        if compactLayout {
186
            return 38
187
        }
188
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
189
    }
190

            
191
    private var chartSectionSpacing: CGFloat {
192
        compactLayout ? 6 : 8
193
    }
194

            
195
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
196
        if compactLayout {
197
            return 24
198
        }
199
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
200
    }
201

            
Bogdan Timofte authored a month ago
202
    private var belowXAxisControlsHeight: CGFloat {
203
        if usesCompactLandscapeOriginControls {
204
            return 40
205
        }
206
        if compactLayout {
207
            return 46
208
        }
209
        return isLargeDisplay ? 58 : 50
210
    }
211

            
Bogdan Timofte authored 2 months ago
212
    private var isPortraitLayout: Bool {
213
        guard availableSize != .zero else { return verticalSizeClass != .compact }
214
        return availableSize.height >= availableSize.width
215
    }
216

            
Bogdan Timofte authored 2 months ago
217
    private var isIPhone: Bool {
218
        #if os(iOS)
219
        return UIDevice.current.userInterfaceIdiom == .phone
220
        #else
221
        return false
222
        #endif
223
    }
224

            
225
    private enum OriginControlsPlacement {
226
        case aboveXAxisLegend
227
        case overXAxisLegend
228
        case belowXAxisLegend
229
    }
230

            
231
    private var originControlsPlacement: OriginControlsPlacement {
232
        if isIPhone {
233
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
234
        }
235
        return .belowXAxisLegend
236
    }
237

            
Bogdan Timofte authored 2 months ago
238
    private var plotSectionHeight: CGFloat {
239
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
240
            return compactLayout ? 300 : 380
241
        }
242

            
243
        if isPortraitLayout {
244
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
245
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
246
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
247
        }
248

            
249
        if compactLayout {
250
            return min(max(availableSize.height * 0.36, 240), 300)
251
        }
252

            
253
        return min(max(availableSize.height * 0.5, 300), 440)
254
    }
255

            
256
    private var stackedToolbarLayout: Bool {
257
        if availableSize.width > 0 {
258
            return availableSize.width < 640
259
        }
260

            
261
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
262
    }
263

            
264
    private var showsLabeledOriginControls: Bool {
265
        !compactLayout && !stackedToolbarLayout
266
    }
267

            
Bogdan Timofte authored 2 months ago
268
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
269
        #if os(iOS)
270
        if UIDevice.current.userInterfaceIdiom == .phone {
271
            return false
272
        }
273
        #endif
274

            
Bogdan Timofte authored 2 months ago
275
        if availableSize.width > 0 {
276
            return availableSize.width >= 900 || availableSize.height >= 700
277
        }
278
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
279
    }
280

            
281
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
282
        if isIPhone && isPortraitLayout {
283
            return .caption
284
        }
285
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
286
    }
287

            
Bogdan Timofte authored 2 months ago
288
    private var usesCompactLandscapeOriginControls: Bool {
289
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
290
    }
291

            
Bogdan Timofte authored 2 months ago
292
    var body: some View {
Bogdan Timofte authored 2 months ago
293
        let availableTimeRange = availableSelectionTimeRange()
294
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
295
        let powerSeries = series(
296
            for: measurements.power,
297
            kind: .power,
298
            minimumYSpan: minimumPowerSpan,
299
            visibleTimeRange: visibleTimeRange
300
        )
Bogdan Timofte authored 2 months ago
301
        let energySeries = series(
302
            for: measurements.energy,
303
            kind: .energy,
304
            minimumYSpan: minimumEnergySpan,
305
            visibleTimeRange: visibleTimeRange
306
        )
Bogdan Timofte authored 2 months ago
307
        let voltageSeries = series(
308
            for: measurements.voltage,
309
            kind: .voltage,
310
            minimumYSpan: minimumVoltageSpan,
311
            visibleTimeRange: visibleTimeRange
312
        )
313
        let currentSeries = series(
314
            for: measurements.current,
315
            kind: .current,
316
            minimumYSpan: minimumCurrentSpan,
317
            visibleTimeRange: visibleTimeRange
318
        )
319
        let temperatureSeries = series(
320
            for: measurements.temperature,
321
            kind: .temperature,
322
            minimumYSpan: minimumTemperatureSpan,
323
            visibleTimeRange: visibleTimeRange
324
        )
Bogdan Timofte authored 2 months ago
325
        let primarySeries = displayedPrimarySeries(
326
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
327
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
328
            voltageSeries: voltageSeries,
329
            currentSeries: currentSeries
330
        )
Bogdan Timofte authored 2 months ago
331
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
332

            
Bogdan Timofte authored 2 months ago
333
        Group {
Bogdan Timofte authored 2 months ago
334
            if let primarySeries {
335
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
336
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
337

            
338
                    GeometryReader { geometry in
Bogdan Timofte authored a month ago
339
                        let reservedBottomHeight =
340
                            xAxisHeight
341
                            + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
342
                        let plotHeight = max(
343
                            geometry.size.height - reservedBottomHeight,
344
                            compactLayout ? 180 : 220
345
                        )
Bogdan Timofte authored 2 months ago
346

            
347
                        VStack(spacing: 6) {
348
                            HStack(spacing: chartSectionSpacing) {
349
                                primaryAxisView(
350
                                    height: plotHeight,
351
                                    powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
352
                                    energySeries: energySeries,
Bogdan Timofte authored 2 months ago
353
                                    voltageSeries: voltageSeries,
354
                                    currentSeries: currentSeries
355
                                )
356
                                .frame(width: axisColumnWidth, height: plotHeight)
357

            
358
                                ZStack {
359
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
360
                                        .fill(Color.primary.opacity(0.05))
361

            
362
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
363
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
364

            
365
                                    horizontalGuides(context: primarySeries.context)
366
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored 2 months ago
367
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 months ago
368
                                    renderedChart(
369
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
370
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
371
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
372
                                        currentSeries: currentSeries,
373
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
374
                                    )
Bogdan Timofte authored 2 months ago
375
                                }
Bogdan Timofte authored 2 months ago
376
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
377
                                .frame(maxWidth: .infinity)
378
                                .frame(height: plotHeight)
379

            
380
                                secondaryAxisView(
381
                                    height: plotHeight,
382
                                    powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
383
                                    energySeries: energySeries,
Bogdan Timofte authored 2 months ago
384
                                    voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
385
                                    currentSeries: currentSeries,
386
                                    temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
387
                                )
388
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
389
                            }
Bogdan Timofte authored 2 months ago
390
                            .overlay(alignment: .bottom) {
Bogdan Timofte authored 2 months ago
391
                                if originControlsPlacement == .aboveXAxisLegend {
392
                                    scaleControlsPill(
393
                                        voltageSeries: voltageSeries,
394
                                        currentSeries: currentSeries
395
                                    )
396
                                    .padding(.bottom, compactLayout ? 6 : 10)
397
                                }
Bogdan Timofte authored 2 months ago
398
                            }
Bogdan Timofte authored 2 months ago
399

            
Bogdan Timofte authored 2 months ago
400
                            switch originControlsPlacement {
401
                            case .aboveXAxisLegend:
402
                                xAxisLabelsView(context: primarySeries.context)
403
                                    .frame(height: xAxisHeight)
404
                            case .overXAxisLegend:
405
                                xAxisLabelsView(context: primarySeries.context)
406
                                    .frame(height: xAxisHeight)
407
                                    .overlay(alignment: .center) {
408
                                        scaleControlsPill(
409
                                            voltageSeries: voltageSeries,
410
                                            currentSeries: currentSeries
411
                                        )
Bogdan Timofte authored 2 months ago
412
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 2 months ago
413
                                    }
414
                            case .belowXAxisLegend:
415
                                xAxisLabelsView(context: primarySeries.context)
416
                                    .frame(height: xAxisHeight)
417

            
418
                                HStack {
419
                                    Spacer(minLength: 0)
420
                                    scaleControlsPill(
421
                                        voltageSeries: voltageSeries,
422
                                        currentSeries: currentSeries
423
                                    )
424
                                    Spacer(minLength: 0)
425
                                }
426
                            }
Bogdan Timofte authored 2 months ago
427

            
Bogdan Timofte authored a month ago
428
                            if showsRangeSelector,
429
                               let availableTimeRange,
Bogdan Timofte authored 2 months ago
430
                               let selectorSeries,
431
                               shouldShowRangeSelector(
432
                                availableTimeRange: availableTimeRange,
433
                                series: selectorSeries
434
                               ) {
435
                                TimeRangeSelectorView(
436
                                    points: selectorSeries.points,
437
                                    context: selectorSeries.context,
438
                                    availableTimeRange: availableTimeRange,
Bogdan Timofte authored 2 months ago
439
                                    selectorTint: selectorTint,
Bogdan Timofte authored 2 months ago
440
                                    compactLayout: compactLayout,
441
                                    minimumSelectionSpan: minimumTimeSpan,
Bogdan Timofte authored a month ago
442
                                    configuration: resolvedRangeSelectorConfiguration(),
Bogdan Timofte authored 2 months ago
443
                                    selectedTimeRange: $selectedVisibleTimeRange,
444
                                    isPinnedToPresent: $isPinnedToPresent,
445
                                    presentTrackingMode: $presentTrackingMode
446
                                )
447
                            }
Bogdan Timofte authored 2 months ago
448
                        }
Bogdan Timofte authored 2 months ago
449
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 months ago
450
                    }
Bogdan Timofte authored 2 months ago
451
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 months ago
452
                }
Bogdan Timofte authored 2 months ago
453
            } else {
454
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
455
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
456
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
457
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
458
                }
459
            }
Bogdan Timofte authored 2 months ago
460
        }
Bogdan Timofte authored 2 months ago
461
        .font(chartBaseFont)
Bogdan Timofte authored 2 months ago
462
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
463
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
Bogdan Timofte authored a month ago
464
            guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
Bogdan Timofte authored 2 months ago
465
            chartNow = now
466
        }
Bogdan Timofte authored 2 months ago
467
    }
468

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

            
Bogdan Timofte authored a month ago
473
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
474
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
475
        }
476
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
477
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
478
        .background(
479
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
480
                .fill(Color.primary.opacity(0.045))
481
        )
482
        .overlay(
483
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
484
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
485
        )
Bogdan Timofte authored 2 months ago
486

            
Bogdan Timofte authored 2 months ago
487
        return Group {
Bogdan Timofte authored 2 months ago
488
            if stackedToolbarLayout {
Bogdan Timofte authored 2 months ago
489
                controlsPanel
Bogdan Timofte authored 2 months ago
490
            } else {
Bogdan Timofte authored 2 months ago
491
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
492
                    controlsPanel
Bogdan Timofte authored 2 months ago
493
                }
Bogdan Timofte authored 2 months ago
494
            }
495
        }
496
        .frame(maxWidth: .infinity, alignment: .leading)
497
    }
498

            
Bogdan Timofte authored 2 months ago
499
    private var shouldFloatScaleControlsOverChart: Bool {
500
        #if os(iOS)
501
        if availableSize.width > 0, availableSize.height > 0 {
502
            return availableSize.width > availableSize.height
503
        }
504
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
505
        #else
506
        return false
507
        #endif
508
    }
509

            
510
    private func scaleControlsPill(
511
        voltageSeries: SeriesData,
512
        currentSeries: SeriesData
513
    ) -> some View {
514
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
515
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 2 months ago
516
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
517
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
518

            
519
        return originControlsRow(
520
            voltageSeries: voltageSeries,
521
            currentSeries: currentSeries,
522
            condensedLayout: condensedLayout,
523
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
524
        )
Bogdan Timofte authored 2 months ago
525
        .padding(.horizontal, horizontalPadding)
526
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 2 months ago
527
        .background(
528
            Capsule(style: .continuous)
Bogdan Timofte authored 2 months ago
529
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 2 months ago
530
        )
531
        .overlay(
532
            Capsule(style: .continuous)
533
                .stroke(
Bogdan Timofte authored 2 months ago
534
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 2 months ago
535
                    lineWidth: 1
536
                )
537
        )
538
    }
539

            
Bogdan Timofte authored 2 months ago
540
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
541
        HStack(spacing: condensedLayout ? 6 : 8) {
542
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
543
                displayVoltage.toggle()
544
                if displayVoltage {
545
                    displayPower = false
Bogdan Timofte authored 2 months ago
546
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
547
                    if displayTemperature && displayCurrent {
548
                        displayCurrent = false
549
                    }
Bogdan Timofte authored 2 months ago
550
                }
551
            }
552

            
553
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
554
                displayCurrent.toggle()
555
                if displayCurrent {
556
                    displayPower = false
Bogdan Timofte authored 2 months ago
557
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
558
                    if displayTemperature && displayVoltage {
559
                        displayVoltage = false
560
                    }
Bogdan Timofte authored 2 months ago
561
                }
Bogdan Timofte authored 2 months ago
562
            }
563

            
564
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
565
                displayPower.toggle()
566
                if displayPower {
Bogdan Timofte authored 2 months ago
567
                    displayEnergy = false
568
                    displayCurrent = false
569
                    displayVoltage = false
570
                }
571
            }
572

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

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

            
591
    private func originControlsRow(
592
        voltageSeries: SeriesData,
593
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
594
        condensedLayout: Bool,
595
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
596
    ) -> some View {
Bogdan Timofte authored 2 months ago
597
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
598
            if supportsSharedOrigin {
599
                symbolControlChip(
600
                    systemImage: "equal.circle",
601
                    enabled: true,
602
                    active: useSharedOrigin,
603
                    condensedLayout: condensedLayout,
604
                    showsLabel: showsLabel,
605
                    label: "Match Y Scale",
606
                    accessibilityLabel: "Match Y scale"
607
                ) {
608
                    toggleSharedOrigin(
609
                        voltageSeries: voltageSeries,
610
                        currentSeries: currentSeries
611
                    )
612
                }
Bogdan Timofte authored 2 months ago
613
            }
614

            
615
            symbolControlChip(
616
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
617
                enabled: true,
618
                active: pinOrigin,
619
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
620
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
621
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
622
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
623
            ) {
624
                togglePinnedOrigin(
625
                    voltageSeries: voltageSeries,
626
                    currentSeries: currentSeries
627
                )
628
            }
629

            
Bogdan Timofte authored 2 months ago
630
            if !pinnedOriginIsZero {
631
                symbolControlChip(
632
                    systemImage: "0.circle",
633
                    enabled: true,
634
                    active: false,
635
                    condensedLayout: condensedLayout,
636
                    showsLabel: showsLabel,
637
                    label: "Origin 0",
638
                    accessibilityLabel: "Set origin to zero"
639
                ) {
640
                    setVisibleOriginsToZero()
641
                }
Bogdan Timofte authored 2 months ago
642
            }
Bogdan Timofte authored 2 months ago
643

            
Bogdan Timofte authored a month ago
644
            smoothingControlChip(
645
                condensedLayout: condensedLayout,
646
                showsLabel: showsLabel
647
            )
648

            
Bogdan Timofte authored 2 months ago
649
        }
650
    }
651

            
Bogdan Timofte authored a month ago
652
    private func smoothingControlChip(
653
        condensedLayout: Bool,
654
        showsLabel: Bool
655
    ) -> some View {
656
        Menu {
657
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
658
                Button {
659
                    smoothingLevel = level
660
                } label: {
661
                    if smoothingLevel == level {
662
                        Label(level.label, systemImage: "checkmark")
663
                    } else {
664
                        Text(level.label)
Bogdan Timofte authored 2 months ago
665
                    }
666
                }
Bogdan Timofte authored a month ago
667
            }
668
        } label: {
669
            Group {
670
                if showsLabel {
671
                    VStack(alignment: .leading, spacing: 2) {
672
                        Label("Smoothing", systemImage: "waveform.path")
673
                            .font(controlChipFont(condensedLayout: condensedLayout))
674

            
675
                        Text(
Bogdan Timofte authored 2 months ago
676
                            smoothingLevel == .off
Bogdan Timofte authored a month ago
677
                            ? "Off"
678
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
679
                        )
Bogdan Timofte authored a month ago
680
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
681
                        .foregroundColor(.secondary)
682
                        .monospacedDigit()
683
                    }
684
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
685
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
686
                } else {
687
                    VStack(spacing: 1) {
688
                        Image(systemName: "waveform.path")
689
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
690

            
691
                        Text(smoothingLevel.shortLabel)
692
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
693
                            .monospacedDigit()
694
                    }
695
                    .frame(
696
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
697
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
698
                    )
699
                }
Bogdan Timofte authored 2 months ago
700
            }
Bogdan Timofte authored a month ago
701
            .background(
702
                Capsule(style: .continuous)
703
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
704
            )
705
            .overlay(
706
                Capsule(style: .continuous)
707
                    .stroke(
708
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
709
                        lineWidth: 1
710
                    )
711
            )
Bogdan Timofte authored 2 months ago
712
        }
Bogdan Timofte authored a month ago
713
        .buttonStyle(.plain)
714
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
715
    }
716

            
Bogdan Timofte authored 2 months ago
717
    private func seriesToggleButton(
718
        title: String,
719
        isOn: Bool,
720
        condensedLayout: Bool,
721
        action: @escaping () -> Void
722
    ) -> some View {
723
        Button(action: action) {
724
            Text(title)
Bogdan Timofte authored 2 months ago
725
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
726
                .lineLimit(1)
727
                .minimumScaleFactor(0.82)
728
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
729
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
730
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
731
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
732
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
733
                .background(
734
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
735
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
736
                )
737
                .overlay(
738
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
739
                        .stroke(Color.blue, lineWidth: 1.5)
740
                )
741
        }
742
        .buttonStyle(.plain)
743
    }
744

            
745
    private func symbolControlChip(
746
        systemImage: String,
747
        enabled: Bool,
748
        active: Bool,
749
        condensedLayout: Bool,
750
        showsLabel: Bool,
751
        label: String,
752
        accessibilityLabel: String,
753
        action: @escaping () -> Void
754
    ) -> some View {
755
        Button(action: {
756
            action()
757
        }) {
758
            Group {
759
                if showsLabel {
760
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
761
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
762
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
763
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
764
                } else {
765
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
766
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
767
                        .frame(
Bogdan Timofte authored 2 months ago
768
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
769
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
770
                        )
Bogdan Timofte authored 2 months ago
771
                }
772
            }
773
                .background(
774
                    Capsule(style: .continuous)
775
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
776
                )
777
        }
778
        .buttonStyle(.plain)
779
        .foregroundColor(enabled ? .primary : .secondary)
780
        .opacity(enabled ? 1 : 0.55)
781
        .accessibilityLabel(accessibilityLabel)
782
    }
783

            
Bogdan Timofte authored 2 months ago
784
    private func resetBuffer() {
785
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
786
    }
787

            
Bogdan Timofte authored a month ago
788
    private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
789
        if let rangeSelectorConfiguration {
790
            return rangeSelectorConfiguration
791
        }
792

            
793
        return MeasurementChartRangeSelectorConfiguration(
794
            keepAction: MeasurementChartSelectionAction(
795
                title: compactLayout ? "Keep" : "Keep Selection",
796
                systemName: "scissors",
797
                tone: .destructive,
798
                handler: trimBufferToSelection
799
            ),
800
            removeAction: MeasurementChartSelectionAction(
801
                title: compactLayout ? "Cut" : "Remove Selection",
802
                systemName: "minus.circle",
803
                tone: .destructive,
804
                handler: removeSelectionFromBuffer
805
            ),
806
            resetAction: MeasurementChartResetAction(
807
                title: compactLayout ? "Reset" : "Reset Buffer",
808
                systemName: "trash",
809
                tone: .destructiveProminent,
810
                confirmationTitle: "Reset captured measurements?",
811
                confirmationButtonTitle: "Reset buffer",
812
                handler: resetBuffer
813
            )
814
        )
815
    }
816

            
Bogdan Timofte authored 2 months ago
817
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
818
        if isLargeDisplay {
819
            return .body.weight(.semibold)
820
        }
821
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
822
    }
823

            
824
    private func controlChipFont(condensedLayout: Bool) -> Font {
825
        if isLargeDisplay {
826
            return .callout.weight(.semibold)
827
        }
828
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
829
    }
830

            
Bogdan Timofte authored 2 months ago
831
    @ViewBuilder
832
    private func primaryAxisView(
833
        height: CGFloat,
Bogdan Timofte authored 2 months ago
834
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
835
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
836
        voltageSeries: SeriesData,
837
        currentSeries: SeriesData
Bogdan Timofte authored 2 months ago
838
    ) -> some View {
839
        if displayPower {
840
            yAxisLabelsView(
841
                height: height,
842
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
843
                seriesKind: .power,
844
                measurementUnit: powerSeries.kind.unit,
845
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
846
            )
Bogdan Timofte authored 2 months ago
847
        } else if displayEnergy {
848
            yAxisLabelsView(
849
                height: height,
850
                context: energySeries.context,
851
                seriesKind: .energy,
852
                measurementUnit: energySeries.kind.unit,
853
                tint: energySeries.kind.tint
854
            )
Bogdan Timofte authored 2 months ago
855
        } else if displayVoltage {
856
            yAxisLabelsView(
857
                height: height,
858
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
859
                seriesKind: .voltage,
860
                measurementUnit: voltageSeries.kind.unit,
861
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
862
            )
863
        } else if displayCurrent {
864
            yAxisLabelsView(
865
                height: height,
866
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
867
                seriesKind: .current,
868
                measurementUnit: currentSeries.kind.unit,
869
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
870
            )
871
        }
872
    }
873

            
874
    @ViewBuilder
875
    private func renderedChart(
Bogdan Timofte authored 2 months ago
876
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
877
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
878
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
879
        currentSeries: SeriesData,
880
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
881
    ) -> some View {
882
        if self.displayPower {
883
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
884
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
885
        } else if self.displayEnergy {
886
            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
887
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
888
        } else {
889
            if self.displayVoltage {
890
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
891
                    .opacity(0.78)
892
            }
893
            if self.displayCurrent {
894
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
895
                    .opacity(0.78)
896
            }
897
        }
Bogdan Timofte authored 2 months ago
898

            
899
        if displayTemperature {
900
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
901
                .opacity(0.86)
902
        }
Bogdan Timofte authored 2 months ago
903
    }
904

            
905
    @ViewBuilder
906
    private func secondaryAxisView(
907
        height: CGFloat,
Bogdan Timofte authored 2 months ago
908
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
909
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
910
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
911
        currentSeries: SeriesData,
912
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
913
    ) -> some View {
Bogdan Timofte authored 2 months ago
914
        if displayTemperature {
915
            yAxisLabelsView(
916
                height: height,
917
                context: temperatureSeries.context,
918
                seriesKind: .temperature,
919
                measurementUnit: measurementUnit(for: .temperature),
920
                tint: temperatureSeries.kind.tint
921
            )
922
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 months ago
923
            yAxisLabelsView(
924
                height: height,
925
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
926
                seriesKind: .current,
927
                measurementUnit: currentSeries.kind.unit,
928
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
929
            )
930
        } else {
931
            primaryAxisView(
932
                height: height,
933
                powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
934
                energySeries: energySeries,
Bogdan Timofte authored 2 months ago
935
                voltageSeries: voltageSeries,
936
                currentSeries: currentSeries
937
            )
Bogdan Timofte authored 2 months ago
938
        }
939
    }
Bogdan Timofte authored 2 months ago
940

            
941
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
942
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
943
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
944
        voltageSeries: SeriesData,
945
        currentSeries: SeriesData
946
    ) -> SeriesData? {
Bogdan Timofte authored 2 months ago
947
        if displayPower {
Bogdan Timofte authored 2 months ago
948
            return powerSeries
Bogdan Timofte authored 2 months ago
949
        }
Bogdan Timofte authored 2 months ago
950
        if displayEnergy {
951
            return energySeries
952
        }
Bogdan Timofte authored 2 months ago
953
        if displayVoltage {
Bogdan Timofte authored 2 months ago
954
            return voltageSeries
Bogdan Timofte authored 2 months ago
955
        }
956
        if displayCurrent {
Bogdan Timofte authored 2 months ago
957
            return currentSeries
Bogdan Timofte authored 2 months ago
958
        }
959
        return nil
960
    }
961

            
962
    private func series(
963
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
964
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
965
        minimumYSpan: Double,
966
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
967
    ) -> SeriesData {
Bogdan Timofte authored 2 months ago
968
        let rawPoints = filteredPoints(
Bogdan Timofte authored 2 months ago
969
            measurement,
970
            visibleTimeRange: visibleTimeRange
971
        )
Bogdan Timofte authored a month ago
972
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
973
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
974
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
975
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
976

            
977
        let autoBounds = automaticYBounds(
978
            for: samplePoints,
979
            minimumYSpan: minimumYSpan
980
        )
Bogdan Timofte authored 2 months ago
981
        let xBounds = xBounds(
982
            for: samplePoints,
983
            visibleTimeRange: visibleTimeRange
984
        )
Bogdan Timofte authored 2 months ago
985
        let lowerBound = resolvedLowerBound(
986
            for: kind,
987
            autoLowerBound: autoBounds.lowerBound
988
        )
989
        let upperBound = resolvedUpperBound(
990
            for: kind,
991
            lowerBound: lowerBound,
992
            autoUpperBound: autoBounds.upperBound,
993
            maximumSampleValue: samplePoints.map(\.value).max(),
994
            minimumYSpan: minimumYSpan
995
        )
996

            
997
        context.setBounds(
998
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
999
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
1000
            yMin: CGFloat(lowerBound),
1001
            yMax: CGFloat(upperBound)
1002
        )
1003

            
1004
        return SeriesData(
1005
            kind: kind,
1006
            points: points,
1007
            samplePoints: samplePoints,
1008
            context: context,
1009
            autoLowerBound: autoBounds.lowerBound,
1010
            autoUpperBound: autoBounds.upperBound,
1011
            maximumSampleValue: samplePoints.map(\.value).max()
1012
        )
1013
    }
1014

            
Bogdan Timofte authored a month ago
1015
    private func normalizedPoints(
1016
        _ points: [Measurements.Measurement.Point],
1017
        for kind: SeriesKind
1018
    ) -> [Measurements.Measurement.Point] {
1019
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
1020
            return points
1021
        }
1022

            
1023
        guard let baseline = points.first(where: \.isSample)?.value else {
1024
            return points
1025
        }
1026

            
1027
        return points.enumerated().map { index, point in
1028
            Measurements.Measurement.Point(
1029
                id: point.id == index ? point.id : index,
1030
                timestamp: point.timestamp,
1031
                value: point.value - baseline,
1032
                kind: point.kind
1033
            )
1034
        }
1035
    }
1036

            
Bogdan Timofte authored 2 months ago
1037
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
1038
        series(
1039
            for: measurement(for: kind),
1040
            kind: kind,
1041
            minimumYSpan: minimumYSpan(for: kind)
1042
        )
1043
    }
1044

            
Bogdan Timofte authored 2 months ago
1045
    private func smoothedPoints(
1046
        from points: [Measurements.Measurement.Point]
1047
    ) -> [Measurements.Measurement.Point] {
1048
        guard smoothingLevel != .off else { return points }
1049

            
1050
        var smoothedPoints: [Measurements.Measurement.Point] = []
1051
        var currentSegment: [Measurements.Measurement.Point] = []
1052

            
1053
        func flushCurrentSegment() {
1054
            guard !currentSegment.isEmpty else { return }
1055

            
1056
            for point in smoothedSegment(currentSegment) {
1057
                smoothedPoints.append(
1058
                    Measurements.Measurement.Point(
1059
                        id: smoothedPoints.count,
1060
                        timestamp: point.timestamp,
1061
                        value: point.value,
1062
                        kind: .sample
1063
                    )
1064
                )
1065
            }
1066

            
1067
            currentSegment.removeAll(keepingCapacity: true)
1068
        }
1069

            
1070
        for point in points {
1071
            if point.isDiscontinuity {
1072
                flushCurrentSegment()
1073

            
1074
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1075
                    smoothedPoints.append(
1076
                        Measurements.Measurement.Point(
1077
                            id: smoothedPoints.count,
1078
                            timestamp: point.timestamp,
1079
                            value: smoothedPoints.last?.value ?? point.value,
1080
                            kind: .discontinuity
1081
                        )
1082
                    )
1083
                }
1084
            } else {
1085
                currentSegment.append(point)
1086
            }
1087
        }
1088

            
1089
        flushCurrentSegment()
1090
        return smoothedPoints
1091
    }
1092

            
1093
    private func smoothedSegment(
1094
        _ segment: [Measurements.Measurement.Point]
1095
    ) -> [Measurements.Measurement.Point] {
1096
        let windowSize = smoothingLevel.movingAverageWindowSize
1097
        guard windowSize > 1, segment.count > 2 else { return segment }
1098

            
1099
        let radius = windowSize / 2
1100
        var prefixSums: [Double] = [0]
1101
        prefixSums.reserveCapacity(segment.count + 1)
1102

            
1103
        for point in segment {
1104
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1105
        }
1106

            
1107
        return segment.enumerated().map { index, point in
1108
            let lowerBound = max(0, index - radius)
1109
            let upperBound = min(segment.count - 1, index + radius)
1110
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1111
            let average = sum / Double(upperBound - lowerBound + 1)
1112

            
1113
            return Measurements.Measurement.Point(
1114
                id: point.id,
1115
                timestamp: point.timestamp,
1116
                value: average,
1117
                kind: .sample
1118
            )
1119
        }
1120
    }
1121

            
Bogdan Timofte authored 2 months ago
1122
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1123
        switch kind {
1124
        case .power:
1125
            return measurements.power
Bogdan Timofte authored 2 months ago
1126
        case .energy:
1127
            return measurements.energy
Bogdan Timofte authored 2 months ago
1128
        case .voltage:
1129
            return measurements.voltage
1130
        case .current:
1131
            return measurements.current
1132
        case .temperature:
1133
            return measurements.temperature
1134
        }
1135
    }
1136

            
1137
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1138
        switch kind {
1139
        case .power:
1140
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1141
        case .energy:
1142
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1143
        case .voltage:
1144
            return minimumVoltageSpan
1145
        case .current:
1146
            return minimumCurrentSpan
1147
        case .temperature:
1148
            return minimumTemperatureSpan
1149
        }
1150
    }
1151

            
Bogdan Timofte authored 2 months ago
1152
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 2 months ago
1153
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored 2 months ago
1154
    }
1155

            
Bogdan Timofte authored 2 months ago
1156
    private var minimumSharedScaleSpan: Double {
1157
        max(minimumVoltageSpan, minimumCurrentSpan)
1158
    }
1159

            
Bogdan Timofte authored 2 months ago
1160
    private var pinnedOriginIsZero: Bool {
1161
        if useSharedOrigin && supportsSharedOrigin {
1162
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1163
        }
Bogdan Timofte authored 2 months ago
1164

            
1165
        if displayPower {
1166
            return pinOrigin && powerAxisOrigin == 0
1167
        }
1168

            
Bogdan Timofte authored 2 months ago
1169
        if displayEnergy {
1170
            return pinOrigin && energyAxisOrigin == 0
1171
        }
1172

            
Bogdan Timofte authored 2 months ago
1173
        let visibleOrigins = [
1174
            displayVoltage ? voltageAxisOrigin : nil,
1175
            displayCurrent ? currentAxisOrigin : nil
1176
        ]
1177
        .compactMap { $0 }
1178

            
1179
        guard !visibleOrigins.isEmpty else { return false }
1180
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1181
    }
1182

            
1183
    private func toggleSharedOrigin(
1184
        voltageSeries: SeriesData,
1185
        currentSeries: SeriesData
1186
    ) {
1187
        guard supportsSharedOrigin else { return }
1188

            
1189
        if useSharedOrigin {
1190
            useSharedOrigin = false
1191
            return
1192
        }
1193

            
1194
        captureCurrentOrigins(
1195
            voltageSeries: voltageSeries,
1196
            currentSeries: currentSeries
1197
        )
1198
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1199
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1200
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1201
        useSharedOrigin = true
1202
        pinOrigin = true
1203
    }
1204

            
1205
    private func togglePinnedOrigin(
1206
        voltageSeries: SeriesData,
1207
        currentSeries: SeriesData
1208
    ) {
1209
        if pinOrigin {
1210
            pinOrigin = false
1211
            return
1212
        }
1213

            
1214
        captureCurrentOrigins(
1215
            voltageSeries: voltageSeries,
1216
            currentSeries: currentSeries
1217
        )
1218
        pinOrigin = true
1219
    }
1220

            
1221
    private func setVisibleOriginsToZero() {
1222
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1223
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1224
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1225
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1226
            voltageAxisOrigin = 0
1227
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1228
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1229
        } else {
1230
            if displayPower {
1231
                powerAxisOrigin = 0
1232
            }
Bogdan Timofte authored 2 months ago
1233
            if displayEnergy {
1234
                energyAxisOrigin = 0
1235
            }
Bogdan Timofte authored 2 months ago
1236
            if displayVoltage {
1237
                voltageAxisOrigin = 0
1238
            }
1239
            if displayCurrent {
1240
                currentAxisOrigin = 0
1241
            }
Bogdan Timofte authored 2 months ago
1242
            if displayTemperature {
1243
                temperatureAxisOrigin = 0
1244
            }
Bogdan Timofte authored 2 months ago
1245
        }
1246

            
1247
        pinOrigin = true
1248
    }
1249

            
1250
    private func captureCurrentOrigins(
1251
        voltageSeries: SeriesData,
1252
        currentSeries: SeriesData
1253
    ) {
1254
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1255
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1256
        voltageAxisOrigin = voltageSeries.autoLowerBound
1257
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1258
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored 2 months ago
1259
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1260
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1261
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1262
    }
1263

            
1264
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1265
        let visibleTimeRange = activeVisibleTimeRange
1266

            
Bogdan Timofte authored 2 months ago
1267
        switch kind {
1268
        case .power:
Bogdan Timofte authored 2 months ago
1269
            return pinOrigin
1270
                ? powerAxisOrigin
1271
                : automaticYBounds(
1272
                    for: filteredSamplePoints(
1273
                        measurements.power,
1274
                        visibleTimeRange: visibleTimeRange
1275
                    ),
1276
                    minimumYSpan: minimumPowerSpan
1277
                ).lowerBound
Bogdan Timofte authored 2 months ago
1278
        case .energy:
1279
            return pinOrigin
1280
                ? energyAxisOrigin
1281
                : automaticYBounds(
1282
                    for: filteredSamplePoints(
1283
                        measurements.energy,
1284
                        visibleTimeRange: visibleTimeRange
1285
                    ),
1286
                    minimumYSpan: minimumEnergySpan
1287
                ).lowerBound
Bogdan Timofte authored 2 months ago
1288
        case .voltage:
1289
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1290
                return sharedAxisOrigin
1291
            }
Bogdan Timofte authored 2 months ago
1292
            return pinOrigin
1293
                ? voltageAxisOrigin
1294
                : automaticYBounds(
1295
                    for: filteredSamplePoints(
1296
                        measurements.voltage,
1297
                        visibleTimeRange: visibleTimeRange
1298
                    ),
1299
                    minimumYSpan: minimumVoltageSpan
1300
                ).lowerBound
Bogdan Timofte authored 2 months ago
1301
        case .current:
1302
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1303
                return sharedAxisOrigin
1304
            }
Bogdan Timofte authored 2 months ago
1305
            return pinOrigin
1306
                ? currentAxisOrigin
1307
                : automaticYBounds(
1308
                    for: filteredSamplePoints(
1309
                        measurements.current,
1310
                        visibleTimeRange: visibleTimeRange
1311
                    ),
1312
                    minimumYSpan: minimumCurrentSpan
1313
                ).lowerBound
Bogdan Timofte authored 2 months ago
1314
        case .temperature:
Bogdan Timofte authored 2 months ago
1315
            return pinOrigin
1316
                ? temperatureAxisOrigin
1317
                : automaticYBounds(
1318
                    for: filteredSamplePoints(
1319
                        measurements.temperature,
1320
                        visibleTimeRange: visibleTimeRange
1321
                    ),
1322
                    minimumYSpan: minimumTemperatureSpan
1323
                ).lowerBound
Bogdan Timofte authored 2 months ago
1324
        }
1325
    }
1326

            
Bogdan Timofte authored 2 months ago
1327
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1328
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1329
    }
1330

            
1331
    private func filteredPoints(
1332
        _ measurement: Measurements.Measurement,
1333
        visibleTimeRange: ClosedRange<Date>? = nil
1334
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1335
        let resolvedRange: ClosedRange<Date>?
1336

            
1337
        switch (timeRange, visibleTimeRange) {
1338
        case let (baseRange?, visibleRange?):
1339
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1340
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1341
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1342
        case let (baseRange?, nil):
1343
            resolvedRange = baseRange
1344
        case let (nil, visibleRange?):
1345
            resolvedRange = visibleRange
1346
        case (nil, nil):
1347
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1348
        }
Bogdan Timofte authored 2 months ago
1349

            
1350
        guard let resolvedRange else {
1351
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1352
        }
1353

            
1354
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1355
    }
1356

            
1357
    private func filteredSamplePoints(
1358
        _ measurement: Measurements.Measurement,
1359
        visibleTimeRange: ClosedRange<Date>? = nil
1360
    ) -> [Measurements.Measurement.Point] {
1361
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1362
            point.isSample
Bogdan Timofte authored 2 months ago
1363
        }
1364
    }
1365

            
1366
    private func xBounds(
Bogdan Timofte authored 2 months ago
1367
        for samplePoints: [Measurements.Measurement.Point],
1368
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1369
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1370
        if let visibleTimeRange {
1371
            return normalizedTimeRange(visibleTimeRange)
1372
        }
1373

            
Bogdan Timofte authored 2 months ago
1374
        if let timeRange {
Bogdan Timofte authored 2 months ago
1375
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1376
        }
1377

            
1378
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1379
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1380

            
Bogdan Timofte authored 2 months ago
1381
        return normalizedTimeRange(lowerBound...upperBound)
1382
    }
1383

            
1384
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1385
        if let timeRange {
1386
            return normalizedTimeRange(timeRange)
1387
        }
1388

            
1389
        let samplePoints = timelineSamplePoints()
Bogdan Timofte authored a month ago
1390
        let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp
1391
        guard let lowerBound else {
Bogdan Timofte authored 2 months ago
1392
            return nil
1393
        }
1394

            
Bogdan Timofte authored a month ago
1395
        let latestSampleTimestamp = samplePoints.last?.timestamp
1396
        let resolvedUpperBound = timeRangeUpperBound ?? {
1397
            guard extendsTimelineToPresent else {
1398
                return latestSampleTimestamp ?? lowerBound
1399
            }
1400
            return max(latestSampleTimestamp ?? chartNow, chartNow)
1401
        }()
1402
        let upperBound = max(resolvedUpperBound, lowerBound)
Bogdan Timofte authored 2 months ago
1403
        return normalizedTimeRange(lowerBound...upperBound)
1404
    }
1405

            
1406
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1407
        let candidates = [
1408
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1409
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1410
            filteredSamplePoints(measurements.voltage),
1411
            filteredSamplePoints(measurements.current),
1412
            filteredSamplePoints(measurements.temperature)
1413
        ]
1414

            
1415
        return candidates.first(where: { !$0.isEmpty }) ?? []
1416
    }
1417

            
1418
    private func resolvedVisibleTimeRange(
1419
        within availableTimeRange: ClosedRange<Date>?
1420
    ) -> ClosedRange<Date>? {
1421
        guard let availableTimeRange else { return nil }
1422
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1423

            
1424
        if isPinnedToPresent {
1425
            let pinnedRange: ClosedRange<Date>
1426

            
1427
            switch presentTrackingMode {
1428
            case .keepDuration:
1429
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1430
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1431
            case .keepStartTimestamp:
1432
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1433
            }
1434

            
1435
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1436
        }
1437

            
1438
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1439
    }
1440

            
1441
    private func clampedTimeRange(
1442
        _ candidateRange: ClosedRange<Date>,
1443
        within bounds: ClosedRange<Date>
1444
    ) -> ClosedRange<Date> {
1445
        let normalizedBounds = normalizedTimeRange(bounds)
1446
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1447

            
1448
        guard boundsSpan > 0 else {
1449
            return normalizedBounds
1450
        }
1451

            
1452
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1453
        let requestedSpan = min(
1454
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1455
            boundsSpan
1456
        )
1457

            
1458
        if requestedSpan >= boundsSpan {
1459
            return normalizedBounds
1460
        }
1461

            
1462
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1463
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1464

            
1465
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1466
            if lowerBound == normalizedBounds.lowerBound {
1467
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1468
            } else {
1469
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1470
            }
1471
        }
1472

            
1473
        if upperBound > normalizedBounds.upperBound {
1474
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1475
            upperBound = normalizedBounds.upperBound
1476
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1477
        }
1478

            
Bogdan Timofte authored 2 months ago
1479
        if lowerBound < normalizedBounds.lowerBound {
1480
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1481
            lowerBound = normalizedBounds.lowerBound
1482
            upperBound = upperBound.addingTimeInterval(delta)
1483
        }
1484

            
1485
        return lowerBound...upperBound
1486
    }
1487

            
1488
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1489
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1490
        guard span < minimumTimeSpan else { return range }
1491

            
1492
        let expansion = (minimumTimeSpan - span) / 2
1493
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1494
    }
1495

            
1496
    private func shouldShowRangeSelector(
1497
        availableTimeRange: ClosedRange<Date>,
1498
        series: SeriesData
1499
    ) -> Bool {
1500
        series.samplePoints.count > 1 &&
1501
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1502
    }
1503

            
1504
    private func automaticYBounds(
1505
        for samplePoints: [Measurements.Measurement.Point],
1506
        minimumYSpan: Double
1507
    ) -> (lowerBound: Double, upperBound: Double) {
1508
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1509

            
1510
        guard
1511
            let minimumSampleValue = samplePoints.map(\.value).min(),
1512
            let maximumSampleValue = samplePoints.map(\.value).max()
1513
        else {
1514
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1515
        }
Bogdan Timofte authored 2 months ago
1516

            
1517
        var lowerBound = minimumSampleValue
1518
        var upperBound = maximumSampleValue
1519
        let currentSpan = upperBound - lowerBound
1520

            
1521
        if currentSpan < minimumYSpan {
1522
            let expansion = (minimumYSpan - currentSpan) / 2
1523
            lowerBound -= expansion
1524
            upperBound += expansion
1525
        }
1526

            
1527
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1528
            let shift = -negativeAllowance - lowerBound
1529
            lowerBound += shift
1530
            upperBound += shift
1531
        }
1532

            
1533
        let snappedLowerBound = snappedOriginValue(lowerBound)
1534
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1535
        return (snappedLowerBound, resolvedUpperBound)
1536
    }
1537

            
1538
    private func resolvedLowerBound(
1539
        for kind: SeriesKind,
1540
        autoLowerBound: Double
1541
    ) -> Double {
1542
        guard pinOrigin else { return autoLowerBound }
1543

            
1544
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1545
            return sharedAxisOrigin
1546
        }
1547

            
1548
        switch kind {
1549
        case .power:
1550
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
1551
        case .energy:
1552
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1553
        case .voltage:
1554
            return voltageAxisOrigin
1555
        case .current:
1556
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1557
        case .temperature:
1558
            return temperatureAxisOrigin
Bogdan Timofte authored 2 months ago
1559
        }
1560
    }
1561

            
1562
    private func resolvedUpperBound(
1563
        for kind: SeriesKind,
1564
        lowerBound: Double,
1565
        autoUpperBound: Double,
1566
        maximumSampleValue: Double?,
1567
        minimumYSpan: Double
1568
    ) -> Double {
1569
        guard pinOrigin else {
1570
            return autoUpperBound
1571
        }
1572

            
Bogdan Timofte authored 2 months ago
1573
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1574
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1575
        }
1576

            
Bogdan Timofte authored 2 months ago
1577
        if kind == .temperature {
1578
            return autoUpperBound
1579
        }
1580

            
Bogdan Timofte authored 2 months ago
1581
        return max(
1582
            maximumSampleValue ?? lowerBound,
1583
            lowerBound + minimumYSpan,
1584
            autoUpperBound
1585
        )
1586
    }
1587

            
Bogdan Timofte authored 2 months ago
1588
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
1589
        let baseline = displayedLowerBoundForSeries(kind)
1590
        let proposedOrigin = snappedOriginValue(baseline + delta)
1591

            
1592
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
1593
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1594
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
1595
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1596
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1597
        } else {
1598
            switch kind {
1599
            case .power:
1600
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
1601
            case .energy:
1602
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
1603
            case .voltage:
1604
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1605
            case .current:
1606
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
1607
            case .temperature:
1608
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored 2 months ago
1609
            }
1610
        }
1611

            
1612
        pinOrigin = true
1613
    }
1614

            
Bogdan Timofte authored 2 months ago
1615
    private func clearOriginOffset(for kind: SeriesKind) {
1616
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1617
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1618
            sharedAxisOrigin = 0
1619
            sharedAxisUpperBound = currentSpan
1620
            ensureSharedScaleSpan()
1621
            voltageAxisOrigin = 0
1622
            currentAxisOrigin = 0
1623
        } else {
1624
            switch kind {
1625
            case .power:
1626
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1627
            case .energy:
1628
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1629
            case .voltage:
1630
                voltageAxisOrigin = 0
1631
            case .current:
1632
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1633
            case .temperature:
1634
                temperatureAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1635
            }
1636
        }
1637

            
1638
        pinOrigin = true
1639
    }
1640

            
1641
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1642
        guard totalHeight > 1 else { return }
1643

            
1644
        let normalized = max(0, min(1, locationY / totalHeight))
1645
        if normalized < (1.0 / 3.0) {
1646
            applyOriginDelta(-1, kind: kind)
1647
        } else if normalized < (2.0 / 3.0) {
1648
            clearOriginOffset(for: kind)
1649
        } else {
1650
            applyOriginDelta(1, kind: kind)
1651
        }
1652
    }
1653

            
Bogdan Timofte authored 2 months ago
1654
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1655
        let visibleTimeRange = activeVisibleTimeRange
1656

            
Bogdan Timofte authored 2 months ago
1657
        switch kind {
1658
        case .power:
Bogdan Timofte authored 2 months ago
1659
            return snappedOriginValue(
1660
                filteredSamplePoints(
1661
                    measurements.power,
1662
                    visibleTimeRange: visibleTimeRange
1663
                ).map(\.value).min() ?? 0
1664
            )
Bogdan Timofte authored 2 months ago
1665
        case .energy:
1666
            return snappedOriginValue(
1667
                filteredSamplePoints(
1668
                    measurements.energy,
1669
                    visibleTimeRange: visibleTimeRange
1670
                ).map(\.value).min() ?? 0
1671
            )
Bogdan Timofte authored 2 months ago
1672
        case .voltage:
Bogdan Timofte authored 2 months ago
1673
            return snappedOriginValue(
1674
                filteredSamplePoints(
1675
                    measurements.voltage,
1676
                    visibleTimeRange: visibleTimeRange
1677
                ).map(\.value).min() ?? 0
1678
            )
Bogdan Timofte authored 2 months ago
1679
        case .current:
Bogdan Timofte authored 2 months ago
1680
            return snappedOriginValue(
1681
                filteredSamplePoints(
1682
                    measurements.current,
1683
                    visibleTimeRange: visibleTimeRange
1684
                ).map(\.value).min() ?? 0
1685
            )
Bogdan Timofte authored 2 months ago
1686
        case .temperature:
Bogdan Timofte authored 2 months ago
1687
            return snappedOriginValue(
1688
                filteredSamplePoints(
1689
                    measurements.temperature,
1690
                    visibleTimeRange: visibleTimeRange
1691
                ).map(\.value).min() ?? 0
1692
            )
Bogdan Timofte authored 2 months ago
1693
        }
1694
    }
1695

            
1696
    private func maximumVisibleSharedOrigin() -> Double {
1697
        min(
1698
            maximumVisibleOrigin(for: .voltage),
1699
            maximumVisibleOrigin(for: .current)
1700
        )
1701
    }
1702

            
Bogdan Timofte authored 2 months ago
1703
    private func measurementUnit(for kind: SeriesKind) -> String {
1704
        switch kind {
1705
        case .temperature:
1706
            let locale = Locale.autoupdatingCurrent
1707
            if #available(iOS 16.0, *) {
1708
                switch locale.measurementSystem {
1709
                case .us:
1710
                    return "°F"
1711
                default:
1712
                    return "°C"
1713
                }
1714
            }
1715

            
1716
            let regionCode = locale.regionCode ?? ""
1717
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1718
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1719
        default:
1720
            return kind.unit
1721
        }
1722
    }
1723

            
Bogdan Timofte authored 2 months ago
1724
    private func ensureSharedScaleSpan() {
1725
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1726
    }
1727

            
Bogdan Timofte authored 2 months ago
1728
    private func snappedOriginValue(_ value: Double) -> Double {
1729
        if value >= 0 {
1730
            return value.rounded(.down)
1731
        }
1732

            
1733
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
1734
    }
Bogdan Timofte authored 2 months ago
1735

            
Bogdan Timofte authored 2 months ago
1736
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1737
        measurements.keepOnly(in: range)
1738
        selectedVisibleTimeRange = nil
1739
        isPinnedToPresent = false
1740
    }
1741

            
1742
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1743
        measurements.removeValues(in: range)
1744
        selectedVisibleTimeRange = nil
1745
        isPinnedToPresent = false
1746
    }
1747

            
Bogdan Timofte authored 2 months ago
1748
    private func yGuidePosition(
1749
        for labelIndex: Int,
1750
        context: ChartContext,
1751
        height: CGFloat
1752
    ) -> CGFloat {
1753
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1754
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1755
        return context.placeInRect(point: anchorPoint).y * height
1756
    }
1757

            
1758
    private func xGuidePosition(
1759
        for labelIndex: Int,
1760
        context: ChartContext,
1761
        width: CGFloat
1762
    ) -> CGFloat {
1763
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1764
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1765
        return context.placeInRect(point: anchorPoint).x * width
1766
    }
Bogdan Timofte authored 2 months ago
1767

            
1768
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
1769
    fileprivate func xAxisLabelsView(
1770
        context: ChartContext
1771
    ) -> some View {
Bogdan Timofte authored 2 months ago
1772
        var timeFormat: String?
1773
        switch context.size.width {
1774
        case 0..<3600: timeFormat = "HH:mm:ss"
1775
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
1776
        default: timeFormat = "E HH:mm"
1777
        }
1778
        let labels = (1...xLabels).map {
1779
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
1780
        }
Bogdan Timofte authored 2 months ago
1781
        let axisLabelFont: Font = {
1782
            if isIPhone && isPortraitLayout {
1783
                return .caption2.weight(.semibold)
1784
            }
1785
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1786
        }()
Bogdan Timofte authored 2 months ago
1787

            
1788
        return HStack(spacing: chartSectionSpacing) {
1789
            Color.clear
1790
                .frame(width: axisColumnWidth)
1791

            
1792
            GeometryReader { geometry in
1793
                let labelWidth = max(
1794
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1795
                    1
1796
                )
1797

            
1798
                ZStack(alignment: .topLeading) {
1799
                    Path { path in
1800
                        for labelIndex in 1...self.xLabels {
1801
                            let x = xGuidePosition(
1802
                                for: labelIndex,
1803
                                context: context,
1804
                                width: geometry.size.width
1805
                            )
1806
                            path.move(to: CGPoint(x: x, y: 0))
1807
                            path.addLine(to: CGPoint(x: x, y: 6))
1808
                        }
1809
                    }
1810
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1811

            
1812
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1813
                        let labelIndex = item.offset + 1
1814
                        let centerX = xGuidePosition(
1815
                            for: labelIndex,
1816
                            context: context,
1817
                            width: geometry.size.width
1818
                        )
1819

            
1820
                        Text(item.element)
Bogdan Timofte authored 2 months ago
1821
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
1822
                            .monospacedDigit()
1823
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
1824
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
1825
                            .frame(width: labelWidth)
1826
                            .position(
1827
                                x: centerX,
1828
                                y: geometry.size.height * 0.7
1829
                            )
Bogdan Timofte authored 2 months ago
1830
                    }
1831
                }
Bogdan Timofte authored 2 months ago
1832
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
1833
            }
Bogdan Timofte authored 2 months ago
1834

            
1835
            Color.clear
1836
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
1837
        }
1838
    }
1839

            
Bogdan Timofte authored 2 months ago
1840
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
1841
        height: CGFloat,
1842
        context: ChartContext,
Bogdan Timofte authored 2 months ago
1843
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
1844
        measurementUnit: String,
1845
        tint: Color
1846
    ) -> some View {
Bogdan Timofte authored 2 months ago
1847
        let yAxisFont: Font = {
1848
            if isIPhone && isPortraitLayout {
1849
                return .caption2.weight(.semibold)
1850
            }
1851
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1852
        }()
1853

            
1854
        let unitFont: Font = {
1855
            if isIPhone && isPortraitLayout {
1856
                return .caption2.weight(.bold)
1857
            }
1858
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1859
        }()
1860

            
1861
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
1862
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1863
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1864
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1865

            
Bogdan Timofte authored 2 months ago
1866
            ZStack(alignment: .top) {
1867
                ForEach(0..<yLabels, id: \.self) { row in
1868
                    let labelIndex = yLabels - row
1869

            
1870
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
1871
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
1872
                        .monospacedDigit()
1873
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
1874
                        .minimumScaleFactor(0.8)
1875
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
1876
                        .position(
1877
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
1878
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
1879
                                for: labelIndex,
1880
                                context: context,
Bogdan Timofte authored 2 months ago
1881
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
1882
                            )
1883
                        )
Bogdan Timofte authored 2 months ago
1884
                }
Bogdan Timofte authored 2 months ago
1885

            
Bogdan Timofte authored 2 months ago
1886
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
1887
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
1888
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
1889
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1890
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
1891
                    .background(
1892
                        Capsule(style: .continuous)
1893
                            .fill(tint.opacity(0.14))
1894
                    )
Bogdan Timofte authored 2 months ago
1895
                    .padding(.top, 8)
1896

            
Bogdan Timofte authored 2 months ago
1897
            }
1898
        }
Bogdan Timofte authored 2 months ago
1899
        .frame(height: height)
1900
        .background(
1901
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1902
                .fill(tint.opacity(0.12))
1903
        )
1904
        .overlay(
1905
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1906
                .stroke(tint.opacity(0.20), lineWidth: 1)
1907
        )
Bogdan Timofte authored 2 months ago
1908
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1909
        .gesture(
Bogdan Timofte authored 2 months ago
1910
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
1911
                .onEnded { value in
Bogdan Timofte authored 2 months ago
1912
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
1913
                }
1914
        )
Bogdan Timofte authored 2 months ago
1915
    }
1916

            
Bogdan Timofte authored 2 months ago
1917
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 months ago
1918
        GeometryReader { geometry in
1919
            Path { path in
Bogdan Timofte authored 2 months ago
1920
                for labelIndex in 1...self.yLabels {
1921
                    let y = yGuidePosition(
1922
                        for: labelIndex,
1923
                        context: context,
1924
                        height: geometry.size.height
1925
                    )
1926
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 months ago
1927
                }
Bogdan Timofte authored 2 months ago
1928
            }
1929
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 months ago
1930
        }
1931
    }
1932

            
Bogdan Timofte authored 2 months ago
1933
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 months ago
1934
        GeometryReader { geometry in
1935
            Path { path in
1936

            
Bogdan Timofte authored 2 months ago
1937
                for labelIndex in 2..<self.xLabels {
1938
                    let x = xGuidePosition(
1939
                        for: labelIndex,
1940
                        context: context,
1941
                        width: geometry.size.width
1942
                    )
1943
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 months ago
1944
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1945
                }
Bogdan Timofte authored 2 months ago
1946
            }
1947
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 months ago
1948
        }
1949
    }
Bogdan Timofte authored 2 months ago
1950

            
1951
    fileprivate func discontinuityMarkers(
1952
        points: [Measurements.Measurement.Point],
1953
        context: ChartContext
1954
    ) -> some View {
1955
        GeometryReader { geometry in
1956
            Path { path in
1957
                for point in points where point.isDiscontinuity {
1958
                    let markerX = context.placeInRect(
1959
                        point: CGPoint(
1960
                            x: point.timestamp.timeIntervalSince1970,
1961
                            y: context.origin.y
1962
                        )
1963
                    ).x * geometry.size.width
1964
                    path.move(to: CGPoint(x: markerX, y: 0))
1965
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1966
                }
1967
            }
1968
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1969
        }
1970
    }
Bogdan Timofte authored 2 months ago
1971

            
1972
}
1973

            
Bogdan Timofte authored 2 months ago
1974
private struct TimeRangeSelectorView: View {
1975
    private enum DragTarget {
1976
        case lowerBound
1977
        case upperBound
1978
        case window
1979
    }
1980

            
1981
    private struct DragState {
1982
        let target: DragTarget
1983
        let initialRange: ClosedRange<Date>
1984
    }
1985

            
1986
    let points: [Measurements.Measurement.Point]
1987
    let context: ChartContext
1988
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
1989
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
1990
    let compactLayout: Bool
1991
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored a month ago
1992
    let configuration: MeasurementChartRangeSelectorConfiguration
Bogdan Timofte authored 2 months ago
1993

            
1994
    @Binding var selectedTimeRange: ClosedRange<Date>?
1995
    @Binding var isPinnedToPresent: Bool
1996
    @Binding var presentTrackingMode: PresentTrackingMode
1997
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
1998
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
1999

            
2000
    private var totalSpan: TimeInterval {
2001
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
2002
    }
2003

            
2004
    private var currentRange: ClosedRange<Date> {
2005
        resolvedSelectionRange()
2006
    }
2007

            
2008
    private var trackHeight: CGFloat {
2009
        compactLayout ? 72 : 86
2010
    }
2011

            
2012
    private var cornerRadius: CGFloat {
2013
        compactLayout ? 14 : 16
2014
    }
2015

            
2016
    private var boundaryFont: Font {
2017
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
2018
    }
2019

            
2020
    private var symbolButtonSize: CGFloat {
2021
        compactLayout ? 28 : 32
2022
    }
2023

            
2024
    var body: some View {
2025
        let coversFullRange = selectionCoversFullRange(currentRange)
2026

            
2027
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
2028
            if !coversFullRange || isPinnedToPresent {
2029
                HStack(spacing: 8) {
2030
                    alignmentButton(
2031
                        systemName: "arrow.left.to.line.compact",
2032
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
2033
                        action: alignSelectionToLeadingEdge,
2034
                        accessibilityLabel: "Align selection to start"
2035
                    )
2036

            
2037
                    alignmentButton(
2038
                        systemName: "arrow.right.to.line.compact",
2039
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
2040
                        action: alignSelectionToTrailingEdge,
2041
                        accessibilityLabel: "Align selection to present"
2042
                    )
2043

            
2044
                    Spacer(minLength: 0)
2045

            
2046
                    if isPinnedToPresent {
2047
                        trackingModeToggleButton()
2048
                    }
2049
                }
2050
            }
2051

            
Bogdan Timofte authored 2 months ago
2052
            HStack(spacing: 8) {
2053
                if !coversFullRange {
2054
                    actionButton(
Bogdan Timofte authored a month ago
2055
                        title: configuration.keepAction.title,
2056
                        systemName: configuration.keepAction.systemName,
2057
                        tone: configuration.keepAction.tone,
Bogdan Timofte authored 2 months ago
2058
                        action: {
Bogdan Timofte authored a month ago
2059
                            configuration.keepAction.handler(currentRange)
2060
                            resetSelectionState()
Bogdan Timofte authored 2 months ago
2061
                        }
2062
                    )
2063

            
Bogdan Timofte authored a month ago
2064
                    if let removeAction = configuration.removeAction {
2065
                        actionButton(
2066
                            title: removeAction.title,
2067
                            systemName: removeAction.systemName,
2068
                            tone: removeAction.tone,
2069
                            action: {
2070
                                removeAction.handler(currentRange)
2071
                                resetSelectionState()
2072
                            }
2073
                        )
2074
                    }
Bogdan Timofte authored 2 months ago
2075
                }
2076

            
2077
                Spacer(minLength: 0)
2078

            
2079
                actionButton(
Bogdan Timofte authored a month ago
2080
                    title: configuration.resetAction.title,
2081
                    systemName: configuration.resetAction.systemName,
2082
                    tone: configuration.resetAction.tone,
Bogdan Timofte authored 2 months ago
2083
                    action: {
2084
                        showResetConfirmation = true
2085
                    }
2086
                )
2087
            }
Bogdan Timofte authored a month ago
2088
            .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2089
                Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2090
                    configuration.resetAction.handler()
2091
                    resetSelectionState()
Bogdan Timofte authored 2 months ago
2092
                }
2093
                Button("Cancel", role: .cancel) {}
2094
            }
2095

            
Bogdan Timofte authored 2 months ago
2096
            GeometryReader { geometry in
2097
                let selectionFrame = selectionFrame(in: geometry.size)
2098
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2099

            
2100
                ZStack(alignment: .topLeading) {
2101
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2102
                        .fill(Color.primary.opacity(0.05))
2103

            
2104
                    Chart(
2105
                        points: points,
2106
                        context: context,
2107
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2108
                        strokeColor: selectorTint,
2109
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2110
                    )
2111
                    .opacity(0.94)
2112
                    .allowsHitTesting(false)
2113

            
2114
                    Chart(
2115
                        points: points,
2116
                        context: context,
Bogdan Timofte authored 2 months ago
2117
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2118
                    )
2119
                    .opacity(0.82)
2120
                    .allowsHitTesting(false)
2121

            
2122
                    if selectionFrame.minX > 0 {
2123
                        Rectangle()
2124
                            .fill(dimmingColor)
2125
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2126
                            .allowsHitTesting(false)
2127
                    }
2128

            
2129
                    if selectionFrame.maxX < geometry.size.width {
2130
                        Rectangle()
2131
                            .fill(dimmingColor)
2132
                            .frame(
2133
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2134
                                height: geometry.size.height
2135
                            )
2136
                            .offset(x: selectionFrame.maxX)
2137
                            .allowsHitTesting(false)
2138
                    }
2139

            
2140
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2141
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2142
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2143
                        .offset(x: selectionFrame.minX)
2144
                        .allowsHitTesting(false)
2145

            
2146
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2147
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2148
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2149
                        .offset(x: selectionFrame.minX)
2150
                        .allowsHitTesting(false)
2151

            
2152
                    handleView(height: max(geometry.size.height - 18, 16))
2153
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2154
                        .allowsHitTesting(false)
2155

            
2156
                    handleView(height: max(geometry.size.height - 18, 16))
2157
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2158
                        .allowsHitTesting(false)
2159
                }
2160
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2161
                .overlay(
2162
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2163
                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
2164
                )
2165
                .contentShape(Rectangle())
2166
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2167
            }
2168
            .frame(height: trackHeight)
2169

            
2170
            HStack {
2171
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2172
                Spacer(minLength: 0)
2173
                Text(boundaryLabel(for: availableTimeRange.upperBound))
2174
            }
2175
            .font(boundaryFont)
2176
            .foregroundColor(.secondary)
2177
            .monospacedDigit()
2178
        }
2179
    }
2180

            
2181
    private func handleView(height: CGFloat) -> some View {
2182
        Capsule(style: .continuous)
2183
            .fill(Color.white.opacity(0.95))
2184
            .frame(width: 6, height: height)
2185
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2186
    }
2187

            
2188
    private func alignmentButton(
2189
        systemName: String,
2190
        isActive: Bool,
2191
        action: @escaping () -> Void,
2192
        accessibilityLabel: String
2193
    ) -> some View {
2194
        Button(action: action) {
2195
            Image(systemName: systemName)
2196
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2197
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2198
        }
2199
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2200
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2201
        .background(
2202
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2203
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2204
        )
2205
        .overlay(
2206
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2207
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2208
        )
2209
        .accessibilityLabel(accessibilityLabel)
2210
    }
2211

            
2212
    private func trackingModeToggleButton() -> some View {
2213
        Button {
2214
            presentTrackingMode = presentTrackingMode == .keepDuration
2215
                ? .keepStartTimestamp
2216
                : .keepDuration
2217
        } label: {
2218
            Image(systemName: trackingModeSymbolName)
2219
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2220
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2221
        }
2222
        .buttonStyle(.plain)
2223
        .foregroundColor(.white)
2224
        .background(
2225
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2226
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2227
        )
2228
        .overlay(
2229
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2230
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2231
        )
2232
        .accessibilityLabel(trackingModeAccessibilityLabel)
2233
        .accessibilityHint("Toggles how the interval follows the present")
2234
    }
2235

            
Bogdan Timofte authored 2 months ago
2236
    private func actionButton(
2237
        title: String,
2238
        systemName: String,
Bogdan Timofte authored a month ago
2239
        tone: MeasurementChartSelectorActionTone,
Bogdan Timofte authored 2 months ago
2240
        action: @escaping () -> Void
2241
    ) -> some View {
2242
        let foregroundColor: Color = {
2243
            switch tone {
2244
            case .reversible, .destructive:
2245
                return toneColor(for: tone)
2246
            case .destructiveProminent:
2247
                return .white
2248
            }
2249
        }()
2250

            
2251
        return Button(action: action) {
2252
            Label(title, systemImage: systemName)
2253
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2254
                .padding(.horizontal, compactLayout ? 10 : 12)
2255
                .padding(.vertical, compactLayout ? 7 : 8)
2256
        }
2257
        .buttonStyle(.plain)
2258
        .foregroundColor(foregroundColor)
2259
        .background(
2260
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2261
                .fill(actionButtonBackground(for: tone))
2262
        )
2263
        .overlay(
2264
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2265
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2266
        )
2267
    }
2268

            
Bogdan Timofte authored a month ago
2269
    private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2270
        switch tone {
2271
        case .reversible:
2272
            return selectorTint
2273
        case .destructive, .destructiveProminent:
2274
            return .red
2275
        }
2276
    }
2277

            
Bogdan Timofte authored a month ago
2278
    private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2279
        switch tone {
2280
        case .reversible:
2281
            return selectorTint.opacity(0.12)
2282
        case .destructive:
2283
            return Color.red.opacity(0.12)
2284
        case .destructiveProminent:
2285
            return Color.red.opacity(0.82)
2286
        }
2287
    }
2288

            
Bogdan Timofte authored a month ago
2289
    private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
Bogdan Timofte authored 2 months ago
2290
        switch tone {
2291
        case .reversible:
2292
            return selectorTint.opacity(0.22)
2293
        case .destructive:
2294
            return Color.red.opacity(0.22)
2295
        case .destructiveProminent:
2296
            return Color.red.opacity(0.72)
2297
        }
2298
    }
2299

            
Bogdan Timofte authored 2 months ago
2300
    private var trackingModeSymbolName: String {
2301
        switch presentTrackingMode {
2302
        case .keepDuration:
2303
            return "arrow.left.and.right"
2304
        case .keepStartTimestamp:
2305
            return "arrow.left.to.line.compact"
2306
        }
2307
    }
2308

            
2309
    private var trackingModeAccessibilityLabel: String {
2310
        switch presentTrackingMode {
2311
        case .keepDuration:
2312
            return "Follow present keeping span"
2313
        case .keepStartTimestamp:
2314
            return "Follow present keeping start"
2315
        }
2316
    }
2317

            
2318
    private func alignSelectionToLeadingEdge() {
2319
        let alignedRange = normalizedSelectionRange(
2320
            availableTimeRange.lowerBound...currentRange.upperBound
2321
        )
2322
        applySelection(alignedRange, pinToPresent: false)
2323
    }
2324

            
2325
    private func alignSelectionToTrailingEdge() {
2326
        let alignedRange = normalizedSelectionRange(
2327
            currentRange.lowerBound...availableTimeRange.upperBound
2328
        )
2329
        applySelection(alignedRange, pinToPresent: true)
2330
    }
2331

            
2332
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2333
        DragGesture(minimumDistance: 0)
2334
            .onChanged { value in
2335
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2336
            }
2337
            .onEnded { _ in
2338
                dragState = nil
2339
            }
2340
    }
2341

            
2342
    private func updateSelectionDrag(
2343
        value: DragGesture.Value,
2344
        totalWidth: CGFloat
2345
    ) {
2346
        let startingRange = resolvedSelectionRange()
2347

            
2348
        if dragState == nil {
2349
            dragState = DragState(
2350
                target: dragTarget(
2351
                    for: value.startLocation.x,
2352
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2353
                ),
2354
                initialRange: startingRange
2355
            )
2356
        }
2357

            
2358
        guard let dragState else { return }
2359

            
2360
        let resultingRange = snappedToEdges(
2361
            adjustedRange(
2362
                from: dragState.initialRange,
2363
                target: dragState.target,
2364
                translationX: value.translation.width,
2365
                totalWidth: totalWidth
2366
            ),
2367
            target: dragState.target,
2368
            totalWidth: totalWidth
2369
        )
2370

            
2371
        applySelection(
2372
            resultingRange,
2373
            pinToPresent: shouldKeepPresentPin(
2374
                during: dragState.target,
2375
                initialRange: dragState.initialRange,
2376
                resultingRange: resultingRange
2377
            ),
2378
        )
2379
    }
2380

            
2381
    private func dragTarget(
2382
        for startX: CGFloat,
2383
        selectionFrame: CGRect
2384
    ) -> DragTarget {
2385
        let handleZone: CGFloat = compactLayout ? 20 : 24
2386

            
2387
        if abs(startX - selectionFrame.minX) <= handleZone {
2388
            return .lowerBound
2389
        }
2390

            
2391
        if abs(startX - selectionFrame.maxX) <= handleZone {
2392
            return .upperBound
2393
        }
2394

            
2395
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2396
            return .window
2397
        }
2398

            
2399
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2400
    }
2401

            
2402
    private func adjustedRange(
2403
        from initialRange: ClosedRange<Date>,
2404
        target: DragTarget,
2405
        translationX: CGFloat,
2406
        totalWidth: CGFloat
2407
    ) -> ClosedRange<Date> {
2408
        guard totalSpan > 0, totalWidth > 0 else {
2409
            return availableTimeRange
2410
        }
2411

            
2412
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2413
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2414

            
2415
        switch target {
2416
        case .lowerBound:
2417
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2418
            let newLowerBound = min(
2419
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2420
                maximumLowerBound
2421
            )
2422
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2423

            
2424
        case .upperBound:
2425
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2426
            let newUpperBound = max(
2427
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2428
                minimumUpperBound
2429
            )
2430
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2431

            
2432
        case .window:
2433
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2434
            guard span < totalSpan else { return availableTimeRange }
2435

            
2436
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2437
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2438

            
2439
            if lowerBound < availableTimeRange.lowerBound {
2440
                upperBound = upperBound.addingTimeInterval(
2441
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2442
                )
2443
                lowerBound = availableTimeRange.lowerBound
2444
            }
2445

            
2446
            if upperBound > availableTimeRange.upperBound {
2447
                lowerBound = lowerBound.addingTimeInterval(
2448
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2449
                )
2450
                upperBound = availableTimeRange.upperBound
2451
            }
2452

            
2453
            return normalizedSelectionRange(lowerBound...upperBound)
2454
        }
2455
    }
2456

            
2457
    private func snappedToEdges(
2458
        _ candidateRange: ClosedRange<Date>,
2459
        target: DragTarget,
2460
        totalWidth: CGFloat
2461
    ) -> ClosedRange<Date> {
2462
        guard totalSpan > 0 else {
2463
            return availableTimeRange
2464
        }
2465

            
2466
        let snapInterval = edgeSnapInterval(for: totalWidth)
2467
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2468
        var lowerBound = candidateRange.lowerBound
2469
        var upperBound = candidateRange.upperBound
2470

            
2471
        if target != .upperBound,
2472
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2473
            lowerBound = availableTimeRange.lowerBound
2474
            if target == .window {
2475
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2476
            }
2477
        }
2478

            
2479
        if target != .lowerBound,
2480
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2481
            upperBound = availableTimeRange.upperBound
2482
            if target == .window {
2483
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2484
            }
2485
        }
2486

            
2487
        return normalizedSelectionRange(lowerBound...upperBound)
2488
    }
2489

            
2490
    private func edgeSnapInterval(
2491
        for totalWidth: CGFloat
2492
    ) -> TimeInterval {
2493
        guard totalWidth > 0 else { return minimumSelectionSpan }
2494

            
2495
        let snapWidth = min(
2496
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2497
            totalWidth * 0.18
2498
        )
2499
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2500
        return min(
2501
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2502
            totalSpan / 4
2503
        )
2504
    }
2505

            
2506
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2507
        guard let selectedTimeRange else { return availableTimeRange }
2508

            
2509
        if isPinnedToPresent {
2510
            switch presentTrackingMode {
2511
            case .keepDuration:
2512
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2513
                return normalizedSelectionRange(
2514
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2515
                )
2516
            case .keepStartTimestamp:
2517
                return normalizedSelectionRange(
2518
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2519
                )
2520
            }
2521
        }
2522

            
2523
        return normalizedSelectionRange(selectedTimeRange)
2524
    }
2525

            
2526
    private func normalizedSelectionRange(
2527
        _ candidateRange: ClosedRange<Date>
2528
    ) -> ClosedRange<Date> {
2529
        let availableSpan = totalSpan
2530
        guard availableSpan > 0 else { return availableTimeRange }
2531

            
2532
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2533
        let requestedSpan = min(
2534
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2535
            availableSpan
2536
        )
2537

            
2538
        if requestedSpan >= availableSpan {
2539
            return availableTimeRange
2540
        }
2541

            
2542
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2543
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2544

            
2545
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2546
            if lowerBound == availableTimeRange.lowerBound {
2547
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2548
            } else {
2549
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2550
            }
2551
        }
2552

            
2553
        if upperBound > availableTimeRange.upperBound {
2554
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2555
            upperBound = availableTimeRange.upperBound
2556
            lowerBound = lowerBound.addingTimeInterval(-delta)
2557
        }
2558

            
2559
        if lowerBound < availableTimeRange.lowerBound {
2560
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2561
            lowerBound = availableTimeRange.lowerBound
2562
            upperBound = upperBound.addingTimeInterval(delta)
2563
        }
2564

            
2565
        return lowerBound...upperBound
2566
    }
2567

            
2568
    private func shouldKeepPresentPin(
2569
        during target: DragTarget,
2570
        initialRange: ClosedRange<Date>,
2571
        resultingRange: ClosedRange<Date>
2572
    ) -> Bool {
2573
        let startedPinnedToPresent =
2574
            isPinnedToPresent ||
2575
            selectionCoversFullRange(initialRange)
2576

            
2577
        guard startedPinnedToPresent else {
2578
            return selectionTouchesPresent(resultingRange)
2579
        }
2580

            
2581
        switch target {
2582
        case .lowerBound:
2583
            return true
2584
        case .upperBound, .window:
2585
            return selectionTouchesPresent(resultingRange)
2586
        }
2587
    }
2588

            
2589
    private func applySelection(
2590
        _ candidateRange: ClosedRange<Date>,
2591
        pinToPresent: Bool
2592
    ) {
2593
        let normalizedRange = normalizedSelectionRange(candidateRange)
2594

            
2595
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2596
            selectedTimeRange = nil
2597
        } else {
2598
            selectedTimeRange = normalizedRange
2599
        }
2600

            
2601
        isPinnedToPresent = pinToPresent
2602
    }
2603

            
Bogdan Timofte authored a month ago
2604
    private func resetSelectionState() {
2605
        selectedTimeRange = nil
2606
        isPinnedToPresent = false
2607
    }
2608

            
Bogdan Timofte authored 2 months ago
2609
    private func selectionTouchesPresent(
2610
        _ range: ClosedRange<Date>
2611
    ) -> Bool {
2612
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2613
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2614
    }
2615

            
2616
    private func selectionCoversFullRange(
2617
        _ range: ClosedRange<Date>
2618
    ) -> Bool {
2619
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2620
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2621
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2622
    }
2623

            
2624
    private func selectionFrame(in size: CGSize) -> CGRect {
2625
        selectionFrame(for: currentRange, width: size.width)
2626
    }
2627

            
2628
    private func selectionFrame(
2629
        for range: ClosedRange<Date>,
2630
        width: CGFloat
2631
    ) -> CGRect {
2632
        guard width > 0, totalSpan > 0 else {
2633
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2634
        }
2635

            
2636
        let minimumX = xPosition(for: range.lowerBound, width: width)
2637
        let maximumX = xPosition(for: range.upperBound, width: width)
2638
        return CGRect(
2639
            x: minimumX,
2640
            y: 0,
2641
            width: max(maximumX - minimumX, 2),
2642
            height: trackHeight
2643
        )
2644
    }
2645

            
2646
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2647
        guard width > 0, totalSpan > 0 else { return 0 }
2648

            
2649
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2650
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2651
        return CGFloat(normalizedOffset) * width
2652
    }
2653

            
2654
    private func boundaryLabel(for date: Date) -> String {
2655
        date.format(as: boundaryDateFormat)
2656
    }
2657

            
2658
    private var boundaryDateFormat: String {
2659
        switch totalSpan {
2660
        case 0..<86400:
2661
            return "HH:mm"
2662
        case 86400..<604800:
2663
            return "MMM d HH:mm"
2664
        default:
2665
            return "MMM d"
2666
        }
2667
    }
2668
}
2669

            
Bogdan Timofte authored 2 months ago
2670
struct Chart : View {
2671

            
Bogdan Timofte authored 2 months ago
2672
    @Environment(\.displayScale) private var displayScale
2673

            
Bogdan Timofte authored 2 months ago
2674
    let points: [Measurements.Measurement.Point]
2675
    let context: ChartContext
Bogdan Timofte authored 2 months ago
2676
    var areaChart: Bool = false
2677
    var strokeColor: Color = .black
Bogdan Timofte authored 2 months ago
2678
    var areaFillColor: Color? = nil
Bogdan Timofte authored 2 months ago
2679

            
2680
    var body : some View {
2681
        GeometryReader { geometry in
2682
            if self.areaChart {
Bogdan Timofte authored 2 months ago
2683
                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
Bogdan Timofte authored 2 months ago
2684
                self.path( geometry: geometry )
Bogdan Timofte authored 2 months ago
2685
                    .fill(
2686
                        LinearGradient(
2687
                            gradient: .init(
2688
                                colors: [
2689
                                    fillColor.opacity(0.72),
2690
                                    fillColor.opacity(0.18)
2691
                                ]
2692
                            ),
2693
                            startPoint: .init(x: 0.5, y: 0.08),
2694
                            endPoint: .init(x: 0.5, y: 0.92)
2695
                        )
2696
                    )
Bogdan Timofte authored 2 months ago
2697
            } else {
2698
                self.path( geometry: geometry )
2699
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2700
            }
2701
        }
2702
    }
2703

            
2704
    fileprivate func path(geometry: GeometryProxy) -> Path {
Bogdan Timofte authored 2 months ago
2705
        let displayedPoints = scaledPoints(for: geometry.size.width)
2706
        let baselineY = context.placeInRect(
2707
            point: CGPoint(x: context.origin.x, y: context.origin.y)
2708
        ).y * geometry.size.height
2709

            
Bogdan Timofte authored 2 months ago
2710
        return Path { path in
Bogdan Timofte authored 2 months ago
2711
            var firstRenderedPoint: CGPoint?
2712
            var lastRenderedPoint: CGPoint?
Bogdan Timofte authored 2 months ago
2713
            var needsMove = true
2714

            
Bogdan Timofte authored 2 months ago
2715
            for point in displayedPoints {
Bogdan Timofte authored 2 months ago
2716
                if point.isDiscontinuity {
Bogdan Timofte authored 2 months ago
2717
                    closeAreaSegment(
2718
                        in: &path,
2719
                        firstPoint: firstRenderedPoint,
2720
                        lastPoint: lastRenderedPoint,
2721
                        baselineY: baselineY
2722
                    )
2723
                    firstRenderedPoint = nil
2724
                    lastRenderedPoint = nil
Bogdan Timofte authored 2 months ago
2725
                    needsMove = true
2726
                    continue
2727
                }
2728

            
2729
                let item = context.placeInRect(point: point.point())
2730
                let renderedPoint = CGPoint(
2731
                    x: item.x * geometry.size.width,
2732
                    y: item.y * geometry.size.height
2733
                )
2734

            
2735
                if needsMove {
2736
                    path.move(to: renderedPoint)
Bogdan Timofte authored 2 months ago
2737
                    firstRenderedPoint = renderedPoint
Bogdan Timofte authored 2 months ago
2738
                    needsMove = false
2739
                } else {
2740
                    path.addLine(to: renderedPoint)
2741
                }
Bogdan Timofte authored 2 months ago
2742

            
2743
                lastRenderedPoint = renderedPoint
Bogdan Timofte authored 2 months ago
2744
            }
Bogdan Timofte authored 2 months ago
2745

            
Bogdan Timofte authored 2 months ago
2746
            closeAreaSegment(
2747
                in: &path,
2748
                firstPoint: firstRenderedPoint,
2749
                lastPoint: lastRenderedPoint,
2750
                baselineY: baselineY
2751
            )
2752
        }
2753
    }
2754

            
2755
    private func closeAreaSegment(
2756
        in path: inout Path,
2757
        firstPoint: CGPoint?,
2758
        lastPoint: CGPoint?,
2759
        baselineY: CGFloat
2760
    ) {
2761
        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2762

            
2763
        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2764
        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2765
        path.closeSubpath()
2766
    }
2767

            
2768
    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2769
        let sampleCount = points.reduce(into: 0) { partialResult, point in
2770
            if point.isSample {
2771
                partialResult += 1
2772
            }
2773
        }
2774

            
2775
        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2776
        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2777

            
2778
        guard sampleCount > maximumSamplesToRender, context.isValid else {
2779
            return points
2780
        }
2781

            
2782
        var scaledPoints: [Measurements.Measurement.Point] = []
2783
        var currentSegment: [Measurements.Measurement.Point] = []
2784

            
2785
        for point in points {
2786
            if point.isDiscontinuity {
2787
                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2788
                currentSegment.removeAll(keepingCapacity: true)
2789

            
2790
                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2791
                    appendScaledPoint(point, to: &scaledPoints)
2792
                }
2793
            } else {
2794
                currentSegment.append(point)
2795
            }
2796
        }
2797

            
2798
        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2799
        return scaledPoints.isEmpty ? points : scaledPoints
2800
    }
2801

            
2802
    private func appendScaledSegment(
2803
        _ segment: [Measurements.Measurement.Point],
2804
        to scaledPoints: inout [Measurements.Measurement.Point],
2805
        displayColumns: Int
2806
    ) {
2807
        guard !segment.isEmpty else { return }
2808

            
2809
        if segment.count <= max(displayColumns * 2, 120) {
2810
            for point in segment {
2811
                appendScaledPoint(point, to: &scaledPoints)
2812
            }
2813
            return
2814
        }
2815

            
2816
        var bucket: [Measurements.Measurement.Point] = []
2817
        var currentColumn: Int?
2818

            
2819
        for point in segment {
2820
            let column = displayColumn(for: point, totalColumns: displayColumns)
2821

            
2822
            if let currentColumn, currentColumn != column {
2823
                appendBucket(bucket, to: &scaledPoints)
2824
                bucket.removeAll(keepingCapacity: true)
Bogdan Timofte authored 2 months ago
2825
            }
Bogdan Timofte authored 2 months ago
2826

            
2827
            bucket.append(point)
2828
            currentColumn = column
2829
        }
2830

            
2831
        appendBucket(bucket, to: &scaledPoints)
2832
    }
2833

            
2834
    private func appendBucket(
2835
        _ bucket: [Measurements.Measurement.Point],
2836
        to scaledPoints: inout [Measurements.Measurement.Point]
2837
    ) {
2838
        guard !bucket.isEmpty else { return }
2839

            
2840
        if bucket.count <= 2 {
2841
            for point in bucket {
2842
                appendScaledPoint(point, to: &scaledPoints)
2843
            }
2844
            return
2845
        }
2846

            
2847
        let firstPoint = bucket.first!
2848
        let lastPoint = bucket.last!
2849
        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2850
        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2851

            
2852
        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2853
            .sorted { lhs, rhs in
2854
                if lhs.timestamp == rhs.timestamp {
2855
                    return lhs.id < rhs.id
2856
                }
2857
                return lhs.timestamp < rhs.timestamp
2858
            }
2859

            
2860
        var emittedPointIDs: Set<Int> = []
2861
        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2862
            appendScaledPoint(point, to: &scaledPoints)
Bogdan Timofte authored 2 months ago
2863
        }
2864
    }
Bogdan Timofte authored 2 months ago
2865

            
2866
    private func appendScaledPoint(
2867
        _ point: Measurements.Measurement.Point,
2868
        to scaledPoints: inout [Measurements.Measurement.Point]
2869
    ) {
2870
        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2871
                scaledPoints.last?.value == point.value &&
2872
                scaledPoints.last?.kind == point.kind) else {
2873
            return
2874
        }
2875

            
2876
        scaledPoints.append(
2877
            Measurements.Measurement.Point(
2878
                id: scaledPoints.count,
2879
                timestamp: point.timestamp,
2880
                value: point.value,
2881
                kind: point.kind
2882
            )
2883
        )
2884
    }
2885

            
2886
    private func displayColumn(
2887
        for point: Measurements.Measurement.Point,
2888
        totalColumns: Int
2889
    ) -> Int {
2890
        let totalColumns = max(totalColumns, 1)
2891
        let timeSpan = max(Double(context.size.width), 1)
2892
        let normalizedOffset = min(
2893
            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2894
            1
2895
        )
2896

            
2897
        return min(
2898
            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2899
            totalColumns - 1
2900
        )
2901
    }
Bogdan Timofte authored 2 months ago
2902

            
2903
}