USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
c34c6eb 19 hours ago History
2769 lines | 99.723kb
Bogdan Timofte authored 2 weeks 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 4 days ago
11
private enum PresentTrackingMode: CaseIterable, Hashable {
12
    case keepDuration
13
    case keepStartTimestamp
14
}
15

            
Bogdan Timofte authored 2 weeks ago
16
struct MeasurementChartView: View {
Bogdan Timofte authored 19 hours ago
17
    private enum SmoothingLevel: CaseIterable, Hashable {
18
        case off
19
        case light
20
        case medium
21
        case strong
22

            
23
        var label: String {
24
            switch self {
25
            case .off: return "Off"
26
            case .light: return "Light"
27
            case .medium: return "Medium"
28
            case .strong: return "Strong"
29
            }
30
        }
31

            
32
        var shortLabel: String {
33
            switch self {
34
            case .off: return "Off"
35
            case .light: return "Low"
36
            case .medium: return "Med"
37
            case .strong: return "High"
38
            }
39
        }
40

            
41
        var movingAverageWindowSize: Int {
42
            switch self {
43
            case .off: return 1
44
            case .light: return 5
45
            case .medium: return 11
46
            case .strong: return 21
47
            }
48
        }
49
    }
50

            
Bogdan Timofte authored a week ago
51
    private enum SeriesKind {
52
        case power
Bogdan Timofte authored 19 hours ago
53
        case energy
Bogdan Timofte authored a week ago
54
        case voltage
55
        case current
Bogdan Timofte authored 4 days ago
56
        case temperature
Bogdan Timofte authored a week ago
57

            
58
        var unit: String {
59
            switch self {
60
            case .power: return "W"
Bogdan Timofte authored 19 hours ago
61
            case .energy: return "Wh"
Bogdan Timofte authored a week ago
62
            case .voltage: return "V"
63
            case .current: return "A"
Bogdan Timofte authored 4 days ago
64
            case .temperature: return ""
Bogdan Timofte authored a week ago
65
            }
66
        }
67

            
68
        var tint: Color {
69
            switch self {
70
            case .power: return .red
Bogdan Timofte authored 19 hours ago
71
            case .energy: return .teal
Bogdan Timofte authored a week ago
72
            case .voltage: return .green
73
            case .current: return .blue
Bogdan Timofte authored 4 days ago
74
            case .temperature: return .orange
Bogdan Timofte authored a week ago
75
            }
76
        }
77
    }
78

            
79
    private struct SeriesData {
80
        let kind: SeriesKind
81
        let points: [Measurements.Measurement.Point]
82
        let samplePoints: [Measurements.Measurement.Point]
83
        let context: ChartContext
84
        let autoLowerBound: Double
85
        let autoUpperBound: Double
86
        let maximumSampleValue: Double?
87
    }
88

            
Bogdan Timofte authored 2 weeks ago
89
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 weeks ago
90
    private let minimumVoltageSpan = 0.5
91
    private let minimumCurrentSpan = 0.5
92
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 19 hours ago
93
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 4 days ago
94
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored a week ago
95
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 19 hours ago
96
    private let selectorTint: Color = .blue
Bogdan Timofte authored a week ago
97

            
98
    let compactLayout: Bool
99
    let availableSize: CGSize
Bogdan Timofte authored 2 weeks ago
100

            
101
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored a week ago
102
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
103
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 weeks ago
104
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 weeks ago
105

            
106
    @State var displayVoltage: Bool = false
107
    @State var displayCurrent: Bool = false
108
    @State var displayPower: Bool = true
Bogdan Timofte authored 19 hours ago
109
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 4 days ago
110
    @State var displayTemperature: Bool = false
Bogdan Timofte authored 19 hours ago
111
    @State private var smoothingLevel: SmoothingLevel = .off
Bogdan Timofte authored a week ago
112
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 4 days ago
113
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
114
    @State private var isPinnedToPresent: Bool = false
115
    @State private var presentTrackingMode: PresentTrackingMode = .keepDuration
Bogdan Timofte authored a week ago
116
    @State private var pinOrigin: Bool = false
117
    @State private var useSharedOrigin: Bool = false
118
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 6 days ago
119
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored a week ago
120
    @State private var powerAxisOrigin: Double = 0
Bogdan Timofte authored 19 hours ago
121
    @State private var energyAxisOrigin: Double = 0
Bogdan Timofte authored a week ago
122
    @State private var voltageAxisOrigin: Double = 0
123
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 4 days ago
124
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored 2 weeks ago
125
    let xLabels: Int = 4
126
    let yLabels: Int = 4
127

            
Bogdan Timofte authored a week ago
128
    init(
129
        compactLayout: Bool = false,
130
        availableSize: CGSize = .zero,
131
        timeRange: ClosedRange<Date>? = nil
132
    ) {
133
        self.compactLayout = compactLayout
134
        self.availableSize = availableSize
135
        self.timeRange = timeRange
136
    }
137

            
138
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 6 days ago
139
        if compactLayout {
140
            return 38
141
        }
142
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored a week ago
143
    }
144

            
145
    private var chartSectionSpacing: CGFloat {
146
        compactLayout ? 6 : 8
147
    }
148

            
149
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 6 days ago
150
        if compactLayout {
151
            return 24
152
        }
153
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored a week ago
154
    }
155

            
Bogdan Timofte authored 6 days ago
156
    private var isPortraitLayout: Bool {
157
        guard availableSize != .zero else { return verticalSizeClass != .compact }
158
        return availableSize.height >= availableSize.width
159
    }
160

            
Bogdan Timofte authored 6 days ago
161
    private var isIPhone: Bool {
162
        #if os(iOS)
163
        return UIDevice.current.userInterfaceIdiom == .phone
164
        #else
165
        return false
166
        #endif
167
    }
168

            
169
    private enum OriginControlsPlacement {
170
        case aboveXAxisLegend
171
        case overXAxisLegend
172
        case belowXAxisLegend
173
    }
174

            
175
    private var originControlsPlacement: OriginControlsPlacement {
176
        if isIPhone {
177
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
178
        }
179
        return .belowXAxisLegend
180
    }
181

            
Bogdan Timofte authored a week ago
182
    private var plotSectionHeight: CGFloat {
183
        if availableSize == .zero {
Bogdan Timofte authored 6 days ago
184
            return compactLayout ? 300 : 380
185
        }
186

            
187
        if isPortraitLayout {
188
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
189
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
190
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored a week ago
191
        }
192

            
193
        if compactLayout {
194
            return min(max(availableSize.height * 0.36, 240), 300)
195
        }
196

            
197
        return min(max(availableSize.height * 0.5, 300), 440)
198
    }
199

            
200
    private var stackedToolbarLayout: Bool {
201
        if availableSize.width > 0 {
202
            return availableSize.width < 640
203
        }
204

            
205
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
206
    }
207

            
208
    private var showsLabeledOriginControls: Bool {
209
        !compactLayout && !stackedToolbarLayout
210
    }
211

            
Bogdan Timofte authored 6 days ago
212
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 6 days ago
213
        #if os(iOS)
214
        if UIDevice.current.userInterfaceIdiom == .phone {
215
            return false
216
        }
217
        #endif
218

            
Bogdan Timofte authored 6 days ago
219
        if availableSize.width > 0 {
220
            return availableSize.width >= 900 || availableSize.height >= 700
221
        }
222
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
223
    }
224

            
225
    private var chartBaseFont: Font {
Bogdan Timofte authored 6 days ago
226
        if isIPhone && isPortraitLayout {
227
            return .caption
228
        }
229
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 6 days ago
230
    }
231

            
Bogdan Timofte authored 6 days ago
232
    private var usesCompactLandscapeOriginControls: Bool {
233
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
234
    }
235

            
Bogdan Timofte authored 2 weeks ago
236
    var body: some View {
Bogdan Timofte authored 4 days ago
237
        let availableTimeRange = availableSelectionTimeRange()
238
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
239
        let powerSeries = series(
240
            for: measurements.power,
241
            kind: .power,
242
            minimumYSpan: minimumPowerSpan,
243
            visibleTimeRange: visibleTimeRange
244
        )
Bogdan Timofte authored 19 hours ago
245
        let energySeries = series(
246
            for: measurements.energy,
247
            kind: .energy,
248
            minimumYSpan: minimumEnergySpan,
249
            visibleTimeRange: visibleTimeRange
250
        )
Bogdan Timofte authored 4 days ago
251
        let voltageSeries = series(
252
            for: measurements.voltage,
253
            kind: .voltage,
254
            minimumYSpan: minimumVoltageSpan,
255
            visibleTimeRange: visibleTimeRange
256
        )
257
        let currentSeries = series(
258
            for: measurements.current,
259
            kind: .current,
260
            minimumYSpan: minimumCurrentSpan,
261
            visibleTimeRange: visibleTimeRange
262
        )
263
        let temperatureSeries = series(
264
            for: measurements.temperature,
265
            kind: .temperature,
266
            minimumYSpan: minimumTemperatureSpan,
267
            visibleTimeRange: visibleTimeRange
268
        )
Bogdan Timofte authored 2 weeks ago
269
        let primarySeries = displayedPrimarySeries(
270
            powerSeries: powerSeries,
Bogdan Timofte authored 19 hours ago
271
            energySeries: energySeries,
Bogdan Timofte authored 2 weeks ago
272
            voltageSeries: voltageSeries,
273
            currentSeries: currentSeries
274
        )
Bogdan Timofte authored 4 days ago
275
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 weeks ago
276

            
Bogdan Timofte authored 2 weeks ago
277
        Group {
Bogdan Timofte authored 2 weeks ago
278
            if let primarySeries {
279
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
280
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
281

            
282
                    GeometryReader { geometry in
Bogdan Timofte authored a week ago
283
                        let plotHeight = max(geometry.size.height - xAxisHeight, compactLayout ? 180 : 220)
Bogdan Timofte authored 2 weeks ago
284

            
285
                        VStack(spacing: 6) {
286
                            HStack(spacing: chartSectionSpacing) {
287
                                primaryAxisView(
288
                                    height: plotHeight,
289
                                    powerSeries: powerSeries,
Bogdan Timofte authored 19 hours ago
290
                                    energySeries: energySeries,
Bogdan Timofte authored 2 weeks ago
291
                                    voltageSeries: voltageSeries,
292
                                    currentSeries: currentSeries
293
                                )
294
                                .frame(width: axisColumnWidth, height: plotHeight)
295

            
296
                                ZStack {
297
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
298
                                        .fill(Color.primary.opacity(0.05))
299

            
300
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
301
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
302

            
303
                                    horizontalGuides(context: primarySeries.context)
304
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
305
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
306
                                    renderedChart(
307
                                        powerSeries: powerSeries,
Bogdan Timofte authored 19 hours ago
308
                                        energySeries: energySeries,
Bogdan Timofte authored 2 weeks ago
309
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 4 days ago
310
                                        currentSeries: currentSeries,
311
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 weeks ago
312
                                    )
Bogdan Timofte authored 2 weeks ago
313
                                }
Bogdan Timofte authored 2 weeks ago
314
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
315
                                .frame(maxWidth: .infinity)
316
                                .frame(height: plotHeight)
317

            
318
                                secondaryAxisView(
319
                                    height: plotHeight,
320
                                    powerSeries: powerSeries,
Bogdan Timofte authored 19 hours ago
321
                                    energySeries: energySeries,
Bogdan Timofte authored 2 weeks ago
322
                                    voltageSeries: voltageSeries,
Bogdan Timofte authored 4 days ago
323
                                    currentSeries: currentSeries,
324
                                    temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 weeks ago
325
                                )
326
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
327
                            }
Bogdan Timofte authored 6 days ago
328
                            .overlay(alignment: .bottom) {
Bogdan Timofte authored 6 days ago
329
                                if originControlsPlacement == .aboveXAxisLegend {
330
                                    scaleControlsPill(
331
                                        voltageSeries: voltageSeries,
332
                                        currentSeries: currentSeries
333
                                    )
334
                                    .padding(.bottom, compactLayout ? 6 : 10)
335
                                }
Bogdan Timofte authored 6 days ago
336
                            }
Bogdan Timofte authored 2 weeks ago
337

            
Bogdan Timofte authored 6 days ago
338
                            switch originControlsPlacement {
339
                            case .aboveXAxisLegend:
340
                                xAxisLabelsView(context: primarySeries.context)
341
                                    .frame(height: xAxisHeight)
342
                            case .overXAxisLegend:
343
                                xAxisLabelsView(context: primarySeries.context)
344
                                    .frame(height: xAxisHeight)
345
                                    .overlay(alignment: .center) {
346
                                        scaleControlsPill(
347
                                            voltageSeries: voltageSeries,
348
                                            currentSeries: currentSeries
349
                                        )
Bogdan Timofte authored 6 days ago
350
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 6 days ago
351
                                    }
352
                            case .belowXAxisLegend:
353
                                xAxisLabelsView(context: primarySeries.context)
354
                                    .frame(height: xAxisHeight)
355

            
356
                                HStack {
357
                                    Spacer(minLength: 0)
358
                                    scaleControlsPill(
359
                                        voltageSeries: voltageSeries,
360
                                        currentSeries: currentSeries
361
                                    )
362
                                    Spacer(minLength: 0)
363
                                }
364
                            }
Bogdan Timofte authored 4 days ago
365

            
366
                            if let availableTimeRange,
367
                               let selectorSeries,
368
                               shouldShowRangeSelector(
369
                                availableTimeRange: availableTimeRange,
370
                                series: selectorSeries
371
                               ) {
372
                                TimeRangeSelectorView(
373
                                    points: selectorSeries.points,
374
                                    context: selectorSeries.context,
375
                                    availableTimeRange: availableTimeRange,
Bogdan Timofte authored 19 hours ago
376
                                    selectorTint: selectorTint,
Bogdan Timofte authored 4 days ago
377
                                    compactLayout: compactLayout,
378
                                    minimumSelectionSpan: minimumTimeSpan,
Bogdan Timofte authored 19 hours ago
379
                                    onKeepSelection: trimBufferToSelection,
380
                                    onRemoveSelection: removeSelectionFromBuffer,
381
                                    onResetBuffer: resetBuffer,
Bogdan Timofte authored 4 days ago
382
                                    selectedTimeRange: $selectedVisibleTimeRange,
383
                                    isPinnedToPresent: $isPinnedToPresent,
384
                                    presentTrackingMode: $presentTrackingMode
385
                                )
386
                            }
Bogdan Timofte authored 2 weeks ago
387
                        }
Bogdan Timofte authored 2 weeks ago
388
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
389
                    }
Bogdan Timofte authored a week ago
390
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 weeks ago
391
                }
Bogdan Timofte authored 2 weeks ago
392
            } else {
393
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
394
                    chartToggleBar()
Bogdan Timofte authored a week ago
395
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 weeks ago
396
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
397
                }
398
            }
