USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
9010128 12 hours ago History
2780 lines | 100.374kb
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 16 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 16 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 16 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 16 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 16 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 16 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 16 hours ago
109
    @State var displayEnergy: Bool = false
Bogdan Timofte authored 4 days ago
110
    @State var displayTemperature: Bool = false
Bogdan Timofte authored 16 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 16 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 5 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 5 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 5 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 16 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 16 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 16 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 16 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 16 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 5 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 5 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 5 days ago
350
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 5 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 16 hours ago
376
                                    selectorTint: selectorTint,
Bogdan Timofte authored 4 days ago
377
                                    compactLayout: compactLayout,
378
                                    minimumSelectionSpan: minimumTimeSpan,
Bogdan Timofte authored 16 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 12 hours ago
412
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
413
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
414
        }
415
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
416
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
417
        .background(
418
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
419
                .fill(Color.primary.opacity(0.045))
420
        )
421
        .overlay(
422
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
423
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
424
        )
Bogdan Timofte authored a week ago
425

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 12 hours ago
583
            smoothingControlChip(
584
                condensedLayout: condensedLayout,
585
                showsLabel: showsLabel
586
            )
587

            
Bogdan Timofte authored a week ago
588
        }
589
    }
590

            
Bogdan Timofte authored 12 hours ago
591
    private func smoothingControlChip(
592
        condensedLayout: Bool,
593
        showsLabel: Bool
594
    ) -> some View {
595
        Menu {
596
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
597
                Button {
598
                    smoothingLevel = level
599
                } label: {
600
                    if smoothingLevel == level {
601
                        Label(level.label, systemImage: "checkmark")
602
                    } else {
603
                        Text(level.label)
Bogdan Timofte authored 16 hours ago
604
                    }
605
                }
Bogdan Timofte authored 12 hours ago
606
            }
607
        } label: {
608
            Group {
609
                if showsLabel {
610
                    VStack(alignment: .leading, spacing: 2) {
611
                        Label("Smoothing", systemImage: "waveform.path")
612
                            .font(controlChipFont(condensedLayout: condensedLayout))
613

            
614
                        Text(
Bogdan Timofte authored 16 hours ago
615
                            smoothingLevel == .off
Bogdan Timofte authored 12 hours ago
616
                            ? "Off"
617
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 16 hours ago
618
                        )
Bogdan Timofte authored 12 hours ago
619
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
620
                        .foregroundColor(.secondary)
621
                        .monospacedDigit()
622
                    }
623
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
624
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
625
                } else {
626
                    VStack(spacing: 1) {
627
                        Image(systemName: "waveform.path")
628
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
629

            
630
                        Text(smoothingLevel.shortLabel)
631
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
632
                            .monospacedDigit()
633
                    }
634
                    .frame(
635
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
636
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
637
                    )
638
                }
Bogdan Timofte authored 16 hours ago
639
            }
Bogdan Timofte authored 12 hours ago
640
            .background(
641
                Capsule(style: .continuous)
642
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
643
            )
644
            .overlay(
645
                Capsule(style: .continuous)
646
                    .stroke(
647
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
648
                        lineWidth: 1
649
                    )
650
            )
Bogdan Timofte authored 16 hours ago
651
        }
Bogdan Timofte authored 12 hours ago
652
        .buttonStyle(.plain)
653
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 16 hours ago
654
    }
655

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

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

            
Bogdan Timofte authored 16 hours ago
723
    private func resetBuffer() {
724
        measurements.resetSeries()
Bogdan Timofte authored 2 weeks ago
725
    }
726

            
Bogdan Timofte authored 6 days ago
727
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
728
        if isLargeDisplay {
729
            return .body.weight(.semibold)
730
        }
731
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
732
    }
733

            
734
    private func controlChipFont(condensedLayout: Bool) -> Font {
735
        if isLargeDisplay {
736
            return .callout.weight(.semibold)
737
        }
738
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
739
    }
740

            
Bogdan Timofte authored 2 weeks ago
741
    @ViewBuilder
742
    private func primaryAxisView(
743
        height: CGFloat,
Bogdan Timofte authored a week ago
744
        powerSeries: SeriesData,
Bogdan Timofte authored 16 hours ago
745
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
746
        voltageSeries: SeriesData,
747
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
748
    ) -> some View {
749
        if displayPower {
750
            yAxisLabelsView(
751
                height: height,
752
                context: powerSeries.context,
Bogdan Timofte authored a week ago
753
                seriesKind: .power,
754
                measurementUnit: powerSeries.kind.unit,
755
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
756
            )
Bogdan Timofte authored 16 hours ago
757
        } else if displayEnergy {
758
            yAxisLabelsView(
759
                height: height,
760
                context: energySeries.context,
761
                seriesKind: .energy,
762
                measurementUnit: energySeries.kind.unit,
763
                tint: energySeries.kind.tint
764
            )
Bogdan Timofte authored 2 weeks ago
765
        } else if displayVoltage {
766
            yAxisLabelsView(
767
                height: height,
768
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
769
                seriesKind: .voltage,
770
                measurementUnit: voltageSeries.kind.unit,
771
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
772
            )
773
        } else if displayCurrent {
774
            yAxisLabelsView(
775
                height: height,
776
                context: currentSeries.context,
Bogdan Timofte authored a week ago
777
                seriesKind: .current,
778
                measurementUnit: currentSeries.kind.unit,
779
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
780
            )
781
        }
782
    }
783

            
784
    @ViewBuilder
785
    private func renderedChart(
Bogdan Timofte authored a week ago
786
        powerSeries: SeriesData,
Bogdan Timofte authored 16 hours ago
787
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
788
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
789
        currentSeries: SeriesData,
790
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
791
    ) -> some View {
792
        if self.displayPower {
793
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
794
                .opacity(0.72)
Bogdan Timofte authored 16 hours ago
795
        } else if self.displayEnergy {
796
            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
797
                .opacity(0.78)
Bogdan Timofte authored 2 weeks ago
798
        } else {
799
            if self.displayVoltage {
800
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
801
                    .opacity(0.78)
802
            }
803
            if self.displayCurrent {
804
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
805
                    .opacity(0.78)
806
            }
807
        }
Bogdan Timofte authored 4 days ago
808

            
809
        if displayTemperature {
810
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
811
                .opacity(0.86)
812
        }
Bogdan Timofte authored 2 weeks ago
813
    }
814

            
815
    @ViewBuilder
816
    private func secondaryAxisView(
817
        height: CGFloat,
Bogdan Timofte authored a week ago
818
        powerSeries: SeriesData,
Bogdan Timofte authored 16 hours ago
819
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
820
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
821
        currentSeries: SeriesData,
822
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
823
    ) -> some View {
Bogdan Timofte authored 4 days ago
824
        if displayTemperature {
825
            yAxisLabelsView(
826
                height: height,
827
                context: temperatureSeries.context,
828
                seriesKind: .temperature,
829
                measurementUnit: measurementUnit(for: .temperature),
830
                tint: temperatureSeries.kind.tint
831
            )
832
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 weeks ago
833
            yAxisLabelsView(
834
                height: height,
835
                context: currentSeries.context,
Bogdan Timofte authored a week ago
836
                seriesKind: .current,
837
                measurementUnit: currentSeries.kind.unit,
838
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
839
            )
840
        } else {
841
            primaryAxisView(
842
                height: height,
843
                powerSeries: powerSeries,
Bogdan Timofte authored 16 hours ago
844
                energySeries: energySeries,
Bogdan Timofte authored 2 weeks ago
845
                voltageSeries: voltageSeries,
846
                currentSeries: currentSeries
847
            )
Bogdan Timofte authored 2 weeks ago
848
        }
849
    }
Bogdan Timofte authored 2 weeks ago
850

            
851
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
852
        powerSeries: SeriesData,
Bogdan Timofte authored 16 hours ago
853
        energySeries: SeriesData,
Bogdan Timofte authored a week ago
854
        voltageSeries: SeriesData,
855
        currentSeries: SeriesData
856
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
857
        if displayPower {
Bogdan Timofte authored a week ago
858
            return powerSeries
Bogdan Timofte authored 2 weeks ago
859
        }
Bogdan Timofte authored 16 hours ago
860
        if displayEnergy {
861
            return energySeries
862
        }
Bogdan Timofte authored 2 weeks ago
863
        if displayVoltage {
Bogdan Timofte authored a week ago
864
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
865
        }