Bogdan Timofte authored 2 weeks ago
399
        }
Bogdan Timofte authored 6 days ago
400
        .font(chartBaseFont)
Bogdan Timofte authored 2 weeks ago
401
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a week ago
402
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
403
            guard timeRange == nil else { return }
404
            chartNow = now
405
        }
Bogdan Timofte authored 2 weeks ago
406
    }
407

            
Bogdan Timofte authored 6 days ago
408
    private func chartToggleBar() -> some View {
Bogdan Timofte authored a week ago
409
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 6 days ago
410
        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
Bogdan Timofte authored a week ago
411

            
Bogdan Timofte authored 19 hours ago
412
        let controlsPanel = VStack(alignment: .leading, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
413
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 19 hours ago
414
            smoothingControlsRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
415
        }
416
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
417
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
418
        .background(
419
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
420
                .fill(Color.primary.opacity(0.045))
421
        )
422
        .overlay(
423
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
424
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
425
        )
Bogdan Timofte authored a week ago
426

            
Bogdan Timofte authored 6 days ago
427
        return Group {
Bogdan Timofte authored a week ago
428
            if stackedToolbarLayout {
Bogdan Timofte authored 19 hours ago
429
                controlsPanel
Bogdan Timofte authored a week ago
430
            } else {
Bogdan Timofte authored 6 days ago
431
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
432
                    controlsPanel
Bogdan Timofte authored 2 weeks ago
433
                }
Bogdan Timofte authored a week ago
434
            }
435
        }
436
        .frame(maxWidth: .infinity, alignment: .leading)
437
    }
438

            
Bogdan Timofte authored 6 days ago
439
    private var shouldFloatScaleControlsOverChart: Bool {
440
        #if os(iOS)
441
        if availableSize.width > 0, availableSize.height > 0 {
442
            return availableSize.width > availableSize.height
443
        }
444
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
445
        #else
446
        return false
447
        #endif
448
    }
449

            
450
    private func scaleControlsPill(
451
        voltageSeries: SeriesData,
452
        currentSeries: SeriesData
453
    ) -> some View {
454
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 6 days ago
455
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 6 days ago
456
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
457
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 6 days ago
458

            
459
        return originControlsRow(
460
            voltageSeries: voltageSeries,
461
            currentSeries: currentSeries,
462
            condensedLayout: condensedLayout,
463
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
464
        )
Bogdan Timofte authored 6 days ago
465
        .padding(.horizontal, horizontalPadding)
466
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 6 days ago
467
        .background(
468
            Capsule(style: .continuous)
Bogdan Timofte authored 6 days ago
469
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 6 days ago
470
        )
471
        .overlay(
472
            Capsule(style: .continuous)
473
                .stroke(
Bogdan Timofte authored 6 days ago
474
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 6 days ago
475
                    lineWidth: 1
476
                )
477
        )
478
    }
479

            
Bogdan Timofte authored a week ago
480
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
481
        HStack(spacing: condensedLayout ? 6 : 8) {
482
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
483
                displayVoltage.toggle()
484
                if displayVoltage {
485
                    displayPower = false
Bogdan Timofte authored 19 hours ago
486
                    displayEnergy = false
Bogdan Timofte authored 4 days ago
487
                    if displayTemperature && displayCurrent {
488
                        displayCurrent = false
489
                    }
Bogdan Timofte authored a week ago
490
                }
491
            }
492

            
493
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
494
                displayCurrent.toggle()
495
                if displayCurrent {
496
                    displayPower = false
Bogdan Timofte authored 19 hours ago
497
                    displayEnergy = false
Bogdan Timofte authored 4 days ago
498
                    if displayTemperature && displayVoltage {
499
                        displayVoltage = false
500
                    }
Bogdan Timofte authored 2 weeks ago
501
                }
Bogdan Timofte authored a week ago
502
            }
503

            
504
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
505
                displayPower.toggle()
506
                if displayPower {
Bogdan Timofte authored 19 hours ago
507
                    displayEnergy = false
508
                    displayCurrent = false
509
                    displayVoltage = false
510
                }
511
            }
512

            
513
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
514
                displayEnergy.toggle()
515
                if displayEnergy {
516
                    displayPower = false
Bogdan Timofte authored a week ago
517
                    displayCurrent = false
518
                    displayVoltage = false
519
                }
520
            }
Bogdan Timofte authored 4 days ago
521

            
522
            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
523
                displayTemperature.toggle()
524
                if displayTemperature && displayVoltage && displayCurrent {
525
                    displayCurrent = false
526
                }
527
            }
Bogdan Timofte authored a week ago
528
        }
529
    }
530

            
531
    private func originControlsRow(
532
        voltageSeries: SeriesData,
533
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
534
        condensedLayout: Bool,
535
        showsLabel: Bool
Bogdan Timofte authored a week ago
536
    ) -> some View {
Bogdan Timofte authored 6 days ago
537
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
538
            if supportsSharedOrigin {
539
                symbolControlChip(
540
                    systemImage: "equal.circle",
541
                    enabled: true,
542
                    active: useSharedOrigin,
543
                    condensedLayout: condensedLayout,
544
                    showsLabel: showsLabel,
545
                    label: "Match Y Scale",
546
                    accessibilityLabel: "Match Y scale"
547
                ) {
548
                    toggleSharedOrigin(
549
                        voltageSeries: voltageSeries,
550
                        currentSeries: currentSeries
551
                    )
552
                }
Bogdan Timofte authored a week ago
553
            }
554

            
555
            symbolControlChip(
556
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
557
                enabled: true,
558
                active: pinOrigin,
559
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
560
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
561
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
562
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
563
            ) {
564
                togglePinnedOrigin(
565
                    voltageSeries: voltageSeries,
566
                    currentSeries: currentSeries
567
                )
568
            }
569

            
Bogdan Timofte authored 6 days ago
570
            if !pinnedOriginIsZero {
571
                symbolControlChip(
572
                    systemImage: "0.circle",
573
                    enabled: true,
574
                    active: false,
575
                    condensedLayout: condensedLayout,
576
                    showsLabel: showsLabel,
577
                    label: "Origin 0",
578
                    accessibilityLabel: "Set origin to zero"
579
                ) {
580
                    setVisibleOriginsToZero()
581
                }
Bogdan Timofte authored a week ago
582
            }
Bogdan Timofte authored 6 days ago
583

            
Bogdan Timofte authored a week ago
584
        }
585
    }
586

            
Bogdan Timofte authored 19 hours ago
587
    private func smoothingControlsRow(condensedLayout: Bool) -> some View {
588
        HStack(spacing: condensedLayout ? 8 : 10) {
589
            Text("Smoothing")
590
                .font((condensedLayout ? Font.caption : .footnote).weight(.semibold))
591
                .foregroundColor(.secondary)
592

            
593
            Menu {
594
                ForEach(SmoothingLevel.allCases, id: \.self) { level in
595
                    Button {
596
                        smoothingLevel = level
597
                    } label: {
598
                        if smoothingLevel == level {
599
                            Label(level.label, systemImage: "checkmark")
600
                        } else {
601
                            Text(level.label)
602
                        }
603
                    }
604
                }
605
            } label: {
606
                Label(
607
                    condensedLayout ? smoothingLevel.shortLabel : smoothingLevel.label,
608
                    systemImage: "waveform.path"
609
                )
610
                .font(controlChipFont(condensedLayout: condensedLayout))
611
                .foregroundColor(smoothingLevel == .off ? .primary : .blue)
612
                .padding(.horizontal, condensedLayout ? 10 : 12)
613
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))
614
                .background(
615
                    Capsule(style: .continuous)
616
                        .fill(
617
                            smoothingLevel == .off
618
                            ? Color.secondary.opacity(0.10)
619
                            : Color.blue.opacity(0.12)
620
                        )
621
                )
622
                .overlay(
623
                    Capsule(style: .continuous)
624
                        .stroke(
625
                            smoothingLevel == .off
626
                            ? Color.secondary.opacity(0.18)
627
                            : Color.blue.opacity(0.28),
628
                            lineWidth: 1
629
                        )
630
                )
631
            }
632
            .fixedSize(horizontal: true, vertical: false)
633

            
634
            if smoothingLevel != .off {
635
                Text("MA \(smoothingLevel.movingAverageWindowSize)")
636
                    .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
637
                    .foregroundColor(.secondary)
638
                    .monospacedDigit()
639
            }
640

            
641
            Spacer(minLength: 0)
642
        }
643
    }
644

            
Bogdan Timofte authored a week ago
645
    private func seriesToggleButton(
646
        title: String,
647
        isOn: Bool,
648
        condensedLayout: Bool,
649
        action: @escaping () -> Void
650
    ) -> some View {
651
        Button(action: action) {
652
            Text(title)
Bogdan Timofte authored 6 days ago
653
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
654
                .lineLimit(1)
655
                .minimumScaleFactor(0.82)
656
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 6 days ago
657
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
658
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
659
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored a week ago
660
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
661
                .background(
662
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
663
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
664
                )
665
                .overlay(
666
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
667
                        .stroke(Color.blue, lineWidth: 1.5)
668
                )
669
        }
670
        .buttonStyle(.plain)
671
    }
672

            
673
    private func symbolControlChip(
674
        systemImage: String,
675
        enabled: Bool,
676
        active: Bool,
677
        condensedLayout: Bool,
678
        showsLabel: Bool,
679
        label: String,
680
        accessibilityLabel: String,
681
        action: @escaping () -> Void
682
    ) -> some View {
683
        Button(action: {
684
            action()
685
        }) {
686
            Group {
687
                if showsLabel {
688
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 6 days ago
689
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 6 days ago
690
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
691
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored a week ago
692
                } else {
693
                    Image(systemName: systemImage)
Bogdan Timofte authored 6 days ago
694
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 6 days ago
695
                        .frame(
Bogdan Timofte authored 6 days ago
696
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
697
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 6 days ago
698
                        )
Bogdan Timofte authored a week ago
699
                }
700
            }
701
                .background(
702
                    Capsule(style: .continuous)
703
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
704
                )
705
        }
706
        .buttonStyle(.plain)
707
        .foregroundColor(enabled ? .primary : .secondary)
708
        .opacity(enabled ? 1 : 0.55)
709
        .accessibilityLabel(accessibilityLabel)
710
    }
711

            
Bogdan Timofte authored 19 hours ago
712
    private func resetBuffer() {
713
        measurements.resetSeries()
Bogdan Timofte authored 2 weeks ago
714
    }
715

            
Bogdan Timofte authored 6 days ago
716
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
717
        if isLargeDisplay {
718
            return .body.weight(.semibold)
719
        }
720
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
721
    }
722

            
723
    private func controlChipFont(condensedLayout: Bool) -> Font {
724
        if isLargeDisplay {
725
            return .callout.weight(.semibold)
726
        }
727
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
728
    }
729

            
Bogdan Timofte authored 2 weeks ago
730
    @ViewBuilder
731
    private func primaryAxisView(
732
        height: CGFloat,
Bogdan Timofte authored a week ago
733
        powerSeries: SeriesData,
Bogdan Timofte authored 19 hours ago
734
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
735
        voltageSeries: SeriesData,
736
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
737
    ) -> some View {
738
        if displayPower {
739
            yAxisLabelsView(
740
                height: height,
741
                context: powerSeries.context,
Bogdan Timofte authored a week ago
742
                seriesKind: .power,
743
                measurementUnit: powerSeries.kind.unit,
744
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
745
            )
Bogdan Timofte authored 19 hours ago
746
        } else if displayEnergy {
747
            yAxisLabelsView(
748
                height: height,
749
                context: energySeries.context,
750
                seriesKind: .energy,
751
                measurementUnit: energySeries.kind.unit,
752
                tint: energySeries.kind.tint
753
            )
Bogdan Timofte authored 2 weeks ago
754
        } else if displayVoltage {
755
            yAxisLabelsView(
756
                height: height,
757
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
758
                seriesKind: .voltage,
759
                measurementUnit: voltageSeries.kind.unit,
760
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
761
            )
762
        } else if displayCurrent {
763
            yAxisLabelsView(
764
                height: height,
765
                context: currentSeries.context,
Bogdan Timofte authored a week ago
766
                seriesKind: .current,
767
                measurementUnit: currentSeries.kind.unit,
768
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
769
            )
770
        }
771
    }
772

            
773
    @ViewBuilder
774
    private func renderedChart(
Bogdan Timofte authored a week ago
775
        powerSeries: SeriesData,
Bogdan Timofte authored 19 hours ago
776
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
777
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
778
        currentSeries: SeriesData,
779
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
780
    ) -> some View {
781
        if self.displayPower {
782
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
783
                .opacity(0.72)
Bogdan Timofte authored 19 hours ago
784
        } else if self.displayEnergy {
785
            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
786
                .opacity(0.78)
Bogdan Timofte authored 2 weeks ago
787
        } else {
788
            if self.displayVoltage {
789
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
790
                    .opacity(0.78)
791
            }
792
            if self.displayCurrent {
793
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
794
                    .opacity(0.78)
795
            }
796
        }
Bogdan Timofte authored 4 days ago
797

            
798
        if displayTemperature {
799
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
800
                .opacity(0.86)
801
        }
Bogdan Timofte authored 2 weeks ago
802
    }
803

            
804
    @ViewBuilder
805
    private func secondaryAxisView(
806
        height: CGFloat,
Bogdan Timofte authored a week ago
807
        powerSeries: SeriesData,
Bogdan Timofte authored 19 hours ago
808
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
809
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
810
        currentSeries: SeriesData,
811
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
812
    ) -> some View {
Bogdan Timofte authored 4 days ago
813
        if displayTemperature {
814
            yAxisLabelsView(
815
                height: height,
816
                context: temperatureSeries.context,
817
                seriesKind: .temperature,
818
                measurementUnit: measurementUnit(for: .temperature),
819
                tint: temperatureSeries.kind.tint
820
            )
821
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 weeks ago
822
            yAxisLabelsView(
823
                height: height,
824
                context: currentSeries.context,
Bogdan Timofte authored a week ago
825
                seriesKind: .current,
826
                measurementUnit: currentSeries.kind.unit,
827
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
828
            )
829
        } else {
830
            primaryAxisView(
831
                height: height,
832
                powerSeries: powerSeries,
Bogdan Timofte authored 19 hours ago
833
                energySeries: energySeries,
Bogdan Timofte authored 2 weeks ago
834
                voltageSeries: voltageSeries,
835
                currentSeries: currentSeries
836
            )
Bogdan Timofte authored 2 weeks ago
837
        }
838
    }
Bogdan Timofte authored 2 weeks ago
839

            
840
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
841
        powerSeries: SeriesData,
Bogdan Timofte authored 19 hours ago
842
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
843
        voltageSeries: SeriesData,
844
        currentSeries: SeriesData
845
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
846
        if displayPower {
Bogdan Timofte authored a week ago
847
            return powerSeries
Bogdan Timofte authored 2 weeks ago
848
        }
Bogdan Timofte authored 19 hours ago
849
        if displayEnergy {
850
            return energySeries
851
        }
Bogdan Timofte authored 2 weeks ago
852
        if displayVoltage {
Bogdan Timofte authored a week ago
853
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
854
        }
855
        if displayCurrent {
Bogdan Timofte authored a week ago
856
            return currentSeries
Bogdan Timofte authored 2 weeks ago
857
        }
858
        return nil
859
    }
860

            
861
    private func series(
862
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
863
        kind: SeriesKind,
Bogdan Timofte authored 4 days ago
864
        minimumYSpan: Double,
865
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a week ago
866
    ) -> SeriesData {
Bogdan Timofte authored 19 hours ago
867
        let rawPoints = filteredPoints(
Bogdan Timofte authored 4 days ago
868
            measurement,
869
            visibleTimeRange: visibleTimeRange
870
        )
Bogdan Timofte authored 19 hours ago
871
        let points = smoothedPoints(from: rawPoints)
Bogdan Timofte authored a week ago
872
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
873
        let context = ChartContext()
Bogdan Timofte authored a week ago
874

            
875
        let autoBounds = automaticYBounds(
876
            for: samplePoints,
877
            minimumYSpan: minimumYSpan
878
        )
Bogdan Timofte authored 4 days ago
879
        let xBounds = xBounds(
880
            for: samplePoints,
881
            visibleTimeRange: visibleTimeRange
882
        )
Bogdan Timofte authored a week ago
883
        let lowerBound = resolvedLowerBound(
884
            for: kind,
885
            autoLowerBound: autoBounds.lowerBound
886
        )
887
        let upperBound = resolvedUpperBound(
888
            for: kind,
889
            lowerBound: lowerBound,
890
            autoUpperBound: autoBounds.upperBound,
891
            maximumSampleValue: samplePoints.map(\.value).max(),
892
            minimumYSpan: minimumYSpan
893
        )
894

            
895
        context.setBounds(
896
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
897
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
898
            yMin: CGFloat(lowerBound),
899
            yMax: CGFloat(upperBound)
900
        )
901

            
902
        return SeriesData(
903
            kind: kind,
904
            points: points,
905
            samplePoints: samplePoints,
906
            context: context,
907
            autoLowerBound: autoBounds.lowerBound,
908
            autoUpperBound: autoBounds.upperBound,
909
            maximumSampleValue: samplePoints.map(\.value).max()
910
        )
911
    }
912

            
Bogdan Timofte authored 4 days ago
913
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
914
        series(
915
            for: measurement(for: kind),
916
            kind: kind,
917
            minimumYSpan: minimumYSpan(for: kind)
918
        )
919
    }
920

            
Bogdan Timofte authored 19 hours ago
921
    private func smoothedPoints(
922
        from points: [Measurements.Measurement.Point]
923
    ) -> [Measurements.Measurement.Point] {
924
        guard smoothingLevel != .off else { return points }
925

            
926
        var smoothedPoints: [Measurements.Measurement.Point] = []
927
        var currentSegment: [Measurements.Measurement.Point] = []
928

            
929
        func flushCurrentSegment() {
930
            guard !currentSegment.isEmpty else { return }
931

            
932
            for point in smoothedSegment(currentSegment) {
933
                smoothedPoints.append(
934
                    Measurements.Measurement.Point(
935
                        id: smoothedPoints.count,
936
                        timestamp: point.timestamp,
937
                        value: point.value,
938
                        kind: .sample
939
                    )
940
                )
941
            }
942

            
943
            currentSegment.removeAll(keepingCapacity: true)
944
        }
945

            
946
        for point in points {
947
            if point.isDiscontinuity {
948
                flushCurrentSegment()
949

            
950
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
951
                    smoothedPoints.append(
952
                        Measurements.Measurement.Point(
953
                            id: smoothedPoints.count,
954
                            timestamp: point.timestamp,
955
                            value: smoothedPoints.last?.value ?? point.value,
956
                            kind: .discontinuity
957
                        )
958
                    )
959
                }
960
            } else {
961
                currentSegment.append(point)
962
            }
963
        }
964

            
965
        flushCurrentSegment()
966
        return smoothedPoints
967
    }
968

            
969
    private func smoothedSegment(
970
        _ segment: [Measurements.Measurement.Point]
971
    ) -> [Measurements.Measurement.Point] {
972
        let windowSize = smoothingLevel.movingAverageWindowSize
973
        guard windowSize > 1, segment.count > 2 else { return segment }
974

            
975
        let radius = windowSize / 2
976
        var prefixSums: [Double] = [0]
977
        prefixSums.reserveCapacity(segment.count + 1)
978

            
979
        for point in segment {
980
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
981
        }
982

            
983
        return segment.enumerated().map { index, point in
984
            let lowerBound = max(0, index - radius)
985
            let upperBound = min(segment.count - 1, index + radius)
986
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
987
            let average = sum / Double(upperBound - lowerBound + 1)
988

            
989
            return Measurements.Measurement.Point(
990
                id: point.id,
991
                timestamp: point.timestamp,
992
                value: average,
993
                kind: .sample
994
            )
995
        }
996
    }