866
        if displayCurrent {
Bogdan Timofte authored a week ago
867
            return currentSeries
Bogdan Timofte authored 2 weeks ago
868
        }
869
        return nil
870
    }
871

            
872
    private func series(
873
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
874
        kind: SeriesKind,
Bogdan Timofte authored 4 days ago
875
        minimumYSpan: Double,
876
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a week ago
877
    ) -> SeriesData {
Bogdan Timofte authored 16 hours ago
878
        let rawPoints = filteredPoints(
Bogdan Timofte authored 4 days ago
879
            measurement,
880
            visibleTimeRange: visibleTimeRange
881
        )
Bogdan Timofte authored 16 hours ago
882
        let points = smoothedPoints(from: rawPoints)
Bogdan Timofte authored a week ago
883
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
884
        let context = ChartContext()
Bogdan Timofte authored a week ago
885

            
886
        let autoBounds = automaticYBounds(
887
            for: samplePoints,
888
            minimumYSpan: minimumYSpan
889
        )
Bogdan Timofte authored 4 days ago
890
        let xBounds = xBounds(
891
            for: samplePoints,
892
            visibleTimeRange: visibleTimeRange
893
        )
Bogdan Timofte authored a week ago
894
        let lowerBound = resolvedLowerBound(
895
            for: kind,
896
            autoLowerBound: autoBounds.lowerBound
897
        )
898
        let upperBound = resolvedUpperBound(
899
            for: kind,
900
            lowerBound: lowerBound,
901
            autoUpperBound: autoBounds.upperBound,
902
            maximumSampleValue: samplePoints.map(\.value).max(),
903
            minimumYSpan: minimumYSpan
904
        )
905

            
906
        context.setBounds(
907
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
908
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
909
            yMin: CGFloat(lowerBound),
910
            yMax: CGFloat(upperBound)
911
        )
912

            
913
        return SeriesData(
914
            kind: kind,
915
            points: points,
916
            samplePoints: samplePoints,
917
            context: context,
918
            autoLowerBound: autoBounds.lowerBound,
919
            autoUpperBound: autoBounds.upperBound,
920
            maximumSampleValue: samplePoints.map(\.value).max()
921
        )
922
    }
923

            
Bogdan Timofte authored 4 days ago
924
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
925
        series(
926
            for: measurement(for: kind),
927
            kind: kind,
928
            minimumYSpan: minimumYSpan(for: kind)
929
        )
930
    }
931

            
Bogdan Timofte authored 16 hours ago
932
    private func smoothedPoints(
933
        from points: [Measurements.Measurement.Point]
934
    ) -> [Measurements.Measurement.Point] {
935
        guard smoothingLevel != .off else { return points }
936

            
937
        var smoothedPoints: [Measurements.Measurement.Point] = []
938
        var currentSegment: [Measurements.Measurement.Point] = []
939

            
940
        func flushCurrentSegment() {
941
            guard !currentSegment.isEmpty else { return }
942

            
943
            for point in smoothedSegment(currentSegment) {
944
                smoothedPoints.append(
945
                    Measurements.Measurement.Point(
946
                        id: smoothedPoints.count,
947
                        timestamp: point.timestamp,
948
                        value: point.value,
949
                        kind: .sample
950
                    )
951
                )
952
            }
953

            
954
            currentSegment.removeAll(keepingCapacity: true)
955
        }
956

            
957
        for point in points {
958
            if point.isDiscontinuity {
959
                flushCurrentSegment()
960

            
961
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
962
                    smoothedPoints.append(
963
                        Measurements.Measurement.Point(
964
                            id: smoothedPoints.count,
965
                            timestamp: point.timestamp,
966
                            value: smoothedPoints.last?.value ?? point.value,
967
                            kind: .discontinuity
968
                        )
969
                    )
970
                }
971
            } else {
972
                currentSegment.append(point)
973
            }
974
        }
975

            
976
        flushCurrentSegment()
977
        return smoothedPoints
978
    }
979

            
980
    private func smoothedSegment(
981
        _ segment: [Measurements.Measurement.Point]
982
    ) -> [Measurements.Measurement.Point] {
983
        let windowSize = smoothingLevel.movingAverageWindowSize
984
        guard windowSize > 1, segment.count > 2 else { return segment }
985

            
986
        let radius = windowSize / 2
987
        var prefixSums: [Double] = [0]
988
        prefixSums.reserveCapacity(segment.count + 1)
989

            
990
        for point in segment {
991
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
992
        }
993

            
994
        return segment.enumerated().map { index, point in
995
            let lowerBound = max(0, index - radius)
996
            let upperBound = min(segment.count - 1, index + radius)
997
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
998
            let average = sum / Double(upperBound - lowerBound + 1)
999

            
1000
            return Measurements.Measurement.Point(
1001
                id: point.id,
1002
                timestamp: point.timestamp,
1003
                value: average,
1004
                kind: .sample
1005
            )
1006
        }
1007
    }
1008

            
Bogdan Timofte authored 4 days ago
1009
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1010
        switch kind {
1011
        case .power:
1012
            return measurements.power
Bogdan Timofte authored 16 hours ago
1013
        case .energy:
1014
            return measurements.energy
Bogdan Timofte authored 4 days ago
1015
        case .voltage:
1016
            return measurements.voltage
1017
        case .current:
1018
            return measurements.current
1019
        case .temperature:
1020
            return measurements.temperature
1021
        }
1022
    }
1023

            
1024
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1025
        switch kind {
1026
        case .power:
1027
            return minimumPowerSpan
Bogdan Timofte authored 16 hours ago
1028
        case .energy:
1029
            return minimumEnergySpan
Bogdan Timofte authored 4 days ago
1030
        case .voltage:
1031
            return minimumVoltageSpan
1032
        case .current:
1033
            return minimumCurrentSpan
1034
        case .temperature:
1035
            return minimumTemperatureSpan
1036
        }
1037
    }
1038

            
Bogdan Timofte authored a week ago
1039
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 16 hours ago
1040
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored a week ago
1041
    }
1042

            
Bogdan Timofte authored 6 days ago
1043
    private var minimumSharedScaleSpan: Double {
1044
        max(minimumVoltageSpan, minimumCurrentSpan)
1045
    }
1046

            
Bogdan Timofte authored a week ago
1047
    private var pinnedOriginIsZero: Bool {
1048
        if useSharedOrigin && supportsSharedOrigin {
1049
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
1050
        }
Bogdan Timofte authored a week ago
1051

            
1052
        if displayPower {
1053
            return pinOrigin && powerAxisOrigin == 0
1054
        }
1055

            
Bogdan Timofte authored 16 hours ago
1056
        if displayEnergy {
1057
            return pinOrigin && energyAxisOrigin == 0
1058
        }
1059

            
Bogdan Timofte authored a week ago
1060
        let visibleOrigins = [
1061
            displayVoltage ? voltageAxisOrigin : nil,
1062
            displayCurrent ? currentAxisOrigin : nil
1063
        ]
1064
        .compactMap { $0 }
1065

            
1066
        guard !visibleOrigins.isEmpty else { return false }
1067
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1068
    }
1069

            
1070
    private func toggleSharedOrigin(
1071
        voltageSeries: SeriesData,
1072
        currentSeries: SeriesData
1073
    ) {
1074
        guard supportsSharedOrigin else { return }
1075

            
1076
        if useSharedOrigin {
1077
            useSharedOrigin = false
1078
            return
1079
        }
1080

            
1081
        captureCurrentOrigins(
1082
            voltageSeries: voltageSeries,
1083
            currentSeries: currentSeries
1084
        )
1085
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
1086
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1087
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1088
        useSharedOrigin = true
1089
        pinOrigin = true
1090
    }
1091

            
1092
    private func togglePinnedOrigin(
1093
        voltageSeries: SeriesData,
1094
        currentSeries: SeriesData
1095
    ) {
1096
        if pinOrigin {
1097
            pinOrigin = false
1098
            return
1099
        }
1100

            
1101
        captureCurrentOrigins(
1102
            voltageSeries: voltageSeries,
1103
            currentSeries: currentSeries
1104
        )
1105
        pinOrigin = true
1106
    }
1107

            
1108
    private func setVisibleOriginsToZero() {
1109
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
1110
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
1111
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1112
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
1113
            voltageAxisOrigin = 0
1114
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1115
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1116
        } else {
1117
            if displayPower {
1118
                powerAxisOrigin = 0
1119
            }
Bogdan Timofte authored 16 hours ago
1120
            if displayEnergy {
1121
                energyAxisOrigin = 0
1122
            }
Bogdan Timofte authored a week ago
1123
            if displayVoltage {
1124
                voltageAxisOrigin = 0
1125
            }
1126
            if displayCurrent {
1127
                currentAxisOrigin = 0
1128
            }
Bogdan Timofte authored 4 days ago
1129
            if displayTemperature {
1130
                temperatureAxisOrigin = 0
1131
            }
Bogdan Timofte authored a week ago
1132
        }
1133

            
1134
        pinOrigin = true
1135
    }
1136

            
1137
    private func captureCurrentOrigins(
1138
        voltageSeries: SeriesData,
1139
        currentSeries: SeriesData
1140
    ) {
1141
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 16 hours ago
1142
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored a week ago
1143
        voltageAxisOrigin = voltageSeries.autoLowerBound
1144
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 4 days ago
1145
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a week ago
1146
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
1147
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1148
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1149
    }
1150

            
1151
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 4 days ago
1152
        let visibleTimeRange = activeVisibleTimeRange
1153

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

            
Bogdan Timofte authored 4 days ago
1214
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1215
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1216
    }
1217

            
1218
    private func filteredPoints(
1219
        _ measurement: Measurements.Measurement,
1220
        visibleTimeRange: ClosedRange<Date>? = nil
1221
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 16 hours ago
1222
        let resolvedRange: ClosedRange<Date>?
1223

            
1224
        switch (timeRange, visibleTimeRange) {
1225
        case let (baseRange?, visibleRange?):
1226
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1227
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1228
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1229
        case let (baseRange?, nil):
1230
            resolvedRange = baseRange
1231
        case let (nil, visibleRange?):
1232
            resolvedRange = visibleRange
1233
        case (nil, nil):
1234
            resolvedRange = nil
Bogdan Timofte authored 4 days ago
1235
        }
Bogdan Timofte authored 16 hours ago
1236

            
1237
        guard let resolvedRange else {
1238
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1239
        }
1240

            
1241
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 4 days ago
1242
    }
1243

            
1244
    private func filteredSamplePoints(
1245
        _ measurement: Measurements.Measurement,
1246
        visibleTimeRange: ClosedRange<Date>? = nil
1247
    ) -> [Measurements.Measurement.Point] {
1248
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1249
            point.isSample
Bogdan Timofte authored a week ago
1250
        }
1251
    }
1252

            
1253
    private func xBounds(
Bogdan Timofte authored 4 days ago
1254
        for samplePoints: [Measurements.Measurement.Point],
1255
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a week ago
1256
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 4 days ago
1257
        if let visibleTimeRange {
1258
            return normalizedTimeRange(visibleTimeRange)
1259
        }
1260

            
Bogdan Timofte authored a week ago
1261
        if let timeRange {
Bogdan Timofte authored 4 days ago
1262
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored a week ago
1263
        }
1264

            
1265
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1266
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1267

            
Bogdan Timofte authored 4 days ago
1268
        return normalizedTimeRange(lowerBound...upperBound)
1269
    }
1270

            
1271
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1272
        if let timeRange {
1273
            return normalizedTimeRange(timeRange)
1274
        }
1275

            
1276
        let samplePoints = timelineSamplePoints()
1277
        guard let lowerBound = samplePoints.first?.timestamp else {
1278
            return nil
1279
        }
1280

            
1281
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1282
        return normalizedTimeRange(lowerBound...upperBound)
1283
    }
1284

            
1285
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1286
        let candidates = [
1287
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 16 hours ago
1288
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 4 days ago
1289
            filteredSamplePoints(measurements.voltage),
1290
            filteredSamplePoints(measurements.current),
1291
            filteredSamplePoints(measurements.temperature)
1292
        ]
1293

            
1294
        return candidates.first(where: { !$0.isEmpty }) ?? []
1295
    }
1296

            
1297
    private func resolvedVisibleTimeRange(
1298
        within availableTimeRange: ClosedRange<Date>?
1299
    ) -> ClosedRange<Date>? {
1300
        guard let availableTimeRange else { return nil }
1301
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1302

            
1303
        if isPinnedToPresent {
1304
            let pinnedRange: ClosedRange<Date>
1305

            
1306
            switch presentTrackingMode {
1307
            case .keepDuration:
1308
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1309
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1310
            case .keepStartTimestamp:
1311
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1312
            }
1313

            
1314
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1315
        }
1316

            
1317
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1318
    }
1319

            
1320
    private func clampedTimeRange(
1321
        _ candidateRange: ClosedRange<Date>,
1322
        within bounds: ClosedRange<Date>
1323
    ) -> ClosedRange<Date> {
1324
        let normalizedBounds = normalizedTimeRange(bounds)
1325
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1326

            
1327
        guard boundsSpan > 0 else {
1328
            return normalizedBounds
1329
        }
1330

            
1331
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1332
        let requestedSpan = min(
1333
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1334
            boundsSpan
1335
        )
1336

            
1337
        if requestedSpan >= boundsSpan {
1338
            return normalizedBounds
1339
        }
1340

            
1341
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1342
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1343

            
1344
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1345
            if lowerBound == normalizedBounds.lowerBound {
1346
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1347
            } else {
1348
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1349
            }
1350
        }
1351

            
1352
        if upperBound > normalizedBounds.upperBound {
1353
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1354
            upperBound = normalizedBounds.upperBound
1355
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored a week ago
1356
        }
1357

            
Bogdan Timofte authored 4 days ago
1358
        if lowerBound < normalizedBounds.lowerBound {
1359
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1360
            lowerBound = normalizedBounds.lowerBound
1361
            upperBound = upperBound.addingTimeInterval(delta)
1362
        }
1363

            
1364
        return lowerBound...upperBound
1365
    }
1366

            
1367
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1368
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1369
        guard span < minimumTimeSpan else { return range }
1370

            
1371
        let expansion = (minimumTimeSpan - span) / 2
1372
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1373
    }
1374

            
1375
    private func shouldShowRangeSelector(
1376
        availableTimeRange: ClosedRange<Date>,
1377
        series: SeriesData
1378
    ) -> Bool {
1379
        series.samplePoints.count > 1 &&
1380
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored a week ago
1381
    }
1382

            
1383
    private func automaticYBounds(
1384
        for samplePoints: [Measurements.Measurement.Point],
1385
        minimumYSpan: Double
1386
    ) -> (lowerBound: Double, upperBound: Double) {
1387
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1388

            
1389
        guard
1390
            let minimumSampleValue = samplePoints.map(\.value).min(),
1391
            let maximumSampleValue = samplePoints.map(\.value).max()
1392
        else {
1393
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
1394
        }
Bogdan Timofte authored a week ago
1395

            
1396
        var lowerBound = minimumSampleValue
1397
        var upperBound = maximumSampleValue
1398
        let currentSpan = upperBound - lowerBound
1399

            
1400
        if currentSpan < minimumYSpan {
1401
            let expansion = (minimumYSpan - currentSpan) / 2
1402
            lowerBound -= expansion
1403
            upperBound += expansion
1404
        }
1405

            
1406
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1407
            let shift = -negativeAllowance - lowerBound
1408
            lowerBound += shift
1409
            upperBound += shift
1410
        }
1411

            
1412
        let snappedLowerBound = snappedOriginValue(lowerBound)
1413
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1414
        return (snappedLowerBound, resolvedUpperBound)
1415
    }
1416

            
1417
    private func resolvedLowerBound(
1418
        for kind: SeriesKind,
1419
        autoLowerBound: Double
1420
    ) -> Double {
1421
        guard pinOrigin else { return autoLowerBound }
1422

            
1423
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1424
            return sharedAxisOrigin
1425
        }
1426

            
1427
        switch kind {
1428
        case .power:
1429
            return powerAxisOrigin
Bogdan Timofte authored 16 hours ago
1430
        case .energy:
1431
            return energyAxisOrigin
Bogdan Timofte authored a week ago
1432
        case .voltage:
1433
            return voltageAxisOrigin
1434
        case .current:
1435
            return currentAxisOrigin
Bogdan Timofte authored 4 days ago
1436
        case .temperature:
1437
            return temperatureAxisOrigin
Bogdan Timofte authored a week ago
1438
        }
1439
    }
1440

            
1441
    private func resolvedUpperBound(
1442
        for kind: SeriesKind,
1443
        lowerBound: Double,
1444
        autoUpperBound: Double,
1445
        maximumSampleValue: Double?,
1446
        minimumYSpan: Double
1447
    ) -> Double {
1448
        guard pinOrigin else {
1449
            return autoUpperBound
1450
        }
1451

            
Bogdan Timofte authored 6 days ago
1452
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1453
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1454
        }