997

            
Bogdan Timofte authored 4 days ago
998
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
999
        switch kind {
1000
        case .power:
1001
            return measurements.power
Bogdan Timofte authored 19 hours ago
1002
        case .energy:
1003
            return measurements.energy
Bogdan Timofte authored 4 days ago
1004
        case .voltage:
1005
            return measurements.voltage
1006
        case .current:
1007
            return measurements.current
1008
        case .temperature:
1009
            return measurements.temperature
1010
        }
1011
    }
1012

            
1013
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1014
        switch kind {
1015
        case .power:
1016
            return minimumPowerSpan
Bogdan Timofte authored 19 hours ago
1017
        case .energy:
1018
            return minimumEnergySpan
Bogdan Timofte authored 4 days ago
1019
        case .voltage:
1020
            return minimumVoltageSpan
1021
        case .current:
1022
            return minimumCurrentSpan
1023
        case .temperature:
1024
            return minimumTemperatureSpan
1025
        }
1026
    }
1027

            
Bogdan Timofte authored a week ago
1028
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 19 hours ago
1029
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored a week ago
1030
    }
1031

            
Bogdan Timofte authored 6 days ago
1032
    private var minimumSharedScaleSpan: Double {
1033
        max(minimumVoltageSpan, minimumCurrentSpan)
1034
    }
1035

            
Bogdan Timofte authored a week ago
1036
    private var pinnedOriginIsZero: Bool {
1037
        if useSharedOrigin && supportsSharedOrigin {
1038
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
1039
        }
Bogdan Timofte authored a week ago
1040

            
1041
        if displayPower {
1042
            return pinOrigin && powerAxisOrigin == 0
1043
        }
1044

            
Bogdan Timofte authored 19 hours ago
1045
        if displayEnergy {
1046
            return pinOrigin && energyAxisOrigin == 0
1047
        }
1048

            
Bogdan Timofte authored a week ago
1049
        let visibleOrigins = [
1050
            displayVoltage ? voltageAxisOrigin : nil,
1051
            displayCurrent ? currentAxisOrigin : nil
1052
        ]
1053
        .compactMap { $0 }
1054

            
1055
        guard !visibleOrigins.isEmpty else { return false }
1056
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1057
    }
1058

            
1059
    private func toggleSharedOrigin(
1060
        voltageSeries: SeriesData,
1061
        currentSeries: SeriesData
1062
    ) {
1063
        guard supportsSharedOrigin else { return }
1064

            
1065
        if useSharedOrigin {
1066
            useSharedOrigin = false
1067
            return
1068
        }
1069

            
1070
        captureCurrentOrigins(
1071
            voltageSeries: voltageSeries,
1072
            currentSeries: currentSeries
1073
        )
1074
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
1075
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1076
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1077
        useSharedOrigin = true
1078
        pinOrigin = true
1079
    }
1080

            
1081
    private func togglePinnedOrigin(
1082
        voltageSeries: SeriesData,
1083
        currentSeries: SeriesData
1084
    ) {
1085
        if pinOrigin {
1086
            pinOrigin = false
1087
            return
1088
        }
1089

            
1090
        captureCurrentOrigins(
1091
            voltageSeries: voltageSeries,
1092
            currentSeries: currentSeries
1093
        )
1094
        pinOrigin = true
1095
    }
1096

            
1097
    private func setVisibleOriginsToZero() {
1098
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
1099
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
1100
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1101
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
1102
            voltageAxisOrigin = 0
1103
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1104
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1105
        } else {
1106
            if displayPower {
1107
                powerAxisOrigin = 0
1108
            }
Bogdan Timofte authored 19 hours ago
1109
            if displayEnergy {
1110
                energyAxisOrigin = 0
1111
            }
Bogdan Timofte authored a week ago
1112
            if displayVoltage {
1113
                voltageAxisOrigin = 0
1114
            }
1115
            if displayCurrent {
1116
                currentAxisOrigin = 0
1117
            }
Bogdan Timofte authored 4 days ago
1118
            if displayTemperature {
1119
                temperatureAxisOrigin = 0
1120
            }
Bogdan Timofte authored a week ago
1121
        }
1122

            
1123
        pinOrigin = true
1124
    }
1125

            
1126
    private func captureCurrentOrigins(
1127
        voltageSeries: SeriesData,
1128
        currentSeries: SeriesData
1129
    ) {
1130
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 19 hours ago
1131
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored a week ago
1132
        voltageAxisOrigin = voltageSeries.autoLowerBound
1133
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 4 days ago
1134
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a week ago
1135
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
1136
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1137
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1138
    }
1139

            
1140
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 4 days ago
1141
        let visibleTimeRange = activeVisibleTimeRange
1142

            
Bogdan Timofte authored a week ago
1143
        switch kind {
1144
        case .power:
Bogdan Timofte authored 4 days ago
1145
            return pinOrigin
1146
                ? powerAxisOrigin
1147
                : automaticYBounds(
1148
                    for: filteredSamplePoints(
1149
                        measurements.power,
1150
                        visibleTimeRange: visibleTimeRange
1151
                    ),
1152
                    minimumYSpan: minimumPowerSpan
1153
                ).lowerBound
Bogdan Timofte authored 19 hours ago
1154
        case .energy:
1155
            return pinOrigin
1156
                ? energyAxisOrigin
1157
                : automaticYBounds(
1158
                    for: filteredSamplePoints(
1159
                        measurements.energy,
1160
                        visibleTimeRange: visibleTimeRange
1161
                    ),
1162
                    minimumYSpan: minimumEnergySpan
1163
                ).lowerBound
Bogdan Timofte authored a week ago
1164
        case .voltage:
1165
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1166
                return sharedAxisOrigin
1167
            }
Bogdan Timofte authored 4 days ago
1168
            return pinOrigin
1169
                ? voltageAxisOrigin
1170
                : automaticYBounds(
1171
                    for: filteredSamplePoints(
1172
                        measurements.voltage,
1173
                        visibleTimeRange: visibleTimeRange
1174
                    ),
1175
                    minimumYSpan: minimumVoltageSpan
1176
                ).lowerBound
Bogdan Timofte authored a week ago
1177
        case .current:
1178
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1179
                return sharedAxisOrigin
1180
            }
Bogdan Timofte authored 4 days ago
1181
            return pinOrigin
1182
                ? currentAxisOrigin
1183
                : automaticYBounds(
1184
                    for: filteredSamplePoints(
1185
                        measurements.current,
1186
                        visibleTimeRange: visibleTimeRange
1187
                    ),
1188
                    minimumYSpan: minimumCurrentSpan
1189
                ).lowerBound
Bogdan Timofte authored 4 days ago
1190
        case .temperature:
Bogdan Timofte authored 4 days ago
1191
            return pinOrigin
1192
                ? temperatureAxisOrigin
1193
                : automaticYBounds(
1194
                    for: filteredSamplePoints(
1195
                        measurements.temperature,
1196
                        visibleTimeRange: visibleTimeRange
1197
                    ),
1198
                    minimumYSpan: minimumTemperatureSpan
1199
                ).lowerBound
Bogdan Timofte authored a week ago
1200
        }
1201
    }
1202

            
Bogdan Timofte authored 4 days ago
1203
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1204
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1205
    }
1206

            
1207
    private func filteredPoints(
1208
        _ measurement: Measurements.Measurement,
1209
        visibleTimeRange: ClosedRange<Date>? = nil
1210
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 19 hours ago
1211
        let resolvedRange: ClosedRange<Date>?
1212

            
1213
        switch (timeRange, visibleTimeRange) {
1214
        case let (baseRange?, visibleRange?):
1215
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1216
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1217
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1218
        case let (baseRange?, nil):
1219
            resolvedRange = baseRange
1220
        case let (nil, visibleRange?):
1221
            resolvedRange = visibleRange
1222
        case (nil, nil):
1223
            resolvedRange = nil
Bogdan Timofte authored 4 days ago
1224
        }
Bogdan Timofte authored 19 hours ago
1225

            
1226
        guard let resolvedRange else {
1227
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1228
        }
1229

            
1230
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 4 days ago
1231
    }
1232

            
1233
    private func filteredSamplePoints(
1234
        _ measurement: Measurements.Measurement,
1235
        visibleTimeRange: ClosedRange<Date>? = nil
1236
    ) -> [Measurements.Measurement.Point] {
1237
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1238
            point.isSample
Bogdan Timofte authored a week ago
1239
        }
1240
    }
1241

            
1242
    private func xBounds(
Bogdan Timofte authored 4 days ago
1243
        for samplePoints: [Measurements.Measurement.Point],
1244
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a week ago
1245
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 4 days ago
1246
        if let visibleTimeRange {
1247
            return normalizedTimeRange(visibleTimeRange)
1248
        }
1249

            
Bogdan Timofte authored a week ago
1250
        if let timeRange {
Bogdan Timofte authored 4 days ago
1251
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored a week ago
1252
        }
1253

            
1254
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1255
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1256

            
Bogdan Timofte authored 4 days ago
1257
        return normalizedTimeRange(lowerBound...upperBound)
1258
    }
1259

            
1260
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1261
        if let timeRange {
1262
            return normalizedTimeRange(timeRange)
1263
        }
1264

            
1265
        let samplePoints = timelineSamplePoints()
1266
        guard let lowerBound = samplePoints.first?.timestamp else {
1267
            return nil
1268
        }
1269

            
1270
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1271
        return normalizedTimeRange(lowerBound...upperBound)
1272
    }
1273

            
1274
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1275
        let candidates = [
1276
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 19 hours ago
1277
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 4 days ago
1278
            filteredSamplePoints(measurements.voltage),
1279
            filteredSamplePoints(measurements.current),
1280
            filteredSamplePoints(measurements.temperature)
1281
        ]
1282

            
1283
        return candidates.first(where: { !$0.isEmpty }) ?? []
1284
    }
1285

            
1286
    private func resolvedVisibleTimeRange(
1287
        within availableTimeRange: ClosedRange<Date>?
1288
    ) -> ClosedRange<Date>? {
1289
        guard let availableTimeRange else { return nil }
1290
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1291

            
1292
        if isPinnedToPresent {
1293
            let pinnedRange: ClosedRange<Date>
1294

            
1295
            switch presentTrackingMode {
1296
            case .keepDuration:
1297
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1298
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1299
            case .keepStartTimestamp:
1300
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1301
            }
1302

            
1303
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1304
        }
1305

            
1306
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1307
    }
1308

            
1309
    private func clampedTimeRange(
1310
        _ candidateRange: ClosedRange<Date>,
1311
        within bounds: ClosedRange<Date>
1312
    ) -> ClosedRange<Date> {
1313
        let normalizedBounds = normalizedTimeRange(bounds)
1314
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1315

            
1316
        guard boundsSpan > 0 else {
1317
            return normalizedBounds
1318
        }
1319

            
1320
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1321
        let requestedSpan = min(
1322
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1323
            boundsSpan
1324
        )
1325

            
1326
        if requestedSpan >= boundsSpan {
1327
            return normalizedBounds
1328
        }
1329

            
1330
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1331
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1332

            
1333
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1334
            if lowerBound == normalizedBounds.lowerBound {
1335
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1336
            } else {
1337
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1338
            }
1339
        }
1340

            
1341
        if upperBound > normalizedBounds.upperBound {
1342
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1343
            upperBound = normalizedBounds.upperBound
1344
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored a week ago
1345
        }
1346

            
Bogdan Timofte authored 4 days ago
1347
        if lowerBound < normalizedBounds.lowerBound {
1348
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1349
            lowerBound = normalizedBounds.lowerBound
1350
            upperBound = upperBound.addingTimeInterval(delta)
1351
        }
1352

            
1353
        return lowerBound...upperBound
1354
    }
1355

            
1356
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1357
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1358
        guard span < minimumTimeSpan else { return range }
1359

            
1360
        let expansion = (minimumTimeSpan - span) / 2
1361
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1362
    }
1363

            
1364
    private func shouldShowRangeSelector(
1365
        availableTimeRange: ClosedRange<Date>,
1366
        series: SeriesData
1367
    ) -> Bool {
1368
        series.samplePoints.count > 1 &&
1369
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored a week ago
1370
    }
1371

            
1372
    private func automaticYBounds(
1373
        for samplePoints: [Measurements.Measurement.Point],
1374
        minimumYSpan: Double
1375
    ) -> (lowerBound: Double, upperBound: Double) {
1376
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1377

            
1378
        guard
1379
            let minimumSampleValue = samplePoints.map(\.value).min(),
1380
            let maximumSampleValue = samplePoints.map(\.value).max()
1381
        else {
1382
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
1383
        }
Bogdan Timofte authored a week ago
1384

            
1385
        var lowerBound = minimumSampleValue
1386
        var upperBound = maximumSampleValue
1387
        let currentSpan = upperBound - lowerBound
1388

            
1389
        if currentSpan < minimumYSpan {
1390
            let expansion = (minimumYSpan - currentSpan) / 2
1391
            lowerBound -= expansion
1392
            upperBound += expansion
1393
        }
1394

            
1395
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1396
            let shift = -negativeAllowance - lowerBound
1397
            lowerBound += shift
1398
            upperBound += shift
1399
        }
1400

            
1401
        let snappedLowerBound = snappedOriginValue(lowerBound)
1402
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1403
        return (snappedLowerBound, resolvedUpperBound)
1404
    }
1405

            
1406
    private func resolvedLowerBound(
1407
        for kind: SeriesKind,
1408
        autoLowerBound: Double
1409
    ) -> Double {
1410
        guard pinOrigin else { return autoLowerBound }
1411

            
1412
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1413
            return sharedAxisOrigin
1414
        }
1415

            
1416
        switch kind {
1417
        case .power:
1418
            return powerAxisOrigin
Bogdan Timofte authored 19 hours ago
1419
        case .energy:
1420
            return energyAxisOrigin
Bogdan Timofte authored a week ago
1421
        case .voltage:
1422
            return voltageAxisOrigin
1423
        case .current:
1424
            return currentAxisOrigin
Bogdan Timofte authored 4 days ago
1425
        case .temperature:
1426
            return temperatureAxisOrigin
Bogdan Timofte authored a week ago
1427
        }
1428
    }
1429

            
1430
    private func resolvedUpperBound(
1431
        for kind: SeriesKind,
1432
        lowerBound: Double,
1433
        autoUpperBound: Double,
1434
        maximumSampleValue: Double?,
1435
        minimumYSpan: Double
1436
    ) -> Double {
1437
        guard pinOrigin else {
1438
            return autoUpperBound
1439
        }
1440

            
Bogdan Timofte authored 6 days ago
1441
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1442
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1443
        }
1444

            
Bogdan Timofte authored 4 days ago
1445
        if kind == .temperature {
1446
            return autoUpperBound
1447
        }
1448

            
Bogdan Timofte authored a week ago
1449
        return max(
1450
            maximumSampleValue ?? lowerBound,
1451
            lowerBound + minimumYSpan,
1452
            autoUpperBound
1453
        )
1454
    }
1455

            
Bogdan Timofte authored 6 days ago
1456
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
1457
        let baseline = displayedLowerBoundForSeries(kind)
1458
        let proposedOrigin = snappedOriginValue(baseline + delta)
1459

            
1460
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
1461
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
1462
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
1463
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1464
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1465
        } else {
1466
            switch kind {
1467
            case .power:
1468
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 19 hours ago
1469
            case .energy:
1470
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored a week ago
1471
            case .voltage:
1472
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1473
            case .current:
1474
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 4 days ago
1475
            case .temperature:
1476
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a week ago
1477
            }
1478
        }
1479

            
1480
        pinOrigin = true
1481
    }
1482

            
Bogdan Timofte authored 6 days ago
1483
    private func clearOriginOffset(for kind: SeriesKind) {
1484
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1485
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1486
            sharedAxisOrigin = 0
1487
            sharedAxisUpperBound = currentSpan
1488
            ensureSharedScaleSpan()
1489
            voltageAxisOrigin = 0
1490
            currentAxisOrigin = 0
1491
        } else {
1492
            switch kind {
1493
            case .power:
1494
                powerAxisOrigin = 0
Bogdan Timofte authored 19 hours ago
1495
            case .energy:
1496
                energyAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1497
            case .voltage:
1498
                voltageAxisOrigin = 0
1499
            case .current:
1500
                currentAxisOrigin = 0
Bogdan Timofte authored 4 days ago
1501
            case .temperature:
1502
                temperatureAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1503
            }
1504
        }
1505

            
1506
        pinOrigin = true
1507
    }
1508

            
1509
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1510
        guard totalHeight > 1 else { return }
1511

            
1512
        let normalized = max(0, min(1, locationY / totalHeight))
1513
        if normalized < (1.0 / 3.0) {
1514
            applyOriginDelta(-1, kind: kind)
1515
        } else if normalized < (2.0 / 3.0) {
1516
            clearOriginOffset(for: kind)
1517
        } else {
1518
            applyOriginDelta(1, kind: kind)
1519
        }
1520
    }
1521

            
Bogdan Timofte authored a week ago
1522
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 4 days ago
1523
        let visibleTimeRange = activeVisibleTimeRange
1524

            
Bogdan Timofte authored a week ago
1525
        switch kind {
1526
        case .power:
Bogdan Timofte authored 4 days ago
1527
            return snappedOriginValue(
1528
                filteredSamplePoints(
1529
                    measurements.power,
1530
                    visibleTimeRange: visibleTimeRange
1531
                ).map(\.value).min() ?? 0
1532
            )
Bogdan Timofte authored 19 hours ago
1533
        case .energy:
1534
            return snappedOriginValue(
1535
                filteredSamplePoints(
1536
                    measurements.energy,
1537
                    visibleTimeRange: visibleTimeRange
1538
                ).map(\.value).min() ?? 0
1539
            )
Bogdan Timofte authored a week ago
1540
        case .voltage:
Bogdan Timofte authored 4 days ago
1541
            return snappedOriginValue(
1542
                filteredSamplePoints(
1543
                    measurements.voltage,
1544
                    visibleTimeRange: visibleTimeRange
1545
                ).map(\.value).min() ?? 0
1546
            )
Bogdan Timofte authored a week ago
1547
        case .current:
Bogdan Timofte authored 4 days ago
1548
            return snappedOriginValue(
1549
                filteredSamplePoints(
1550
                    measurements.current,
1551
                    visibleTimeRange: visibleTimeRange
1552
                ).map(\.value).min() ?? 0
1553
            )
Bogdan Timofte authored 4 days ago
1554
        case .temperature:
Bogdan Timofte authored 4 days ago
1555
            return snappedOriginValue(
1556
                filteredSamplePoints(
1557
                    measurements.temperature,
1558
                    visibleTimeRange: visibleTimeRange
1559
                ).map(\.value).min() ?? 0
1560
            )
Bogdan Timofte authored a week ago
1561
        }
1562
    }
1563

            
1564
    private func maximumVisibleSharedOrigin() -> Double {
1565
        min(
1566
            maximumVisibleOrigin(for: .voltage),
1567
            maximumVisibleOrigin(for: .current)
1568
        )
1569
    }
1570

            
Bogdan Timofte authored 4 days ago
1571
    private func measurementUnit(for kind: SeriesKind) -> String {
1572
        switch kind {
1573
        case .temperature:
1574
            let locale = Locale.autoupdatingCurrent
1575
            if #available(iOS 16.0, *) {
1576
                switch locale.measurementSystem {
1577
                case .us:
1578
                    return "°F"
1579
                default:
1580
                    return "°C"
1581
                }
1582
            }
1583

            
1584
            let regionCode = locale.regionCode ?? ""
1585
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1586
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1587
        default:
1588
            return kind.unit
1589
        }
1590
    }
1591

            
Bogdan Timofte authored 6 days ago
1592
    private func ensureSharedScaleSpan() {
1593
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1594
    }
1595

            
Bogdan Timofte authored a week ago
1596
    private func snappedOriginValue(_ value: Double) -> Double {
1597
        if value >= 0 {
1598
            return value.rounded(.down)
1599
        }
1600

            
1601
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
1602
    }
Bogdan Timofte authored 2 weeks ago
1603

            
Bogdan Timofte authored 19 hours ago
1604
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1605
        measurements.keepOnly(in: range)
1606
        selectedVisibleTimeRange = nil
1607
        isPinnedToPresent = false
1608
    }
1609

            
1610
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1611
        measurements.removeValues(in: range)
1612
        selectedVisibleTimeRange = nil
1613
        isPinnedToPresent = false
1614
    }
1615

            
Bogdan Timofte authored 2 weeks ago
1616
    private func yGuidePosition(
1617
        for labelIndex: Int,
1618
        context: ChartContext,
1619
        height: CGFloat
1620
    ) -> CGFloat {
1621
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1622
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1623
        return context.placeInRect(point: anchorPoint).y * height
1624
    }
1625

            
1626
    private func xGuidePosition(
1627
        for labelIndex: Int,
1628
        context: ChartContext,
1629
        width: CGFloat
1630
    ) -> CGFloat {
1631
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1632
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1633
        return context.placeInRect(point: anchorPoint).x * width
1634
    }
Bogdan Timofte authored 2 weeks ago
1635

            
1636
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
1637
    fileprivate func xAxisLabelsView(
1638
        context: ChartContext
1639
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
1640
        var timeFormat: String?
1641
        switch context.size.width {
1642
        case 0..<3600: timeFormat = "HH:mm:ss"
1643
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
1644
        default: timeFormat = "E HH:mm"
1645
        }
1646
        let labels = (1...xLabels).map {
1647
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
1648
        }
Bogdan Timofte authored 6 days ago
1649
        let axisLabelFont: Font = {
1650
            if isIPhone && isPortraitLayout {
1651
                return .caption2.weight(.semibold)
1652
            }
1653
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1654
        }()
Bogdan Timofte authored 2 weeks ago
1655

            
1656
        return HStack(spacing: chartSectionSpacing) {
1657
            Color.clear
1658
                .frame(width: axisColumnWidth)
1659

            
1660
            GeometryReader { geometry in
1661
                let labelWidth = max(
1662
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1663
                    1
1664
                )
1665

            
1666
                ZStack(alignment: .topLeading) {
1667
                    Path { path in
1668
                        for labelIndex in 1...self.xLabels {
1669
                            let x = xGuidePosition(
1670
                                for: labelIndex,
1671
                                context: context,
1672
                                width: geometry.size.width
1673
                            )
1674
                            path.move(to: CGPoint(x: x, y: 0))
1675
                            path.addLine(to: CGPoint(x: x, y: 6))
1676
                        }
1677
                    }
1678
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1679

            
1680
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1681
                        let labelIndex = item.offset + 1
1682
                        let centerX = xGuidePosition(
1683
                            for: labelIndex,
1684
                            context: context,
1685
                            width: geometry.size.width
1686
                        )
1687

            
1688
                        Text(item.element)
Bogdan Timofte authored 6 days ago
1689
                            .font(axisLabelFont)
Bogdan Timofte authored 2 weeks ago
1690
                            .monospacedDigit()
1691
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1692
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1693
                            .frame(width: labelWidth)
1694
                            .position(
1695
                                x: centerX,
1696
                                y: geometry.size.height * 0.7
1697
                            )
Bogdan Timofte authored 2 weeks ago
1698
                    }
1699
                }
Bogdan Timofte authored 2 weeks ago
1700
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1701
            }
Bogdan Timofte authored 2 weeks ago
1702

            
1703
            Color.clear
1704
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1705
        }