1455

            
Bogdan Timofte authored 4 days ago
1456
        if kind == .temperature {
1457
            return autoUpperBound
1458
        }
1459

            
Bogdan Timofte authored a week ago
1460
        return max(
1461
            maximumSampleValue ?? lowerBound,
1462
            lowerBound + minimumYSpan,
1463
            autoUpperBound
1464
        )
1465
    }
1466

            
Bogdan Timofte authored 6 days ago
1467
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
1468
        let baseline = displayedLowerBoundForSeries(kind)
1469
        let proposedOrigin = snappedOriginValue(baseline + delta)
1470

            
1471
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
1472
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
1473
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
1474
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1475
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1476
        } else {
1477
            switch kind {
1478
            case .power:
1479
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 16 hours ago
1480
            case .energy:
1481
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored a week ago
1482
            case .voltage:
1483
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1484
            case .current:
1485
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 4 days ago
1486
            case .temperature:
1487
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a week ago
1488
            }
1489
        }
1490

            
1491
        pinOrigin = true
1492
    }
1493

            
Bogdan Timofte authored 6 days ago
1494
    private func clearOriginOffset(for kind: SeriesKind) {
1495
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1496
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1497
            sharedAxisOrigin = 0
1498
            sharedAxisUpperBound = currentSpan
1499
            ensureSharedScaleSpan()
1500
            voltageAxisOrigin = 0
1501
            currentAxisOrigin = 0
1502
        } else {
1503
            switch kind {
1504
            case .power:
1505
                powerAxisOrigin = 0
Bogdan Timofte authored 16 hours ago
1506
            case .energy:
1507
                energyAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1508
            case .voltage:
1509
                voltageAxisOrigin = 0
1510
            case .current:
1511
                currentAxisOrigin = 0
Bogdan Timofte authored 4 days ago
1512
            case .temperature:
1513
                temperatureAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1514
            }
1515
        }
1516

            
1517
        pinOrigin = true
1518
    }
1519

            
1520
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1521
        guard totalHeight > 1 else { return }
1522

            
1523
        let normalized = max(0, min(1, locationY / totalHeight))
1524
        if normalized < (1.0 / 3.0) {
1525
            applyOriginDelta(-1, kind: kind)
1526
        } else if normalized < (2.0 / 3.0) {
1527
            clearOriginOffset(for: kind)
1528
        } else {
1529
            applyOriginDelta(1, kind: kind)
1530
        }
1531
    }
1532

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

            
Bogdan Timofte authored a week ago
1536
        switch kind {
1537
        case .power:
Bogdan Timofte authored 4 days ago
1538
            return snappedOriginValue(
1539
                filteredSamplePoints(
1540
                    measurements.power,
1541
                    visibleTimeRange: visibleTimeRange
1542
                ).map(\.value).min() ?? 0
1543
            )
Bogdan Timofte authored 16 hours ago
1544
        case .energy:
1545
            return snappedOriginValue(
1546
                filteredSamplePoints(
1547
                    measurements.energy,
1548
                    visibleTimeRange: visibleTimeRange
1549
                ).map(\.value).min() ?? 0
1550
            )
Bogdan Timofte authored a week ago
1551
        case .voltage:
Bogdan Timofte authored 4 days ago
1552
            return snappedOriginValue(
1553
                filteredSamplePoints(
1554
                    measurements.voltage,
1555
                    visibleTimeRange: visibleTimeRange
1556
                ).map(\.value).min() ?? 0
1557
            )
Bogdan Timofte authored a week ago
1558
        case .current:
Bogdan Timofte authored 4 days ago
1559
            return snappedOriginValue(
1560
                filteredSamplePoints(
1561
                    measurements.current,
1562
                    visibleTimeRange: visibleTimeRange
1563
                ).map(\.value).min() ?? 0
1564
            )
Bogdan Timofte authored 4 days ago
1565
        case .temperature:
Bogdan Timofte authored 4 days ago
1566
            return snappedOriginValue(
1567
                filteredSamplePoints(
1568
                    measurements.temperature,
1569
                    visibleTimeRange: visibleTimeRange
1570
                ).map(\.value).min() ?? 0
1571
            )
Bogdan Timofte authored a week ago
1572
        }
1573
    }
1574

            
1575
    private func maximumVisibleSharedOrigin() -> Double {
1576
        min(
1577
            maximumVisibleOrigin(for: .voltage),
1578
            maximumVisibleOrigin(for: .current)
1579
        )
1580
    }
1581

            
Bogdan Timofte authored 4 days ago
1582
    private func measurementUnit(for kind: SeriesKind) -> String {
1583
        switch kind {
1584
        case .temperature:
1585
            let locale = Locale.autoupdatingCurrent
1586
            if #available(iOS 16.0, *) {
1587
                switch locale.measurementSystem {
1588
                case .us:
1589
                    return "°F"
1590
                default:
1591
                    return "°C"
1592
                }
1593
            }
1594

            
1595
            let regionCode = locale.regionCode ?? ""
1596
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1597
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1598
        default:
1599
            return kind.unit
1600
        }
1601
    }
1602

            
Bogdan Timofte authored 6 days ago
1603
    private func ensureSharedScaleSpan() {
1604
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1605
    }
1606

            
Bogdan Timofte authored a week ago
1607
    private func snappedOriginValue(_ value: Double) -> Double {
1608
        if value >= 0 {
1609
            return value.rounded(.down)
1610
        }
1611

            
1612
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
1613
    }
Bogdan Timofte authored 2 weeks ago
1614

            
Bogdan Timofte authored 16 hours ago
1615
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1616
        measurements.keepOnly(in: range)
1617
        selectedVisibleTimeRange = nil
1618
        isPinnedToPresent = false
1619
    }
1620

            
1621
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1622
        measurements.removeValues(in: range)
1623
        selectedVisibleTimeRange = nil
1624
        isPinnedToPresent = false
1625
    }
1626

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

            
1637
    private func xGuidePosition(
1638
        for labelIndex: Int,
1639
        context: ChartContext,
1640
        width: CGFloat
1641
    ) -> CGFloat {
1642
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1643
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1644
        return context.placeInRect(point: anchorPoint).x * width
1645
    }
Bogdan Timofte authored 2 weeks ago
1646

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

            
1667
        return HStack(spacing: chartSectionSpacing) {
1668
            Color.clear
1669
                .frame(width: axisColumnWidth)
1670

            
1671
            GeometryReader { geometry in
1672
                let labelWidth = max(
1673
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1674
                    1
1675
                )
1676

            
1677
                ZStack(alignment: .topLeading) {
1678
                    Path { path in
1679
                        for labelIndex in 1...self.xLabels {
1680
                            let x = xGuidePosition(
1681
                                for: labelIndex,
1682
                                context: context,
1683
                                width: geometry.size.width
1684
                            )
1685
                            path.move(to: CGPoint(x: x, y: 0))
1686
                            path.addLine(to: CGPoint(x: x, y: 6))
1687
                        }
1688
                    }
1689
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1690

            
1691
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1692
                        let labelIndex = item.offset + 1
1693
                        let centerX = xGuidePosition(
1694
                            for: labelIndex,
1695
                            context: context,
1696
                            width: geometry.size.width
1697
                        )
1698

            
1699
                        Text(item.element)
Bogdan Timofte authored 5 days ago
1700
                            .font(axisLabelFont)
Bogdan Timofte authored 2 weeks ago
1701
                            .monospacedDigit()
1702
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1703
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1704
                            .frame(width: labelWidth)
1705
                            .position(
1706
                                x: centerX,
1707
                                y: geometry.size.height * 0.7
1708
                            )
Bogdan Timofte authored 2 weeks ago
1709
                    }
1710
                }
Bogdan Timofte authored 2 weeks ago
1711
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1712
            }
Bogdan Timofte authored 2 weeks ago
1713

            
1714
            Color.clear
1715
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1716
        }
1717
    }
1718

            
Bogdan Timofte authored a week ago
1719
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1720
        height: CGFloat,
1721
        context: ChartContext,
Bogdan Timofte authored a week ago
1722
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1723
        measurementUnit: String,
1724
        tint: Color