1706
    }
1707

            
Bogdan Timofte authored a week ago
1708
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1709
        height: CGFloat,
1710
        context: ChartContext,
Bogdan Timofte authored a week ago
1711
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1712
        measurementUnit: String,
1713
        tint: Color
1714
    ) -> some View {
Bogdan Timofte authored 6 days ago
1715
        let yAxisFont: Font = {
1716
            if isIPhone && isPortraitLayout {
1717
                return .caption2.weight(.semibold)
1718
            }
1719
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1720
        }()
1721

            
1722
        let unitFont: Font = {
1723
            if isIPhone && isPortraitLayout {
1724
                return .caption2.weight(.bold)
1725
            }
1726
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1727
        }()
1728

            
1729
        return GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1730
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1731
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1732
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1733

            
Bogdan Timofte authored 2 weeks ago
1734
            ZStack(alignment: .top) {
1735
                ForEach(0..<yLabels, id: \.self) { row in
1736
                    let labelIndex = yLabels - row
1737

            
1738
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1739
                        .font(yAxisFont)
Bogdan Timofte authored 2 weeks ago
1740
                        .monospacedDigit()
1741
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1742
                        .minimumScaleFactor(0.8)
1743
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1744
                        .position(
1745
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1746
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1747
                                for: labelIndex,
1748
                                context: context,
Bogdan Timofte authored 6 days ago
1749
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1750
                            )
1751
                        )
Bogdan Timofte authored 2 weeks ago
1752
                }
Bogdan Timofte authored 2 weeks ago
1753

            
Bogdan Timofte authored 2 weeks ago
1754
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1755
                    .font(unitFont)
Bogdan Timofte authored 2 weeks ago
1756
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1757
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1758
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1759
                    .background(
1760
                        Capsule(style: .continuous)
1761
                            .fill(tint.opacity(0.14))
1762
                    )
Bogdan Timofte authored 6 days ago
1763
                    .padding(.top, 8)
1764

            
Bogdan Timofte authored 2 weeks ago
1765
            }
1766
        }
Bogdan Timofte authored 2 weeks ago
1767
        .frame(height: height)
1768
        .background(
1769
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1770
                .fill(tint.opacity(0.12))
1771
        )
1772
        .overlay(
1773
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1774
                .stroke(tint.opacity(0.20), lineWidth: 1)
1775
        )
Bogdan Timofte authored a week ago
1776
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1777
        .gesture(
Bogdan Timofte authored 6 days ago
1778
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1779
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1780
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1781
                }
1782
        )
Bogdan Timofte authored 2 weeks ago
1783
    }
1784

            
Bogdan Timofte authored 2 weeks ago
1785
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1786
        GeometryReader { geometry in
1787
            Path { path in
Bogdan Timofte authored 2 weeks ago
1788
                for labelIndex in 1...self.yLabels {
1789
                    let y = yGuidePosition(
1790
                        for: labelIndex,
1791
                        context: context,
1792
                        height: geometry.size.height
1793
                    )
1794
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1795
                }
Bogdan Timofte authored 2 weeks ago
1796
            }
1797
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1798
        }
1799
    }
1800

            
Bogdan Timofte authored 2 weeks ago
1801
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1802
        GeometryReader { geometry in
1803
            Path { path in
1804

            
Bogdan Timofte authored 2 weeks ago
1805
                for labelIndex in 2..<self.xLabels {
1806
                    let x = xGuidePosition(
1807
                        for: labelIndex,
1808
                        context: context,
1809
                        width: geometry.size.width
1810
                    )
1811
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1812
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1813
                }
Bogdan Timofte authored 2 weeks ago
1814
            }
1815
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1816
        }
1817
    }
Bogdan Timofte authored a week ago
1818

            
1819
    fileprivate func discontinuityMarkers(
1820
        points: [Measurements.Measurement.Point],
1821
        context: ChartContext
1822
    ) -> some View {
1823
        GeometryReader { geometry in
1824
            Path { path in
1825
                for point in points where point.isDiscontinuity {
1826
                    let markerX = context.placeInRect(
1827
                        point: CGPoint(
1828
                            x: point.timestamp.timeIntervalSince1970,
1829
                            y: context.origin.y
1830
                        )
1831
                    ).x * geometry.size.width
1832
                    path.move(to: CGPoint(x: markerX, y: 0))
1833
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1834
                }
1835
            }
1836
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1837
        }
1838
    }
Bogdan Timofte authored 2 weeks ago
1839

            
1840
}
1841

            
Bogdan Timofte authored 4 days ago
1842
private struct TimeRangeSelectorView: View {
1843
    private enum DragTarget {
1844
        case lowerBound
1845
        case upperBound
1846
        case window
1847
    }
1848

            
Bogdan Timofte authored 19 hours ago
1849
    private enum ActionTone {
1850
        case reversible
1851
        case destructive
1852
        case destructiveProminent
1853
    }
1854

            
Bogdan Timofte authored 4 days ago
1855
    private struct DragState {
1856
        let target: DragTarget
1857
        let initialRange: ClosedRange<Date>
1858
    }
1859

            
1860
    let points: [Measurements.Measurement.Point]
1861
    let context: ChartContext
1862
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 19 hours ago
1863
    let selectorTint: Color
Bogdan Timofte authored 4 days ago
1864
    let compactLayout: Bool
1865
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored 19 hours ago
1866
    let onKeepSelection: (ClosedRange<Date>) -> Void
1867
    let onRemoveSelection: (ClosedRange<Date>) -> Void
1868
    let onResetBuffer: () -> Void
Bogdan Timofte authored 4 days ago
1869

            
1870
    @Binding var selectedTimeRange: ClosedRange<Date>?
1871
    @Binding var isPinnedToPresent: Bool
1872
    @Binding var presentTrackingMode: PresentTrackingMode
1873
    @State private var dragState: DragState?
Bogdan Timofte authored 19 hours ago
1874
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 4 days ago
1875

            
1876
    private var totalSpan: TimeInterval {
1877
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
1878
    }
1879

            
1880
    private var currentRange: ClosedRange<Date> {
1881
        resolvedSelectionRange()
1882
    }
1883

            
1884
    private var trackHeight: CGFloat {
1885
        compactLayout ? 72 : 86
1886
    }
1887

            
1888
    private var cornerRadius: CGFloat {
1889
        compactLayout ? 14 : 16
1890
    }
1891

            
1892
    private var boundaryFont: Font {
1893
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1894
    }
1895

            
1896
    private var symbolButtonSize: CGFloat {
1897
        compactLayout ? 28 : 32
1898
    }
1899

            
1900
    var body: some View {
1901
        let coversFullRange = selectionCoversFullRange(currentRange)
1902

            
1903
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
1904
            if !coversFullRange || isPinnedToPresent {
1905
                HStack(spacing: 8) {
1906
                    alignmentButton(
1907
                        systemName: "arrow.left.to.line.compact",
1908
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
1909
                        action: alignSelectionToLeadingEdge,
1910
                        accessibilityLabel: "Align selection to start"
1911
                    )
1912

            
1913
                    alignmentButton(
1914
                        systemName: "arrow.right.to.line.compact",
1915
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
1916
                        action: alignSelectionToTrailingEdge,
1917
                        accessibilityLabel: "Align selection to present"
1918
                    )
1919

            
1920
                    Spacer(minLength: 0)
1921

            
1922
                    if isPinnedToPresent {
1923
                        trackingModeToggleButton()
1924
                    }
1925
                }
1926
            }
1927

            
Bogdan Timofte authored 19 hours ago
1928
            HStack(spacing: 8) {
1929
                if !coversFullRange {
1930
                    actionButton(
1931
                        title: compactLayout ? "Keep" : "Keep Selection",
1932
                        systemName: "scissors",
1933
                        tone: .destructive,
1934
                        action: {
1935
                            onKeepSelection(currentRange)
1936
                        }
1937
                    )
1938

            
1939
                    actionButton(
1940
                        title: compactLayout ? "Cut" : "Remove Selection",
1941
                        systemName: "minus.circle",
1942
                        tone: .destructive,
1943
                        action: {
1944
                            onRemoveSelection(currentRange)
1945
                        }
1946
                    )
1947
                }
1948

            
1949
                Spacer(minLength: 0)
1950

            
1951
                actionButton(
1952
                    title: compactLayout ? "Reset" : "Reset Buffer",
1953
                    systemName: "trash",
1954
                    tone: .destructiveProminent,
1955
                    action: {
1956
                        showResetConfirmation = true
1957
                    }
1958
                )
1959
            }
1960
            .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
1961
                Button("Reset buffer", role: .destructive) {
1962
                    onResetBuffer()
1963
                }
1964
                Button("Cancel", role: .cancel) {}
1965
            }
1966

            
Bogdan Timofte authored 4 days ago
1967
            GeometryReader { geometry in
1968
                let selectionFrame = selectionFrame(in: geometry.size)
1969
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
1970

            
1971
                ZStack(alignment: .topLeading) {
1972
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1973
                        .fill(Color.primary.opacity(0.05))
1974

            
1975
                    Chart(
1976
                        points: points,
1977
                        context: context,
1978
                        areaChart: true,
Bogdan Timofte authored 19 hours ago
1979
                        strokeColor: selectorTint,
1980
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 4 days ago
1981
                    )
1982
                    .opacity(0.94)
1983
                    .allowsHitTesting(false)
1984

            
1985
                    Chart(
1986
                        points: points,
1987
                        context: context,
Bogdan Timofte authored 19 hours ago
1988
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 4 days ago
1989
                    )
1990
                    .opacity(0.82)
1991
                    .allowsHitTesting(false)
1992

            
1993
                    if selectionFrame.minX > 0 {
1994
                        Rectangle()
1995
                            .fill(dimmingColor)
1996
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
1997
                            .allowsHitTesting(false)
1998
                    }
1999

            
2000
                    if selectionFrame.maxX < geometry.size.width {
2001
                        Rectangle()
2002
                            .fill(dimmingColor)
2003
                            .frame(
2004
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2005
                                height: geometry.size.height
2006
                            )
2007
                            .offset(x: selectionFrame.maxX)
2008
                            .allowsHitTesting(false)
2009
                    }
2010

            
2011
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 19 hours ago
2012
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 4 days ago
2013
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2014
                        .offset(x: selectionFrame.minX)
2015
                        .allowsHitTesting(false)
2016

            
2017
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 19 hours ago
2018
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 4 days ago
2019
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2020
                        .offset(x: selectionFrame.minX)
2021
                        .allowsHitTesting(false)
2022

            
2023
                    handleView(height: max(geometry.size.height - 18, 16))
2024
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2025
                        .allowsHitTesting(false)
2026

            
2027
                    handleView(height: max(geometry.size.height - 18, 16))
2028
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2029
                        .allowsHitTesting(false)
2030
                }
2031
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2032
                .overlay(
2033
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2034
                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
2035
                )
2036
                .contentShape(Rectangle())
2037
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2038
            }
2039
            .frame(height: trackHeight)
2040

            
2041
            HStack {
2042
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2043
                Spacer(minLength: 0)
2044
                Text(boundaryLabel(for: availableTimeRange.upperBound))
2045
            }
2046
            .font(boundaryFont)
2047
            .foregroundColor(.secondary)
2048
            .monospacedDigit()
2049
        }