1725
    ) -> some View {
Bogdan Timofte authored 5 days ago
1726
        let yAxisFont: Font = {
1727
            if isIPhone && isPortraitLayout {
1728
                return .caption2.weight(.semibold)
1729
            }
1730
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1731
        }()
1732

            
1733
        let unitFont: Font = {
1734
            if isIPhone && isPortraitLayout {
1735
                return .caption2.weight(.bold)
1736
            }
1737
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1738
        }()
1739

            
1740
        return GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1741
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1742
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1743
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1744

            
Bogdan Timofte authored 2 weeks ago
1745
            ZStack(alignment: .top) {
1746
                ForEach(0..<yLabels, id: \.self) { row in
1747
                    let labelIndex = yLabels - row
1748

            
1749
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 5 days ago
1750
                        .font(yAxisFont)
Bogdan Timofte authored 2 weeks ago
1751
                        .monospacedDigit()
1752
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1753
                        .minimumScaleFactor(0.8)
1754
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1755
                        .position(
1756
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1757
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1758
                                for: labelIndex,
1759
                                context: context,
Bogdan Timofte authored 6 days ago
1760
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1761
                            )
1762
                        )
Bogdan Timofte authored 2 weeks ago
1763
                }
Bogdan Timofte authored 2 weeks ago
1764

            
Bogdan Timofte authored 2 weeks ago
1765
                Text(measurementUnit)
Bogdan Timofte authored 5 days ago
1766
                    .font(unitFont)
Bogdan Timofte authored 2 weeks ago
1767
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1768
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1769
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1770
                    .background(
1771
                        Capsule(style: .continuous)
1772
                            .fill(tint.opacity(0.14))
1773
                    )
Bogdan Timofte authored 6 days ago
1774
                    .padding(.top, 8)
1775

            
Bogdan Timofte authored 2 weeks ago
1776
            }
1777
        }
Bogdan Timofte authored 2 weeks ago
1778
        .frame(height: height)
1779
        .background(
1780
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1781
                .fill(tint.opacity(0.12))
1782
        )
1783
        .overlay(
1784
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1785
                .stroke(tint.opacity(0.20), lineWidth: 1)
1786
        )
Bogdan Timofte authored a week ago
1787
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1788
        .gesture(
Bogdan Timofte authored 6 days ago
1789
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1790
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1791
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1792
                }
1793
        )
Bogdan Timofte authored 2 weeks ago
1794
    }
1795

            
Bogdan Timofte authored 2 weeks ago
1796
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1797
        GeometryReader { geometry in
1798
            Path { path in
Bogdan Timofte authored 2 weeks ago
1799
                for labelIndex in 1...self.yLabels {
1800
                    let y = yGuidePosition(
1801
                        for: labelIndex,
1802
                        context: context,
1803
                        height: geometry.size.height
1804
                    )
1805
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1806
                }
Bogdan Timofte authored 2 weeks ago
1807
            }
1808
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1809
        }
1810
    }
1811

            
Bogdan Timofte authored 2 weeks ago
1812
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1813
        GeometryReader { geometry in
1814
            Path { path in
1815

            
Bogdan Timofte authored 2 weeks ago
1816
                for labelIndex in 2..<self.xLabels {
1817
                    let x = xGuidePosition(
1818
                        for: labelIndex,
1819
                        context: context,
1820
                        width: geometry.size.width
1821
                    )
1822
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1823
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1824
                }
Bogdan Timofte authored 2 weeks ago
1825
            }
1826
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1827
        }
1828
    }
Bogdan Timofte authored a week ago
1829

            
1830
    fileprivate func discontinuityMarkers(
1831
        points: [Measurements.Measurement.Point],
1832
        context: ChartContext
1833
    ) -> some View {
1834
        GeometryReader { geometry in
1835
            Path { path in
1836
                for point in points where point.isDiscontinuity {
1837
                    let markerX = context.placeInRect(
1838
                        point: CGPoint(
1839
                            x: point.timestamp.timeIntervalSince1970,
1840
                            y: context.origin.y
1841
                        )
1842
                    ).x * geometry.size.width
1843
                    path.move(to: CGPoint(x: markerX, y: 0))
1844
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1845
                }
1846
            }
1847
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1848
        }
1849
    }
Bogdan Timofte authored 2 weeks ago
1850

            
1851
}
1852

            
Bogdan Timofte authored 4 days ago
1853
private struct TimeRangeSelectorView: View {
1854
    private enum DragTarget {
1855
        case lowerBound
1856
        case upperBound
1857
        case window
1858
    }
1859

            
Bogdan Timofte authored 16 hours ago
1860
    private enum ActionTone {
1861
        case reversible
1862
        case destructive
1863
        case destructiveProminent
1864
    }
1865

            
Bogdan Timofte authored 4 days ago
1866
    private struct DragState {
1867
        let target: DragTarget
1868
        let initialRange: ClosedRange<Date>
1869
    }
1870

            
1871
    let points: [Measurements.Measurement.Point]
1872
    let context: ChartContext
1873
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 16 hours ago
1874
    let selectorTint: Color
Bogdan Timofte authored 4 days ago
1875
    let compactLayout: Bool
1876
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored 16 hours ago
1877
    let onKeepSelection: (ClosedRange<Date>) -> Void
1878
    let onRemoveSelection: (ClosedRange<Date>) -> Void
1879
    let onResetBuffer: () -> Void
Bogdan Timofte authored 4 days ago
1880

            
1881
    @Binding var selectedTimeRange: ClosedRange<Date>?
1882
    @Binding var isPinnedToPresent: Bool
1883
    @Binding var presentTrackingMode: PresentTrackingMode
1884
    @State private var dragState: DragState?
Bogdan Timofte authored 16 hours ago
1885
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 4 days ago
1886

            
1887
    private var totalSpan: TimeInterval {
1888
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
1889
    }
1890

            
1891
    private var currentRange: ClosedRange<Date> {
1892
        resolvedSelectionRange()
1893
    }
1894

            
1895
    private var trackHeight: CGFloat {
1896
        compactLayout ? 72 : 86
1897
    }
1898

            
1899
    private var cornerRadius: CGFloat {
1900
        compactLayout ? 14 : 16
1901
    }
1902

            
1903
    private var boundaryFont: Font {
1904
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1905
    }
1906

            
1907
    private var symbolButtonSize: CGFloat {
1908
        compactLayout ? 28 : 32
1909
    }
1910

            
1911
    var body: some View {
1912
        let coversFullRange = selectionCoversFullRange(currentRange)
1913

            
1914
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
1915
            if !coversFullRange || isPinnedToPresent {
1916
                HStack(spacing: 8) {
1917
                    alignmentButton(
1918
                        systemName: "arrow.left.to.line.compact",
1919
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
1920
                        action: alignSelectionToLeadingEdge,
1921
                        accessibilityLabel: "Align selection to start"
1922
                    )
1923

            
1924
                    alignmentButton(
1925
                        systemName: "arrow.right.to.line.compact",
1926
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
1927
                        action: alignSelectionToTrailingEdge,
1928
                        accessibilityLabel: "Align selection to present"
1929
                    )
1930

            
1931
                    Spacer(minLength: 0)
1932

            
1933
                    if isPinnedToPresent {
1934
                        trackingModeToggleButton()
1935
                    }
1936
                }
1937
            }
1938

            
Bogdan Timofte authored 16 hours ago
1939
            HStack(spacing: 8) {
1940
                if !coversFullRange {
1941
                    actionButton(
1942
                        title: compactLayout ? "Keep" : "Keep Selection",
1943
                        systemName: "scissors",
1944
                        tone: .destructive,
1945
                        action: {
1946
                            onKeepSelection(currentRange)
1947
                        }
1948
                    )
1949

            
1950
                    actionButton(
1951
                        title: compactLayout ? "Cut" : "Remove Selection",
1952
                        systemName: "minus.circle",
1953
                        tone: .destructive,
1954
                        action: {
1955
                            onRemoveSelection(currentRange)
1956
                        }
1957
                    )
1958
                }
1959

            
1960
                Spacer(minLength: 0)
1961

            
1962
                actionButton(
1963
                    title: compactLayout ? "Reset" : "Reset Buffer",
1964
                    systemName: "trash",
1965
                    tone: .destructiveProminent,
1966
                    action: {
1967
                        showResetConfirmation = true
1968
                    }
1969
                )
1970
            }
1971
            .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
1972
                Button("Reset buffer", role: .destructive) {
1973
                    onResetBuffer()
1974
                }
1975
                Button("Cancel", role: .cancel) {}
1976
            }
1977

            
Bogdan Timofte authored 4 days ago
1978
            GeometryReader { geometry in
1979
                let selectionFrame = selectionFrame(in: geometry.size)
1980
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
1981

            
1982
                ZStack(alignment: .topLeading) {
1983
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1984
                        .fill(Color.primary.opacity(0.05))
1985

            
1986
                    Chart(
1987
                        points: points,
1988
                        context: context,
1989
                        areaChart: true,
Bogdan Timofte authored 16 hours ago
1990
                        strokeColor: selectorTint,
1991
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 4 days ago
1992
                    )
1993
                    .opacity(0.94)
1994
                    .allowsHitTesting(false)
1995

            
1996
                    Chart(
1997
                        points: points,
1998
                        context: context,
Bogdan Timofte authored 16 hours ago
1999
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 4 days ago
2000
                    )
2001
                    .opacity(0.82)
2002
                    .allowsHitTesting(false)
2003

            
2004
                    if selectionFrame.minX > 0 {
2005
                        Rectangle()
2006
                            .fill(dimmingColor)
2007
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2008
                            .allowsHitTesting(false)
2009
                    }
2010

            
2011
                    if selectionFrame.maxX < geometry.size.width {
2012
                        Rectangle()
2013
                            .fill(dimmingColor)
2014
                            .frame(
2015
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2016
                                height: geometry.size.height
2017
                            )
2018
                            .offset(x: selectionFrame.maxX)
2019
                            .allowsHitTesting(false)
2020
                    }
2021

            
2022
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 16 hours ago
2023
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 4 days ago
2024
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2025
                        .offset(x: selectionFrame.minX)
2026
                        .allowsHitTesting(false)
2027

            
2028
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 16 hours ago
2029
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 4 days ago
2030
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2031
                        .offset(x: selectionFrame.minX)
2032
                        .allowsHitTesting(false)
2033

            
2034
                    handleView(height: max(geometry.size.height - 18, 16))
2035
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2036
                        .allowsHitTesting(false)
2037

            
2038
                    handleView(height: max(geometry.size.height - 18, 16))
2039
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2040
                        .allowsHitTesting(false)
2041
                }
2042
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2043
                .overlay(
2044
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2045
                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
2046
                )
2047
                .contentShape(Rectangle())
2048
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2049
            }
2050
            .frame(height: trackHeight)
2051

            
2052
            HStack {
2053
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2054
                Spacer(minLength: 0)
2055
                Text(boundaryLabel(for: availableTimeRange.upperBound))
2056
            }
2057
            .font(boundaryFont)
2058
            .foregroundColor(.secondary)
2059
            .monospacedDigit()
2060
        }