2050
    }
2051

            
2052
    private func handleView(height: CGFloat) -> some View {
2053
        Capsule(style: .continuous)
2054
            .fill(Color.white.opacity(0.95))
2055
            .frame(width: 6, height: height)
2056
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2057
    }
2058

            
2059
    private func alignmentButton(
2060
        systemName: String,
2061
        isActive: Bool,
2062
        action: @escaping () -> Void,
2063
        accessibilityLabel: String
2064
    ) -> some View {
2065
        Button(action: action) {
2066
            Image(systemName: systemName)
2067
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2068
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2069
        }
2070
        .buttonStyle(.plain)
Bogdan Timofte authored 19 hours ago
2071
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 4 days ago
2072
        .background(
2073
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 19 hours ago
2074
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 4 days ago
2075
        )
2076
        .overlay(
2077
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 19 hours ago
2078
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 4 days ago
2079
        )
2080
        .accessibilityLabel(accessibilityLabel)
2081
    }
2082

            
2083
    private func trackingModeToggleButton() -> some View {
2084
        Button {
2085
            presentTrackingMode = presentTrackingMode == .keepDuration
2086
                ? .keepStartTimestamp
2087
                : .keepDuration
2088
        } label: {
2089
            Image(systemName: trackingModeSymbolName)
2090
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2091
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2092
        }
2093
        .buttonStyle(.plain)
2094
        .foregroundColor(.white)
2095
        .background(
2096
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 19 hours ago
2097
                .fill(selectorTint)
Bogdan Timofte authored 4 days ago
2098
        )
2099
        .overlay(
2100
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 19 hours ago
2101
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 4 days ago
2102
        )
2103
        .accessibilityLabel(trackingModeAccessibilityLabel)
2104
        .accessibilityHint("Toggles how the interval follows the present")
2105
    }
2106

            
Bogdan Timofte authored 19 hours ago
2107
    private func actionButton(
2108
        title: String,
2109
        systemName: String,
2110
        tone: ActionTone,
2111
        action: @escaping () -> Void
2112
    ) -> some View {
2113
        let foregroundColor: Color = {
2114
            switch tone {
2115
            case .reversible, .destructive:
2116
                return toneColor(for: tone)
2117
            case .destructiveProminent:
2118
                return .white
2119
            }
2120
        }()
2121

            
2122
        return Button(action: action) {
2123
            Label(title, systemImage: systemName)
2124
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2125
                .padding(.horizontal, compactLayout ? 10 : 12)
2126
                .padding(.vertical, compactLayout ? 7 : 8)
2127
        }
2128
        .buttonStyle(.plain)
2129
        .foregroundColor(foregroundColor)
2130
        .background(
2131
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2132
                .fill(actionButtonBackground(for: tone))
2133
        )
2134
        .overlay(
2135
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2136
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2137
        )
2138
    }
2139

            
2140
    private func toneColor(for tone: ActionTone) -> Color {
2141
        switch tone {
2142
        case .reversible:
2143
            return selectorTint
2144
        case .destructive, .destructiveProminent:
2145
            return .red
2146
        }
2147
    }
2148

            
2149
    private func actionButtonBackground(for tone: ActionTone) -> Color {
2150
        switch tone {
2151
        case .reversible:
2152
            return selectorTint.opacity(0.12)
2153
        case .destructive:
2154
            return Color.red.opacity(0.12)
2155
        case .destructiveProminent:
2156
            return Color.red.opacity(0.82)
2157
        }
2158
    }
2159

            
2160
    private func actionButtonBorder(for tone: ActionTone) -> Color {
2161
        switch tone {
2162
        case .reversible:
2163
            return selectorTint.opacity(0.22)
2164
        case .destructive:
2165
            return Color.red.opacity(0.22)
2166
        case .destructiveProminent:
2167
            return Color.red.opacity(0.72)
2168
        }
2169
    }
2170

            
Bogdan Timofte authored 4 days ago
2171
    private var trackingModeSymbolName: String {
2172
        switch presentTrackingMode {
2173
        case .keepDuration:
2174
            return "arrow.left.and.right"
2175
        case .keepStartTimestamp:
2176
            return "arrow.left.to.line.compact"
2177
        }
2178
    }
2179

            
2180
    private var trackingModeAccessibilityLabel: String {
2181
        switch presentTrackingMode {
2182
        case .keepDuration:
2183
            return "Follow present keeping span"
2184
        case .keepStartTimestamp:
2185
            return "Follow present keeping start"
2186
        }
2187
    }
2188

            
2189
    private func alignSelectionToLeadingEdge() {
2190
        let alignedRange = normalizedSelectionRange(
2191
            availableTimeRange.lowerBound...currentRange.upperBound
2192
        )
2193
        applySelection(alignedRange, pinToPresent: false)
2194
    }
2195

            
2196
    private func alignSelectionToTrailingEdge() {
2197
        let alignedRange = normalizedSelectionRange(
2198
            currentRange.lowerBound...availableTimeRange.upperBound
2199
        )
2200
        applySelection(alignedRange, pinToPresent: true)
2201
    }
2202

            
2203
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2204
        DragGesture(minimumDistance: 0)
2205
            .onChanged { value in
2206
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2207
            }
2208
            .onEnded { _ in
2209
                dragState = nil
2210
            }
2211
    }
2212

            
2213
    private func updateSelectionDrag(
2214
        value: DragGesture.Value,
2215
        totalWidth: CGFloat
2216
    ) {
2217
        let startingRange = resolvedSelectionRange()
2218

            
2219
        if dragState == nil {
2220
            dragState = DragState(
2221
                target: dragTarget(
2222
                    for: value.startLocation.x,
2223
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2224
                ),
2225
                initialRange: startingRange
2226
            )
2227
        }
2228

            
2229
        guard let dragState else { return }
2230

            
2231
        let resultingRange = snappedToEdges(
2232
            adjustedRange(
2233
                from: dragState.initialRange,
2234
                target: dragState.target,
2235
                translationX: value.translation.width,
2236
                totalWidth: totalWidth
2237
            ),
2238
            target: dragState.target,
2239
            totalWidth: totalWidth
2240
        )
2241

            
2242
        applySelection(
2243
            resultingRange,
2244
            pinToPresent: shouldKeepPresentPin(
2245
                during: dragState.target,
2246
                initialRange: dragState.initialRange,
2247
                resultingRange: resultingRange
2248
            ),
2249
        )
2250
    }
2251

            
2252
    private func dragTarget(
2253
        for startX: CGFloat,
2254
        selectionFrame: CGRect
2255
    ) -> DragTarget {
2256
        let handleZone: CGFloat = compactLayout ? 20 : 24
2257

            
2258
        if abs(startX - selectionFrame.minX) <= handleZone {
2259
            return .lowerBound
2260
        }
2261

            
2262
        if abs(startX - selectionFrame.maxX) <= handleZone {
2263
            return .upperBound
2264
        }
2265

            
2266
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2267
            return .window
2268
        }
2269

            
2270
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2271
    }
2272

            
2273
    private func adjustedRange(
2274
        from initialRange: ClosedRange<Date>,
2275
        target: DragTarget,
2276
        translationX: CGFloat,
2277
        totalWidth: CGFloat
2278
    ) -> ClosedRange<Date> {
2279
        guard totalSpan > 0, totalWidth > 0 else {
2280
            return availableTimeRange
2281
        }
2282

            
2283
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2284
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2285

            
2286
        switch target {
2287
        case .lowerBound:
2288
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2289
            let newLowerBound = min(
2290
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2291
                maximumLowerBound
2292
            )
2293
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2294

            
2295
        case .upperBound:
2296
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2297
            let newUpperBound = max(
2298
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2299
                minimumUpperBound
2300
            )
2301
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2302

            
2303
        case .window:
2304
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2305
            guard span < totalSpan else { return availableTimeRange }
2306

            
2307
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2308
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2309

            
2310
            if lowerBound < availableTimeRange.lowerBound {
2311
                upperBound = upperBound.addingTimeInterval(
2312
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2313
                )
2314
                lowerBound = availableTimeRange.lowerBound
2315
            }
2316

            
2317
            if upperBound > availableTimeRange.upperBound {
2318
                lowerBound = lowerBound.addingTimeInterval(
2319
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2320
                )
2321
                upperBound = availableTimeRange.upperBound
2322
            }
2323

            
2324
            return normalizedSelectionRange(lowerBound...upperBound)
2325
        }
2326
    }
2327

            
2328
    private func snappedToEdges(
2329
        _ candidateRange: ClosedRange<Date>,
2330
        target: DragTarget,
2331
        totalWidth: CGFloat
2332
    ) -> ClosedRange<Date> {
2333
        guard totalSpan > 0 else {
2334
            return availableTimeRange
2335
        }
2336

            
2337
        let snapInterval = edgeSnapInterval(for: totalWidth)
2338
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2339
        var lowerBound = candidateRange.lowerBound
2340
        var upperBound = candidateRange.upperBound
2341

            
2342
        if target != .upperBound,
2343
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2344
            lowerBound = availableTimeRange.lowerBound
2345
            if target == .window {
2346
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2347
            }
2348
        }
2349

            
2350
        if target != .lowerBound,
2351
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2352
            upperBound = availableTimeRange.upperBound
2353
            if target == .window {
2354
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2355
            }
2356
        }
2357

            
2358
        return normalizedSelectionRange(lowerBound...upperBound)
2359
    }
2360

            
2361
    private func edgeSnapInterval(
2362
        for totalWidth: CGFloat
2363
    ) -> TimeInterval {
2364
        guard totalWidth > 0 else { return minimumSelectionSpan }
2365

            
2366
        let snapWidth = min(
2367
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2368
            totalWidth * 0.18
2369
        )
2370
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2371
        return min(
2372
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2373
            totalSpan / 4
2374
        )
2375
    }
2376

            
2377
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2378
        guard let selectedTimeRange else { return availableTimeRange }
2379

            
2380
        if isPinnedToPresent {
2381
            switch presentTrackingMode {
2382
            case .keepDuration:
2383
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2384
                return normalizedSelectionRange(
2385
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2386
                )
2387
            case .keepStartTimestamp:
2388
                return normalizedSelectionRange(
2389
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2390
                )
2391
            }
2392
        }
2393

            
2394
        return normalizedSelectionRange(selectedTimeRange)
2395
    }
2396

            
2397
    private func normalizedSelectionRange(
2398
        _ candidateRange: ClosedRange<Date>
2399
    ) -> ClosedRange<Date> {
2400
        let availableSpan = totalSpan
2401
        guard availableSpan > 0 else { return availableTimeRange }
2402

            
2403
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2404
        let requestedSpan = min(
2405
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2406
            availableSpan
2407
        )
2408

            
2409
        if requestedSpan >= availableSpan {
2410
            return availableTimeRange
2411
        }
2412

            
2413
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2414
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2415

            
2416
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2417
            if lowerBound == availableTimeRange.lowerBound {
2418
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2419
            } else {
2420
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2421
            }
2422
        }
2423

            
2424
        if upperBound > availableTimeRange.upperBound {
2425
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2426
            upperBound = availableTimeRange.upperBound
2427
            lowerBound = lowerBound.addingTimeInterval(-delta)
2428
        }
2429

            
2430
        if lowerBound < availableTimeRange.lowerBound {
2431
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2432
            lowerBound = availableTimeRange.lowerBound
2433
            upperBound = upperBound.addingTimeInterval(delta)
2434
        }