2061
    }
2062

            
2063
    private func handleView(height: CGFloat) -> some View {
2064
        Capsule(style: .continuous)
2065
            .fill(Color.white.opacity(0.95))
2066
            .frame(width: 6, height: height)
2067
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2068
    }
2069

            
2070
    private func alignmentButton(
2071
        systemName: String,
2072
        isActive: Bool,
2073
        action: @escaping () -> Void,
2074
        accessibilityLabel: String
2075
    ) -> some View {
2076
        Button(action: action) {
2077
            Image(systemName: systemName)
2078
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2079
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2080
        }
2081
        .buttonStyle(.plain)
Bogdan Timofte authored 16 hours ago
2082
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 4 days ago
2083
        .background(
2084
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 16 hours ago
2085
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 4 days ago
2086
        )
2087
        .overlay(
2088
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 16 hours ago
2089
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 4 days ago
2090
        )
2091
        .accessibilityLabel(accessibilityLabel)
2092
    }
2093

            
2094
    private func trackingModeToggleButton() -> some View {
2095
        Button {
2096
            presentTrackingMode = presentTrackingMode == .keepDuration
2097
                ? .keepStartTimestamp
2098
                : .keepDuration
2099
        } label: {
2100
            Image(systemName: trackingModeSymbolName)
2101
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2102
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2103
        }
2104
        .buttonStyle(.plain)
2105
        .foregroundColor(.white)
2106
        .background(
2107
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 16 hours ago
2108
                .fill(selectorTint)
Bogdan Timofte authored 4 days ago
2109
        )
2110
        .overlay(
2111
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 16 hours ago
2112
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 4 days ago
2113
        )
2114
        .accessibilityLabel(trackingModeAccessibilityLabel)
2115
        .accessibilityHint("Toggles how the interval follows the present")
2116
    }
2117

            
Bogdan Timofte authored 16 hours ago
2118
    private func actionButton(
2119
        title: String,
2120
        systemName: String,
2121
        tone: ActionTone,
2122
        action: @escaping () -> Void
2123
    ) -> some View {
2124
        let foregroundColor: Color = {
2125
            switch tone {
2126
            case .reversible, .destructive:
2127
                return toneColor(for: tone)
2128
            case .destructiveProminent:
2129
                return .white
2130
            }
2131
        }()
2132

            
2133
        return Button(action: action) {
2134
            Label(title, systemImage: systemName)
2135
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2136
                .padding(.horizontal, compactLayout ? 10 : 12)
2137
                .padding(.vertical, compactLayout ? 7 : 8)
2138
        }
2139
        .buttonStyle(.plain)
2140
        .foregroundColor(foregroundColor)
2141
        .background(
2142
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2143
                .fill(actionButtonBackground(for: tone))
2144
        )
2145
        .overlay(
2146
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2147
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2148
        )
2149
    }
2150

            
2151
    private func toneColor(for tone: ActionTone) -> Color {
2152
        switch tone {
2153
        case .reversible:
2154
            return selectorTint
2155
        case .destructive, .destructiveProminent:
2156
            return .red
2157
        }
2158
    }
2159

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

            
2171
    private func actionButtonBorder(for tone: ActionTone) -> Color {
2172
        switch tone {
2173
        case .reversible:
2174
            return selectorTint.opacity(0.22)
2175
        case .destructive:
2176
            return Color.red.opacity(0.22)
2177
        case .destructiveProminent:
2178
            return Color.red.opacity(0.72)
2179
        }
2180
    }
2181

            
Bogdan Timofte authored 4 days ago
2182
    private var trackingModeSymbolName: String {
2183
        switch presentTrackingMode {
2184
        case .keepDuration:
2185
            return "arrow.left.and.right"
2186
        case .keepStartTimestamp:
2187
            return "arrow.left.to.line.compact"
2188
        }
2189
    }
2190

            
2191
    private var trackingModeAccessibilityLabel: String {
2192
        switch presentTrackingMode {
2193
        case .keepDuration:
2194
            return "Follow present keeping span"
2195
        case .keepStartTimestamp:
2196
            return "Follow present keeping start"
2197
        }
2198
    }
2199

            
2200
    private func alignSelectionToLeadingEdge() {
2201
        let alignedRange = normalizedSelectionRange(
2202
            availableTimeRange.lowerBound...currentRange.upperBound
2203
        )
2204
        applySelection(alignedRange, pinToPresent: false)
2205
    }
2206

            
2207
    private func alignSelectionToTrailingEdge() {
2208
        let alignedRange = normalizedSelectionRange(
2209
            currentRange.lowerBound...availableTimeRange.upperBound
2210
        )
2211
        applySelection(alignedRange, pinToPresent: true)
2212
    }
2213

            
2214
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2215
        DragGesture(minimumDistance: 0)
2216
            .onChanged { value in
2217
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2218
            }
2219
            .onEnded { _ in
2220
                dragState = nil
2221
            }
2222
    }
2223

            
2224
    private func updateSelectionDrag(
2225
        value: DragGesture.Value,
2226
        totalWidth: CGFloat
2227
    ) {
2228
        let startingRange = resolvedSelectionRange()
2229

            
2230
        if dragState == nil {
2231
            dragState = DragState(
2232
                target: dragTarget(
2233
                    for: value.startLocation.x,
2234
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2235
                ),
2236
                initialRange: startingRange
2237
            )
2238
        }
2239

            
2240
        guard let dragState else { return }
2241

            
2242
        let resultingRange = snappedToEdges(
2243
            adjustedRange(
2244
                from: dragState.initialRange,
2245
                target: dragState.target,
2246
                translationX: value.translation.width,
2247
                totalWidth: totalWidth
2248
            ),
2249
            target: dragState.target,
2250
            totalWidth: totalWidth
2251
        )
2252

            
2253
        applySelection(
2254
            resultingRange,
2255
            pinToPresent: shouldKeepPresentPin(
2256
                during: dragState.target,
2257
                initialRange: dragState.initialRange,
2258
                resultingRange: resultingRange
2259
            ),
2260
        )
2261
    }