2435

            
2436
        return lowerBound...upperBound
2437
    }
2438

            
2439
    private func shouldKeepPresentPin(
2440
        during target: DragTarget,
2441
        initialRange: ClosedRange<Date>,
2442
        resultingRange: ClosedRange<Date>
2443
    ) -> Bool {
2444
        let startedPinnedToPresent =
2445
            isPinnedToPresent ||
2446
            selectionCoversFullRange(initialRange)
2447

            
2448
        guard startedPinnedToPresent else {
2449
            return selectionTouchesPresent(resultingRange)
2450
        }
2451

            
2452
        switch target {
2453
        case .lowerBound:
2454
            return true
2455
        case .upperBound, .window:
2456
            return selectionTouchesPresent(resultingRange)
2457
        }
2458
    }
2459

            
2460
    private func applySelection(
2461
        _ candidateRange: ClosedRange<Date>,
2462
        pinToPresent: Bool
2463
    ) {
2464
        let normalizedRange = normalizedSelectionRange(candidateRange)
2465

            
2466
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2467
            selectedTimeRange = nil
2468
        } else {
2469
            selectedTimeRange = normalizedRange
2470
        }
2471

            
2472
        isPinnedToPresent = pinToPresent
2473
    }
2474

            
2475
    private func selectionTouchesPresent(
2476
        _ range: ClosedRange<Date>
2477
    ) -> Bool {
2478
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2479
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2480
    }
2481

            
2482
    private func selectionCoversFullRange(
2483
        _ range: ClosedRange<Date>
2484
    ) -> Bool {
2485
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2486
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2487
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2488
    }
2489

            
2490
    private func selectionFrame(in size: CGSize) -> CGRect {
2491
        selectionFrame(for: currentRange, width: size.width)
2492
    }
2493

            
2494
    private func selectionFrame(
2495
        for range: ClosedRange<Date>,
2496
        width: CGFloat
2497
    ) -> CGRect {
2498
        guard width > 0, totalSpan > 0 else {
2499
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2500
        }
2501

            
2502
        let minimumX = xPosition(for: range.lowerBound, width: width)
2503
        let maximumX = xPosition(for: range.upperBound, width: width)
2504
        return CGRect(
2505
            x: minimumX,
2506
            y: 0,
2507
            width: max(maximumX - minimumX, 2),
2508
            height: trackHeight
2509
        )
2510
    }
2511

            
2512
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2513
        guard width > 0, totalSpan > 0 else { return 0 }
2514

            
2515
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2516
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2517
        return CGFloat(normalizedOffset) * width
2518
    }
2519

            
2520
    private func boundaryLabel(for date: Date) -> String {
2521
        date.format(as: boundaryDateFormat)
2522
    }
2523

            
2524
    private var boundaryDateFormat: String {
2525
        switch totalSpan {
2526
        case 0..<86400:
2527
            return "HH:mm"
2528
        case 86400..<604800:
2529
            return "MMM d HH:mm"
2530
        default:
2531
            return "MMM d"
2532
        }
2533
    }
2534
}
2535

            
Bogdan Timofte authored 2 weeks ago
2536
struct Chart : View {
2537

            
Bogdan Timofte authored 19 hours ago
2538
    @Environment(\.displayScale) private var displayScale
2539

            
Bogdan Timofte authored 2 weeks ago
2540
    let points: [Measurements.Measurement.Point]
2541
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
2542
    var areaChart: Bool = false
2543
    var strokeColor: Color = .black
Bogdan Timofte authored 4 days ago
2544
    var areaFillColor: Color? = nil
Bogdan Timofte authored 2 weeks ago
2545

            
2546
    var body : some View {
2547
        GeometryReader { geometry in
2548
            if self.areaChart {
Bogdan Timofte authored 4 days ago
2549
                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
Bogdan Timofte authored 2 weeks ago
2550
                self.path( geometry: geometry )
Bogdan Timofte authored 4 days ago
2551
                    .fill(
2552
                        LinearGradient(
2553
                            gradient: .init(
2554
                                colors: [
2555
                                    fillColor.opacity(0.72),
2556
                                    fillColor.opacity(0.18)
2557
                                ]
2558
                            ),
2559
                            startPoint: .init(x: 0.5, y: 0.08),
2560
                            endPoint: .init(x: 0.5, y: 0.92)
2561
                        )
2562
                    )
Bogdan Timofte authored 2 weeks ago
2563
            } else {
2564
                self.path( geometry: geometry )
2565
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2566
            }
2567
        }
2568
    }
2569

            
2570
    fileprivate func path(geometry: GeometryProxy) -> Path {
Bogdan Timofte authored 19 hours ago
2571
        let displayedPoints = scaledPoints(for: geometry.size.width)
2572
        let baselineY = context.placeInRect(
2573
            point: CGPoint(x: context.origin.x, y: context.origin.y)
2574
        ).y * geometry.size.height
2575

            
Bogdan Timofte authored 2 weeks ago
2576
        return Path { path in
Bogdan Timofte authored 19 hours ago
2577
            var firstRenderedPoint: CGPoint?
2578
            var lastRenderedPoint: CGPoint?
Bogdan Timofte authored a week ago
2579
            var needsMove = true
2580

            
Bogdan Timofte authored 19 hours ago
2581
            for point in displayedPoints {
Bogdan Timofte authored a week ago
2582
                if point.isDiscontinuity {
Bogdan Timofte authored 19 hours ago
2583
                    closeAreaSegment(
2584
                        in: &path,
2585
                        firstPoint: firstRenderedPoint,
2586
                        lastPoint: lastRenderedPoint,
2587
                        baselineY: baselineY
2588
                    )
2589
                    firstRenderedPoint = nil
2590
                    lastRenderedPoint = nil
Bogdan Timofte authored a week ago
2591
                    needsMove = true
2592
                    continue
2593
                }
2594

            
2595
                let item = context.placeInRect(point: point.point())
2596
                let renderedPoint = CGPoint(
2597
                    x: item.x * geometry.size.width,
2598
                    y: item.y * geometry.size.height
2599
                )
2600

            
2601
                if needsMove {
2602
                    path.move(to: renderedPoint)
Bogdan Timofte authored 19 hours ago
2603
                    firstRenderedPoint = renderedPoint
Bogdan Timofte authored a week ago
2604
                    needsMove = false
2605
                } else {
2606
                    path.addLine(to: renderedPoint)
2607
                }
Bogdan Timofte authored 19 hours ago
2608

            
2609
                lastRenderedPoint = renderedPoint
Bogdan Timofte authored 2 weeks ago
2610
            }
Bogdan Timofte authored a week ago
2611

            
Bogdan Timofte authored 19 hours ago
2612
            closeAreaSegment(
2613
                in: &path,
2614
                firstPoint: firstRenderedPoint,
2615
                lastPoint: lastRenderedPoint,
2616
                baselineY: baselineY
2617
            )
2618
        }
2619
    }
2620

            
2621
    private func closeAreaSegment(
2622
        in path: inout Path,
2623
        firstPoint: CGPoint?,
2624
        lastPoint: CGPoint?,
2625
        baselineY: CGFloat
2626
    ) {
2627
        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2628

            
2629
        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2630
        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2631
        path.closeSubpath()
2632
    }
2633

            
2634
    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2635
        let sampleCount = points.reduce(into: 0) { partialResult, point in
2636
            if point.isSample {
2637
                partialResult += 1
2638
            }
2639
        }
2640

            
2641
        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2642
        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2643

            
2644
        guard sampleCount > maximumSamplesToRender, context.isValid else {
2645
            return points
2646
        }
2647

            
2648
        var scaledPoints: [Measurements.Measurement.Point] = []
2649
        var currentSegment: [Measurements.Measurement.Point] = []
2650

            
2651
        for point in points {
2652
            if point.isDiscontinuity {
2653
                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2654
                currentSegment.removeAll(keepingCapacity: true)
2655

            
2656
                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2657
                    appendScaledPoint(point, to: &scaledPoints)
2658
                }
2659
            } else {
2660
                currentSegment.append(point)
2661
            }
2662
        }
2663

            
2664
        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2665
        return scaledPoints.isEmpty ? points : scaledPoints
2666
    }
2667

            
2668
    private func appendScaledSegment(
2669
        _ segment: [Measurements.Measurement.Point],
2670
        to scaledPoints: inout [Measurements.Measurement.Point],
2671
        displayColumns: Int
2672
    ) {
2673
        guard !segment.isEmpty else { return }
2674

            
2675
        if segment.count <= max(displayColumns * 2, 120) {
2676
            for point in segment {
2677
                appendScaledPoint(point, to: &scaledPoints)
2678
            }
2679
            return
2680
        }
2681

            
2682
        var bucket: [Measurements.Measurement.Point] = []
2683
        var currentColumn: Int?
2684

            
2685
        for point in segment {
2686
            let column = displayColumn(for: point, totalColumns: displayColumns)
2687

            
2688
            if let currentColumn, currentColumn != column {
2689
                appendBucket(bucket, to: &scaledPoints)
2690
                bucket.removeAll(keepingCapacity: true)
Bogdan Timofte authored 2 weeks ago
2691
            }
Bogdan Timofte authored 19 hours ago
2692

            
2693
            bucket.append(point)
2694
            currentColumn = column
2695
        }
2696

            
2697
        appendBucket(bucket, to: &scaledPoints)
2698
    }
2699

            
2700
    private func appendBucket(
2701
        _ bucket: [Measurements.Measurement.Point],
2702
        to scaledPoints: inout [Measurements.Measurement.Point]
2703
    ) {
2704
        guard !bucket.isEmpty else { return }
2705

            
2706
        if bucket.count <= 2 {
2707
            for point in bucket {
2708
                appendScaledPoint(point, to: &scaledPoints)
2709
            }
2710
            return
2711
        }
2712

            
2713
        let firstPoint = bucket.first!
2714
        let lastPoint = bucket.last!
2715
        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2716
        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2717

            
2718
        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2719
            .sorted { lhs, rhs in
2720
                if lhs.timestamp == rhs.timestamp {
2721
                    return lhs.id < rhs.id
2722
                }
2723
                return lhs.timestamp < rhs.timestamp
2724
            }
2725

            
2726
        var emittedPointIDs: Set<Int> = []
2727
        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2728
            appendScaledPoint(point, to: &scaledPoints)
Bogdan Timofte authored 2 weeks ago
2729
        }
2730
    }
Bogdan Timofte authored 19 hours ago
2731

            
2732
    private func appendScaledPoint(
2733
        _ point: Measurements.Measurement.Point,
2734
        to scaledPoints: inout [Measurements.Measurement.Point]
2735
    ) {
2736
        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2737
                scaledPoints.last?.value == point.value &&
2738
                scaledPoints.last?.kind == point.kind) else {
2739
            return
2740
        }
2741

            
2742
        scaledPoints.append(
2743
            Measurements.Measurement.Point(
2744
                id: scaledPoints.count,
2745
                timestamp: point.timestamp,
2746
                value: point.value,
2747
                kind: point.kind
2748
            )
2749
        )
2750
    }
2751

            
2752
    private func displayColumn(
2753
        for point: Measurements.Measurement.Point,
2754
        totalColumns: Int
2755
    ) -> Int {
2756
        let totalColumns = max(totalColumns, 1)
2757
        let timeSpan = max(Double(context.size.width), 1)
2758
        let normalizedOffset = min(
2759
            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2760
            1
2761
        )
2762

            
2763
        return min(
2764
            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2765
            totalColumns - 1
2766
        )
2767
    }
Bogdan Timofte authored 2 weeks ago
2768

            
2769
}