2262

            
2263
    private func dragTarget(
2264
        for startX: CGFloat,
2265
        selectionFrame: CGRect
2266
    ) -> DragTarget {
2267
        let handleZone: CGFloat = compactLayout ? 20 : 24
2268

            
2269
        if abs(startX - selectionFrame.minX) <= handleZone {
2270
            return .lowerBound
2271
        }
2272

            
2273
        if abs(startX - selectionFrame.maxX) <= handleZone {
2274
            return .upperBound
2275
        }
2276

            
2277
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2278
            return .window
2279
        }
2280

            
2281
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2282
    }
2283

            
2284
    private func adjustedRange(
2285
        from initialRange: ClosedRange<Date>,
2286
        target: DragTarget,
2287
        translationX: CGFloat,
2288
        totalWidth: CGFloat
2289
    ) -> ClosedRange<Date> {
2290
        guard totalSpan > 0, totalWidth > 0 else {
2291
            return availableTimeRange
2292
        }
2293

            
2294
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2295
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2296

            
2297
        switch target {
2298
        case .lowerBound:
2299
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2300
            let newLowerBound = min(
2301
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2302
                maximumLowerBound
2303
            )
2304
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2305

            
2306
        case .upperBound:
2307
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2308
            let newUpperBound = max(
2309
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2310
                minimumUpperBound
2311
            )
2312
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2313

            
2314
        case .window:
2315
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2316
            guard span < totalSpan else { return availableTimeRange }
2317

            
2318
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2319
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2320

            
2321
            if lowerBound < availableTimeRange.lowerBound {
2322
                upperBound = upperBound.addingTimeInterval(
2323
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2324
                )
2325
                lowerBound = availableTimeRange.lowerBound
2326
            }
2327

            
2328
            if upperBound > availableTimeRange.upperBound {
2329
                lowerBound = lowerBound.addingTimeInterval(
2330
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2331
                )
2332
                upperBound = availableTimeRange.upperBound
2333
            }
2334

            
2335
            return normalizedSelectionRange(lowerBound...upperBound)
2336
        }
2337
    }
2338

            
2339
    private func snappedToEdges(
2340
        _ candidateRange: ClosedRange<Date>,
2341
        target: DragTarget,
2342
        totalWidth: CGFloat
2343
    ) -> ClosedRange<Date> {
2344
        guard totalSpan > 0 else {
2345
            return availableTimeRange
2346
        }
2347

            
2348
        let snapInterval = edgeSnapInterval(for: totalWidth)
2349
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2350
        var lowerBound = candidateRange.lowerBound
2351
        var upperBound = candidateRange.upperBound
2352

            
2353
        if target != .upperBound,
2354
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2355
            lowerBound = availableTimeRange.lowerBound
2356
            if target == .window {
2357
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2358
            }
2359
        }
2360

            
2361
        if target != .lowerBound,
2362
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2363
            upperBound = availableTimeRange.upperBound
2364
            if target == .window {
2365
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2366
            }
2367
        }
2368

            
2369
        return normalizedSelectionRange(lowerBound...upperBound)
2370
    }
2371

            
2372
    private func edgeSnapInterval(
2373
        for totalWidth: CGFloat
2374
    ) -> TimeInterval {
2375
        guard totalWidth > 0 else { return minimumSelectionSpan }
2376

            
2377
        let snapWidth = min(
2378
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2379
            totalWidth * 0.18
2380
        )
2381
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2382
        return min(
2383
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2384
            totalSpan / 4
2385
        )
2386
    }
2387

            
2388
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2389
        guard let selectedTimeRange else { return availableTimeRange }
2390

            
2391
        if isPinnedToPresent {
2392
            switch presentTrackingMode {
2393
            case .keepDuration:
2394
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2395
                return normalizedSelectionRange(
2396
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2397
                )
2398
            case .keepStartTimestamp:
2399
                return normalizedSelectionRange(
2400
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2401
                )
2402
            }
2403
        }
2404

            
2405
        return normalizedSelectionRange(selectedTimeRange)
2406
    }
2407

            
2408
    private func normalizedSelectionRange(
2409
        _ candidateRange: ClosedRange<Date>
2410
    ) -> ClosedRange<Date> {
2411
        let availableSpan = totalSpan
2412
        guard availableSpan > 0 else { return availableTimeRange }
2413

            
2414
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2415
        let requestedSpan = min(
2416
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2417
            availableSpan
2418
        )
2419

            
2420
        if requestedSpan >= availableSpan {
2421
            return availableTimeRange
2422
        }
2423

            
2424
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2425
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2426

            
2427
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2428
            if lowerBound == availableTimeRange.lowerBound {
2429
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2430
            } else {
2431
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2432
            }
2433
        }
2434

            
2435
        if upperBound > availableTimeRange.upperBound {
2436
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2437
            upperBound = availableTimeRange.upperBound
2438
            lowerBound = lowerBound.addingTimeInterval(-delta)
2439
        }
2440

            
2441
        if lowerBound < availableTimeRange.lowerBound {
2442
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2443
            lowerBound = availableTimeRange.lowerBound
2444
            upperBound = upperBound.addingTimeInterval(delta)
2445
        }
2446

            
2447
        return lowerBound...upperBound
2448
    }
2449

            
2450
    private func shouldKeepPresentPin(
2451
        during target: DragTarget,
2452
        initialRange: ClosedRange<Date>,
2453
        resultingRange: ClosedRange<Date>
2454
    ) -> Bool {
2455
        let startedPinnedToPresent =
2456
            isPinnedToPresent ||
2457
            selectionCoversFullRange(initialRange)
2458

            
2459
        guard startedPinnedToPresent else {
2460
            return selectionTouchesPresent(resultingRange)
2461
        }
2462

            
2463
        switch target {
2464
        case .lowerBound:
2465
            return true
2466
        case .upperBound, .window:
2467
            return selectionTouchesPresent(resultingRange)
2468
        }
2469
    }
2470

            
2471
    private func applySelection(
2472
        _ candidateRange: ClosedRange<Date>,
2473
        pinToPresent: Bool
2474
    ) {
2475
        let normalizedRange = normalizedSelectionRange(candidateRange)
2476

            
2477
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2478
            selectedTimeRange = nil
2479
        } else {
2480
            selectedTimeRange = normalizedRange
2481
        }
2482

            
2483
        isPinnedToPresent = pinToPresent
2484
    }
2485

            
2486
    private func selectionTouchesPresent(
2487
        _ range: ClosedRange<Date>
2488
    ) -> Bool {
2489
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2490
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2491
    }
2492

            
2493
    private func selectionCoversFullRange(
2494
        _ range: ClosedRange<Date>
2495
    ) -> Bool {
2496
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2497
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2498
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2499
    }
2500

            
2501
    private func selectionFrame(in size: CGSize) -> CGRect {
2502
        selectionFrame(for: currentRange, width: size.width)
2503
    }
2504

            
2505
    private func selectionFrame(
2506
        for range: ClosedRange<Date>,
2507
        width: CGFloat
2508
    ) -> CGRect {
2509
        guard width > 0, totalSpan > 0 else {
2510
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2511
        }
2512

            
2513
        let minimumX = xPosition(for: range.lowerBound, width: width)
2514
        let maximumX = xPosition(for: range.upperBound, width: width)
2515
        return CGRect(
2516
            x: minimumX,
2517
            y: 0,
2518
            width: max(maximumX - minimumX, 2),
2519
            height: trackHeight
2520
        )
2521
    }
2522

            
2523
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2524
        guard width > 0, totalSpan > 0 else { return 0 }
2525

            
2526
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2527
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2528
        return CGFloat(normalizedOffset) * width
2529
    }
2530

            
2531
    private func boundaryLabel(for date: Date) -> String {
2532
        date.format(as: boundaryDateFormat)
2533
    }
2534

            
2535
    private var boundaryDateFormat: String {
2536
        switch totalSpan {
2537
        case 0..<86400:
2538
            return "HH:mm"
2539
        case 86400..<604800:
2540
            return "MMM d HH:mm"
2541
        default:
2542
            return "MMM d"
2543
        }
2544
    }
2545
}
2546

            
Bogdan Timofte authored 2 weeks ago
2547
struct Chart : View {
2548

            
Bogdan Timofte authored 16 hours ago
2549
    @Environment(\.displayScale) private var displayScale
2550

            
Bogdan Timofte authored 2 weeks ago
2551
    let points: [Measurements.Measurement.Point]
2552
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
2553
    var areaChart: Bool = false
2554
    var strokeColor: Color = .black
Bogdan Timofte authored 4 days ago
2555
    var areaFillColor: Color? = nil
Bogdan Timofte authored 2 weeks ago
2556

            
2557
    var body : some View {
2558
        GeometryReader { geometry in
2559
            if self.areaChart {
Bogdan Timofte authored 4 days ago
2560
                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
Bogdan Timofte authored 2 weeks ago
2561
                self.path( geometry: geometry )
Bogdan Timofte authored 4 days ago
2562
                    .fill(
2563
                        LinearGradient(
2564
                            gradient: .init(
2565
                                colors: [
2566
                                    fillColor.opacity(0.72),
2567
                                    fillColor.opacity(0.18)
2568
                                ]
2569
                            ),
2570
                            startPoint: .init(x: 0.5, y: 0.08),
2571
                            endPoint: .init(x: 0.5, y: 0.92)
2572
                        )
2573
                    )
Bogdan Timofte authored 2 weeks ago
2574
            } else {
2575
                self.path( geometry: geometry )
2576
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2577
            }
2578
        }
2579
    }
2580

            
2581
    fileprivate func path(geometry: GeometryProxy) -> Path {
Bogdan Timofte authored 16 hours ago
2582
        let displayedPoints = scaledPoints(for: geometry.size.width)
2583
        let baselineY = context.placeInRect(
2584
            point: CGPoint(x: context.origin.x, y: context.origin.y)
2585
        ).y * geometry.size.height
2586

            
Bogdan Timofte authored 2 weeks ago
2587
        return Path { path in
Bogdan Timofte authored 16 hours ago
2588
            var firstRenderedPoint: CGPoint?
2589
            var lastRenderedPoint: CGPoint?
Bogdan Timofte authored a week ago
2590
            var needsMove = true
2591

            
Bogdan Timofte authored 16 hours ago
2592
            for point in displayedPoints {
Bogdan Timofte authored a week ago
2593
                if point.isDiscontinuity {
Bogdan Timofte authored 16 hours ago
2594
                    closeAreaSegment(
2595
                        in: &path,
2596
                        firstPoint: firstRenderedPoint,
2597
                        lastPoint: lastRenderedPoint,
2598
                        baselineY: baselineY
2599
                    )
2600
                    firstRenderedPoint = nil
2601
                    lastRenderedPoint = nil
Bogdan Timofte authored a week ago
2602
                    needsMove = true
2603
                    continue
2604
                }
2605

            
2606
                let item = context.placeInRect(point: point.point())
2607
                let renderedPoint = CGPoint(
2608
                    x: item.x * geometry.size.width,
2609
                    y: item.y * geometry.size.height
2610
                )
2611

            
2612
                if needsMove {
2613
                    path.move(to: renderedPoint)
Bogdan Timofte authored 16 hours ago
2614
                    firstRenderedPoint = renderedPoint
Bogdan Timofte authored a week ago
2615
                    needsMove = false
2616
                } else {
2617
                    path.addLine(to: renderedPoint)
2618
                }
Bogdan Timofte authored 16 hours ago
2619

            
2620
                lastRenderedPoint = renderedPoint
Bogdan Timofte authored 2 weeks ago
2621
            }
Bogdan Timofte authored a week ago
2622

            
Bogdan Timofte authored 16 hours ago
2623
            closeAreaSegment(
2624
                in: &path,
2625
                firstPoint: firstRenderedPoint,
2626
                lastPoint: lastRenderedPoint,
2627
                baselineY: baselineY
2628
            )
2629
        }
2630
    }
2631

            
2632
    private func closeAreaSegment(
2633
        in path: inout Path,
2634
        firstPoint: CGPoint?,
2635
        lastPoint: CGPoint?,
2636
        baselineY: CGFloat
2637
    ) {
2638
        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2639

            
2640
        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2641
        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2642
        path.closeSubpath()
2643
    }
2644

            
2645
    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2646
        let sampleCount = points.reduce(into: 0) { partialResult, point in
2647
            if point.isSample {
2648
                partialResult += 1
2649
            }
2650
        }
2651

            
2652
        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2653
        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2654

            
2655
        guard sampleCount > maximumSamplesToRender, context.isValid else {
2656
            return points
2657
        }
2658

            
2659
        var scaledPoints: [Measurements.Measurement.Point] = []
2660
        var currentSegment: [Measurements.Measurement.Point] = []
2661

            
2662
        for point in points {
2663
            if point.isDiscontinuity {
2664
                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2665
                currentSegment.removeAll(keepingCapacity: true)
2666

            
2667
                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2668
                    appendScaledPoint(point, to: &scaledPoints)
2669
                }
2670
            } else {
2671
                currentSegment.append(point)
2672
            }
2673
        }
2674

            
2675
        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2676
        return scaledPoints.isEmpty ? points : scaledPoints
2677
    }
2678

            
2679
    private func appendScaledSegment(
2680
        _ segment: [Measurements.Measurement.Point],
2681
        to scaledPoints: inout [Measurements.Measurement.Point],
2682
        displayColumns: Int
2683
    ) {
2684
        guard !segment.isEmpty else { return }
2685

            
2686
        if segment.count <= max(displayColumns * 2, 120) {
2687
            for point in segment {
2688
                appendScaledPoint(point, to: &scaledPoints)
2689
            }
2690
            return
2691
        }
2692

            
2693
        var bucket: [Measurements.Measurement.Point] = []
2694
        var currentColumn: Int?
2695

            
2696
        for point in segment {
2697
            let column = displayColumn(for: point, totalColumns: displayColumns)
2698

            
2699
            if let currentColumn, currentColumn != column {
2700
                appendBucket(bucket, to: &scaledPoints)
2701
                bucket.removeAll(keepingCapacity: true)
Bogdan Timofte authored 2 weeks ago
2702
            }
Bogdan Timofte authored 16 hours ago
2703

            
2704
            bucket.append(point)
2705
            currentColumn = column
2706
        }
2707

            
2708
        appendBucket(bucket, to: &scaledPoints)
2709
    }
2710

            
2711
    private func appendBucket(
2712
        _ bucket: [Measurements.Measurement.Point],
2713
        to scaledPoints: inout [Measurements.Measurement.Point]
2714
    ) {
2715
        guard !bucket.isEmpty else { return }
2716

            
2717
        if bucket.count <= 2 {
2718
            for point in bucket {
2719
                appendScaledPoint(point, to: &scaledPoints)
2720
            }
2721
            return
2722
        }
2723

            
2724
        let firstPoint = bucket.first!
2725
        let lastPoint = bucket.last!
2726
        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2727
        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2728

            
2729
        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2730
            .sorted { lhs, rhs in
2731
                if lhs.timestamp == rhs.timestamp {
2732
                    return lhs.id < rhs.id
2733
                }
2734
                return lhs.timestamp < rhs.timestamp
2735
            }
2736

            
2737
        var emittedPointIDs: Set<Int> = []
2738
        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2739
            appendScaledPoint(point, to: &scaledPoints)
Bogdan Timofte authored 2 weeks ago
2740
        }
2741
    }
Bogdan Timofte authored 16 hours ago
2742

            
2743
    private func appendScaledPoint(
2744
        _ point: Measurements.Measurement.Point,
2745
        to scaledPoints: inout [Measurements.Measurement.Point]
2746
    ) {
2747
        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2748
                scaledPoints.last?.value == point.value &&
2749
                scaledPoints.last?.kind == point.kind) else {
2750
            return
2751
        }
2752

            
2753
        scaledPoints.append(
2754
            Measurements.Measurement.Point(
2755
                id: scaledPoints.count,
2756
                timestamp: point.timestamp,
2757
                value: point.value,
2758
                kind: point.kind
2759
            )
2760
        )
2761
    }
2762

            
2763
    private func displayColumn(
2764
        for point: Measurements.Measurement.Point,
2765
        totalColumns: Int
2766
    ) -> Int {
2767
        let totalColumns = max(totalColumns, 1)
2768
        let timeSpan = max(Double(context.size.width), 1)
2769
        let normalizedOffset = min(
2770
            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2771
            1
2772
        )
2773

            
2774
        return min(
2775
            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2776
            totalColumns - 1
2777
        )
2778
    }
Bogdan Timofte authored 2 weeks ago
2779

            
2780
}